1use 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#[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#[derive(Debug, Clone)]
44pub struct CurrentUser {
45 pub session_id: Uuid,
46 pub user_id: Uuid,
47 pub role: UserRole,
48 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 pub fn require_admin(&self) -> Result<(), ApiError> {
79 if self.role == UserRole::Admin {
80 return Ok(());
81 }
82 Err(ApiError::new(StatusCode::FORBIDDEN, "not allowed"))
85 }
86}
87
88fn 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
97pub 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 let refused = || ApiError::new(StatusCode::UNAUTHORIZED, "wrong email or password");
108
109 let Some((user_id, Some(hash))) = row else {
110 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
140pub 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
151pub 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
170pub 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
185async 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
193fn 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
205fn 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
211const 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 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 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 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}