| 1 | //! Web authentication: cookie sessions, login/logout, the `CurrentUser` |
| 2 | //! extractor, and HTTP Basic auth for git push. |
| 3 | |
| 4 | use std::convert::Infallible; |
| 5 | |
| 6 | use anvil_core::{ |
| 7 | App, |
| 8 | User, |
| 9 | api_tokens, |
| 10 | sessions, |
| 11 | users, |
| 12 | }; |
| 13 | use axum::{ |
| 14 | Form, |
| 15 | extract::{ |
| 16 | FromRequestParts, |
| 17 | Request, |
| 18 | State, |
| 19 | }, |
| 20 | http::{ |
| 21 | Method, |
| 22 | StatusCode, |
| 23 | request::Parts, |
| 24 | }, |
| 25 | middleware::Next, |
| 26 | response::{ |
| 27 | IntoResponse, |
| 28 | Redirect, |
| 29 | Response, |
| 30 | }, |
| 31 | }; |
| 32 | use axum_extra::extract::cookie::{ |
| 33 | Cookie, |
| 34 | CookieJar, |
| 35 | SameSite, |
| 36 | }; |
| 37 | use maud::{ |
| 38 | Markup, |
| 39 | html, |
| 40 | }; |
| 41 | |
| 42 | use crate::ui::layout; |
| 43 | |
| 44 | const SESSION_COOKIE: &str = "anvil_session"; |
| 45 | |
| 46 | /// Hidden form field (and header) name carrying the CSRF token. |
| 47 | pub const CSRF_FIELD: &str = "csrf"; |
| 48 | |
| 49 | tokio::task_local! { |
| 50 | /// Request-scoped CSRF token, set by [`csrf_context`] for the duration of |
| 51 | /// each request and read by the layout to populate htmx's `hx-headers` |
| 52 | /// (so JS-driven actions carry the token without a hidden field). Empty for |
| 53 | /// unauthenticated requests. |
| 54 | static CSRF_TOKEN: String; |
| 55 | } |
| 56 | |
| 57 | /// The current request's CSRF token, or empty outside a request scope. |
| 58 | pub(crate) fn current_csrf() -> String { |
| 59 | CSRF_TOKEN.try_with(|t| t.clone()).unwrap_or_default() |
| 60 | } |
| 61 | |
| 62 | /// Middleware that derives the session's CSRF token and makes it available to |
| 63 | /// the layout (via [`current_csrf`]) for the rest of the request. |
| 64 | pub async fn csrf_context(State(app): State<App>, req: Request, next: Next) -> Response { |
| 65 | let token = CookieJar::from_headers(req.headers()) |
| 66 | .get(SESSION_COOKIE) |
| 67 | .map(|c| app.csrf_token(c.value())) |
| 68 | .unwrap_or_default(); |
| 69 | CSRF_TOKEN.scope(token, next.run(req)).await |
| 70 | } |
| 71 | |
| 72 | /// Extractor yielding the logged-in user, if any. Authenticates from the |
| 73 | /// session cookie, or — on safe (GET/HEAD) requests only — from a |
| 74 | /// `Authorization: Bearer <pat>` personal access token. Never fails: absence |
| 75 | /// of a valid credential simply yields `None`. |
| 76 | /// |
| 77 | /// PAT auth is deliberately confined to read methods: a token grants the |
| 78 | /// `read` scope and nothing more, so a leaked token can never mutate (and |
| 79 | /// mutating handlers also require a session-bound CSRF token a bearer lacks). |
| 80 | pub struct CurrentUser(pub Option<User>); |
| 81 | |
| 82 | impl FromRequestParts<App> for CurrentUser { |
| 83 | type Rejection = Infallible; |
| 84 | |
| 85 | async fn from_request_parts(parts: &mut Parts, app: &App) -> Result<Self, Infallible> { |
| 86 | let jar = CookieJar::from_headers(&parts.headers); |
| 87 | let mut user = match jar.get(SESSION_COOKIE) { |
| 88 | Some(cookie) => sessions::lookup_user(&app.db, cookie.value()) |
| 89 | .await |
| 90 | .ok() |
| 91 | .flatten(), |
| 92 | None => None, |
| 93 | }; |
| 94 | |
| 95 | // Read-only PAT fallback for API clients (no session cookie). |
| 96 | let safe = parts.method == Method::GET || parts.method == Method::HEAD; |
| 97 | if user.is_none() |
| 98 | && safe |
| 99 | && let Some(token) = bearer_token(&parts.headers) |
| 100 | && let Ok(Some(tok)) = api_tokens::lookup(&app.db, token).await |
| 101 | && api_tokens::has_scope(&tok, api_tokens::READ) |
| 102 | { |
| 103 | user = users::find_by_id(&app.db, tok.user_id).await.ok().flatten(); |
| 104 | } |
| 105 | |
| 106 | Ok(CurrentUser(user)) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// Extract the credential from an `Authorization: Bearer <token>` header. |
| 111 | fn bearer_token(headers: &axum::http::HeaderMap) -> Option<&str> { |
| 112 | headers |
| 113 | .get(axum::http::header::AUTHORIZATION)? |
| 114 | .to_str() |
| 115 | .ok()? |
| 116 | .strip_prefix("Bearer ") |
| 117 | .map(str::trim) |
| 118 | } |
| 119 | |
| 120 | /// Extractor yielding the CSRF token bound to the caller's session, or an empty |
| 121 | /// string when unauthenticated. Embed it in forms via [`crate::ui::csrf_input`] |
| 122 | /// and verify mutating POSTs with [`verify_csrf`]. |
| 123 | pub struct Csrf(pub String); |
| 124 | |
| 125 | impl FromRequestParts<App> for Csrf { |
| 126 | type Rejection = Infallible; |
| 127 | |
| 128 | async fn from_request_parts(parts: &mut Parts, app: &App) -> Result<Self, Infallible> { |
| 129 | let jar = CookieJar::from_headers(&parts.headers); |
| 130 | let token = jar |
| 131 | .get(SESSION_COOKIE) |
| 132 | .map(|c| app.csrf_token(c.value())) |
| 133 | .unwrap_or_default(); |
| 134 | Ok(Csrf(token)) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | /// Verify a submitted CSRF token against the session-bound expected value. |
| 139 | /// Rejects when unauthenticated (empty expected) or on any mismatch. Comparison |
| 140 | /// is constant-time to avoid leaking the token byte-by-byte. |
| 141 | pub fn verify_csrf(expected: &Csrf, submitted: &str) -> Result<(), Response> { |
| 142 | let ok = |
| 143 | !expected.0.is_empty() && constant_time_eq(expected.0.as_bytes(), submitted.as_bytes()); |
| 144 | if ok { |
| 145 | Ok(()) |
| 146 | } else { |
| 147 | Err((StatusCode::FORBIDDEN, "invalid or missing CSRF token").into_response()) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | /// Length-independent constant-time byte comparison. |
| 152 | fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { |
| 153 | if a.len() != b.len() { |
| 154 | return false; |
| 155 | } |
| 156 | let mut diff = 0u8; |
| 157 | for (x, y) in a.iter().zip(b.iter()) { |
| 158 | diff |= x ^ y; |
| 159 | } |
| 160 | diff == 0 |
| 161 | } |
| 162 | |
| 163 | #[derive(serde::Deserialize)] |
| 164 | pub struct LoginForm { |
| 165 | username: String, |
| 166 | password: String, |
| 167 | } |
| 168 | |
| 169 | /// A form body carrying only a CSRF token — for POST actions (logout, deletes) |
| 170 | /// that otherwise need no fields. |
| 171 | #[derive(serde::Deserialize)] |
| 172 | pub struct CsrfForm { |
| 173 | #[serde(default)] |
| 174 | pub csrf: String, |
| 175 | } |
| 176 | |
| 177 | /// `GET /login` — show the login form (or bounce home if already signed in). |
| 178 | pub async fn login_form(CurrentUser(user): CurrentUser) -> Response { |
| 179 | if user.is_some() { |
| 180 | return Redirect::to("/").into_response(); |
| 181 | } |
| 182 | login_page(None).into_response() |
| 183 | } |
| 184 | |
| 185 | /// `POST /login` — verify credentials, create a session, set the cookie. |
| 186 | pub async fn login_submit( |
| 187 | State(app): State<App>, |
| 188 | jar: CookieJar, |
| 189 | Form(form): Form<LoginForm>, |
| 190 | ) -> Response { |
| 191 | let ok = match users::find_by_username(&app.db, &form.username).await { |
| 192 | Ok(Some(user)) => users::verify_password(&user.password_hash, &form.password) |
| 193 | .unwrap_or(false) |
| 194 | .then_some(user), |
| 195 | _ => None, |
| 196 | }; |
| 197 | |
| 198 | let Some(user) = ok else { |
| 199 | return ( |
| 200 | axum::http::StatusCode::UNAUTHORIZED, |
| 201 | login_page(Some("Invalid username or password.")), |
| 202 | ) |
| 203 | .into_response(); |
| 204 | }; |
| 205 | |
| 206 | match sessions::create(&app.db, user.id).await { |
| 207 | Ok(session) => { |
| 208 | let cookie = Cookie::build((SESSION_COOKIE, session.token)) |
| 209 | .path("/") |
| 210 | .http_only(true) |
| 211 | .secure(app.config.secure_cookies()) |
| 212 | .same_site(SameSite::Lax) |
| 213 | .build(); |
| 214 | (jar.add(cookie), Redirect::to("/")).into_response() |
| 215 | } |
| 216 | Err(e) => { |
| 217 | tracing::error!("session create failed: {e}"); |
| 218 | ( |
| 219 | axum::http::StatusCode::INTERNAL_SERVER_ERROR, |
| 220 | login_page(Some("Could not start a session.")), |
| 221 | ) |
| 222 | .into_response() |
| 223 | } |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | /// `POST /logout` — destroy the session and clear the cookie. Not given an |
| 228 | /// explicit CSRF token: `SameSite=Lax` already withholds the session cookie |
| 229 | /// from cross-site POSTs (so a forced logout can't identify the session), and |
| 230 | /// the impact of a forced logout is trivial. The high-value mutating forms |
| 231 | /// (SSH keys, repo creation/visibility) do carry tokens via [`verify_csrf`]. |
| 232 | pub async fn logout(State(app): State<App>, jar: CookieJar) -> Response { |
| 233 | if let Some(cookie) = jar.get(SESSION_COOKIE) { |
| 234 | let _ = sessions::delete(&app.db, cookie.value()).await; |
| 235 | } |
| 236 | (jar.remove(Cookie::from(SESSION_COOKIE)), Redirect::to("/")).into_response() |
| 237 | } |
| 238 | |
| 239 | fn login_page(error: Option<&str>) -> Markup { |
| 240 | layout( |
| 241 | "Sign in", |
| 242 | None, |
| 243 | html! { |
| 244 | h1 { "Sign in" } |
| 245 | @if let Some(error) = error { |
| 246 | p style="color:#cf222e" { (error) } |
| 247 | } |
| 248 | form method="post" action="/-/login" style="max-width:320px" { |
| 249 | p { label { "Username" br; input name="username" autofocus; } } |
| 250 | p { label { "Password" br; input name="password" type="password"; } } |
| 251 | button type="submit" { "Sign in" } |
| 252 | } |
| 253 | }, |
| 254 | ) |
| 255 | } |
| 256 | |
| 257 | /// Verify HTTP Basic credentials from the `Authorization` header against a user. |
| 258 | /// Returns the authenticated user, or `None` if absent/invalid. |
| 259 | pub async fn basic_auth_user(app: &App, authorization: Option<&str>) -> Option<User> { |
| 260 | use base64::Engine; |
| 261 | |
| 262 | let encoded = authorization?.strip_prefix("Basic ")?; |
| 263 | let decoded = base64::engine::general_purpose::STANDARD |
| 264 | .decode(encoded.trim()) |
| 265 | .ok()?; |
| 266 | let creds = String::from_utf8(decoded).ok()?; |
| 267 | let (username, password) = creds.split_once(':')?; |
| 268 | |
| 269 | let user = users::find_by_username(&app.db, username).await.ok()??; |
| 270 | users::verify_password(&user.password_hash, password) |
| 271 | .unwrap_or(false) |
| 272 | .then_some(user) |
| 273 | } |