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 State,
17 },
18 http::request::Parts,
19 response::{
20 IntoResponse,
21 Redirect,
22 Response,
23 },
24};
25use axum_extra::extract::cookie::{
26 Cookie,
27 CookieJar,
28 SameSite,
29};
30use maud::{
31 Markup,
32 html,
33};
34
35use crate::ui::layout;
36
37const 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`.
41pub struct CurrentUser(pub Option<User>);
42
43impl 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)]
60pub struct LoginForm {
61 username: String,
62 password: String,
63}
64
65/// `GET /login` — show the login form (or bounce home if already signed in).
66pub 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.
74pub 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.
115pub 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
122fn 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.
142pub 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}