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