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 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#[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#[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 pub fn require_admin(&self) -> Result<(), ApiError> {
74 if self.role == UserRole::Admin {
75 return Ok(());
76 }
77 Err(ApiError::new(StatusCode::FORBIDDEN, "not allowed"))
80 }
81}
82
83fn 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
92pub 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 let refused = || ApiError::new(StatusCode::UNAUTHORIZED, "wrong email or password");
103
104 let Some((user_id, Some(hash))) = row else {
105 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
127pub 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
138pub 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
150pub 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
165fn 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
177fn 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
183const 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 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 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 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}