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