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
44pub(crate) const 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.
152pub(crate) 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)]
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/// Query on `GET /login`: where to go once signed in, carried through to the
178/// identity provider so an interrupted request resumes.
179#[derive(serde::Deserialize)]
180pub struct LoginQuery {
181 #[serde(default)]
182 next: Option<String>,
183}
184
185/// `GET /login` — show the login form (or bounce home if already signed in).
186pub async fn login_form(
187 State(app): State<App>,
188 CurrentUser(user): CurrentUser,
189 axum::extract::Query(query): axum::extract::Query<LoginQuery>,
190) -> Response {
191 if user.is_some() {
192 return Redirect::to("/").into_response();
193 }
194 login_page(&app, query.next.as_deref(), None).into_response()
195}
196
197/// `POST /login` — verify credentials, create a session, set the cookie.
198pub async fn login_submit(
199 State(app): State<App>,
200 jar: CookieJar,
201 Form(form): Form<LoginForm>,
202) -> Response {
203 let ok = match users::find_by_username(&app.db, &form.username).await {
204 Ok(Some(user)) => users::verify_password(&user.password_hash, &form.password)
205 .unwrap_or(false)
206 .then_some(user),
207 _ => None,
208 };
209
210 let Some(user) = ok else {
211 return (
212 axum::http::StatusCode::UNAUTHORIZED,
213 login_page(&app, None, Some("Invalid username or password.")),
214 )
215 .into_response();
216 };
217
218 match sessions::create(&app.db, user.id).await {
219 Ok(session) => (
220 jar.add(session_cookie(&app, session.token)),
221 Redirect::to("/"),
222 )
223 .into_response(),
224 Err(e) => {
225 tracing::error!("session create failed: {e}");
226 (
227 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
228 login_page(&app, None, Some("Could not start a session.")),
229 )
230 .into_response()
231 }
232 }
233}
234
235/// `POST /logout` — destroy the session and clear the cookie. Not given an
236/// explicit CSRF token: `SameSite=Lax` already withholds the session cookie
237/// from cross-site POSTs (so a forced logout can't identify the session), and
238/// the impact of a forced logout is trivial. The high-value mutating forms
239/// (SSH keys, repo creation/visibility) do carry tokens via [`verify_csrf`].
240pub async fn logout(
241 State(app): State<App>,
242 CurrentUser(user): CurrentUser,
243 jar: CookieJar,
244) -> Response {
245 if let Some(cookie) = jar.get(SESSION_COOKIE) {
246 let _ = sessions::delete(&app.db, cookie.value()).await;
247 }
248 // For an account that came from the identity provider, ending only anvil's
249 // session would leave the provider ready to sign them straight back in.
250 let destination = crate::oidc::end_session_url(&app, user.as_ref())
251 .await
252 .unwrap_or_else(|| "/".to_string());
253 (
254 jar.remove(Cookie::from(SESSION_COOKIE)),
255 Redirect::to(&destination),
256 )
257 .into_response()
258}
259
260fn login_page(app: &App, next: Option<&str>, error: Option<&str>) -> Markup {
261 layout(
262 "Sign in",
263 None,
264 html! {
265 h1 { "Sign in" }
266 @if let Some(error) = error {
267 p.error-msg { (error) }
268 }
269 (crate::oidc::sign_in_button(&app.config.oidc, next))
270 form method="post" action="/-/login" style="max-width:320px" {
271 p { label { "Username" br; input name="username" autocomplete="username" autofocus; } }
272 p { label { "Password" br; input name="password" type="password" autocomplete="current-password"; } }
273 button type="submit" { "Sign in" }
274 }
275 },
276 )
277}
278
279/// The session cookie for a freshly created session. `Secure` follows the
280/// deployment's scheme (see [`anvil_core::Config::secure_cookies`]), and
281/// `SameSite=Lax` is what lets the CSRF token be the only other defence needed.
282pub(crate) fn session_cookie(app: &App, token: String) -> Cookie<'static> {
283 Cookie::build((SESSION_COOKIE, token))
284 .path("/")
285 .http_only(true)
286 .secure(app.config.secure_cookies())
287 .same_site(SameSite::Lax)
288 .build()
289}
290
291/// Verify HTTP Basic credentials from the `Authorization` header against a user.
292/// Returns the authenticated user, or `None` if absent/invalid.
293pub async fn basic_auth_user(app: &App, authorization: Option<&str>) -> Option<User> {
294 use base64::Engine;
295
296 let encoded = authorization?.strip_prefix("Basic ")?;
297 let decoded = base64::engine::general_purpose::STANDARD
298 .decode(encoded.trim())
299 .ok()?;
300 let creds = String::from_utf8(decoded).ok()?;
301 let (username, password) = creds.split_once(':')?;
302
303 let user = users::find_by_username(&app.db, username).await.ok()??;
304 users::verify_password(&user.password_hash, password)
305 .unwrap_or(false)
306 .then_some(user)
307}