anvilsign in

collin/anvil

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