Skip to main content

codoseo_web/auth/
mod.rs

1//! Login and sessions: magic links, GitHub OAuth, the session cookie, the `Origin` check, and
2//! the [`CurrentUser`] extractor every signed-in route takes.
3
4pub mod email;
5pub mod github;
6pub mod magic;
7pub mod mailer;
8pub mod origin;
9pub mod session;
10
11use axum::Router;
12use axum::extract::{FromRequestParts, OptionalFromRequestParts, State};
13use axum::http::request::Parts;
14use axum::http::{HeaderMap, StatusCode, header};
15use axum::response::{IntoResponse, Redirect, Response};
16use axum::routing::{get, post};
17use codoseo_store::accounts::{Account, SignupPolicy};
18use codoseo_store::sites::Site;
19use uuid::Uuid;
20
21use crate::config::Mode;
22use crate::error::AppError;
23use crate::render::{hx_redirect, is_htmx};
24use crate::state::AppState;
25
26pub fn router() -> Router<AppState> {
27    Router::new()
28        .route("/login", get(magic::login_page).post(magic::request_link))
29        .route(
30            "/auth/magic/{token}",
31            get(magic::confirm_page).post(magic::consume),
32        )
33        .route("/auth/github", get(github::start))
34        .route("/auth/github/callback", get(github::callback))
35        .route("/logout", post(logout))
36}
37
38/// The signed-in account. Taking this as a handler argument makes the route require a login.
39#[derive(Debug, Clone)]
40pub struct CurrentUser {
41    pub account: Account,
42    pub session_id: Uuid,
43}
44
45impl CurrentUser {
46    pub fn id(&self) -> Uuid {
47        self.account.id
48    }
49}
50
51/// Sends a signed-out visitor to `/login`, coming back to where they were afterwards.
52pub enum AuthRejection {
53    Login { htmx: bool, next: String },
54    Error(AppError),
55}
56
57impl IntoResponse for AuthRejection {
58    fn into_response(self) -> Response {
59        match self {
60            AuthRejection::Login { htmx, next } => {
61                let to = format!("/login?next={}", urlencode(&next));
62                if htmx {
63                    (StatusCode::OK, [hx_redirect(&to)]).into_response()
64                } else {
65                    Redirect::to(&to).into_response()
66                }
67            }
68            AuthRejection::Error(e) => e.into_response(),
69        }
70    }
71}
72
73impl FromRequestParts<AppState> for CurrentUser {
74    type Rejection = AuthRejection;
75
76    async fn from_request_parts(
77        parts: &mut Parts,
78        state: &AppState,
79    ) -> Result<CurrentUser, AuthRejection> {
80        let login = || AuthRejection::Login {
81            htmx: is_htmx(&parts.headers),
82            next: current_path(
83                &parts.headers,
84                parts.uri.path_and_query().map(|p| p.as_str()),
85            ),
86        };
87        let Some(token) = session::cookie(&parts.headers, session::SESSION_COOKIE) else {
88            return Err(login());
89        };
90        match codoseo_store::auth::find_session(&state.pool, &session::hash(&token)).await {
91            Ok(Some((session_id, account))) => Ok(CurrentUser {
92                account,
93                session_id,
94            }),
95            Ok(None) => Err(login()),
96            Err(e) => Err(AuthRejection::Error(e.into())),
97        }
98    }
99}
100
101/// `Option<CurrentUser>`: `None` when signed out, for pages that work either way.
102impl OptionalFromRequestParts<AppState> for CurrentUser {
103    type Rejection = AppError;
104
105    async fn from_request_parts(
106        parts: &mut Parts,
107        state: &AppState,
108    ) -> Result<Option<CurrentUser>, AppError> {
109        match <CurrentUser as FromRequestParts<AppState>>::from_request_parts(parts, state).await {
110            Ok(user) => Ok(Some(user)),
111            Err(AuthRejection::Login { .. }) => Ok(None),
112            Err(AuthRejection::Error(e)) => Err(e),
113        }
114    }
115}
116
117/// Where to return after login. For an htmx request that is the page the user is on
118/// (`HX-Current-URL`), not the fragment URL.
119fn current_path(headers: &HeaderMap, uri: Option<&str>) -> String {
120    let from_hx = headers
121        .get("hx-current-url")
122        .and_then(|v| v.to_str().ok())
123        .and_then(|u| url::Url::parse(u).ok())
124        .map(|u| match u.query() {
125            Some(q) => format!("{}?{q}", u.path()),
126            None => u.path().to_owned(),
127        });
128    safe_next(from_hx.as_deref().or(uri)).to_owned()
129}
130
131/// Only same-site relative paths are allowed as a post-login destination, so `?next=` can't
132/// be used to bounce a user to another site. Browsers drop tabs and newlines from a redirect
133/// address (`/\t/evil.com` becomes `//evil.com`), so any whitespace or control character
134/// rejects the value outright; it also keeps the `Location` header valid.
135pub fn safe_next(next: Option<&str>) -> &str {
136    match next {
137        Some(n)
138            if n.starts_with('/')
139                && !n.starts_with("//")
140                && !n.contains('\\')
141                && !n.chars().any(|c| c.is_control() || c.is_whitespace()) =>
142        {
143            n
144        }
145        _ => "/",
146    }
147}
148
149pub fn urlencode(s: &str) -> String {
150    url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
151}
152
153/// The policy for creating accounts in this mode.
154pub fn signup_policy(state: &AppState) -> SignupPolicy {
155    SignupPolicy {
156        self_hosted: state.config.mode == Mode::SelfHost,
157    }
158}
159
160/// One of the user's sites, or a 404 (another account's site looks exactly like a missing one).
161pub async fn load_site(
162    state: &AppState,
163    user: &CurrentUser,
164    site_id: Uuid,
165) -> Result<Site, AppError> {
166    codoseo_store::sites::get_for_account(&state.pool, user.id(), site_id)
167        .await?
168        .ok_or(AppError::NotFound)
169}
170
171async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Result<Response, AppError> {
172    if let Some(token) = session::cookie(&headers, session::SESSION_COOKIE) {
173        codoseo_store::auth::revoke_session(&state.pool, &session::hash(&token)).await?;
174    }
175    let clear = session::set_cookie(
176        session::SESSION_COOKIE,
177        "",
178        0,
179        state.config.secure_cookies(),
180    );
181    Ok(([(header::SET_COOKIE, clear)], Redirect::to("/login")).into_response())
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn next_must_be_a_local_path() {
190        assert_eq!(safe_next(Some("/s/1/audit?x=1")), "/s/1/audit?x=1");
191        assert_eq!(safe_next(Some("//evil.com")), "/");
192        assert_eq!(safe_next(Some("https://evil.com")), "/");
193        assert_eq!(safe_next(Some("/\\evil.com")), "/");
194        assert_eq!(safe_next(None), "/");
195        // Browsers strip these, turning the path into `//evil.com`.
196        for sneaky in [
197            "/\t/evil.com",
198            "/\n/evil.com",
199            "/\r/evil.com",
200            "/ /evil.com",
201            "/\u{0}x",
202        ] {
203            assert_eq!(safe_next(Some(sneaky)), "/", "{sneaky:?}");
204        }
205    }
206}