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::{
7 App,
8 User,
9 api_tokens,
10 sessions,
11 users,
12};
13use 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};
32use axum_extra::extract::cookie::{
33 Cookie,
34 CookieJar,
35 SameSite,
36};
37use maud::{
38 Markup,
39 html,
40};
41
42use crate::ui::layout;
43
44const SESSION_COOKIE: &str = "anvil_session";
45
46/// Hidden form field (and header) name carrying the CSRF token.
47pub const CSRF_FIELD: &str = "csrf";
48
49tokio::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.
58pub(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.
64pub 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).
80pub struct CurrentUser(pub Option<User>);
81
82impl 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.
111fn 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`].
123pub struct Csrf(pub String);
124
125impl 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.
141pub 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.
152fn 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)]
164pub 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)]
172pub 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).
178pub 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.
186pub 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`].
232pub 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
239fn 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.
259pub 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}