OXYGENHUB
# 4e2cad00a484 oxygenhub / crates / hub / src / web / session.rs
12.8 KiB · Rust · last changed Sign up through an invitation with GitHub, GitLab or Google 20 days ago source blame history raw
19bb31ff Andy McCall ↶ 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};
aa0bfbd1 Andy McCall ↶ 6
use crate::throttle::{self, Key};
19bb31ff Andy McCall ↶ 7
use crate::Shared;
8
use askama::Template;
9
use axum::extract::{Form, Query, State};
10
use axum::http::header;
11
use axum::response::{IntoResponse, Redirect, Response};
12
use serde::Deserialize;
13
14
#[derive(Template)]
15
#[template(path = "login.html")]
16
pub struct LoginPage {
17
    shell: Shell,
18
    error: Option<String>,
19
    next: String,
20
    /// Second step: the password was right, a one-time code is needed.
21
    totp_for: Option<String>,
bff9106e Andy McCall ↶ 22
    /// That step's proof of the password (`auth::pending_login`), so it
23
    /// asks only for the code.
24
    pending: String,
34beb1a7 Andy McCall ↶ 25
    /// Arrived from a password reset.
26
    reset: bool,
83940d53 Andy McCall ↶ 27
    /// Providers to sign in with (#106): (id, name).
28
    providers: Vec<(&'static str, &'static str)>,
19bb31ff Andy McCall ↶ 29
}
30
31
#[derive(Deserialize)]
32
pub struct NextQuery {
33
    next: Option<String>,
34beb1a7 Andy McCall ↶ 34
    reset: Option<String>,
83940d53 Andy McCall ↶ 35
    #[serde(default)]
36
    error: Option<String>,
19bb31ff Andy McCall ↶ 37
}
38
39
fn safe_next(next: Option<String>) -> String {
40
    match next {
41
        Some(n) if n.starts_with('/') && !n.starts_with("//") => n,
42
        _ => "/".into(),
43
    }
44
}
45
46
pub async fn login_form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<NextQuery>) -> Response {
47
    if viewer.signed_in() {
48
        return Redirect::to(&safe_next(q.next)).into_response();
49
    }
34beb1a7 Andy McCall ↶ 50
    let reset = q.reset.is_some();
83940d53 Andy McCall ↶ 51
    let providers = super::signin::buttons(&app).await;
52
    Page(LoginPage {
53
        shell: Shell::new(&app, "/login", &viewer),
54
        error: q.error,
55
        next: safe_next(q.next),
56
        totp_for: None,
bff9106e Andy McCall ↶ 57
        pending: String::new(),
83940d53 Andy McCall ↶ 58
        reset,
59
        providers,
60
    })
61
    .into_response()
19bb31ff Andy McCall ↶ 62
}
63
64
#[derive(Deserialize)]
65
pub struct LoginForm {
66
    username: String,
bff9106e Andy McCall ↶ 67
    #[serde(default)]
19bb31ff Andy McCall ↶ 68
    password: String,
bff9106e Andy McCall ↶ 69
    /// The code step's proof of the password (`auth::pending_login`).
70
    #[serde(default)]
71
    pending: String,
19bb31ff Andy McCall ↶ 72
    #[serde(default)]
73
    code: String,
74
    #[serde(default)]
75
    next: String,
76
}
77
aa0bfbd1 Andy McCall ↶ 78
pub async fn login(
79
    State(app): State<Shared>,
80
    viewer: Viewer,
81
    headers: axum::http::HeaderMap,
82
    extensions: axum::http::Extensions,
83
    Form(f): Form<LoginForm>,
84
) -> Result<Response, Error> {
19bb31ff Andy McCall ↶ 85
    let shell = Shell::new(&app, "/login", &viewer);
86
    let next = safe_next(Some(f.next.clone()));
87
    let username = f.username.trim().to_string();
88
    let account = accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?;
bff9106e Andy McCall ↶ 89
    // The code step carries proof of the password, good for a few minutes;
90
    // a fresh one goes with each retry of the code.
91
    let now_ms = db::now_ms();
92
    let proven = !f.pending.is_empty()
93
        && account.as_ref().is_some_and(|a| auth::pending_login_ok(&app.cookie_key, &a.username, &a.session_salt, &f.pending, now_ms));
94
    let pending = account
95
        .as_ref()
96
        .map(|a| auth::pending_login(&app.cookie_key, &a.username, &a.session_salt, now_ms + PENDING_LOGIN_MS))
97
        .unwrap_or_default();
98
    let fail = |msg: &str, totp_for: Option<String>| {
99
        Page(LoginPage {
100
            shell: shell.clone(),
101
            error: Some(msg.into()),
102
            next: next.clone(),
103
            totp_for,
104
            pending: pending.clone(),
105
            reset: false,
106
            providers: vec![],
107
        })
108
        .into_response()
109
    };
aa0bfbd1 Andy McCall ↶ 110
    // Guessing is slowed per account and per address (oxygenhub#221),
111
    // before the password is even looked at.
112
    let addr = crate::api::client_addr(&app, &headers, &extensions);
113
    let keys = [Key::account("login", &username), Key::address("login", &addr)];
114
    if let Some(wait) = app.throttle.lock().unwrap().wait(&keys, now_ms) {
115
        return Ok(fail(&throttle::message(wait), proven.then(|| username.clone())));
116
    }
117
    let failed = |app: &Shared| {
118
        let wait = app.throttle.lock().unwrap().failed(&keys, now_ms);
119
        let (app, username, addr) = (app.clone(), username.clone(), addr.clone());
120
        async move {
121
            if let Some(wait) = wait {
122
                let detail = format!("from {} for {}s", if addr.is_empty() { "an unknown address" } else { &addr }, wait / 1000);
123
                accounts::audit(&app.db, None, "login.throttled", &username, &detail).await.ok();
124
            }
125
        }
126
    };
bff9106e Andy McCall ↶ 127
    if !f.pending.is_empty() && !proven {
128
        return Ok(fail("That took too long: sign in again.", None));
129
    }
130
    let Some(account) = account.filter(|a| proven || auth::verify_password(&a.password_hash, &f.password)) else {
19bb31ff Andy McCall ↶ 131
        accounts::audit(&app.db, None, "login.failed", &username, "").await.ok();
aa0bfbd1 Andy McCall ↶ 132
        failed(&app).await;
19bb31ff Andy McCall ↶ 133
        return Ok(fail("Wrong username or password.", None));
134
    };
135
    if account.banned {
136
        return Ok(fail("This account is banned.", None));
137
    }
6a178055 Andy McCall ↶ 138
    if account.disabled {
139
        accounts::audit(&app.db, None, "login.disabled", &username, "").await.ok();
140
        return Ok(fail("Your account has been disabled. Contact the hub's administrator.", None));
141
    }
19bb31ff Andy McCall ↶ 142
    if let Some(secret) = &account.totp_secret {
143
        if f.code.trim().is_empty() {
144
            return Ok(fail("Enter the code from your authenticator app.", Some(username)));
145
        }
146
        let now = (db::now_ms() / 1000) as u64;
147
        if !totp::verify(secret, &f.code, now) {
148
            match totp::use_recovery_code(&account.recovery_codes, &f.code) {
149
                Some(rest) => {
150
                    accounts::set_totp(&app.db, account.id, Some(secret), &rest).await.map_err(|e| Error::from_db(shell.clone(), e))?
151
                }
152
                None => {
153
                    accounts::audit(&app.db, Some(&account), "login.totp_failed", &username, "").await.ok();
aa0bfbd1 Andy McCall ↶ 154
                    failed(&app).await;
19bb31ff Andy McCall ↶ 155
                    return Ok(fail("That code is not right.", Some(username)));
156
                }
157
            }
158
        }
159
    }
aa0bfbd1 Andy McCall ↶ 160
    app.throttle.lock().unwrap().cleared(&keys[0]);
19bb31ff Andy McCall ↶ 161
    accounts::audit(&app.db, Some(&account), "login", &username, "").await.ok();
0024ccb1 Andy McCall ↶ 162
    Ok(signed_in(&app, &account, &next).await)
19bb31ff Andy McCall ↶ 163
}
bff9106e Andy McCall ↶ 164
165
/// How long the code step of a sign-in may take after the password.
166
const PENDING_LOGIN_MS: i64 = 5 * 60 * 1000;
19bb31ff Andy McCall ↶ 167
0024ccb1 Andy McCall ↶ 168
/// A redirect that sets the session cookie: a new session each time, so
169
/// signing in (after the code, with two-factor) always rotates it
170
/// (oxygenhub#223).
171
pub async fn signed_in(app: &Shared, account: &accounts::Account, to: &str) -> Response {
172
    let value = auth::new_session_id();
173
    if let Err(e) = db::sessions::create(&app.db, account.id, &auth::token_hash(&value), &account.session_salt).await {
174
        tracing::warn!("session for {}: {e}", account.username);
175
        return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Signing in failed for a moment: try again.").into_response();
176
    }
19bb31ff Andy McCall ↶ 177
    let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" };
178
    let cookie = format!("{}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000{secure}", auth::SESSION_COOKIE);
179
    ([(header::SET_COOKIE, cookie)], Redirect::to(to)).into_response()
180
}
181
182
#[derive(Deserialize)]
183
pub struct CsrfForm {
184
    #[serde(rename = "_csrf")]
185
    pub csrf: String,
186
}
187
188
pub async fn logout(State(app): State<Shared>, viewer: Viewer, Form(f): Form<CsrfForm>) -> Response {
189
    if !viewer.csrf_ok(&f.csrf) {
190
        return Redirect::to("/").into_response();
191
    }
0024ccb1 Andy McCall ↶ 192
    if let Some(v) = &viewer.0 {
193
        db::sessions::end_hash(&app.db, &auth::token_hash(&v.session)).await.ok();
194
        accounts::audit(&app.db, Some(&v.account), "logout", &v.account.username, "").await.ok();
19bb31ff Andy McCall ↶ 195
    }
9a8e3b44 Andy McCall ↶ 196
    let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" };
197
    let cookie = format!("{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}", auth::SESSION_COOKIE);
19bb31ff Andy McCall ↶ 198
    ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response()
199
}
200
201
#[derive(Template)]
202
#[template(path = "register.html")]
203
pub struct RegisterPage {
204
    shell: Shell,
205
    error: Option<String>,
206
    mode: String,
207
    first: bool,
208
    invite: String,
3ff52534 Andy McCall ↶ 209
    /// Providers to sign up with, when registration is open (#213) or
210
    /// the page has a usable invitation (oxygenhub#214).
810896d4 Andy McCall ↶ 211
    providers: Vec<(&'static str, &'static str)>,
3ff52534 Andy McCall ↶ 212
    /// What the provider buttons carry: `?invite=CODE`, or nothing.
213
    provider_query: String,
19bb31ff Andy McCall ↶ 214
}
215
216
/// The registration mode: the settings table overrides `hub.toml`.
217
pub async fn registration_mode(app: &Shared) -> String {
218
    accounts::setting(&app.db, "registration_mode").await.ok().flatten().unwrap_or_else(|| app.config.registration.mode.clone())
219
}
220
221
#[derive(Deserialize)]
222
pub struct InviteQuery {
223
    invite: Option<String>,
224
}
225
226
pub async fn register_form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<InviteQuery>) -> Result<Response, Error> {
227
    if viewer.signed_in() {
228
        return Ok(Redirect::to("/").into_response());
229
    }
230
    let shell = Shell::new(&app, "/register", &viewer);
231
    let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0;
810896d4 Andy McCall ↶ 232
    let mode = registration_mode(&app).await;
3ff52534 Andy McCall ↶ 233
    let invite = q.invite.unwrap_or_default().trim().to_string();
234
    let usable_invite = mode == "invite"
235
        && super::signin::invite_code_ok(&invite)
236
        && accounts::invitation_unused(&app.db, &invite).await.map_err(|e| Error::from_db(shell.clone(), e))?;
237
    let providers = if !first && (mode == "open" || usable_invite) { super::signin::buttons(&app).await } else { vec![] };
238
    let provider_query = if usable_invite { format!("?invite={invite}") } else { String::new() };
239
    Ok(Page(RegisterPage { shell, error: None, mode, first, invite, providers, provider_query }).into_response())
19bb31ff Andy McCall ↶ 240
}
241
242
#[derive(Deserialize)]
243
pub struct RegisterForm {
244
    username: String,
245
    email: String,
246
    password: String,
247
    #[serde(default)]
248
    invite: String,
249
}
250
251
pub async fn register(State(app): State<Shared>, viewer: Viewer, Form(f): Form<RegisterForm>) -> Result<Response, Error> {
252
    let shell = Shell::new(&app, "/register", &viewer);
253
    let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0;
254
    let mode = registration_mode(&app).await;
255
    let fail = |msg: &str| {
810896d4 Andy McCall ↶ 256
        Page(RegisterPage {
257
            shell: shell.clone(),
258
            error: Some(msg.into()),
259
            mode: mode.clone(),
260
            first,
261
            invite: f.invite.clone(),
262
            providers: vec![],
3ff52534 Andy McCall ↶ 263
            provider_query: String::new(),
810896d4 Andy McCall ↶ 264
        })
265
        .into_response()
19bb31ff Andy McCall ↶ 266
    };
267
    let username = f.username.trim().to_string();
268
    if !crate::repos::names::valid_owner(&username) {
269
        return Ok(fail("A username is letters, digits, dots, dashes and underscores, and is not a reserved word."));
270
    }
271
    if let Err(e) = auth::password_ok(&f.password) {
272
        return Ok(fail(e));
273
    }
274
    if !first {
275
        match mode.as_str() {
276
            "open" => {}
277
            "invite" => {
278
                let ok = !f.invite.trim().is_empty()
279
                    && accounts::invitation_unused(&app.db, f.invite.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
280
                if !ok {
281
                    return Ok(fail("Registration needs a valid invitation code."));
282
                }
283
            }
284
            _ => return Ok(fail("Registration is closed.")),
285
        }
286
    }
287
    if accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
288
        || accounts::org_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
6a178055 Andy McCall ↶ 289
        || accounts::username_retired(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?
19bb31ff Andy McCall ↶ 290
    {
291
        return Ok(fail("That name is taken."));
292
    }
293
    let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
294
    let account = accounts::create_account(&app.db, &username, f.email.trim(), &hash, &auth::random_hex(16), first)
295
        .await
296
        .map_err(|e| Error::from_db(shell.clone(), e))?;
297
    if !first && mode == "invite" {
298
        accounts::use_invitation(&app.db, f.invite.trim(), account.id).await.ok();
299
    }
300
    accounts::audit(&app.db, Some(&account), "register", &username, if first { "first account: administrator" } else { "" }).await.ok();
34beb1a7 Andy McCall ↶ 301
    if let Err(e) = super::recovery::send_verification(&app, &account).await {
302
        tracing::info!("verification for {username}: {e}");
bff9106e Andy McCall ↶ 303
    }
304
    // The site's administrators hear of each new account (oxygenhub#183).
305
    if !first {
306
        let admins: Vec<(i64, crate::db::notify::Reason)> = accounts::accounts(&app.db)
307
            .await
308
            .unwrap_or_default()
309
            .into_iter()
310
            .filter(|a| a.is_admin)
311
            .map(|a| (a.id, crate::db::notify::Reason::Participating))
312
            .collect();
313
        let title = format!("New account: {username} signed up on {}", app.config.site.name);
314
        crate::notify::tell(&app, "account.registered", &title, &format!("/{username}"), &username, None, admins).await;
34beb1a7 Andy McCall ↶ 315
    }
0024ccb1 Andy McCall ↶ 316
    Ok(signed_in(&app, &account, "/").await)
19bb31ff Andy McCall ↶ 317
}