| 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 | sessions, |
| 10 | users, |
| 11 | }; |
| 12 | use axum::{ |
| 13 | Form, |
| 14 | extract::{ |
| 15 | FromRequestParts, |
| 16 | State, |
| 17 | }, |
| 18 | http::request::Parts, |
| 19 | response::{ |
| 20 | IntoResponse, |
| 21 | Redirect, |
| 22 | Response, |
| 23 | }, |
| 24 | }; |
| 25 | use axum_extra::extract::cookie::{ |
| 26 | Cookie, |
| 27 | CookieJar, |
| 28 | SameSite, |
| 29 | }; |
| 30 | use maud::{ |
| 31 | Markup, |
| 32 | html, |
| 33 | }; |
| 34 | |
| 35 | use crate::ui::layout; |
| 36 | |
| 37 | const SESSION_COOKIE: &str = "anvil_session"; |
| 38 | |
| 39 | /// Extractor yielding the logged-in user, if any, from the session cookie. |
| 40 | /// Never fails — absence of a valid session simply yields `None`. |
| 41 | pub struct CurrentUser(pub Option<User>); |
| 42 | |
| 43 | impl FromRequestParts<App> for CurrentUser { |
| 44 | type Rejection = Infallible; |
| 45 | |
| 46 | async fn from_request_parts(parts: &mut Parts, app: &App) -> Result<Self, Infallible> { |
| 47 | let jar = CookieJar::from_headers(&parts.headers); |
| 48 | let user = match jar.get(SESSION_COOKIE) { |
| 49 | Some(cookie) => sessions::lookup_user(&app.db, cookie.value()) |
| 50 | .await |
| 51 | .ok() |
| 52 | .flatten(), |
| 53 | None => None, |
| 54 | }; |
| 55 | Ok(CurrentUser(user)) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | #[derive(serde::Deserialize)] |
| 60 | pub struct LoginForm { |
| 61 | username: String, |
| 62 | password: String, |
| 63 | } |
| 64 | |
| 65 | /// `GET /login` — show the login form (or bounce home if already signed in). |
| 66 | pub async fn login_form(CurrentUser(user): CurrentUser) -> Response { |
| 67 | if user.is_some() { |
| 68 | return Redirect::to("/").into_response(); |
| 69 | } |
| 70 | login_page(None).into_response() |
| 71 | } |
| 72 | |
| 73 | /// `POST /login` — verify credentials, create a session, set the cookie. |
| 74 | pub async fn login_submit( |
| 75 | State(app): State<App>, |
| 76 | jar: CookieJar, |
| 77 | Form(form): Form<LoginForm>, |
| 78 | ) -> Response { |
| 79 | let ok = match users::find_by_username(&app.db, &form.username).await { |
| 80 | Ok(Some(user)) => users::verify_password(&user.password_hash, &form.password) |
| 81 | .unwrap_or(false) |
| 82 | .then_some(user), |
| 83 | _ => None, |
| 84 | }; |
| 85 | |
| 86 | let Some(user) = ok else { |
| 87 | return ( |
| 88 | axum::http::StatusCode::UNAUTHORIZED, |
| 89 | login_page(Some("Invalid username or password.")), |
| 90 | ) |
| 91 | .into_response(); |
| 92 | }; |
| 93 | |
| 94 | match sessions::create(&app.db, user.id).await { |
| 95 | Ok(session) => { |
| 96 | let cookie = Cookie::build((SESSION_COOKIE, session.token)) |
| 97 | .path("/") |
| 98 | .http_only(true) |
| 99 | .same_site(SameSite::Lax) |
| 100 | .build(); |
| 101 | (jar.add(cookie), Redirect::to("/")).into_response() |
| 102 | } |
| 103 | Err(e) => { |
| 104 | tracing::error!("session create failed: {e}"); |
| 105 | ( |
| 106 | axum::http::StatusCode::INTERNAL_SERVER_ERROR, |
| 107 | login_page(Some("Could not start a session.")), |
| 108 | ) |
| 109 | .into_response() |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | /// `POST /logout` — destroy the session and clear the cookie. |
| 115 | pub async fn logout(State(app): State<App>, jar: CookieJar) -> Response { |
| 116 | if let Some(cookie) = jar.get(SESSION_COOKIE) { |
| 117 | let _ = sessions::delete(&app.db, cookie.value()).await; |
| 118 | } |
| 119 | (jar.remove(Cookie::from(SESSION_COOKIE)), Redirect::to("/")).into_response() |
| 120 | } |
| 121 | |
| 122 | fn login_page(error: Option<&str>) -> Markup { |
| 123 | layout( |
| 124 | "Sign in", |
| 125 | None, |
| 126 | html! { |
| 127 | h1 { "Sign in" } |
| 128 | @if let Some(error) = error { |
| 129 | p style="color:#cf222e" { (error) } |
| 130 | } |
| 131 | form method="post" action="/-/login" style="max-width:320px" { |
| 132 | p { label { "Username" br; input name="username" autofocus; } } |
| 133 | p { label { "Password" br; input name="password" type="password"; } } |
| 134 | button type="submit" { "Sign in" } |
| 135 | } |
| 136 | }, |
| 137 | ) |
| 138 | } |
| 139 | |
| 140 | /// Verify HTTP Basic credentials from the `Authorization` header against a user. |
| 141 | /// Returns the authenticated user, or `None` if absent/invalid. |
| 142 | pub async fn basic_auth_user(app: &App, authorization: Option<&str>) -> Option<User> { |
| 143 | use base64::Engine; |
| 144 | |
| 145 | let encoded = authorization?.strip_prefix("Basic ")?; |
| 146 | let decoded = base64::engine::general_purpose::STANDARD |
| 147 | .decode(encoded.trim()) |
| 148 | .ok()?; |
| 149 | let creds = String::from_utf8(decoded).ok()?; |
| 150 | let (username, password) = creds.split_once(':')?; |
| 151 | |
| 152 | let user = users::find_by_username(&app.db, username).await.ok()??; |
| 153 | users::verify_password(&user.password_hash, password) |
| 154 | .unwrap_or(false) |
| 155 | .then_some(user) |
| 156 | } |