michal/tit
Browse tree · Show commit · Download archive
Diff
b48e40edd69a → 46e73d7742dd
.github/workflows/ci.yml
Mode 100644 → 100644; object 38478417152b → 3816cc016a90
@@ -30,7 +30,7 @@
persist-credentials: false
- run: cargo --version
- run: cargo test --locked --all-targets --all-features
- - run: cargo test --locked --release --test auth --test ssh --test account_lifecycle
+ - run: cargo test --locked --release --test auth --test ssh --test account_lifecycle --test web_session
- run: cargo test --locked --release --test git_repository --test git_http --test git_ssh --test public_routes --test serve
- run: cargo test --locked --release --test git_reads measures_bounded_search_without_an_index -- --ignored --nocapture
- run: cargo test --locked --release --test sqlite_workload -- --ignored --nocapture
Cargo.lock
Mode 100644 → 100644; object 033fe5aa70e8 → f4e2775996e8
@@ -180,6 +180,7 @@ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -2428,6 +2429,23 @@ ] [[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3323,6 +3341,12 @@ "libc", "windows-sys 0.61.2", ] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "spki"
Cargo.toml
Mode 100644 → 100644; object b63fcde59c22 → 962fc54987a6
@@ -13,7 +13,7 @@
[dependencies]
ammonia = "4.1.4"
askama = { version = "0.16", default-features = false, features = ["derive", "std"] }
-axum = { version = "0.8", default-features = false, features = ["http1", "original-uri", "query", "tokio"] }
+axum = { version = "0.8", default-features = false, features = ["http1", "multipart", "original-uri", "query", "tokio"] }
clap = { version = "4.6", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
gix = { version = "0.84", default-features = false, features = ["blame", "parallel", "sha1", "sha256"] }
gix-pack = { version = "0.71", default-features = false, features = ["generate", "sha1", "sha256", "streaming-input"] }
README.md
Mode 100644 → 100644; object 70b93ea7e446 → 39797a0677f5
@@ -159,3 +159,17 @@ suspension, and the owner-only control socket. Read the [account lifecycle architectural decision record](docs/adr/0010-account-lifecycle.md) for the credential and failure behavior. + +## Milestone 3.2 gate + +Install stock OpenSSH. Then, run the Web login gate: + +```text +./scripts/check-m3-2 +``` + +Open `/login`, create a challenge, and sign its exact content with the +`tit-auth` SSHSIG namespace. The Web UI accepts the pasted SSHSIG envelope and +creates an opaque session. Read the +[Web login architectural decision record](docs/adr/0011-web-login-sessions.md) +for the session and CSRF behavior.
docs/adr/0011-web-login-sessions.md
Mode → 100644; object → e66a5b838e68
@@ -1,0 +1,65 @@ +# Architectural decision record 0011: Web login sessions + +Status: Accepted + +Date: 2026-07-22 + +## Context + +The Web UI must use the same SSH key identity as the SSH server. A login +challenge must not be reusable. A database copy must not contain a session +token or a CSRF token that a user can use directly. + +## Decision + +The login form requires a username and an active SSH public key. The server +creates a five-minute `tit-auth-v1` challenge and stores only the SHA-256 hash +of its random nonce. The user signs the exact challenge with the `tit-auth` +SSHSIG namespace and pastes the complete SSHSIG envelope into the Web UI. + +The server verifies the origin, username, key fingerprint, time, namespace, +signature algorithm, and signature. The user can paste the envelope or upload +the signature file. In one SQLite transaction, the server consumes the nonce +and creates a seven-day session. The response stores an opaque session +token in an `HttpOnly` cookie and a CSRF token in a second cookie. Both cookies +use `SameSite=Strict`. HTTPS responses also use `Secure`. SQLite stores only +SHA-256 hashes of both values. + +Challenge creation also sets a five-minute, `HttpOnly` login CSRF cookie. The +verification form must submit the matching value, and the nonce row stores only +its hash. Thus, a different site cannot complete a login in the user's browser. + +Each state-changing account form must compare its submitted CSRF token with the +CSRF cookie and the stored hash. The initial account page supplies a logout +form that uses this rule. Logout ends all sessions for the account. Recovery, +key addition, key revocation, suspension, and restoration also end all account +sessions in the same transaction as the privilege change. + +## Failure and threat cases + +Login and signature forms have fixed body limits. Axum and `multer` parse the +uploaded form within the 64-KiB limit. Challenge and SSHSIG parsing have the +authentication limits from architectural decision record 0002. A bad +identity, signature, expired challenge, and consumed challenge use a common Web +UI error. The response does not disclose which input failed. + +The server keeps a maximum of 1,024 active login nonces and removes consumed or +expired nonces before it creates one. Cookie parsing rejects duplicate, empty, +and oversized values. A session is not +valid after its expiry, account suspension, logout, or a privilege change. A +CSRF failure does not end the session because an external request must not be +able to log out a user. + +## Evidence + +The session test signs with stock `ssh-keygen`, restarts the login service +between issue and verification, rejects challenge replay and a bad CSRF token, +checks the stored hashes, invalidates a session after key addition, and tests +the function that ends all sessions. The executable test completes login and +logout through HTTP. It also confirms CSRF rejection and session invalidation. + +## Consequences + +The user must supply the public key during login so the server can select the +correct key without an account-enumeration page. The interface accepts a pasted +SSHSIG envelope or an SSHSIG file.
scripts/check-m3-2
Mode → 100755; object → 61a9631093ed
@@ -1,0 +1,6 @@ +#!/bin/sh +set -eu + +./scripts/check +cargo test --locked --release --test auth --test web_session +cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh
src/account.rs
Mode 100644 → 100644; object 1f2cbb147a5d → 10bf2111644e
@@ -96,7 +96,7 @@
pub(crate) fn suspend(&self, username: &str, suspended: bool) -> Result<(), AccountError> {
validate_username(username)?;
- Store::open(&self.database)?.suspend_account(username, suspended)?;
+ Store::open(&self.database)?.suspend_account(username, suspended, now()?)?;
Ok(())
}
src/auth.rs
Mode 100644 → 100644; object 569ee5f258d9 → 797f5c74c10b
@@ -64,19 +64,8 @@
impl LoginChallenges {
pub(crate) fn new(public_url: &Url) -> Result<Self, AuthError> {
- if !matches!(public_url.scheme(), "http" | "https")
- || public_url.host().is_none()
- || !public_url.username().is_empty()
- || public_url.password().is_some()
- || public_url.query().is_some()
- || public_url.fragment().is_some()
- || public_url.path() != "/"
- {
- return Err(AuthError::InvalidOrigin);
- }
-
Ok(Self {
- origin: public_url.origin().ascii_serialization(),
+ origin: login_origin(public_url)?,
nonces: Mutex::new(HashMap::new()),
})
}
@@ -97,11 +86,13 @@
.ok_or(AuthError::InvalidLifetime)?;
let nonce = self.new_nonce(issued_at, expires_at)?;
- Ok(format!(
- "{CHALLENGE_HEADER}\npurpose={CHALLENGE_PURPOSE}\norigin={}\nusername={username}\nfingerprint={}\nnonce={}\nissued-at={issued_at}\nexpires-at={expires_at}\n",
- self.origin,
- key.fingerprint(),
- encode_hex(&nonce)
+ Ok(format_login_challenge(
+ &self.origin,
+ username,
+ key,
+ &nonce,
+ issued_at,
+ expires_at,
))
}
@@ -113,52 +104,25 @@
expected_key: &SshPublicKey,
now: u64,
) -> Result<VerifiedLogin, AuthError> {
- if challenge.len() > MAX_CHALLENGE_BYTES {
- return Err(AuthError::InputTooLarge("login challenge"));
- }
- if signature.len() > MAX_SIGNATURE_BYTES {
- return Err(AuthError::InputTooLarge("SSHSIG envelope"));
- }
- validate_username(expected_username)?;
-
- let fields = ChallengeFields::parse(challenge)?;
- if fields.origin != self.origin {
- return Err(AuthError::WrongOrigin);
- }
- if fields.username != expected_username {
- return Err(AuthError::WrongUsername);
- }
- if fields.fingerprint != expected_key.fingerprint() {
- return Err(AuthError::WrongKey);
- }
- if fields.expires_at <= fields.issued_at
- || fields.expires_at - fields.issued_at > MAX_CHALLENGE_LIFETIME_SECONDS
- {
- return Err(AuthError::InvalidLifetime);
- }
- if now < fields.issued_at || now > fields.expires_at {
- return Err(AuthError::ExpiredChallenge);
- }
-
- let sshsig = SshSig::from_pem(signature).map_err(AuthError::SignatureEnvelope)?;
- validate_signature_algorithm(expected_key.public_key(), &sshsig)?;
- expected_key
- .public_key()
- .verify(SIGNATURE_NAMESPACE, challenge.as_bytes(), &sshsig)
- .map_err(AuthError::SignatureVerification)?;
-
- let nonce_hash = hash_nonce(&fields.nonce);
+ let verified = verify_login_challenge(
+ &self.origin,
+ challenge,
+ signature,
+ expected_username,
+ expected_key,
+ now,
+ )?;
let mut nonces = self.nonces.lock().map_err(|_| AuthError::NonceStore)?;
- match nonces.get(&nonce_hash) {
- Some(stored_expiry) if *stored_expiry == fields.expires_at => {
- nonces.remove(&nonce_hash);
+ match nonces.get(&verified.nonce_hash) {
+ Some(stored_expiry) if *stored_expiry == verified.expires_at => {
+ nonces.remove(&verified.nonce_hash);
}
_ => return Err(AuthError::ConsumedChallenge),
}
Ok(VerifiedLogin {
- username: fields.username.to_owned(),
- fingerprint: fields.fingerprint.to_owned(),
+ username: verified.username,
+ fingerprint: verified.fingerprint,
})
}
@@ -180,6 +144,89 @@
}
}
}
+}
+
+pub(crate) fn login_origin(public_url: &Url) -> Result<String, AuthError> {
+ if !matches!(public_url.scheme(), "http" | "https")
+ || public_url.host().is_none()
+ || !public_url.username().is_empty()
+ || public_url.password().is_some()
+ || public_url.query().is_some()
+ || public_url.fragment().is_some()
+ || public_url.path() != "/"
+ {
+ return Err(AuthError::InvalidOrigin);
+ }
+ Ok(public_url.origin().ascii_serialization())
+}
+
+pub(crate) fn format_login_challenge(
+ origin: &str,
+ username: &str,
+ key: &SshPublicKey,
+ nonce: &[u8; NONCE_BYTES],
+ issued_at: u64,
+ expires_at: u64,
+) -> String {
+ format!(
+ "{CHALLENGE_HEADER}\npurpose={CHALLENGE_PURPOSE}\norigin={origin}\nusername={username}\nfingerprint={}\nnonce={}\nissued-at={issued_at}\nexpires-at={expires_at}\n",
+ key.fingerprint(),
+ encode_hex(nonce)
+ )
+}
+
+pub(crate) fn verify_login_challenge(
+ origin: &str,
+ challenge: &str,
+ signature: &str,
+ expected_username: &str,
+ expected_key: &SshPublicKey,
+ now: u64,
+) -> Result<PersistentVerifiedLogin, AuthError> {
+ if challenge.len() > MAX_CHALLENGE_BYTES {
+ return Err(AuthError::InputTooLarge("login challenge"));
+ }
+ if signature.len() > MAX_SIGNATURE_BYTES {
+ return Err(AuthError::InputTooLarge("SSHSIG envelope"));
+ }
+ validate_username(expected_username)?;
+ let fields = ChallengeFields::parse(challenge)?;
+ if fields.origin != origin {
+ return Err(AuthError::WrongOrigin);
+ }
+ if fields.username != expected_username {
+ return Err(AuthError::WrongUsername);
+ }
+ if fields.fingerprint != expected_key.fingerprint() {
+ return Err(AuthError::WrongKey);
+ }
+ if fields.expires_at <= fields.issued_at
+ || fields.expires_at - fields.issued_at > MAX_CHALLENGE_LIFETIME_SECONDS
+ {
+ return Err(AuthError::InvalidLifetime);
+ }
+ if now < fields.issued_at || now > fields.expires_at {
+ return Err(AuthError::ExpiredChallenge);
+ }
+ let sshsig = SshSig::from_pem(signature).map_err(AuthError::SignatureEnvelope)?;
+ validate_signature_algorithm(expected_key.public_key(), &sshsig)?;
+ expected_key
+ .public_key()
+ .verify(SIGNATURE_NAMESPACE, challenge.as_bytes(), &sshsig)
+ .map_err(AuthError::SignatureVerification)?;
+ Ok(PersistentVerifiedLogin {
+ username: fields.username.to_owned(),
+ fingerprint: fields.fingerprint.to_owned(),
+ nonce_hash: hash_nonce(&fields.nonce),
+ expires_at: fields.expires_at,
+ })
+}
+
+pub(crate) struct PersistentVerifiedLogin {
+ pub(crate) username: String,
+ pub(crate) fingerprint: String,
+ pub(crate) nonce_hash: [u8; 32],
+ pub(crate) expires_at: u64,
}
#[derive(Debug, Eq, PartialEq)]
src/http/mod.rs
Mode 100644 → 100644; object 86a77cb649a2 → 7671cc6a00d1
@@ -7,7 +7,9 @@
use askama::Template;
use axum::Router;
use axum::body::{Body, Bytes, HttpBody};
-use axum::extract::{DefaultBodyLimit, Extension, OriginalUri, RawQuery, Request, State};
+use axum::extract::{
+ DefaultBodyLimit, Extension, Multipart, OriginalUri, RawQuery, Request, State,
+};
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::Response;
@@ -20,6 +22,7 @@
use crate::account::{AccountError, AccountService};
use crate::auth::validate_username;
use crate::domain::repository::validate_slug;
+use crate::session::{SessionError, WebLoginService};
use crate::store::StoreError;
use self::public::PublicWeb;
@@ -28,6 +31,9 @@
const MAX_LOCATION_QUERY_BYTES: usize = 512;
const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
const MAX_BLOCKING_WEB_JOBS: usize = 8;
+const SESSION_COOKIE: &str = "tit-session";
+const CSRF_COOKIE: &str = "tit-csrf";
+const LOGIN_CSRF_COOKIE: &str = "tit-login-csrf";
#[derive(Clone)]
struct WebState {
@@ -35,6 +41,8 @@
accounts: Option<AccountService>,
jobs: Arc<Semaphore>,
key_reloader: Option<AccountKeyReloader>,
+ login: Option<WebLoginService>,
+ secure_cookies: bool,
}
type AccountKeyReloader = Arc<dyn Fn(&AccountService) -> Result<(), AccountError> + Send + Sync>;
@@ -61,6 +69,8 @@
accounts: None,
jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
key_reloader: None,
+ login: None,
+ secure_cookies: false,
},
)
.await
@@ -87,7 +97,12 @@
key_reloader: Option<AccountKeyReloader>,
) -> Result<Self, WebError> {
let jobs = Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS));
- let accounts = AccountService::new(config.instance_dir.join(crate::store::DATABASE_FILE));
+ let database = config.instance_dir.join(crate::store::DATABASE_FILE);
+ let accounts = AccountService::new(database.clone());
+ let public_url = url::Url::parse(&format!("{}/", config.http_clone_base))
+ .map_err(WebError::CanonicalUrl)?;
+ let secure_cookies = public_url.scheme() == "https";
+ let login = WebLoginService::new(database, &public_url)?;
let public = PublicWeb::open(config, Arc::clone(&jobs))?;
Self::start_with_state(
address,
@@ -96,6 +111,8 @@
accounts: Some(accounts),
jobs,
key_reloader,
+ login: Some(login),
+ secure_cookies,
},
)
.await
@@ -136,6 +153,8 @@
accounts: None,
jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
key_reloader: None,
+ login: None,
+ secure_cookies: false,
})
}
@@ -154,6 +173,25 @@
get(recovery_form)
.post(recover)
.layer(DefaultBodyLimit::max(20 * 1024)),
+ )
+ .route(
+ "/login",
+ get(login_form)
+ .post(login_challenge)
+ .layer(DefaultBodyLimit::max(32 * 1024)),
+ )
+ .route(
+ "/login/verify",
+ axum::routing::post(login_verify).layer(DefaultBodyLimit::max(64 * 1024)),
+ )
+ .route(
+ "/login/verify-file",
+ axum::routing::post(login_verify_file).layer(DefaultBodyLimit::max(64 * 1024)),
+ )
+ .route("/account", get(account_page))
+ .route(
+ "/logout",
+ axum::routing::post(logout).layer(DefaultBodyLimit::max(1024)),
)
.route("/assets/style.css", get(style))
.merge(public::routes())
@@ -249,6 +287,387 @@
})
.await;
account_result(result, &request_id.0, AccountFormKind::Recovery, &username)
+}
+
+async fn login_form(Extension(request_id): Extension<RequestId>) -> Response {
+ render(
+ StatusCode::OK,
+ &LoginTemplate {
+ request_id: &request_id.0,
+ username: "",
+ error: "",
+ has_error: false,
+ },
+ )
+}
+
+async fn login_challenge(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ let fields = match parse_named_form(&headers, &body, &["username", "public-key"]) {
+ Ok(fields) => fields,
+ Err(()) => return login_error(&request_id.0, "", "The login request is not valid."),
+ };
+ let username = fields[0].clone();
+ let display_username = username.clone();
+ let public_key = fields[1].clone();
+ let secure = state.secure_cookies;
+ let result = login_job(state, move |login| login.issue(&username, &public_key)).await;
+ match result {
+ Ok(challenge) => {
+ let mut response = render(
+ StatusCode::OK,
+ &LoginChallengeTemplate {
+ request_id: &request_id.0,
+ username: &display_username,
+ public_key: &challenge.public_key,
+ challenge: &challenge.challenge,
+ login_csrf: &challenge.login_csrf,
+ },
+ );
+ append_cookie(
+ response.headers_mut(),
+ LOGIN_CSRF_COOKIE,
+ &challenge.login_csrf,
+ true,
+ secure,
+ 5 * 60,
+ );
+ response
+ }
+ Err(_) => login_error(
+ &request_id.0,
+ &display_username,
+ "The username or SSH public key is not valid.",
+ ),
+ }
+}
+
+async fn login_verify(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ let fields = match parse_named_form(
+ &headers,
+ &body,
+ &[
+ "username",
+ "public-key",
+ "challenge",
+ "signature",
+ "login-csrf",
+ ],
+ ) {
+ Ok(fields) => fields,
+ Err(()) => return login_error(&request_id.0, "", "The login response is not valid."),
+ };
+ let username = fields[0].clone();
+ let public_key = fields[1].clone();
+ let challenge = fields[2].clone();
+ let signature = fields[3].clone();
+ let login_csrf = fields[4].clone();
+ if cookie(&headers, LOGIN_CSRF_COOKIE).as_deref() != Some(login_csrf.as_str()) {
+ return login_error(&request_id.0, &username, "The login response is not valid.");
+ }
+ complete_login(
+ state,
+ &request_id.0,
+ username,
+ public_key,
+ challenge,
+ signature,
+ login_csrf,
+ )
+ .await
+}
+
+async fn login_verify_file(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ headers: HeaderMap,
+ mut multipart: Multipart,
+) -> Response {
+ let mut username = None;
+ let mut public_key = None;
+ let mut challenge = None;
+ let mut signature = None;
+ let mut login_csrf = None;
+ loop {
+ let field = match multipart.next_field().await {
+ Ok(Some(field)) => field,
+ Ok(None) => break,
+ Err(_) => return login_error(&request_id.0, "", "The login response is not valid."),
+ };
+ match field.name() {
+ Some("username") if username.is_none() => username = field.text().await.ok(),
+ Some("public-key") if public_key.is_none() => public_key = field.text().await.ok(),
+ Some("challenge") if challenge.is_none() => challenge = field.text().await.ok(),
+ Some("signature-file") if signature.is_none() => signature = field.text().await.ok(),
+ Some("login-csrf") if login_csrf.is_none() => login_csrf = field.text().await.ok(),
+ _ => return login_error(&request_id.0, "", "The login response is not valid."),
+ }
+ }
+ let (Some(username), Some(public_key), Some(challenge), Some(signature), Some(login_csrf)) =
+ (username, public_key, challenge, signature, login_csrf)
+ else {
+ return login_error(&request_id.0, "", "The login response is not valid.");
+ };
+ if cookie(&headers, LOGIN_CSRF_COOKIE).as_deref() != Some(login_csrf.as_str()) {
+ return login_error(&request_id.0, &username, "The login response is not valid.");
+ }
+ complete_login(
+ state,
+ &request_id.0,
+ username,
+ public_key,
+ challenge,
+ signature,
+ login_csrf,
+ )
+ .await
+}
+
+async fn complete_login(
+ state: WebState,
+ request_id: &str,
+ username: String,
+ public_key: String,
+ challenge: String,
+ signature: String,
+ login_csrf: String,
+) -> Response {
+ let display_username = username.clone();
+ let secure = state.secure_cookies;
+ let result = login_job(state, move |login| {
+ login.verify(&username, &public_key, &challenge, &signature, &login_csrf)
+ })
+ .await;
+ match result {
+ Ok(session) => {
+ let mut response = Response::builder()
+ .status(StatusCode::SEE_OTHER)
+ .header(header::LOCATION, "/account")
+ .header(header::CACHE_CONTROL, "no-store")
+ .body(Body::empty())
+ .expect("the login redirect is valid");
+ append_cookie(
+ response.headers_mut(),
+ SESSION_COOKIE,
+ &session.token,
+ true,
+ secure,
+ SESSION_COOKIE_MAX_AGE,
+ );
+ append_cookie(
+ response.headers_mut(),
+ LOGIN_CSRF_COOKIE,
+ "",
+ true,
+ secure,
+ 0,
+ );
+ append_cookie(
+ response.headers_mut(),
+ CSRF_COOKIE,
+ &session.csrf,
+ false,
+ secure,
+ SESSION_COOKIE_MAX_AGE,
+ );
+ response
+ }
+ Err(_) => login_error(
+ request_id,
+ &display_username,
+ "The signature is not valid or the challenge has expired.",
+ ),
+ }
+}
+
+const SESSION_COOKIE_MAX_AGE: i64 = 7 * 24 * 60 * 60;
+
+async fn account_page(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ headers: HeaderMap,
+) -> Response {
+ let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
+ return login_redirect(false);
+ };
+ let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
+ return login_redirect(true);
+ };
+ let csrf_for_auth = csrf.clone();
+ match login_job(state, move |login| {
+ login.authenticate(&session_token, Some(&csrf_for_auth))
+ })
+ .await
+ {
+ Ok(session) => render(
+ StatusCode::OK,
+ &AccountTemplate {
+ request_id: &request_id.0,
+ username: &session.username,
+ administrator: session.is_administrator,
+ csrf: &csrf,
+ },
+ ),
+ Err(_) => login_redirect(true),
+ }
+}
+
+async fn logout(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ let fields = match parse_named_form(&headers, &body, &["csrf"]) {
+ Ok(fields) => fields,
+ Err(()) => {
+ return render_error(
+ StatusCode::FORBIDDEN,
+ &request_id.0,
+ "Forbidden",
+ "The request is not authorized.",
+ );
+ }
+ };
+ let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
+ return login_redirect(true);
+ };
+ let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
+ return login_redirect(true);
+ };
+ if fields[0] != csrf {
+ return render_error(
+ StatusCode::FORBIDDEN,
+ &request_id.0,
+ "Forbidden",
+ "The request is not authorized.",
+ );
+ }
+ let result = login_job(state, move |login| {
+ let session = login.authenticate(&session_token, Some(&csrf))?;
+ login.end_all(&session.username)
+ })
+ .await;
+ if result.is_err() {
+ return login_redirect(true);
+ }
+ login_redirect(true)
+}
+
+async fn login_job<T: Send + 'static>(
+ state: WebState,
+ operation: impl FnOnce(WebLoginService) -> Result<T, SessionError> + Send + 'static,
+) -> Result<T, SessionError> {
+ let login = state.login.ok_or(SessionError::Unavailable)?;
+ let permit = state
+ .jobs
+ .acquire_owned()
+ .await
+ .map_err(|_| SessionError::Unavailable)?;
+ tokio::task::spawn_blocking(move || {
+ let _permit = permit;
+ operation(login)
+ })
+ .await
+ .map_err(|_| SessionError::Unavailable)?
+}
+
+fn parse_named_form(
+ headers: &HeaderMap,
+ body: &[u8],
+ expected: &[&str],
+) -> Result<Vec<String>, ()> {
+ if headers
+ .get(header::CONTENT_TYPE)
+ .and_then(|value| value.to_str().ok())
+ .and_then(|value| value.split(';').next())
+ != Some("application/x-www-form-urlencoded")
+ || !valid_percent_encoding(body)
+ {
+ return Err(());
+ }
+ let mut values = vec![None; expected.len()];
+ for (name, value) in url::form_urlencoded::parse(body) {
+ let Some(index) = expected.iter().position(|candidate| *candidate == name) else {
+ return Err(());
+ };
+ if values[index].is_some() {
+ return Err(());
+ }
+ values[index] = Some(value.into_owned());
+ }
+ values.into_iter().collect::<Option<Vec<_>>>().ok_or(())
+}
+
+fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
+ let value = headers.get(header::COOKIE)?.to_str().ok()?;
+ let mut found = None;
+ for pair in value.split(';') {
+ let (candidate, value) = pair.trim().split_once('=')?;
+ if candidate == name {
+ if found.is_some() || value.is_empty() || value.len() > 128 {
+ return None;
+ }
+ found = Some(value.to_owned());
+ }
+ }
+ found
+}
+
+fn append_cookie(
+ headers: &mut HeaderMap,
+ name: &str,
+ value: &str,
+ http_only: bool,
+ secure: bool,
+ max_age: i64,
+) {
+ let mut cookie = format!("{name}={value}; Path=/; SameSite=Strict; Max-Age={max_age}");
+ if http_only {
+ cookie.push_str("; HttpOnly");
+ }
+ if secure {
+ cookie.push_str("; Secure");
+ }
+ headers.append(
+ header::SET_COOKIE,
+ HeaderValue::from_str(&cookie).expect("the session cookie is a header value"),
+ );
+}
+
+fn login_redirect(clear: bool) -> Response {
+ let mut response = Response::builder()
+ .status(StatusCode::SEE_OTHER)
+ .header(header::LOCATION, "/login")
+ .header(header::CACHE_CONTROL, "no-store")
+ .body(Body::empty())
+ .expect("the login redirect is valid");
+ if clear {
+ append_cookie(response.headers_mut(), SESSION_COOKIE, "", true, false, 0);
+ append_cookie(response.headers_mut(), CSRF_COOKIE, "", false, false, 0);
+ }
+ response
+}
+
+fn login_error(request_id: &str, username: &str, error: &str) -> Response {
+ render(
+ StatusCode::BAD_REQUEST,
+ &LoginTemplate {
+ request_id,
+ username,
+ error,
+ has_error: true,
+ },
+ )
}
async fn account_job<T: Send + 'static>(
@@ -424,7 +843,8 @@
"This page does not accept the request method.",
);
let allow = match uri.path() {
- "/signup" | "/recover" => "GET, HEAD, POST",
+ "/signup" | "/recover" | "/login" => "GET, HEAD, POST",
+ "/login/verify" | "/login/verify-file" | "/logout" => "POST",
path if path.ends_with("/git-upload-pack") => "POST",
_ => "GET, HEAD",
};
@@ -617,12 +1037,44 @@
recovery: &'a str,
}
+#[derive(Template)]
+#[template(path = "login.html")]
+struct LoginTemplate<'a> {
+ request_id: &'a str,
+ username: &'a str,
+ error: &'a str,
+ has_error: bool,
+}
+
+#[derive(Template)]
+#[template(path = "login-challenge.html")]
+struct LoginChallengeTemplate<'a> {
+ request_id: &'a str,
+ username: &'a str,
+ public_key: &'a str,
+ challenge: &'a str,
+ login_csrf: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "account-page.html")]
+struct AccountTemplate<'a> {
+ request_id: &'a str,
+ username: &'a str,
+ administrator: bool,
+ csrf: &'a str,
+}
+
#[derive(Debug, Error)]
pub(crate) enum WebError {
#[error("HTTP listener error: {0}")]
Io(#[from] std::io::Error),
#[error("public Web configuration error: {0}")]
Public(#[from] public::PublicWebError),
+ #[error("canonical URL error: {0}")]
+ CanonicalUrl(url::ParseError),
+ #[error(transparent)]
+ Session(#[from] SessionError),
#[error("HTTP server task failed")]
Join,
}
src/main.rs
Mode 100644 → 100644; object 0906c51d68c5 → f51455176375
@@ -18,6 +18,7 @@ mod instance; mod markdown; mod serve; +mod session; #[allow(dead_code, reason = "the server uses only part of the shared SSH API")] mod ssh; mod store;
src/session.rs
Mode → 100644; object → 6fd36f88f62b
@@ -1,0 +1,194 @@
+use std::path::PathBuf;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use rand::TryRng;
+use sha2::{Digest, Sha256};
+use thiserror::Error;
+use url::Url;
+
+use crate::auth::{
+ AuthError, SshPublicKey, format_login_challenge, login_origin, verify_login_challenge,
+};
+use crate::store::{NewLoginNonce, NewWebSession, Store, StoreError, WebSessionRecord};
+
+const CHALLENGE_LIFETIME_SECONDS: u64 = 5 * 60;
+const SESSION_LIFETIME_SECONDS: i64 = 7 * 24 * 60 * 60;
+const SECRET_BYTES: usize = 32;
+
+#[derive(Clone)]
+pub(crate) struct WebLoginService {
+ database: PathBuf,
+ origin: String,
+}
+
+impl WebLoginService {
+ pub(crate) fn new(database: PathBuf, public_url: &Url) -> Result<Self, SessionError> {
+ Ok(Self {
+ database,
+ origin: login_origin(public_url)?,
+ })
+ }
+
+ pub(crate) fn issue(
+ &self,
+ username: &str,
+ public_key: &str,
+ ) -> Result<IssuedChallenge, SessionError> {
+ let key = SshPublicKey::parse(public_key)?;
+ let created_at = now()?;
+ let issued_at = u64::try_from(created_at).map_err(|_| SessionError::Clock)?;
+ let expires_at = issued_at
+ .checked_add(CHALLENGE_LIFETIME_SECONDS)
+ .ok_or(SessionError::Clock)?;
+ let nonce = random_bytes()?;
+ let login_csrf = encode_hex(&random_bytes()?);
+ Store::open(&self.database)?.create_login_nonce(&NewLoginNonce {
+ nonce_hash: &hash(&nonce),
+ csrf_hash: &hash(login_csrf.as_bytes()),
+ username,
+ fingerprint: key.fingerprint(),
+ created_at,
+ expires_at: i64::try_from(expires_at).map_err(|_| SessionError::Clock)?,
+ })?;
+ Ok(IssuedChallenge {
+ challenge: format_login_challenge(
+ &self.origin,
+ username,
+ &key,
+ &nonce,
+ issued_at,
+ expires_at,
+ ),
+ public_key: key.canonical().to_owned(),
+ login_csrf,
+ })
+ }
+
+ pub(crate) fn verify(
+ &self,
+ username: &str,
+ public_key: &str,
+ challenge: &str,
+ signature: &str,
+ login_csrf: &str,
+ ) -> Result<NewSession, SessionError> {
+ validate_token(login_csrf)?;
+ let key = SshPublicKey::parse(public_key)?;
+ let created_at = now()?;
+ let verified = verify_login_challenge(
+ &self.origin,
+ challenge,
+ signature,
+ username,
+ &key,
+ u64::try_from(created_at).map_err(|_| SessionError::Clock)?,
+ )?;
+ let session = encode_hex(&random_bytes()?);
+ let csrf = encode_hex(&random_bytes()?);
+ let expires_at = created_at
+ .checked_add(SESSION_LIFETIME_SECONDS)
+ .ok_or(SessionError::Clock)?;
+ Store::open(&self.database)?.consume_login_nonce(&NewWebSession {
+ nonce_hash: &verified.nonce_hash,
+ login_csrf_hash: &hash(login_csrf.as_bytes()),
+ username: &verified.username,
+ fingerprint: &verified.fingerprint,
+ session_hash: &hash(session.as_bytes()),
+ csrf_hash: &hash(csrf.as_bytes()),
+ created_at,
+ expires_at,
+ })?;
+ Ok(NewSession {
+ token: session,
+ csrf,
+ })
+ }
+
+ pub(crate) fn authenticate(
+ &self,
+ session: &str,
+ csrf: Option<&str>,
+ ) -> Result<WebSessionRecord, SessionError> {
+ validate_token(session)?;
+ if let Some(csrf) = csrf {
+ validate_token(csrf)?;
+ }
+ Store::open(&self.database)?
+ .web_session(
+ &hash(session.as_bytes()),
+ csrf.map(|value| hash(value.as_bytes())).as_ref(),
+ now()?,
+ )
+ .map_err(Into::into)
+ }
+
+ pub(crate) fn end_all(&self, username: &str) -> Result<(), SessionError> {
+ Store::open(&self.database)?.end_account_sessions(username, now()?)?;
+ Ok(())
+ }
+}
+
+pub(crate) struct IssuedChallenge {
+ pub(crate) challenge: String,
+ pub(crate) public_key: String,
+ pub(crate) login_csrf: String,
+}
+
+pub(crate) struct NewSession {
+ pub(crate) token: String,
+ pub(crate) csrf: String,
+}
+
+fn random_bytes() -> Result<[u8; SECRET_BYTES], SessionError> {
+ let mut bytes = [0_u8; SECRET_BYTES];
+ rand::rngs::SysRng
+ .try_fill_bytes(&mut bytes)
+ .map_err(|_| SessionError::Random)?;
+ Ok(bytes)
+}
+
+fn hash(value: &[u8]) -> [u8; 32] {
+ Sha256::digest(value).into()
+}
+
+fn encode_hex(value: &[u8]) -> String {
+ let mut result = String::with_capacity(value.len() * 2);
+ for byte in value {
+ use std::fmt::Write as _;
+ write!(result, "{byte:02x}").expect("writing to a string cannot fail");
+ }
+ result
+}
+
+fn validate_token(token: &str) -> Result<(), SessionError> {
+ if token.len() != SECRET_BYTES * 2 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+ return Err(SessionError::InvalidToken);
+ }
+ Ok(())
+}
+
+fn now() -> Result<i64, SessionError> {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_err(|_| SessionError::Clock)?
+ .as_secs()
+ .try_into()
+ .map_err(|_| SessionError::Clock)
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum SessionError {
+ #[error(transparent)]
+ Auth(#[from] AuthError),
+ #[error(transparent)]
+ Store(#[from] StoreError),
+ #[error("session token is not valid")]
+ InvalidToken,
+ #[error("cannot create random session data")]
+ Random,
+ #[error("system clock is before the Unix epoch")]
+ Clock,
+ #[allow(dead_code, reason = "integration tests use the service without HTTP")]
+ #[error("Web login service is not available")]
+ Unavailable,
+}
src/store/migrations/008_web_sessions.sql
Mode → 100644; object → f547b414f56b
@@ -1,0 +1,26 @@ +CREATE TABLE login_nonce ( + id INTEGER PRIMARY KEY, + nonce_hash BLOB NOT NULL UNIQUE CHECK (length(nonce_hash) = 32), + csrf_hash BLOB NOT NULL CHECK (length(csrf_hash) = 32), + account_id INTEGER NOT NULL REFERENCES account (id) ON DELETE RESTRICT, + ssh_public_key_id INTEGER NOT NULL REFERENCES ssh_public_key (id) ON DELETE RESTRICT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + expires_at INTEGER NOT NULL CHECK (expires_at > created_at), + consumed_at INTEGER CHECK (consumed_at IS NULL OR consumed_at >= created_at) +) STRICT; + +CREATE INDEX login_nonce_active +ON login_nonce (expires_at, consumed_at); + +CREATE TABLE web_session ( + id INTEGER PRIMARY KEY, + session_hash BLOB NOT NULL UNIQUE CHECK (length(session_hash) = 32), + csrf_hash BLOB NOT NULL CHECK (length(csrf_hash) = 32), + account_id INTEGER NOT NULL REFERENCES account (id) ON DELETE RESTRICT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + expires_at INTEGER NOT NULL CHECK (expires_at > created_at), + ended_at INTEGER CHECK (ended_at IS NULL OR ended_at >= created_at) +) STRICT; + +CREATE INDEX web_session_account_active +ON web_session (account_id, expires_at, ended_at);
src/store/mod.rs
Mode 100644 → 100644; object adeecabcd438 → b6b32acbd397
@@ -9,7 +9,7 @@
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
-const SCHEMA_VERSION: i64 = 7;
+const SCHEMA_VERSION: i64 = 8;
#[allow(
dead_code,
reason = "the integration test imports this module without the CLI operation"
@@ -19,7 +19,7 @@
dead_code,
reason = "M1A proves migrations before the M2 server calls them"
)]
-const MIGRATIONS: [&str; 7] = [
+const MIGRATIONS: [&str; 8] = [
include_str!("migrations/001_initial.sql"),
include_str!("migrations/002_state.sql"),
include_str!("migrations/003_git_intents.sql"),
@@ -27,6 +27,7 @@
include_str!("migrations/005_repository.sql"),
include_str!("migrations/006_repository_events.sql"),
include_str!("migrations/007_account_lifecycle.sql"),
+ include_str!("migrations/008_web_sessions.sql"),
];
#[allow(
@@ -75,6 +76,14 @@
KeyNotFound,
#[error("an account must have at least one active SSH public key")]
LastKey,
+ #[error("login identity does not exist or is not active")]
+ LoginIdentity,
+ #[error("too many login challenges are active")]
+ LoginNonceLimit,
+ #[error("login challenge is invalid, expired, or already used")]
+ InvalidLoginChallenge,
+ #[error("Web session is invalid or expired")]
+ InvalidSession,
#[error("repository does not exist: {0}/{1}")]
RepositoryNotFound(String, String),
#[error("repository already exists: {0}/{1}")]
@@ -506,6 +515,7 @@
SET credential_hash = ?2, created_at = ?3 WHERE account_id = ?1",
rusqlite::params![account_id, recovery.new_recovery_hash, recovery.created_at],
)?;
+ end_sessions(&transaction, account_id, recovery.created_at)?;
transaction.commit()?;
Ok(())
}
@@ -525,6 +535,7 @@
.transaction_with_behavior(TransactionBehavior::Immediate)?;
let account_id = active_account_id(&transaction, username)?;
insert_ssh_key(&transaction, account_id, key, created_at)?;
+ end_sessions(&transaction, account_id, created_at)?;
transaction.commit()?;
Ok(())
}
@@ -559,6 +570,7 @@
if changed != 1 {
return Err(StoreError::KeyNotFound);
}
+ end_sessions(&transaction, account_id, revoked_at)?;
transaction.commit()?;
Ok(())
}
@@ -568,18 +580,193 @@
reason = "some integration tests compile storage without accounts"
)]
pub(crate) fn suspend_account(
- &self,
+ &mut self,
username: &str,
suspended: bool,
+ changed_at: i64,
) -> Result<(), StoreError> {
let state = if suspended { "suspended" } else { "active" };
- let changed = self.connection.execute(
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let changed = transaction.execute(
"UPDATE account SET state = ?2 WHERE username = ?1",
rusqlite::params![username, state],
)?;
if changed != 1 {
return Err(StoreError::AccountNotFound(username.to_owned()));
}
+ let account_id: i64 = transaction.query_row(
+ "SELECT id FROM account WHERE username = ?1",
+ [username],
+ |row| row.get(0),
+ )?;
+ end_sessions(&transaction, account_id, changed_at)?;
+ transaction.commit()?;
+ Ok(())
+ }
+
+ #[allow(
+ dead_code,
+ reason = "some integration tests compile storage without Web login"
+ )]
+ pub(crate) fn create_login_nonce(
+ &mut self,
+ nonce: &NewLoginNonce<'_>,
+ ) -> Result<(), StoreError> {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ transaction.execute(
+ "DELETE FROM login_nonce WHERE expires_at < ?1 OR consumed_at IS NOT NULL",
+ [nonce.created_at],
+ )?;
+ let active: i64 =
+ transaction.query_row("SELECT count(*) FROM login_nonce", [], |row| row.get(0))?;
+ if active >= 1_024 {
+ return Err(StoreError::LoginNonceLimit);
+ }
+ let key = transaction
+ .query_row(
+ "SELECT account.id, ssh_public_key.id
+ FROM account
+ JOIN ssh_public_key ON ssh_public_key.account_id = account.id
+ WHERE account.username = ?1 AND account.state = 'active'
+ AND ssh_public_key.fingerprint = ?2
+ AND ssh_public_key.revoked_at IS NULL",
+ rusqlite::params![nonce.username, nonce.fingerprint],
+ |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
+ )
+ .optional()?
+ .ok_or(StoreError::LoginIdentity)?;
+ transaction.execute(
+ "INSERT INTO login_nonce
+ (nonce_hash, csrf_hash, account_id, ssh_public_key_id,
+ created_at, expires_at, consumed_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL)",
+ rusqlite::params![
+ nonce.nonce_hash,
+ nonce.csrf_hash,
+ key.0,
+ key.1,
+ nonce.created_at,
+ nonce.expires_at,
+ ],
+ )?;
+ transaction.commit()?;
+ Ok(())
+ }
+
+ #[allow(
+ dead_code,
+ reason = "some integration tests compile storage without Web login"
+ )]
+ pub(crate) fn consume_login_nonce(
+ &mut self,
+ login: &NewWebSession<'_>,
+ ) -> Result<(), StoreError> {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let account_id = transaction
+ .query_row(
+ "SELECT login_nonce.account_id
+ FROM login_nonce
+ JOIN account ON account.id = login_nonce.account_id
+ JOIN ssh_public_key ON ssh_public_key.id = login_nonce.ssh_public_key_id
+ WHERE login_nonce.nonce_hash = ?1
+ AND login_nonce.consumed_at IS NULL AND login_nonce.expires_at >= ?2
+ AND account.username = ?3 AND account.state = 'active'
+ AND ssh_public_key.fingerprint = ?4 AND ssh_public_key.revoked_at IS NULL
+ AND login_nonce.csrf_hash = ?5",
+ rusqlite::params![
+ login.nonce_hash,
+ login.created_at,
+ login.username,
+ login.fingerprint,
+ login.login_csrf_hash,
+ ],
+ |row| row.get::<_, i64>(0),
+ )
+ .optional()?
+ .ok_or(StoreError::InvalidLoginChallenge)?;
+ let changed = transaction.execute(
+ "UPDATE login_nonce SET consumed_at = ?2
+ WHERE nonce_hash = ?1 AND consumed_at IS NULL",
+ rusqlite::params![login.nonce_hash, login.created_at],
+ )?;
+ if changed != 1 {
+ return Err(StoreError::InvalidLoginChallenge);
+ }
+ transaction.execute(
+ "INSERT INTO web_session
+ (session_hash, csrf_hash, account_id, created_at, expires_at, ended_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, NULL)",
+ rusqlite::params![
+ login.session_hash,
+ login.csrf_hash,
+ account_id,
+ login.created_at,
+ login.expires_at,
+ ],
+ )?;
+ transaction.commit()?;
+ Ok(())
+ }
+
+ #[allow(
+ dead_code,
+ reason = "some integration tests compile storage without Web login"
+ )]
+ pub(crate) fn web_session(
+ &self,
+ session_hash: &[u8; 32],
+ csrf_hash: Option<&[u8; 32]>,
+ now: i64,
+ ) -> Result<WebSessionRecord, StoreError> {
+ self.connection
+ .query_row(
+ "SELECT account.username, account.is_administrator, web_session.expires_at
+ FROM web_session
+ JOIN account ON account.id = web_session.account_id
+ WHERE web_session.session_hash = ?1 AND web_session.ended_at IS NULL
+ AND web_session.expires_at >= ?3 AND account.state = 'active'
+ AND (?2 IS NULL OR web_session.csrf_hash = ?2)",
+ rusqlite::params![session_hash, csrf_hash, now],
+ |row| {
+ Ok(WebSessionRecord {
+ username: row.get(0)?,
+ is_administrator: row.get(1)?,
+ expires_at: row.get(2)?,
+ })
+ },
+ )
+ .optional()?
+ .ok_or(StoreError::InvalidSession)
+ }
+
+ #[allow(
+ dead_code,
+ reason = "some integration tests compile storage without Web login"
+ )]
+ pub(crate) fn end_account_sessions(
+ &mut self,
+ username: &str,
+ ended_at: i64,
+ ) -> Result<(), StoreError> {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let account_id: i64 = transaction
+ .query_row(
+ "SELECT id FROM account WHERE username = ?1",
+ [username],
+ |row| row.get(0),
+ )
+ .optional()?
+ .ok_or_else(|| StoreError::AccountNotFound(username.to_owned()))?;
+ end_sessions(&transaction, account_id, ended_at)?;
+ transaction.commit()?;
Ok(())
}
@@ -892,6 +1079,44 @@
pub(crate) created_at: i64,
}
+#[allow(
+ dead_code,
+ reason = "some integration tests compile storage without Web login"
+)]
+pub(crate) struct NewLoginNonce<'a> {
+ pub(crate) nonce_hash: &'a [u8; 32],
+ pub(crate) csrf_hash: &'a [u8; 32],
+ pub(crate) username: &'a str,
+ pub(crate) fingerprint: &'a str,
+ pub(crate) created_at: i64,
+ pub(crate) expires_at: i64,
+}
+
+#[allow(
+ dead_code,
+ reason = "some integration tests compile storage without Web login"
+)]
+pub(crate) struct NewWebSession<'a> {
+ pub(crate) nonce_hash: &'a [u8; 32],
+ pub(crate) login_csrf_hash: &'a [u8; 32],
+ pub(crate) username: &'a str,
+ pub(crate) fingerprint: &'a str,
+ pub(crate) session_hash: &'a [u8; 32],
+ pub(crate) csrf_hash: &'a [u8; 32],
+ pub(crate) created_at: i64,
+ pub(crate) expires_at: i64,
+}
+
+#[allow(
+ dead_code,
+ reason = "some integration tests compile storage without Web login"
+)]
+pub(crate) struct WebSessionRecord {
+ pub(crate) username: String,
+ pub(crate) is_administrator: bool,
+ pub(crate) expires_at: i64,
+}
+
pub(crate) struct NewRepository<'a> {
pub(crate) id: &'a str,
pub(crate) owner: &'a str,
@@ -1000,6 +1225,19 @@
Err(error) => Err(error.into()),
Ok(_) => unreachable!("an INSERT changes one row"),
}
+}
+
+fn end_sessions(
+ transaction: &rusqlite::Transaction<'_>,
+ account_id: i64,
+ ended_at: i64,
+) -> Result<(), StoreError> {
+ transaction.execute(
+ "UPDATE web_session SET ended_at = ?2
+ WHERE account_id = ?1 AND ended_at IS NULL",
+ rusqlite::params![account_id, ended_at],
+ )?;
+ Ok(())
}
fn insert_push_events(
templates/account-page.html
Mode → 100644; object → 2e96854719fe
@@ -1,0 +1,15 @@
+{% extends "base.html" %}
+{% block title %}Account · tit{% endblock %}
+{% block content %}
+ <h1>Account</h1>
+ <dl>
+ <dt>Username</dt>
+ <dd>{{ username }}</dd>
+ <dt>Administrator</dt>
+ <dd>{% if administrator %}Yes{% else %}No{% endif %}</dd>
+ </dl>
+ <form action="/logout" method="post">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <button type="submit">Log out all sessions</button>
+ </form>
+{% endblock %}
templates/base.html
Mode 100644 → 100644; object fa3cc9823fc0 → 3242bac8ffe1
@@ -14,6 +14,7 @@
<a href="/">Home</a>
<a href="/signup">Create account</a>
<a href="/recover">Recover account</a>
+ <a href="/login">Log in</a>
</nav>
</header>
<main id="main">
templates/login-challenge.html
Mode → 100644; object → 70e0fc3ab2c4
@@ -1,0 +1,33 @@
+{% extends "base.html" %}
+{% block title %}Sign login challenge · tit{% endblock %}
+{% block content %}
+ <h1>Sign the login challenge</h1>
+ <p>Save the exact challenge text to a file. Sign it with <code>ssh-keygen -Y sign -n tit-auth -f KEY_FILE CHALLENGE_FILE</code>. Paste the complete SSH signature below.</p>
+ <div class="field">
+ <label for="challenge-display">Challenge</label>
+ <textarea id="challenge-display" readonly rows="10">{{ challenge }}</textarea>
+ </div>
+ <form action="/login/verify" method="post">
+ <input type="hidden" name="username" value="{{ username }}">
+ <input type="hidden" name="public-key" value="{{ public_key }}">
+ <input type="hidden" name="challenge" value="{{ challenge }}">
+ <input type="hidden" name="login-csrf" value="{{ login_csrf }}">
+ <div class="field">
+ <label for="signature">SSH signature</label>
+ <textarea id="signature" name="signature" required rows="10" autocomplete="off" autocapitalize="none" spellcheck="false"></textarea>
+ </div>
+ <button type="submit">Log in</button>
+ </form>
+ <h2>Upload a signature</h2>
+ <form action="/login/verify-file" method="post" enctype="multipart/form-data">
+ <input type="hidden" name="username" value="{{ username }}">
+ <input type="hidden" name="public-key" value="{{ public_key }}">
+ <input type="hidden" name="challenge" value="{{ challenge }}">
+ <input type="hidden" name="login-csrf" value="{{ login_csrf }}">
+ <div class="field">
+ <label for="signature-file">SSH signature file</label>
+ <input id="signature-file" name="signature-file" type="file" required>
+ </div>
+ <button type="submit">Upload and log in</button>
+ </form>
+{% endblock %}
templates/login.html
Mode → 100644; object → 467932f76446
@@ -1,0 +1,20 @@
+{% extends "base.html" %}
+{% block title %}Log in · tit{% endblock %}
+{% block content %}
+ <h1>Log in</h1>
+ <p>Enter your username and one active SSH public key.</p>
+{% if has_error %}
+ <p class="error" role="alert">{{ error }}</p>
+{% endif %}
+ <form action="/login" method="post">
+ <div class="field">
+ <label for="username">Username</label>
+ <input id="username" name="username" value="{{ username }}" required autocomplete="username" autocapitalize="none" spellcheck="false">
+ </div>
+ <div class="field">
+ <label for="public-key">SSH public key</label>
+ <textarea id="public-key" name="public-key" required rows="5" autocomplete="off" autocapitalize="none" spellcheck="false"></textarea>
+ </div>
+ <button type="submit">Create challenge</button>
+ </form>
+{% endblock %}
tests/cli.rs
Mode 100644 → 100644; object ee664552b531 → b4f6b5d4fab8
@@ -12,11 +12,12 @@
};
use tempfile::TempDir;
-const V7_DATABASE: &str = concat!(
+const V8_DATABASE: &str = concat!(
include_str!("fixtures/sqlite/v5.sql"),
include_str!("../src/store/migrations/006_repository_events.sql"),
include_str!("../src/store/migrations/007_account_lifecycle.sql"),
- "PRAGMA user_version = 7;\n",
+ include_str!("../src/store/migrations/008_web_sessions.sql"),
+ "PRAGMA user_version = 8;\n",
);
#[test]
@@ -72,7 +73,7 @@
let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
.expect("open the instance database");
database
- .execute_batch(V7_DATABASE)
+ .execute_batch(V8_DATABASE)
.expect("create the current database");
drop(database);
@@ -124,7 +125,7 @@
let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
.expect("open the instance database");
database
- .execute_batch(V7_DATABASE)
+ .execute_batch(V8_DATABASE)
.expect("create the current database");
database
.pragma_update(None, "foreign_keys", false)
tests/fixtures/sqlite/v7.sql
Mode → 100644; object → 07c5d2f340ec
@@ -1,0 +1,191 @@
+CREATE TABLE m1a_parent (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE CHECK (length(name) BETWEEN 1 AND 64),
+ created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE INDEX m1a_parent_created_at ON m1a_parent (created_at, id);
+
+CREATE TABLE m1a_child (
+ id INTEGER PRIMARY KEY,
+ parent_id INTEGER NOT NULL REFERENCES m1a_parent (id) ON DELETE RESTRICT,
+ sequence INTEGER NOT NULL CHECK (sequence >= 0),
+ body TEXT NOT NULL,
+ state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')),
+ UNIQUE (parent_id, sequence)
+) STRICT;
+
+CREATE INDEX m1a_child_state_parent
+ON m1a_child (state, parent_id, sequence);
+
+CREATE TABLE git_operation_intent (
+ id TEXT PRIMARY KEY CHECK (length(id) = 32),
+ repository_path TEXT NOT NULL CHECK (length(repository_path) BETWEEN 1 AND 4096),
+ actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 256),
+ initial_refs BLOB NOT NULL,
+ proposed_refs BLOB NOT NULL,
+ event_payload BLOB NOT NULL,
+ quarantine_path TEXT NOT NULL CHECK (length(quarantine_path) BETWEEN 1 AND 4096),
+ state TEXT NOT NULL CHECK (state IN ('pending', 'promoted', 'completed', 'abandoned')),
+ pack_name TEXT,
+ created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE INDEX git_operation_intent_incomplete
+ON git_operation_intent (state, created_at, id)
+WHERE state IN ('pending', 'promoted');
+
+CREATE TABLE git_operation_event (
+ id INTEGER PRIMARY KEY,
+ intent_id TEXT NOT NULL UNIQUE
+ REFERENCES git_operation_intent (id) ON DELETE RESTRICT,
+ payload BLOB NOT NULL
+) STRICT;
+
+CREATE TABLE account (
+ id INTEGER PRIMARY KEY,
+ username TEXT NOT NULL UNIQUE
+ CHECK (
+ length(username) BETWEEN 1 AND 40
+ AND username NOT GLOB '*[^a-z0-9-]*'
+ AND substr(username, 1, 1) != '-'
+ AND substr(username, -1, 1) != '-'
+ ),
+ is_administrator INTEGER NOT NULL
+ CHECK (is_administrator IN (0, 1)),
+ state TEXT NOT NULL
+ CHECK (state IN ('active', 'suspended')),
+ created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE TABLE ssh_public_key (
+ id INTEGER PRIMARY KEY,
+ account_id INTEGER NOT NULL
+ REFERENCES account (id) ON DELETE RESTRICT,
+ canonical_key TEXT NOT NULL
+ CHECK (length(canonical_key) BETWEEN 1 AND 16384),
+ fingerprint TEXT NOT NULL UNIQUE
+ CHECK (length(fingerprint) BETWEEN 1 AND 256),
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
+ UNIQUE (account_id, canonical_key)
+) STRICT;
+
+CREATE INDEX ssh_public_key_account
+ON ssh_public_key (account_id, id);
+
+CREATE TABLE recovery_credential (
+ account_id INTEGER PRIMARY KEY
+ REFERENCES account (id) ON DELETE RESTRICT,
+ credential_hash BLOB NOT NULL UNIQUE
+ CHECK (length(credential_hash) = 32),
+ created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE TABLE repository (
+ id TEXT PRIMARY KEY
+ CHECK (length(id) = 32 AND id NOT GLOB '*[^0-9a-f]*'),
+ owner_account_id INTEGER NOT NULL
+ REFERENCES account (id) ON DELETE RESTRICT,
+ slug TEXT NOT NULL
+ CHECK (
+ length(slug) BETWEEN 1 AND 100
+ AND slug NOT GLOB '*[^a-z0-9._-]*'
+ AND substr(slug, 1, 1) GLOB '[a-z0-9]'
+ AND substr(slug, -1, 1) GLOB '[a-z0-9]'
+ AND substr(slug, -4) != '.git'
+ AND slug NOT IN ('admin', 'api', 'assets', 'feeds', 'issues', 'setup')
+ ),
+ visibility TEXT NOT NULL DEFAULT 'public'
+ CHECK (visibility IN ('public', 'private')),
+ state TEXT NOT NULL DEFAULT 'active'
+ CHECK (state IN ('active', 'archived')),
+ object_format TEXT NOT NULL
+ CHECK (object_format IN ('sha1', 'sha256')),
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
+ archived_at INTEGER
+ CHECK (archived_at IS NULL OR archived_at >= created_at),
+ UNIQUE (owner_account_id, slug),
+ CHECK (
+ (state = 'active' AND archived_at IS NULL)
+ OR (state = 'archived' AND archived_at IS NOT NULL)
+ )
+) STRICT;
+
+INSERT INTO m1a_parent (id, name, created_at)
+VALUES (1, 'fixture', 1);
+
+INSERT INTO m1a_child (id, parent_id, sequence, body, state)
+VALUES (1, 1, 1, 'version five', 'closed');
+
+PRAGMA user_version = 5;
+
+CREATE TABLE repository_event (
+ id INTEGER PRIMARY KEY,
+ repository_id TEXT NOT NULL
+ REFERENCES repository (id) ON DELETE RESTRICT,
+ source_intent_id TEXT
+ REFERENCES git_operation_intent (id) ON DELETE RESTRICT,
+ source_ordinal INTEGER CHECK (source_ordinal IS NULL OR source_ordinal >= 0),
+ kind TEXT NOT NULL
+ CHECK (kind IN (
+ 'repository-created', 'repository-imported', 'push',
+ 'ref-created', 'ref-updated', 'ref-deleted',
+ 'tag-created', 'tag-updated', 'tag-deleted'
+ )),
+ actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 256),
+ ref_name BLOB,
+ old_target TEXT,
+ new_target TEXT,
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
+ UNIQUE (source_intent_id, source_ordinal),
+ CHECK (
+ (source_intent_id IS NULL AND source_ordinal IS NULL)
+ OR (source_intent_id IS NOT NULL AND source_ordinal IS NOT NULL)
+ ),
+ CHECK (
+ (kind IN ('repository-created', 'repository-imported', 'push')
+ AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+ OR
+ (kind LIKE 'ref-%' OR kind LIKE 'tag-%')
+ AND ref_name IS NOT NULL
+ AND (old_target IS NOT NULL OR new_target IS NOT NULL)
+ )
+) STRICT;
+
+INSERT INTO repository_event (repository_id, kind, actor, created_at)
+SELECT repository.id, 'repository-created', account.username, repository.created_at
+FROM repository
+JOIN account ON account.id = repository.owner_account_id
+ORDER BY repository.created_at, repository.id;
+
+CREATE INDEX repository_event_feed
+ON repository_event (repository_id, id DESC);
+
+PRAGMA user_version = 6;
+
+
+CREATE TABLE signup_invitation (
+ id INTEGER PRIMARY KEY,
+ code_hash BLOB NOT NULL UNIQUE CHECK (length(code_hash) = 32),
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
+ expires_at INTEGER NOT NULL CHECK (expires_at > created_at),
+ consumed_at INTEGER CHECK (consumed_at IS NULL OR consumed_at >= created_at)
+) STRICT;
+
+CREATE INDEX signup_invitation_active
+ON signup_invitation (expires_at, consumed_at);
+
+ALTER TABLE ssh_public_key
+ADD COLUMN label TEXT NOT NULL DEFAULT 'initial'
+ CHECK (length(label) BETWEEN 1 AND 80);
+
+ALTER TABLE ssh_public_key
+ADD COLUMN last_used_at INTEGER
+ CHECK (last_used_at IS NULL OR last_used_at >= created_at);
+
+ALTER TABLE ssh_public_key
+ADD COLUMN revoked_at INTEGER
+ CHECK (revoked_at IS NULL OR revoked_at >= created_at);
+
+PRAGMA user_version = 7;
+
tests/public_routes.rs
Mode 100644 → 100644; object e202c68a76e2 → 6c0ff2e83fe8
@@ -34,6 +34,9 @@
mod instance;
#[path = "../src/markdown.rs"]
mod markdown;
+#[allow(dead_code, reason = "the public-route test does not complete a login")]
+#[path = "../src/session.rs"]
+mod session;
#[allow(
dead_code,
reason = "the public-route test does not use each store API"
tests/serve.rs
Mode 100644 → 100644; object b86785ce74f5 → c6070f477dbd
@@ -74,6 +74,91 @@
0o600
);
+ let login_challenge = http_form(
+ http,
+ "/login",
+ &[("username", "alice"), ("public-key", public_key.trim())],
+ );
+ assert!(login_challenge.starts_with("HTTP/1.1 200"));
+ let challenge = between(
+ &login_challenge,
+ "<textarea id=\"challenge-display\" readonly rows=\"10\">",
+ "</textarea>",
+ );
+ let signature = sign_challenge(instance.path(), &private_key, challenge);
+ let login_csrf_cookies = response_cookies(&login_challenge);
+ let login_csrf = cookie_value(&login_csrf_cookies, "tit-login-csrf");
+ let rejected_login = http_form_with_headers(
+ http,
+ "/login/verify",
+ &[
+ ("username", "alice"),
+ ("public-key", public_key.trim()),
+ ("challenge", challenge),
+ ("signature", &signature),
+ ("login-csrf", &"0".repeat(64)),
+ ],
+ &[("Cookie", &login_csrf_cookies)],
+ );
+ assert!(rejected_login.starts_with("HTTP/1.1 400"));
+ let login = http_form_with_headers(
+ http,
+ "/login/verify",
+ &[
+ ("username", "alice"),
+ ("public-key", public_key.trim()),
+ ("challenge", challenge),
+ ("signature", &signature),
+ ("login-csrf", login_csrf),
+ ],
+ &[("Cookie", &login_csrf_cookies)],
+ );
+ assert!(login.starts_with("HTTP/1.1 303"), "{login}");
+ let cookies = response_cookies(&login);
+ let account = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
+ assert!(account.starts_with("HTTP/1.1 200"));
+ assert!(account.contains("<dd>alice</dd>"));
+ let csrf = cookie_value(&cookies, "tit-csrf");
+ let rejected_logout = http_form_with_headers(
+ http,
+ "/logout",
+ &[("csrf", &"0".repeat(64))],
+ &[("Cookie", &cookies)],
+ );
+ assert!(rejected_logout.starts_with("HTTP/1.1 403"));
+ let logout =
+ http_form_with_headers(http, "/logout", &[("csrf", csrf)], &[("Cookie", &cookies)]);
+ assert!(logout.starts_with("HTTP/1.1 303"));
+ let ended = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
+ assert!(ended.starts_with("HTTP/1.1 303"));
+
+ let upload_challenge_page = http_form(
+ http,
+ "/login",
+ &[("username", "alice"), ("public-key", public_key.trim())],
+ );
+ let upload_challenge = between(
+ &upload_challenge_page,
+ "<textarea id=\"challenge-display\" readonly rows=\"10\">",
+ "</textarea>",
+ );
+ let upload_signature = sign_challenge(instance.path(), &private_key, upload_challenge);
+ let upload_csrf_cookies = response_cookies(&upload_challenge_page);
+ let upload_csrf = cookie_value(&upload_csrf_cookies, "tit-login-csrf");
+ let uploaded = http_multipart(
+ http,
+ "/login/verify-file",
+ &[
+ ("username", "alice"),
+ ("public-key", public_key.trim()),
+ ("challenge", upload_challenge),
+ ("signature-file", &upload_signature),
+ ("login-csrf", upload_csrf),
+ ],
+ &[("Cookie", &upload_csrf_cookies)],
+ );
+ assert!(uploaded.starts_with("HTTP/1.1 303"), "{uploaded}");
+
let invitation_output = Command::new(env!("CARGO_BIN_EXE_tit"))
.args([
"--config",
@@ -316,12 +401,19 @@
}
fn http_get(address: SocketAddr, path: &str) -> String {
+ http_get_with_headers(address, path, &[])
+}
+
+fn http_get_with_headers(address: SocketAddr, path: &str, headers: &[(&str, &str)]) -> String {
let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
- write!(
- stream,
- "GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n"
- )
- .expect("write an HTTP request");
+ let mut request = format!("GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n");
+ for (name, value) in headers {
+ request.push_str(&format!("{name}: {value}\r\n"));
+ }
+ request.push_str("\r\n");
+ stream
+ .write_all(request.as_bytes())
+ .expect("write an HTTP request");
let mut response = String::new();
stream
.read_to_string(&mut response)
@@ -330,21 +422,120 @@
}
fn http_form(address: SocketAddr, path: &str, fields: &[(&str, &str)]) -> String {
+ http_form_with_headers(address, path, fields, &[])
+}
+
+fn http_form_with_headers(
+ address: SocketAddr,
+ path: &str,
+ fields: &[(&str, &str)],
+ headers: &[(&str, &str)],
+) -> String {
let body = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(fields.iter().copied())
.finish();
let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
- write!(
- stream,
- "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+ let mut request = format!(
+ "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n",
body.len()
- )
- .expect("write an HTTP form");
+ );
+ for (name, value) in headers {
+ request.push_str(&format!("{name}: {value}\r\n"));
+ }
+ request.push_str("\r\n");
+ request.push_str(&body);
+ stream
+ .write_all(request.as_bytes())
+ .expect("write an HTTP form");
let mut response = String::new();
stream
.read_to_string(&mut response)
.expect("read an HTTP response");
response
+}
+
+fn response_cookies(response: &str) -> String {
+ response
+ .lines()
+ .filter_map(|line| line.split_once(':'))
+ .filter(|(name, _)| name.eq_ignore_ascii_case("set-cookie"))
+ .map(|(_, value)| {
+ value
+ .trim()
+ .split_once(';')
+ .map_or(value.trim(), |(cookie, _)| cookie)
+ })
+ .collect::<Vec<_>>()
+ .join("; ")
+}
+
+fn http_multipart(
+ address: SocketAddr,
+ path: &str,
+ fields: &[(&str, &str)],
+ headers: &[(&str, &str)],
+) -> String {
+ let boundary = "tit-test-boundary";
+ let mut body = String::new();
+ for (name, value) in fields {
+ if *name == "signature-file" {
+ body.push_str(&format!(
+ "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"signature.sig\"\r\nContent-Type: application/octet-stream\r\n\r\n{value}\r\n"
+ ));
+ } else {
+ body.push_str(&format!(
+ "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n"
+ ));
+ }
+ }
+ body.push_str(&format!("--{boundary}--\r\n"));
+ let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
+ let mut request = format!(
+ "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: multipart/form-data; boundary={boundary}\r\nContent-Length: {}\r\nConnection: close\r\n",
+ body.len()
+ );
+ for (name, value) in headers {
+ request.push_str(&format!("{name}: {value}\r\n"));
+ }
+ request.push_str("\r\n");
+ request.push_str(&body);
+ stream
+ .write_all(request.as_bytes())
+ .expect("write a multipart HTTP form");
+ let mut response = String::new();
+ stream
+ .read_to_string(&mut response)
+ .expect("read an HTTP response");
+ response
+}
+
+fn cookie_value<'a>(cookies: &'a str, name: &str) -> &'a str {
+ cookies
+ .split("; ")
+ .find_map(|cookie| cookie.strip_prefix(&format!("{name}=")))
+ .expect("find the cookie")
+}
+
+fn sign_challenge(directory: &Path, private_key: &Path, challenge: &str) -> String {
+ let nonce = challenge
+ .lines()
+ .find_map(|line| line.strip_prefix("nonce="))
+ .expect("find the Web login nonce");
+ let path = directory.join(format!("web-login-{nonce}.challenge"));
+ fs::write(&path, challenge).expect("write the Web login challenge");
+ let output = Command::new("ssh-keygen")
+ .args(["-q", "-Y", "sign", "-f"])
+ .arg(private_key)
+ .args(["-n", "tit-auth"])
+ .arg(&path)
+ .output()
+ .expect("sign the Web login challenge");
+ assert!(
+ output.status.success(),
+ "cannot sign the Web login challenge: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ fs::read_to_string(path.with_extension("challenge.sig")).expect("read the Web login signature")
}
fn between<'a>(value: &'a str, start: &str, end: &str) -> &'a str {
tests/snapshots/web/bad-request.html
Mode 100644 → 100644; object 159711d92b81 → cb36f34dd1de
@@ -14,6 +14,7 @@
<a href="/">Home</a>
<a href="/signup">Create account</a>
<a href="/recover">Recover account</a>
+ <a href="/login">Log in</a>
</nav>
</header>
<main id="main">
tests/snapshots/web/home.html
Mode 100644 → 100644; object 6500823ed072 → 64f08cd9b85c
@@ -14,6 +14,7 @@
<a href="/">Home</a>
<a href="/signup">Create account</a>
<a href="/recover">Recover account</a>
+ <a href="/login">Log in</a>
</nav>
</header>
<main id="main">
tests/snapshots/web/method-not-allowed.html
Mode 100644 → 100644; object c415a2c6edb7 → cad969198904
@@ -14,6 +14,7 @@
<a href="/">Home</a>
<a href="/signup">Create account</a>
<a href="/recover">Recover account</a>
+ <a href="/login">Log in</a>
</nav>
</header>
<main id="main">
tests/snapshots/web/not-found.html
Mode 100644 → 100644; object 89966cf7e55f → 3d27c4f01d27
@@ -14,6 +14,7 @@
<a href="/">Home</a>
<a href="/signup">Create account</a>
<a href="/recover">Recover account</a>
+ <a href="/login">Log in</a>
</nav>
</header>
<main id="main">
tests/sqlite.rs
Mode 100644 → 100644; object fd5cfa743118 → 92695311f3d3
@@ -21,6 +21,7 @@
const V4_FIXTURE: &str = include_str!("fixtures/sqlite/v4.sql");
const V5_FIXTURE: &str = include_str!("fixtures/sqlite/v5.sql");
const V6_FIXTURE: &str = include_str!("fixtures/sqlite/v6.sql");
+const V7_FIXTURE: &str = include_str!("fixtures/sqlite/v7.sql");
fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
directory.path().join(name)
@@ -138,7 +139,7 @@
let directory = TempDir::new().expect("create a temporary directory");
let store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
- assert_eq!(store.schema_version().expect("read the schema version"), 7);
+ assert_eq!(store.schema_version().expect("read the schema version"), 8);
assert_eq!(
store
.connection()
@@ -774,13 +775,14 @@
(V4_FIXTURE, 4),
(V5_FIXTURE, 5),
(V6_FIXTURE, 6),
+ (V7_FIXTURE, 7),
] {
let directory = TempDir::new().expect("create a temporary directory");
let path = database(&directory, "tit.sqlite3");
create_fixture(&path, fixture);
let store = Store::open(&path).expect("migrate the fixture");
- assert_eq!(store.schema_version().expect("read the schema version"), 7);
+ assert_eq!(store.schema_version().expect("read the schema version"), 8);
store.integrity_check().expect("check migrated integrity");
let state: String = store
.connection()
@@ -846,7 +848,7 @@
#[test]
fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
- for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 7)] {
+ for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 8)] {
let directory = TempDir::new().expect("create a temporary directory");
let path = database(&directory, "fixture.sqlite");
create_fixture(&path, V1_FIXTURE);
tests/web_session.rs
Mode → 100644; object → caa94aecfbfe
@@ -1,0 +1,178 @@
+#[allow(dead_code, reason = "the Web session test uses one shared test helper")]
+mod support;
+
+#[allow(
+ dead_code,
+ reason = "the Web session test uses part of account management"
+)]
+#[path = "../src/account.rs"]
+mod account;
+#[allow(dead_code, reason = "the Web session test uses part of authentication")]
+#[path = "../src/auth.rs"]
+mod auth;
+#[path = "../src/session.rs"]
+mod session;
+#[allow(
+ dead_code,
+ reason = "the Web session test does not use each store operation"
+)]
+#[path = "../src/store/mod.rs"]
+mod store;
+
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use sha2::{Digest, Sha256};
+use support::create_ssh_key_fixture;
+use tempfile::TempDir;
+use url::Url;
+
+use account::AccountService;
+use auth::SshPublicKey;
+use session::{SessionError, WebLoginService};
+use store::{InitialAdministrator, Store, StoreError};
+
+#[test]
+fn persists_one_time_challenges_and_opaque_revocable_sessions() {
+ let directory = TempDir::new().expect("create a Web session directory");
+ let database = directory.path().join("tit.sqlite3");
+ let private_key = directory.path().join("identity");
+ create_ssh_key_fixture(&private_key);
+ let public_key =
+ fs::read_to_string(private_key.with_extension("pub")).expect("read the SSH public key");
+ let parsed = SshPublicKey::parse(&public_key).expect("parse the SSH public key");
+ let mut store = Store::open(&database).expect("create the database");
+ store
+ .create_initial_administrator(&InitialAdministrator {
+ username: "alice",
+ canonical_key: parsed.canonical(),
+ fingerprint: parsed.fingerprint(),
+ recovery_hash: &[1; 32],
+ created_at: now(),
+ })
+ .expect("create the account");
+
+ let origin = Url::parse("https://tit.example/").expect("parse the origin");
+ let login = WebLoginService::new(database.clone(), &origin).expect("create the login service");
+ let issued = login
+ .issue("alice", &public_key)
+ .expect("issue a login challenge");
+ let signature = sign(directory.path(), &private_key, &issued.challenge);
+
+ let restarted =
+ WebLoginService::new(database.clone(), &origin).expect("restart the login service");
+ let session = restarted
+ .verify(
+ "alice",
+ &issued.public_key,
+ &issued.challenge,
+ &signature,
+ &issued.login_csrf,
+ )
+ .expect("verify the login challenge after restart");
+ assert_eq!(
+ restarted
+ .authenticate(&session.token, Some(&session.csrf))
+ .expect("authenticate the Web session")
+ .username,
+ "alice"
+ );
+ assert!(matches!(
+ restarted.authenticate(&session.token, Some(&"0".repeat(64))),
+ Err(SessionError::Store(StoreError::InvalidSession))
+ ));
+ assert!(matches!(
+ restarted.verify(
+ "alice",
+ &issued.public_key,
+ &issued.challenge,
+ &signature,
+ &issued.login_csrf,
+ ),
+ Err(SessionError::Store(StoreError::InvalidLoginChallenge))
+ ));
+
+ let connection = Store::open(&database).expect("open the database");
+ let (session_hash, csrf_hash): (Vec<u8>, Vec<u8>) = connection
+ .connection()
+ .query_row(
+ "SELECT session_hash, csrf_hash FROM web_session",
+ [],
+ |row| Ok((row.get(0)?, row.get(1)?)),
+ )
+ .expect("read stored session hashes");
+ assert_eq!(
+ session_hash,
+ Sha256::digest(session.token.as_bytes()).as_slice()
+ );
+ assert_eq!(
+ csrf_hash,
+ Sha256::digest(session.csrf.as_bytes()).as_slice()
+ );
+ assert_ne!(session_hash, session.token.as_bytes());
+
+ let second_key = directory.path().join("second-identity");
+ create_ssh_key_fixture(&second_key);
+ let second_public = fs::read_to_string(second_key.with_extension("pub"))
+ .expect("read the second SSH public key");
+ AccountService::new(database.clone())
+ .add_key("alice", "second", &second_public)
+ .expect("change account privileges");
+ assert!(matches!(
+ restarted.authenticate(&session.token, None),
+ Err(SessionError::Store(StoreError::InvalidSession))
+ ));
+
+ let next = restarted
+ .issue("alice", &public_key)
+ .expect("issue another challenge");
+ let next_signature = sign(directory.path(), &private_key, &next.challenge);
+ let next_session = restarted
+ .verify(
+ "alice",
+ &next.public_key,
+ &next.challenge,
+ &next_signature,
+ &next.login_csrf,
+ )
+ .expect("create another session");
+ restarted.end_all("alice").expect("end all sessions");
+ assert!(matches!(
+ restarted.authenticate(&next_session.token, None),
+ Err(SessionError::Store(StoreError::InvalidSession))
+ ));
+}
+
+fn sign(directory: &Path, private_key: &Path, challenge: &str) -> String {
+ let nonce = challenge
+ .lines()
+ .find_map(|line| line.strip_prefix("nonce="))
+ .expect("find the challenge nonce");
+ let challenge_path = directory.join(format!("login-{nonce}.challenge"));
+ fs::write(&challenge_path, challenge).expect("write the challenge");
+ let output = Command::new("ssh-keygen")
+ .args(["-q", "-Y", "sign", "-f"])
+ .arg(private_key)
+ .args(["-n", "tit-auth"])
+ .arg(&challenge_path)
+ .output()
+ .expect("sign the challenge");
+ assert!(
+ output.status.success(),
+ "cannot sign the challenge: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ fs::read_to_string(challenge_path.with_extension("challenge.sig"))
+ .expect("read the SSH signature")
+}
+
+fn now() -> i64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("read the clock")
+ .as_secs()
+ .try_into()
+ .expect("convert the clock")
+}
tests/web_shell.rs
Mode 100644 → 100644; object 9093f88fdfbf → a4e4e6a8fc55
@@ -32,6 +32,9 @@ mod instance; #[path = "../src/markdown.rs"] mod markdown; +#[allow(dead_code, reason = "the Web shell test does not complete a login")] +#[path = "../src/session.rs"] +mod session; #[allow(dead_code, reason = "the shell test does not use repository storage")] #[path = "../src/store/mod.rs"] mod store;