OXYGENHUB
# c4c87f481d05 oxygenhub / crates / hub / src / web / session.rs
7.9 KiB · Rust · last changed Password reset and email verification: single-use hashed links, reset by verified email or an administrator's link, verification on every address change, notification mail only to verified addresses, and a file:// mail transport for development 1 month ago source blame history raw
1
//! Sign in, sign out and registration (hub spec §5.1).
2
3
use super::{Error, Page, Shell};
4
use crate::auth::{self, totp, Viewer};
5
use crate::db::{self, accounts};
6
use crate::Shared;
7
use askama::Template;
8
use axum::extract::{Form, Query, State};
9
use axum::http::header;
10
use axum::response::{IntoResponse, Redirect, Response};
11
use serde::Deserialize;
12
13
#[derive(Template)]
14
#[template(path = "login.html")]
15
pub struct LoginPage {
16
    shell: Shell,
17
    error: Option<String>,
18
    next: String,
19
    /// Second step: the password was right, a one-time code is needed.
20
    totp_for: Option<String>,
21
    /// Arrived from a password reset.
22
    reset: bool,
23
}
24
25
#[derive(Deserialize)]
26
pub struct NextQuery {
27
    next: Option<String>,
28
    reset: Option<String>,
29
}
30
31
fn safe_next(next: Option<String>) -> String {
32
    match next {
33
        Some(n) if n.starts_with('/') && !n.starts_with("//") => n,
34
        _ => "/".into(),
35
    }
36
}
37
38
pub async fn login_form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<NextQuery>) -> Response {
39
    if viewer.signed_in() {
40
        return Redirect::to(&safe_next(q.next)).into_response();
41
    }
42
    let reset = q.reset.is_some();
43
    Page(LoginPage { shell: Shell::new(&app, "/login", &viewer), error: None, next: safe_next(q.next), totp_for: None, reset })
44
        .into_response()
45
}
46
47
#[derive(Deserialize)]
48
pub struct LoginForm {
49
    username: String,
50
    password: String,
51
    #[serde(default)]
52
    code: String,
53
    #[serde(default)]
54
    next: String,
55
}
56
57
pub async fn login(State(app): State<Shared>, viewer: Viewer, Form(f): Form<LoginForm>) -> Result<Response, Error> {
58
    let shell = Shell::new(&app, "/login", &viewer);
59
    let next = safe_next(Some(f.next.clone()));
60
    let fail = |msg: &str, totp_for: Option<String>| {
61
        Page(LoginPage { shell: shell.clone(), error: Some(msg.into()), next: next.clone(), totp_for, reset: false }).into_response()
62
    };
63
    let username = f.username.trim().to_string();
64
    let account = accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?;
65
    let Some(account) = account.filter(|a| auth::verify_password(&a.password_hash, &f.password)) else {
66
        accounts::audit(&app.db, None, "login.failed", &username, "").await.ok();
67
        return Ok(fail("Wrong username or password.", None));
68
    };
69
    if account.banned {
70
        return Ok(fail("This account is banned.", None));
71
    }
72
    if let Some(secret) = &account.totp_secret {
73
        if f.code.trim().is_empty() {
74
            return Ok(fail("Enter the code from your authenticator app.", Some(username)));
75
        }
76
        let now = (db::now_ms() / 1000) as u64;
77
        if !totp::verify(secret, &f.code, now) {
78
            match totp::use_recovery_code(&account.recovery_codes, &f.code) {
79
                Some(rest) => {
80
                    accounts::set_totp(&app.db, account.id, Some(secret), &rest).await.map_err(|e| Error::from_db(shell.clone(), e))?
81
                }
82
                None => {
83
                    accounts::audit(&app.db, Some(&account), "login.totp_failed", &username, "").await.ok();
84
                    return Ok(fail("That code is not right.", Some(username)));
85
                }
86
            }
87
        }
88
    }
89
    accounts::audit(&app.db, Some(&account), "login", &username, "").await.ok();
90
    Ok(signed_in(&app, &account, &next))
91
}
92
93
/// A redirect that sets the session cookie.
94
pub fn signed_in(app: &Shared, account: &accounts::Account, to: &str) -> Response {
95
    let value = auth::session_value(&app.cookie_key, &account.username, &account.session_salt);
96
    let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" };
97
    let cookie = format!("{}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000{secure}", auth::SESSION_COOKIE);
98
    ([(header::SET_COOKIE, cookie)], Redirect::to(to)).into_response()
99
}
100
101
#[derive(Deserialize)]
102
pub struct CsrfForm {
103
    #[serde(rename = "_csrf")]
104
    pub csrf: String,
105
}
106
107
pub async fn logout(State(app): State<Shared>, viewer: Viewer, Form(f): Form<CsrfForm>) -> Response {
108
    if !viewer.csrf_ok(&f.csrf) {
109
        return Redirect::to("/").into_response();
110
    }
111
    if let Some(a) = viewer.account() {
112
        accounts::audit(&app.db, Some(a), "logout", &a.username, "").await.ok();
113
    }
114
    let cookie = format!("{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", auth::SESSION_COOKIE);
115
    ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response()
116
}
117
118
#[derive(Template)]
119
#[template(path = "register.html")]
120
pub struct RegisterPage {
121
    shell: Shell,
122
    error: Option<String>,
123
    mode: String,
124
    first: bool,
125
    invite: String,
126
}
127
128
/// The registration mode: the settings table overrides `hub.toml`.
129
pub async fn registration_mode(app: &Shared) -> String {
130
    accounts::setting(&app.db, "registration_mode").await.ok().flatten().unwrap_or_else(|| app.config.registration.mode.clone())
131
}
132
133
#[derive(Deserialize)]
134
pub struct InviteQuery {
135
    invite: Option<String>,
136
}
137
138
pub async fn register_form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<InviteQuery>) -> Result<Response, Error> {
139
    if viewer.signed_in() {
140
        return Ok(Redirect::to("/").into_response());
141
    }
142
    let shell = Shell::new(&app, "/register", &viewer);
143
    let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0;
144
    Ok(Page(RegisterPage { shell, error: None, mode: registration_mode(&app).await, first, invite: q.invite.unwrap_or_default() })
145
        .into_response())
146
}
147
148
#[derive(Deserialize)]
149
pub struct RegisterForm {
150
    username: String,
151
    email: String,
152
    password: String,
153
    #[serde(default)]
154
    invite: String,
155
}
156
157
pub async fn register(State(app): State<Shared>, viewer: Viewer, Form(f): Form<RegisterForm>) -> Result<Response, Error> {
158
    let shell = Shell::new(&app, "/register", &viewer);
159
    let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0;
160
    let mode = registration_mode(&app).await;
161
    let fail = |msg: &str| {
162
        Page(RegisterPage { shell: shell.clone(), error: Some(msg.into()), mode: mode.clone(), first, invite: f.invite.clone() })
163
            .into_response()
164
    };
165
    let username = f.username.trim().to_string();
166
    if !crate::repos::names::valid_owner(&username) {
167
        return Ok(fail("A username is letters, digits, dots, dashes and underscores, and is not a reserved word."));
168
    }
169
    if let Err(e) = auth::password_ok(&f.password) {
170
        return Ok(fail(e));
171
    }
172
    if !first {
173
        match mode.as_str() {
174
            "open" => {}
175
            "invite" => {
176
                let ok = !f.invite.trim().is_empty()
177
                    && accounts::invitation_unused(&app.db, f.invite.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
178
                if !ok {
179
                    return Ok(fail("Registration needs a valid invitation code."));
180
                }
181
            }
182
            _ => return Ok(fail("Registration is closed.")),
183
        }
184
    }
185
    if accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
186
        || accounts::org_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
187
    {
188
        return Ok(fail("That name is taken."));
189
    }
190
    let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
191
    let account = accounts::create_account(&app.db, &username, f.email.trim(), &hash, &auth::random_hex(16), first)
192
        .await
193
        .map_err(|e| Error::from_db(shell.clone(), e))?;
194
    if !first && mode == "invite" {
195
        accounts::use_invitation(&app.db, f.invite.trim(), account.id).await.ok();
196
    }
197
    accounts::audit(&app.db, Some(&account), "register", &username, if first { "first account: administrator" } else { "" }).await.ok();
198
    if let Err(e) = super::recovery::send_verification(&app, &account).await {
199
        tracing::info!("verification for {username}: {e}");
200
    }
201
    Ok(signed_in(&app, &account, "/"))
202
}