Skip to main content

kellnr_http/
identity.rs

1//! Who sends a request and how they logged in.
2//!
3//! Every endpoint names the login methods it accepts with its extractor:
4//! [`TokenUser`] for a cargo token (or basic auth), [`SessionUser`] for the
5//! session cookie of the web UI, [`AnyUser`] for either, and [`AdminUser`]
6//! for an admin session. Wrapped in an `Option`, the login is optional.
7
8use std::ops::Deref;
9
10use axum::RequestPartsExt;
11use axum::extract::{FromRequestParts, OptionalFromRequestParts};
12use axum::http::StatusCode;
13use axum::http::request::Parts;
14use axum_extra::extract::PrivateCookieJar;
15use kellnr_common::account_name::UserName;
16use kellnr_common::plain_token::PlainToken;
17use kellnr_db::SessionInfo;
18use kellnr_db::error::DbError;
19use kellnr_settings::constants::COOKIE_SESSION_ID;
20use tracing::error;
21
22use crate::authz;
23use crate::error::ApiError;
24use crate::state::AppStateData;
25use crate::token::Token;
26
27/// How a user logged in.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum Source {
30    /// A cargo token, or basic auth credentials if the token is `None`.
31    Token(Option<PlainToken>),
32    /// The session cookie of the web UI.
33    Session,
34}
35
36/// A logged in user.
37#[derive(Debug, Clone)]
38pub struct Identity {
39    pub name: UserName,
40    pub is_admin: bool,
41    pub is_read_only: bool,
42    pub source: Source,
43}
44
45impl From<Token> for Identity {
46    fn from(token: Token) -> Self {
47        Self {
48            name: token.user,
49            is_admin: token.is_admin,
50            is_read_only: token.is_read_only,
51            source: Source::Token(token.value),
52        }
53    }
54}
55
56impl From<SessionInfo> for Identity {
57    fn from(session: SessionInfo) -> Self {
58        Self {
59            name: session.name,
60            is_admin: session.is_admin,
61            is_read_only: session.is_read_only,
62            source: Source::Session,
63        }
64    }
65}
66
67/// The login methods an endpoint accepts.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Accepts {
70    Token,
71    Session,
72    TokenOrSession,
73}
74
75/// Identifies the user with one of the `accepts` login methods. Without any
76/// credentials, the result is 401. With both, the token is tried first and
77/// the session only if the token fails.
78pub(crate) async fn identify(
79    parts: &mut Parts,
80    state: &AppStateData,
81    accepts: Accepts,
82) -> Result<Identity, StatusCode> {
83    match accepts {
84        Accepts::Token => from_token(parts, state).await,
85        Accepts::Session => from_session(parts, state).await,
86        Accepts::TokenOrSession => match from_token(parts, state).await {
87            Ok(identity) => Ok(identity),
88            Err(_) => from_session(parts, state).await,
89        },
90    }
91}
92
93async fn from_token(parts: &Parts, state: &AppStateData) -> Result<Identity, StatusCode> {
94    Token::from_header(&parts.headers, state)
95        .await
96        .map(Identity::from)
97}
98
99async fn from_session(parts: &mut Parts, state: &AppStateData) -> Result<Identity, StatusCode> {
100    let jar: PrivateCookieJar = parts
101        .extract_with_state(state)
102        .await
103        .map_err(|_| StatusCode::UNAUTHORIZED)?;
104    let cookie = jar.get(COOKIE_SESSION_ID).ok_or(StatusCode::UNAUTHORIZED)?;
105    validate_session(state, cookie.value()).await
106}
107
108/// Looks up the session of `session_token`. A missing or expired session is
109/// a 401, any other failure a 500.
110async fn validate_session(
111    state: &AppStateData,
112    session_token: &str,
113) -> Result<Identity, StatusCode> {
114    state
115        .db
116        .validate_session(&PlainToken::from(session_token))
117        .await
118        .map(Identity::from)
119        .map_err(|e| match e {
120            DbError::SessionNotFound => StatusCode::UNAUTHORIZED,
121            e => {
122                error!("Failed to validate session: {e}");
123                StatusCode::INTERNAL_SERVER_ERROR
124            }
125        })
126}
127
128/// Whether a failed login means that no credentials were sent at all.
129fn no_credentials(status: StatusCode) -> bool {
130    status == StatusCode::UNAUTHORIZED
131}
132
133macro_rules! identity_extractor {
134    ($(#[$doc:meta])* $name:ident) => {
135        $(#[$doc])*
136        #[derive(Debug, Clone)]
137        pub struct $name(pub Identity);
138
139        impl Deref for $name {
140            type Target = Identity;
141
142            fn deref(&self) -> &Identity {
143                &self.0
144            }
145        }
146    };
147}
148
149identity_extractor!(
150    /// A user logged in with a cargo token. Optional, a request without an
151    /// `Authorization` header is anonymous, while an invalid token is
152    /// rejected.
153    TokenUser
154);
155
156identity_extractor!(
157    /// A user logged in with a session cookie. Optional, a request without a
158    /// session cookie is anonymous, while an invalid session is rejected.
159    SessionUser
160);
161
162identity_extractor!(
163    /// A user logged in with a cargo token or a session cookie. Optional,
164    /// every failed login is anonymous.
165    AnyUser
166);
167
168identity_extractor!(
169    /// An admin logged in with a session cookie.
170    AdminUser
171);
172
173impl FromRequestParts<AppStateData> for TokenUser {
174    type Rejection = ApiError;
175
176    async fn from_request_parts(
177        parts: &mut Parts,
178        state: &AppStateData,
179    ) -> Result<Self, Self::Rejection> {
180        Ok(Self(identify(parts, state, Accepts::Token).await?))
181    }
182}
183
184impl OptionalFromRequestParts<AppStateData> for TokenUser {
185    type Rejection = ApiError;
186
187    async fn from_request_parts(
188        parts: &mut Parts,
189        state: &AppStateData,
190    ) -> Result<Option<Self>, Self::Rejection> {
191        match identify(parts, state, Accepts::Token).await {
192            Ok(identity) => Ok(Some(Self(identity))),
193            Err(status) if no_credentials(status) => Ok(None),
194            Err(status) => Err(status.into()),
195        }
196    }
197}
198
199impl FromRequestParts<AppStateData> for SessionUser {
200    type Rejection = ApiError;
201
202    async fn from_request_parts(
203        parts: &mut Parts,
204        state: &AppStateData,
205    ) -> Result<Self, Self::Rejection> {
206        Ok(Self(identify(parts, state, Accepts::Session).await?))
207    }
208}
209
210impl OptionalFromRequestParts<AppStateData> for SessionUser {
211    type Rejection = ApiError;
212
213    async fn from_request_parts(
214        parts: &mut Parts,
215        state: &AppStateData,
216    ) -> Result<Option<Self>, Self::Rejection> {
217        let jar: PrivateCookieJar = parts
218            .extract_with_state(state)
219            .await
220            .map_err(|_| StatusCode::UNAUTHORIZED)?;
221        match jar.get(COOKIE_SESSION_ID) {
222            Some(cookie) => Ok(Some(Self(validate_session(state, cookie.value()).await?))),
223            None => Ok(None),
224        }
225    }
226}
227
228impl FromRequestParts<AppStateData> for AnyUser {
229    type Rejection = ApiError;
230
231    async fn from_request_parts(
232        parts: &mut Parts,
233        state: &AppStateData,
234    ) -> Result<Self, Self::Rejection> {
235        Ok(Self(identify(parts, state, Accepts::TokenOrSession).await?))
236    }
237}
238
239impl OptionalFromRequestParts<AppStateData> for AnyUser {
240    type Rejection = ApiError;
241
242    async fn from_request_parts(
243        parts: &mut Parts,
244        state: &AppStateData,
245    ) -> Result<Option<Self>, Self::Rejection> {
246        Ok(identify(parts, state, Accepts::TokenOrSession)
247            .await
248            .ok()
249            .map(Self))
250    }
251}
252
253impl FromRequestParts<AppStateData> for AdminUser {
254    type Rejection = ApiError;
255
256    async fn from_request_parts(
257        parts: &mut Parts,
258        state: &AppStateData,
259    ) -> Result<Self, Self::Rejection> {
260        let identity = identify(parts, state, Accepts::Session).await?;
261        authz::require_admin(&identity)?;
262        Ok(Self(identity))
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use axum::Router;
269    use axum::body::Body;
270    use axum::http::{Request, header};
271    use axum::routing::get;
272    use kellnr_db::UserFlags;
273    use kellnr_db::test_utils::test_drop_table;
274    use kellnr_test_db::TestDb;
275    use tower::ServiceExt;
276
277    use super::*;
278    use crate::test_support::{encode_cookies, session_cookie, sqlite_test_state, test_state};
279
280    type Result<T = (), E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
281
282    async fn admin_endpoint(_user: AdminUser) {}
283
284    async fn session_endpoint(_user: SessionUser) {}
285
286    async fn optional_session_endpoint(user: Option<SessionUser>) -> String {
287        user.map(|u| u.name.to_string()).unwrap_or_default()
288    }
289
290    async fn any_endpoint(user: AnyUser) -> String {
291        format!("{:?}", user.source)
292    }
293
294    async fn optional_any_endpoint(user: Option<AnyUser>) -> String {
295        user.map(|u| u.name.to_string()).unwrap_or_default()
296    }
297
298    async fn optional_token_endpoint(user: Option<TokenUser>) -> String {
299        user.map(|u| u.name.to_string()).unwrap_or_default()
300    }
301
302    fn app(state: AppStateData) -> Router {
303        Router::new()
304            .route("/admin", get(admin_endpoint))
305            .route("/session", get(session_endpoint))
306            .route("/optional_session", get(optional_session_endpoint))
307            .route("/any", get(any_endpoint))
308            .route("/optional_any", get(optional_any_endpoint))
309            .route("/optional_token", get(optional_token_endpoint))
310            .with_state(state)
311    }
312
313    /// An app on a database that holds `admin` with the API token `token`.
314    async fn db_app() -> (Router, TestDb) {
315        let (state, db) = sqlite_test_state().await;
316        (app(state), db)
317    }
318
319    /// A cookie with a session that does not exist.
320    fn unknown_session() -> String {
321        encode_cookies([(COOKIE_SESSION_ID, "1234")])
322    }
323
324    async fn user_session(db: &TestDb) -> String {
325        session_cookie(db, "user", UserFlags::default()).await
326    }
327
328    async fn send(
329        app: Router,
330        uri: &str,
331        headers: &[(header::HeaderName, String)],
332    ) -> Result<(StatusCode, String)> {
333        let mut request = Request::get(uri);
334        for (name, value) in headers {
335            request = request.header(name, value);
336        }
337        let response = app.oneshot(request.body(Body::empty())?).await?;
338        let status = response.status();
339        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
340        Ok((status, String::from_utf8(body.to_vec())?))
341    }
342
343    #[tokio::test]
344    async fn admin_session_is_admin_user() -> Result {
345        let (app, db) = db_app().await;
346        let cookie = session_cookie(&db, "admin", UserFlags::default()).await;
347        let (status, _) = send(app, "/admin", &[(header::COOKIE, cookie)]).await?;
348        assert_eq!(status, StatusCode::OK);
349        Ok(())
350    }
351
352    #[tokio::test]
353    async fn normal_session_is_no_admin_user() -> Result {
354        let (app, db) = db_app().await;
355        let cookie = user_session(&db).await;
356        let (status, _) = send(app, "/admin", &[(header::COOKIE, cookie)]).await?;
357        assert_eq!(status, StatusCode::FORBIDDEN);
358        Ok(())
359    }
360
361    #[tokio::test]
362    async fn admin_user_needs_a_session() -> Result {
363        let (status, _) = send(app(test_state()), "/admin", &[]).await?;
364        assert_eq!(status, StatusCode::UNAUTHORIZED);
365        Ok(())
366    }
367
368    #[tokio::test]
369    async fn admin_user_with_unknown_session() -> Result {
370        let (app, _db) = db_app().await;
371        let (status, _) = send(app, "/admin", &[(header::COOKIE, unknown_session())]).await?;
372        assert_eq!(status, StatusCode::UNAUTHORIZED);
373        Ok(())
374    }
375
376    #[tokio::test]
377    async fn session_user_with_session() -> Result {
378        let (app, db) = db_app().await;
379        let cookie = user_session(&db).await;
380        let (status, _) = send(app, "/session", &[(header::COOKIE, cookie)]).await?;
381        assert_eq!(status, StatusCode::OK);
382        Ok(())
383    }
384
385    #[tokio::test]
386    async fn session_user_without_session() -> Result {
387        let (status, _) = send(app(test_state()), "/session", &[]).await?;
388        assert_eq!(status, StatusCode::UNAUTHORIZED);
389        Ok(())
390    }
391
392    #[tokio::test]
393    async fn session_user_ignores_tokens() -> Result {
394        let (app, _db) = db_app().await;
395        let (status, _) = send(app, "/session", &[(header::AUTHORIZATION, "token".into())]).await?;
396        assert_eq!(status, StatusCode::UNAUTHORIZED);
397        Ok(())
398    }
399
400    /// A failing database is not a missing login.
401    #[tokio::test]
402    async fn session_user_with_database_error() -> Result {
403        let (app, db) = db_app().await;
404        let cookie = user_session(&db).await;
405        test_drop_table(&db, "session").await?;
406        let (status, _) = send(app, "/session", &[(header::COOKIE, cookie)]).await?;
407        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
408        Ok(())
409    }
410
411    #[tokio::test]
412    async fn session_user_with_unknown_session() -> Result {
413        let (app, _db) = db_app().await;
414        let (status, _) = send(app, "/session", &[(header::COOKIE, unknown_session())]).await?;
415        assert_eq!(status, StatusCode::UNAUTHORIZED);
416        Ok(())
417    }
418
419    #[tokio::test]
420    async fn optional_session_user_without_session_is_anonymous() -> Result {
421        let (status, body) = send(app(test_state()), "/optional_session", &[]).await?;
422        assert_eq!((status, body.as_str()), (StatusCode::OK, ""));
423        Ok(())
424    }
425
426    #[tokio::test]
427    async fn optional_session_user_with_unknown_session_is_rejected() -> Result {
428        let (app, _db) = db_app().await;
429        let (status, _) = send(
430            app,
431            "/optional_session",
432            &[(header::COOKIE, unknown_session())],
433        )
434        .await?;
435        assert_eq!(status, StatusCode::UNAUTHORIZED);
436        Ok(())
437    }
438
439    #[tokio::test]
440    async fn any_user_prefers_the_token() -> Result {
441        let (app, db) = db_app().await;
442        let cookie = user_session(&db).await;
443        let (status, body) = send(
444            app,
445            "/any",
446            &[
447                (header::AUTHORIZATION, "token".into()),
448                (header::COOKIE, cookie),
449            ],
450        )
451        .await?;
452        assert_eq!(status, StatusCode::OK);
453        assert!(body.starts_with("Token("), "{body}");
454        Ok(())
455    }
456
457    #[tokio::test]
458    async fn any_user_falls_back_to_the_session_if_the_token_fails() -> Result {
459        let (app, db) = db_app().await;
460        let cookie = user_session(&db).await;
461        let (status, body) = send(
462            app,
463            "/any",
464            &[
465                (header::AUTHORIZATION, "invalid".into()),
466                (header::COOKIE, cookie),
467            ],
468        )
469        .await?;
470        assert_eq!((status, body.as_str()), (StatusCode::OK, "Session"));
471        Ok(())
472    }
473
474    #[tokio::test]
475    async fn any_user_without_login() -> Result {
476        let (status, _) = send(app(test_state()), "/any", &[]).await?;
477        assert_eq!(status, StatusCode::UNAUTHORIZED);
478        Ok(())
479    }
480
481    #[tokio::test]
482    async fn optional_any_user_with_invalid_token_is_anonymous() -> Result {
483        let (app, _db) = db_app().await;
484        let (status, body) = send(
485            app,
486            "/optional_any",
487            &[(header::AUTHORIZATION, "invalid".into())],
488        )
489        .await?;
490        assert_eq!((status, body.as_str()), (StatusCode::OK, ""));
491        Ok(())
492    }
493
494    #[tokio::test]
495    async fn optional_token_user_without_token_is_anonymous() -> Result {
496        let (status, body) = send(app(test_state()), "/optional_token", &[]).await?;
497        assert_eq!((status, body.as_str()), (StatusCode::OK, ""));
498        Ok(())
499    }
500
501    #[tokio::test]
502    async fn optional_token_user_with_invalid_token_is_rejected() -> Result {
503        let (app, _db) = db_app().await;
504        let (status, _) = send(
505            app,
506            "/optional_token",
507            &[(header::AUTHORIZATION, "invalid".into())],
508        )
509        .await?;
510        assert_eq!(status, StatusCode::FORBIDDEN);
511        Ok(())
512    }
513
514    #[tokio::test]
515    async fn optional_token_user_with_token() -> Result {
516        let (app, _db) = db_app().await;
517        let (status, body) = send(
518            app,
519            "/optional_token",
520            &[(header::AUTHORIZATION, "token".into())],
521        )
522        .await?;
523        assert_eq!((status, body.as_str()), (StatusCode::OK, "admin"));
524        Ok(())
525    }
526}