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