OXYGENHUB
# 32cfb089f5e6 oxygenhub / crates / hub / src / web / session.rs
10.6 KiB · Rust · last changed HTTPS only: nothing taken over plain http through a proxy 20 days 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
    /// That step's proof of the password (`auth::pending_login`), so it
22
    /// asks only for the code.
23
    pending: String,
24
    /// Arrived from a password reset.
25
    reset: bool,
26
    /// Providers to sign in with (#106): (id, name).
27
    providers: Vec<(&'static str, &'static str)>,
28
}
29
30
#[derive(Deserialize)]
31
pub struct NextQuery {
32
    next: Option<String>,
33
    reset: Option<String>,
34
    #[serde(default)]
35
    error: Option<String>,
36
}
37
38
fn safe_next(next: Option<String>) -> String {
39
    match next {
40
        Some(n) if n.starts_with('/') && !n.starts_with("//") => n,
41
        _ => "/".into(),
42
    }
43
}
44
45
pub async fn login_form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<NextQuery>) -> Response {
46
    if viewer.signed_in() {
47
        return Redirect::to(&safe_next(q.next)).into_response();
48
    }
49
    let reset = q.reset.is_some();
50
    let providers = super::signin::buttons(&app).await;
51
    Page(LoginPage {
52
        shell: Shell::new(&app, "/login", &viewer),
53
        error: q.error,
54
        next: safe_next(q.next),
55
        totp_for: None,
56
        pending: String::new(),
57
        reset,
58
        providers,
59
    })
60
    .into_response()
61
}
62
63
#[derive(Deserialize)]
64
pub struct LoginForm {
65
    username: String,
66
    #[serde(default)]
67
    password: String,
68
    /// The code step's proof of the password (`auth::pending_login`).
69
    #[serde(default)]
70
    pending: String,
71
    #[serde(default)]
72
    code: String,
73
    #[serde(default)]
74
    next: String,
75
}
76
77
pub async fn login(State(app): State<Shared>, viewer: Viewer, Form(f): Form<LoginForm>) -> Result<Response, Error> {
78
    let shell = Shell::new(&app, "/login", &viewer);
79
    let next = safe_next(Some(f.next.clone()));
80
    let username = f.username.trim().to_string();
81
    let account = accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?;
82
    // The code step carries proof of the password, good for a few minutes;
83
    // a fresh one goes with each retry of the code.
84
    let now_ms = db::now_ms();
85
    let proven = !f.pending.is_empty()
86
        && account.as_ref().is_some_and(|a| auth::pending_login_ok(&app.cookie_key, &a.username, &a.session_salt, &f.pending, now_ms));
87
    let pending = account
88
        .as_ref()
89
        .map(|a| auth::pending_login(&app.cookie_key, &a.username, &a.session_salt, now_ms + PENDING_LOGIN_MS))
90
        .unwrap_or_default();
91
    let fail = |msg: &str, totp_for: Option<String>| {
92
        Page(LoginPage {
93
            shell: shell.clone(),
94
            error: Some(msg.into()),
95
            next: next.clone(),
96
            totp_for,
97
            pending: pending.clone(),
98
            reset: false,
99
            providers: vec![],
100
        })
101
        .into_response()
102
    };
103
    if !f.pending.is_empty() && !proven {
104
        return Ok(fail("That took too long: sign in again.", None));
105
    }
106
    let Some(account) = account.filter(|a| proven || auth::verify_password(&a.password_hash, &f.password)) else {
107
        accounts::audit(&app.db, None, "login.failed", &username, "").await.ok();
108
        return Ok(fail("Wrong username or password.", None));
109
    };
110
    if account.banned {
111
        return Ok(fail("This account is banned.", None));
112
    }
113
    if account.disabled {
114
        accounts::audit(&app.db, None, "login.disabled", &username, "").await.ok();
115
        return Ok(fail("Your account has been disabled. Contact the hub's administrator.", None));
116
    }
117
    if let Some(secret) = &account.totp_secret {
118
        if f.code.trim().is_empty() {
119
            return Ok(fail("Enter the code from your authenticator app.", Some(username)));
120
        }
121
        let now = (db::now_ms() / 1000) as u64;
122
        if !totp::verify(secret, &f.code, now) {
123
            match totp::use_recovery_code(&account.recovery_codes, &f.code) {
124
                Some(rest) => {
125
                    accounts::set_totp(&app.db, account.id, Some(secret), &rest).await.map_err(|e| Error::from_db(shell.clone(), e))?
126
                }
127
                None => {
128
                    accounts::audit(&app.db, Some(&account), "login.totp_failed", &username, "").await.ok();
129
                    return Ok(fail("That code is not right.", Some(username)));
130
                }
131
            }
132
        }
133
    }
134
    accounts::audit(&app.db, Some(&account), "login", &username, "").await.ok();
135
    Ok(signed_in(&app, &account, &next))
136
}
137
138
/// How long the code step of a sign-in may take after the password.
139
const PENDING_LOGIN_MS: i64 = 5 * 60 * 1000;
140
141
/// A redirect that sets the session cookie.
142
pub fn signed_in(app: &Shared, account: &accounts::Account, to: &str) -> Response {
143
    let value = auth::session_value(&app.cookie_key, &account.username, &account.session_salt);
144
    let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" };
145
    let cookie = format!("{}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000{secure}", auth::SESSION_COOKIE);
146
    ([(header::SET_COOKIE, cookie)], Redirect::to(to)).into_response()
147
}
148
149
#[derive(Deserialize)]
150
pub struct CsrfForm {
151
    #[serde(rename = "_csrf")]
152
    pub csrf: String,
153
}
154
155
pub async fn logout(State(app): State<Shared>, viewer: Viewer, Form(f): Form<CsrfForm>) -> Response {
156
    if !viewer.csrf_ok(&f.csrf) {
157
        return Redirect::to("/").into_response();
158
    }
159
    if let Some(a) = viewer.account() {
160
        accounts::audit(&app.db, Some(a), "logout", &a.username, "").await.ok();
161
    }
162
    let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" };
163
    let cookie = format!("{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}", auth::SESSION_COOKIE);
164
    ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response()
165
}
166
167
#[derive(Template)]
168
#[template(path = "register.html")]
169
pub struct RegisterPage {
170
    shell: Shell,
171
    error: Option<String>,
172
    mode: String,
173
    first: bool,
174
    invite: String,
175
    /// Providers to sign up with, when registration is open (#213).
176
    providers: Vec<(&'static str, &'static str)>,
177
}
178
179
/// The registration mode: the settings table overrides `hub.toml`.
180
pub async fn registration_mode(app: &Shared) -> String {
181
    accounts::setting(&app.db, "registration_mode").await.ok().flatten().unwrap_or_else(|| app.config.registration.mode.clone())
182
}
183
184
#[derive(Deserialize)]
185
pub struct InviteQuery {
186
    invite: Option<String>,
187
}
188
189
pub async fn register_form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<InviteQuery>) -> Result<Response, Error> {
190
    if viewer.signed_in() {
191
        return Ok(Redirect::to("/").into_response());
192
    }
193
    let shell = Shell::new(&app, "/register", &viewer);
194
    let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0;
195
    let mode = registration_mode(&app).await;
196
    let providers = if mode == "open" && !first { super::signin::buttons(&app).await } else { vec![] };
197
    Ok(Page(RegisterPage { shell, error: None, mode, first, invite: q.invite.unwrap_or_default(), providers }).into_response())
198
}
199
200
#[derive(Deserialize)]
201
pub struct RegisterForm {
202
    username: String,
203
    email: String,
204
    password: String,
205
    #[serde(default)]
206
    invite: String,
207
}
208
209
pub async fn register(State(app): State<Shared>, viewer: Viewer, Form(f): Form<RegisterForm>) -> Result<Response, Error> {
210
    let shell = Shell::new(&app, "/register", &viewer);
211
    let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0;
212
    let mode = registration_mode(&app).await;
213
    let fail = |msg: &str| {
214
        Page(RegisterPage {
215
            shell: shell.clone(),
216
            error: Some(msg.into()),
217
            mode: mode.clone(),
218
            first,
219
            invite: f.invite.clone(),
220
            providers: vec![],
221
        })
222
        .into_response()
223
    };
224
    let username = f.username.trim().to_string();
225
    if !crate::repos::names::valid_owner(&username) {
226
        return Ok(fail("A username is letters, digits, dots, dashes and underscores, and is not a reserved word."));
227
    }
228
    if let Err(e) = auth::password_ok(&f.password) {
229
        return Ok(fail(e));
230
    }
231
    if !first {
232
        match mode.as_str() {
233
            "open" => {}
234
            "invite" => {
235
                let ok = !f.invite.trim().is_empty()
236
                    && accounts::invitation_unused(&app.db, f.invite.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
237
                if !ok {
238
                    return Ok(fail("Registration needs a valid invitation code."));
239
                }
240
            }
241
            _ => return Ok(fail("Registration is closed.")),
242
        }
243
    }
244
    if accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
245
        || accounts::org_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
246
        || accounts::username_retired(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?
247
    {
248
        return Ok(fail("That name is taken."));
249
    }
250
    let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
251
    let account = accounts::create_account(&app.db, &username, f.email.trim(), &hash, &auth::random_hex(16), first)
252
        .await
253
        .map_err(|e| Error::from_db(shell.clone(), e))?;
254
    if !first && mode == "invite" {
255
        accounts::use_invitation(&app.db, f.invite.trim(), account.id).await.ok();
256
    }
257
    accounts::audit(&app.db, Some(&account), "register", &username, if first { "first account: administrator" } else { "" }).await.ok();
258
    if let Err(e) = super::recovery::send_verification(&app, &account).await {
259
        tracing::info!("verification for {username}: {e}");
260
    }
261
    // The site's administrators hear of each new account (oxygenhub#183).
262
    if !first {
263
        let admins: Vec<(i64, crate::db::notify::Reason)> = accounts::accounts(&app.db)
264
            .await
265
            .unwrap_or_default()
266
            .into_iter()
267
            .filter(|a| a.is_admin)
268
            .map(|a| (a.id, crate::db::notify::Reason::Participating))
269
            .collect();
270
        let title = format!("New account: {username} signed up on {}", app.config.site.name);
271
        crate::notify::tell(&app, "account.registered", &title, &format!("/{username}"), &username, None, admins).await;
272
    }
273
    Ok(signed_in(&app, &account, "/"))
274
}