OXYGENHUB
# 8a3334b7a364 oxygenhub / crates / hub / src / web / session.rs
8.2 KiB · Rust · last changed Admins disable, enable and delete accounts 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 account.disabled {
73
        accounts::audit(&app.db, None, "login.disabled", &username, "").await.ok();
74
        return Ok(fail("Your account has been disabled. Contact the hub's administrator.", None));
75
    }
76
    if let Some(secret) = &account.totp_secret {
77
        if f.code.trim().is_empty() {
78
            return Ok(fail("Enter the code from your authenticator app.", Some(username)));
79
        }
80
        let now = (db::now_ms() / 1000) as u64;
81
        if !totp::verify(secret, &f.code, now) {
82
            match totp::use_recovery_code(&account.recovery_codes, &f.code) {
83
                Some(rest) => {
84
                    accounts::set_totp(&app.db, account.id, Some(secret), &rest).await.map_err(|e| Error::from_db(shell.clone(), e))?
85
                }
86
                None => {
87
                    accounts::audit(&app.db, Some(&account), "login.totp_failed", &username, "").await.ok();
88
                    return Ok(fail("That code is not right.", Some(username)));
89
                }
90
            }
91
        }
92
    }
93
    accounts::audit(&app.db, Some(&account), "login", &username, "").await.ok();
94
    Ok(signed_in(&app, &account, &next))
95
}
96
97
/// A redirect that sets the session cookie.
98
pub fn signed_in(app: &Shared, account: &accounts::Account, to: &str) -> Response {
99
    let value = auth::session_value(&app.cookie_key, &account.username, &account.session_salt);
100
    let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" };
101
    let cookie = format!("{}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000{secure}", auth::SESSION_COOKIE);
102
    ([(header::SET_COOKIE, cookie)], Redirect::to(to)).into_response()
103
}
104
105
#[derive(Deserialize)]
106
pub struct CsrfForm {
107
    #[serde(rename = "_csrf")]
108
    pub csrf: String,
109
}
110
111
pub async fn logout(State(app): State<Shared>, viewer: Viewer, Form(f): Form<CsrfForm>) -> Response {
112
    if !viewer.csrf_ok(&f.csrf) {
113
        return Redirect::to("/").into_response();
114
    }
115
    if let Some(a) = viewer.account() {
116
        accounts::audit(&app.db, Some(a), "logout", &a.username, "").await.ok();
117
    }
118
    let cookie = format!("{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", auth::SESSION_COOKIE);
119
    ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response()
120
}
121
122
#[derive(Template)]
123
#[template(path = "register.html")]
124
pub struct RegisterPage {
125
    shell: Shell,
126
    error: Option<String>,
127
    mode: String,
128
    first: bool,
129
    invite: String,
130
}
131
132
/// The registration mode: the settings table overrides `hub.toml`.
133
pub async fn registration_mode(app: &Shared) -> String {
134
    accounts::setting(&app.db, "registration_mode").await.ok().flatten().unwrap_or_else(|| app.config.registration.mode.clone())
135
}
136
137
#[derive(Deserialize)]
138
pub struct InviteQuery {
139
    invite: Option<String>,
140
}
141
142
pub async fn register_form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<InviteQuery>) -> Result<Response, Error> {
143
    if viewer.signed_in() {
144
        return Ok(Redirect::to("/").into_response());
145
    }
146
    let shell = Shell::new(&app, "/register", &viewer);
147
    let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0;
148
    Ok(Page(RegisterPage { shell, error: None, mode: registration_mode(&app).await, first, invite: q.invite.unwrap_or_default() })
149
        .into_response())
150
}
151
152
#[derive(Deserialize)]
153
pub struct RegisterForm {
154
    username: String,
155
    email: String,
156
    password: String,
157
    #[serde(default)]
158
    invite: String,
159
}
160
161
pub async fn register(State(app): State<Shared>, viewer: Viewer, Form(f): Form<RegisterForm>) -> Result<Response, Error> {
162
    let shell = Shell::new(&app, "/register", &viewer);
163
    let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0;
164
    let mode = registration_mode(&app).await;
165
    let fail = |msg: &str| {
166
        Page(RegisterPage { shell: shell.clone(), error: Some(msg.into()), mode: mode.clone(), first, invite: f.invite.clone() })
167
            .into_response()
168
    };
169
    let username = f.username.trim().to_string();
170
    if !crate::repos::names::valid_owner(&username) {
171
        return Ok(fail("A username is letters, digits, dots, dashes and underscores, and is not a reserved word."));
172
    }
173
    if let Err(e) = auth::password_ok(&f.password) {
174
        return Ok(fail(e));
175
    }
176
    if !first {
177
        match mode.as_str() {
178
            "open" => {}
179
            "invite" => {
180
                let ok = !f.invite.trim().is_empty()
181
                    && accounts::invitation_unused(&app.db, f.invite.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
182
                if !ok {
183
                    return Ok(fail("Registration needs a valid invitation code."));
184
                }
185
            }
186
            _ => return Ok(fail("Registration is closed.")),
187
        }
188
    }
189
    if accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
190
        || accounts::org_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
191
        || accounts::username_retired(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?
192
    {
193
        return Ok(fail("That name is taken."));
194
    }
195
    let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
196
    let account = accounts::create_account(&app.db, &username, f.email.trim(), &hash, &auth::random_hex(16), first)
197
        .await
198
        .map_err(|e| Error::from_db(shell.clone(), e))?;
199
    if !first && mode == "invite" {
200
        accounts::use_invitation(&app.db, f.invite.trim(), account.id).await.ok();
201
    }
202
    accounts::audit(&app.db, Some(&account), "register", &username, if first { "first account: administrator" } else { "" }).await.ok();
203
    if let Err(e) = super::recovery::send_verification(&app, &account).await {
204
        tracing::info!("verification for {username}: {e}");
205
    }
206
    Ok(signed_in(&app, &account, "/"))
207
}