Skip to main content

kasl_server/
login.rs

1//! The login endpoints and the extractor that guards everything behind them.
2//!
3//! Only the browser goes through here. kasl agents keep presenting their bearer
4//! token to the ingest routes and are unaffected by any of this - a working
5//! agent must keep working across a server upgrade (ADR 0004).
6
7use axum::{
8    Json,
9    extract::{FromRequestParts, State},
10    http::{StatusCode, header, request::Parts},
11    response::{IntoResponse, Response},
12};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::{
17    app::AppState,
18    audit,
19    error::ApiError,
20    model::UserRole,
21    session::{self, SESSION_COOKIE},
22};
23
24#[derive(Debug, Deserialize)]
25pub struct Credentials {
26    pub email: String,
27    pub password: String,
28}
29
30/// Who the caller is, as the UI needs to know it.
31#[derive(Debug, Serialize)]
32pub struct Identity {
33    pub id: Uuid,
34    pub email: String,
35    pub display_name: String,
36    pub role: UserRole,
37}
38
39/// An authenticated person, taken as a handler argument.
40///
41/// A route that omits it has no user to act for, which makes forgetting the
42/// check a compile error rather than a security hole.
43#[derive(Debug, Clone)]
44pub struct CurrentUser {
45    pub session_id: Uuid,
46    pub user_id: Uuid,
47    pub role: UserRole,
48    /// Carried so an audit entry can name who acted without a second query on
49    /// every recorded action, and stay readable after a rename.
50    pub email: String,
51}
52
53impl FromRequestParts<AppState> for CurrentUser {
54    type Rejection = ApiError;
55
56    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
57        let token = session_cookie(parts).ok_or_else(|| ApiError::new(StatusCode::UNAUTHORIZED, "not signed in"))?;
58
59        let user = session::authenticate(&state.pool, &token)
60            .await
61            .map_err(|error| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
62            .ok_or_else(|| ApiError::new(StatusCode::UNAUTHORIZED, "the session has expired or been ended"))?;
63
64        Ok(Self {
65            session_id: user.session_id,
66            user_id: user.user_id,
67            role: user.role,
68            email: user.email,
69        })
70    }
71}
72
73impl CurrentUser {
74    /// Refuses anyone who is not an administrator.
75    ///
76    /// Roles get their own milestone; this is the one check that cannot wait,
77    /// because the account-management routes arrive with it.
78    pub fn require_admin(&self) -> Result<(), ApiError> {
79        if self.role == UserRole::Admin {
80            return Ok(());
81        }
82        // Deliberately not "you are not an admin": whether a route exists is
83        // not something a signed-in employee needs confirmed.
84        Err(ApiError::new(StatusCode::FORBIDDEN, "not allowed"))
85    }
86}
87
88/// Reads our cookie out of the `Cookie` header.
89fn session_cookie(parts: &Parts) -> Option<String> {
90    let header = parts.headers.get(header::COOKIE)?.to_str().ok()?;
91    header.split(';').find_map(|pair| {
92        let (name, value) = pair.split_once('=')?;
93        (name.trim() == SESSION_COOKIE).then(|| value.trim().to_string())
94    })
95}
96
97/// Signs in, or refuses without saying which half was wrong.
98pub async fn login(State(state): State<AppState>, Json(credentials): Json<Credentials>) -> Result<Response, ApiError> {
99    let row: Option<(Uuid, Option<String>)> = sqlx::query_as("SELECT id, password_hash FROM users WHERE lower(email) = lower($1) AND active")
100        .bind(&credentials.email)
101        .fetch_optional(&state.pool)
102        .await?;
103
104    // An unknown email, a deactivated account and a wrong password are one
105    // answer. Distinguishing them would turn the login form into a way to
106    // enumerate who works here.
107    let refused = || ApiError::new(StatusCode::UNAUTHORIZED, "wrong email or password");
108
109    let Some((user_id, Some(hash))) = row else {
110        // An account with no password set cannot be logged into - and the work
111        // to verify a password is done anyway, so that "no such user" and
112        // "wrong password" do not differ by a measurable pause.
113        session::verify_password(&credentials.password, DUMMY_HASH);
114        record_failure(&state.pool, &credentials.email).await;
115        return Err(refused());
116    };
117
118    if !session::verify_password(&credentials.password, &hash) {
119        record_failure(&state.pool, &credentials.email).await;
120        return Err(refused());
121    }
122
123    let issued = session::issue(&state.pool, user_id).await?;
124    tracing::info!(%user_id, "signed in");
125    audit::Entry::new(audit::action::LOGIN_SUCCEEDED)
126        .by(user_id)
127        .by_email(&credentials.email)
128        .on(user_id)
129        .record(&state.pool)
130        .await;
131
132    Ok((
133        StatusCode::OK,
134        [(header::SET_COOKIE, cookie_for(&issued.token, state.secure_cookies))],
135        Json(serde_json::json!({"status": "ok"})),
136    )
137        .into_response())
138}
139
140/// Signs out of this session only.
141pub async fn logout(State(state): State<AppState>, user: CurrentUser) -> Result<Response, ApiError> {
142    session::revoke(&state.pool, user.session_id).await?;
143    Ok((
144        StatusCode::OK,
145        [(header::SET_COOKIE, expired_cookie(state.secure_cookies))],
146        Json(serde_json::json!({"status": "ok"})),
147    )
148        .into_response())
149}
150
151/// Signs out everywhere - the answer to a laptop left on a train.
152pub async fn logout_everywhere(State(state): State<AppState>, user: CurrentUser) -> Result<Response, ApiError> {
153    let ended = session::revoke_all(&state.pool, user.user_id).await?;
154    tracing::info!(user_id = %user.user_id, ended, "ended every session");
155    audit::Entry::new(audit::action::SESSIONS_ENDED)
156        .by(user.user_id)
157        .by_email(&user.email)
158        .on(user.user_id)
159        .with(serde_json::json!({"ended": ended}))
160        .record(&state.pool)
161        .await;
162    Ok((
163        StatusCode::OK,
164        [(header::SET_COOKIE, expired_cookie(state.secure_cookies))],
165        Json(serde_json::json!({"status": "ok", "ended": ended})),
166    )
167        .into_response())
168}
169
170/// Who am I - what the SPA calls on load to decide whether to show the login.
171pub async fn me(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
172    let display_name: String = sqlx::query_scalar("SELECT display_name FROM users WHERE id = $1")
173        .bind(user.user_id)
174        .fetch_one(&state.pool)
175        .await?;
176
177    Ok(Json(Identity {
178        id: user.user_id,
179        email: user.email.clone(),
180        display_name,
181        role: user.role,
182    }))
183}
184
185/// Records a refused sign-in.
186///
187/// The attempted address is kept because a run of failures against one account
188/// is the thing worth seeing; the password never is, not even its length.
189async fn record_failure(pool: &sqlx::PgPool, attempted_email: &str) {
190    audit::Entry::new(audit::action::LOGIN_FAILED).by_email(attempted_email).record(pool).await;
191}
192
193/// Builds the session cookie.
194///
195/// `HttpOnly` so no script can read the token even if one is injected;
196/// `SameSite=Strict` because every caller is our own page on our own origin,
197/// which also makes CSRF tokens unnecessary; `Secure` unless the operator is on
198/// plain HTTP, where an unconditional flag would silently break every login.
199fn cookie_for(token: &str, secure: bool) -> String {
200    let max_age = session::SESSION_LIFETIME_DAYS * 24 * 60 * 60;
201    let secure = if secure { "; Secure" } else { "" };
202    format!("{SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}{secure}")
203}
204
205/// The same cookie, already expired: what tells the browser to forget it.
206fn expired_cookie(secure: bool) -> String {
207    let secure = if secure { "; Secure" } else { "" };
208    format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0{secure}")
209}
210
211/// A real Argon2 hash of a value nobody knows, verified against when the email
212/// is unknown so that the refusal costs the same either way.
213const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols";
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use axum::http::{HeaderValue, Request, header::COOKIE};
219
220    fn parts_with(cookie: &str) -> Parts {
221        let mut request = Request::new(());
222        request.headers_mut().insert(COOKIE, HeaderValue::from_str(cookie).unwrap());
223        request.into_parts().0
224    }
225
226    #[test]
227    fn finds_our_cookie_among_the_others() {
228        assert_eq!(session_cookie(&parts_with("kasl_session=abc")).as_deref(), Some("abc"));
229        assert_eq!(
230            session_cookie(&parts_with("theme=dark; kasl_session=abc; lang=en")).as_deref(),
231            Some("abc"),
232            "a browser sends everything it has for the origin"
233        );
234        assert_eq!(session_cookie(&parts_with("kasl_session=abc ")).as_deref(), Some("abc"));
235    }
236
237    #[test]
238    fn ignores_cookies_that_are_not_ours() {
239        assert!(session_cookie(&parts_with("theme=dark")).is_none());
240        // A prefix match would accept this one, and it is not our cookie.
241        assert!(session_cookie(&parts_with("kasl_session_other=abc")).is_none());
242    }
243
244    #[test]
245    fn the_cookie_cannot_be_read_by_script_or_sent_across_sites() {
246        let cookie = cookie_for("token-value", true);
247        assert!(cookie.contains("HttpOnly"), "a readable token is a stealable token: {cookie}");
248        assert!(cookie.contains("SameSite=Strict"), "{cookie}");
249        assert!(cookie.contains("Secure"), "{cookie}");
250        assert!(cookie.contains("Max-Age=1209600"), "fourteen days in seconds: {cookie}");
251    }
252
253    #[test]
254    fn plain_http_gets_a_cookie_without_secure() {
255        // A Secure cookie on http:// is silently dropped by the browser, which
256        // looks exactly like "login does nothing".
257        let cookie = cookie_for("token-value", false);
258        assert!(!cookie.contains("Secure"), "{cookie}");
259        assert!(cookie.contains("HttpOnly"), "the rest of the protection stays: {cookie}");
260    }
261
262    #[test]
263    fn logging_out_clears_the_cookie() {
264        let cookie = expired_cookie(true);
265        assert!(cookie.contains("Max-Age=0"), "{cookie}");
266        assert!(cookie.starts_with("kasl_session=;"), "with no value left behind: {cookie}");
267    }
268
269    #[test]
270    fn the_dummy_hash_is_a_real_hash_that_matches_nothing() {
271        // If this stopped parsing, the unknown-email path would return early
272        // from a parse error instead of doing the work it exists to do.
273        assert!(!crate::session::verify_password("", DUMMY_HASH));
274        assert!(!crate::session::verify_password("password", DUMMY_HASH));
275    }
276
277    #[test]
278    fn only_an_admin_passes_the_admin_check() {
279        let user = |role| CurrentUser {
280            session_id: Uuid::nil(),
281            user_id: Uuid::nil(),
282            role,
283            email: "someone@example.test".to_string(),
284        };
285        assert!(user(UserRole::Admin).require_admin().is_ok());
286        assert!(user(UserRole::Manager).require_admin().is_err());
287        assert!(user(UserRole::Employee).require_admin().is_err());
288        assert_eq!(
289            user(UserRole::Employee).require_admin().unwrap_err().to_string(),
290            "not allowed",
291            "the refusal must not confirm what the route is"
292        );
293    }
294}