OXYGENHUB
# 8d2caf122517 oxygenhub / crates / hub / src / web / settings.rs
18.2 KiB · Rust · last changed Milestone 0.2: accounts and access 1 month ago source blame history raw
1
//! Account settings (hub spec §6.3 `/settings/*`): profile, security,
2
//! linked identities, tokens.
3
4
use super::{filters, Error, Page, Shell};
5
use crate::auth::{self, totp, Viewer, ViewerAccount};
6
use crate::db::{self, accounts};
7
use crate::Shared;
8
use askama::Template;
9
use axum::extract::{Form, Multipart, Path, State};
10
use axum::response::{IntoResponse, Response};
11
use oxy_core::identity::{Identity, Signature};
12
use serde::Deserialize;
13
14
/// The signed-in account, or a redirect to sign in.
15
pub fn require(viewer: &Viewer, shell: &Shell) -> Result<ViewerAccount, Error> {
16
    viewer.0.clone().ok_or_else(|| Error::redirect(format!("/login?next={}", urlencode(&shell.path))))
17
}
18
19
pub fn urlencode(s: &str) -> String {
20
    let mut out = String::new();
21
    for b in s.bytes() {
22
        match b {
23
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => out.push(b as char),
24
            _ => out.push_str(&format!("%{b:02X}")),
25
        }
26
    }
27
    out
28
}
29
30
pub fn csrf(viewer: &Viewer, token: &str, shell: &Shell) -> Result<(), Error> {
31
    if viewer.csrf_ok(token) {
32
        Ok(())
33
    } else {
34
        Err(Error::bad(shell.clone(), "This form has expired; reload the page and try again."))
35
    }
36
}
37
38
#[derive(Template)]
39
#[template(path = "settings.html")]
40
pub struct SettingsPage {
41
    shell: Shell,
42
    me: accounts::Account,
43
    notice: Option<String>,
44
    error: Option<String>,
45
}
46
47
pub async fn profile(State(app): State<Shared>, viewer: Viewer) -> Result<Page<SettingsPage>, Error> {
48
    let shell = Shell::new(&app, "/settings", &viewer);
49
    let me = require(&viewer, &shell)?;
50
    Ok(Page(SettingsPage { shell, me: me.account, notice: None, error: None }))
51
}
52
53
#[derive(Deserialize)]
54
pub struct ProfileForm {
55
    #[serde(rename = "_csrf")]
56
    csrf_token: String,
57
    display_name: String,
58
    email: String,
59
}
60
61
pub async fn update_profile(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ProfileForm>) -> Result<Page<SettingsPage>, Error> {
62
    let shell = Shell::new(&app, "/settings", &viewer);
63
    let me = require(&viewer, &shell)?;
64
    csrf(&viewer, &f.csrf_token, &shell)?;
65
    accounts::set_profile(&app.db, me.account.id, f.display_name.trim(), f.email.trim())
66
        .await
67
        .map_err(|e| Error::from_db(shell.clone(), e))?;
68
    let updated =
69
        accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
70
    Ok(Page(SettingsPage { shell, me: updated, notice: Some("Profile saved.".into()), error: None }))
71
}
72
73
#[derive(Deserialize)]
74
pub struct PasswordForm {
75
    #[serde(rename = "_csrf")]
76
    csrf_token: String,
77
    current: String,
78
    password: String,
79
}
80
81
pub async fn change_password(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PasswordForm>) -> Result<Response, Error> {
82
    let shell = Shell::new(&app, "/settings", &viewer);
83
    let me = require(&viewer, &shell)?;
84
    csrf(&viewer, &f.csrf_token, &shell)?;
85
    if !auth::verify_password(&me.account.password_hash, &f.current) {
86
        return Ok(Page(SettingsPage { shell, me: me.account, notice: None, error: Some("Your current password is wrong.".into()) })
87
            .into_response());
88
    }
89
    if let Err(e) = auth::password_ok(&f.password) {
90
        return Ok(Page(SettingsPage { shell, me: me.account, notice: None, error: Some(e.into()) }).into_response());
91
    }
92
    let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
93
    // A new session salt signs out every other session; this one is renewed.
94
    accounts::set_password(&app.db, me.account.id, &hash, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
95
    accounts::audit(&app.db, Some(&me.account), "password.changed", &me.account.username, "").await.ok();
96
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
97
    Ok(super::session::signed_in(&app, &fresh, "/settings/security"))
98
}
99
100
#[derive(Template)]
101
#[template(path = "settings_security.html")]
102
pub struct SecurityPage {
103
    shell: Shell,
104
    me: accounts::Account,
105
    notice: Option<String>,
106
    error: Option<String>,
107
    /// Enrolment in progress: secret, URI and QR.
108
    enrol: Option<(String, String, String)>,
109
    recovery: Vec<String>,
110
}
111
112
pub async fn security(State(app): State<Shared>, viewer: Viewer) -> Result<Page<SecurityPage>, Error> {
113
    let shell = Shell::new(&app, "/settings/security", &viewer);
114
    let me = require(&viewer, &shell)?;
115
    Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: None, recovery: vec![] }))
116
}
117
118
#[derive(Deserialize)]
119
pub struct TotpForm {
120
    #[serde(rename = "_csrf")]
121
    csrf_token: String,
122
    #[serde(default)]
123
    secret: String,
124
    #[serde(default)]
125
    code: String,
126
    #[serde(default)]
127
    password: String,
128
}
129
130
/// Step one: show a fresh secret to scan.
131
pub async fn totp_begin(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
132
    let shell = Shell::new(&app, "/settings/security", &viewer);
133
    let me = require(&viewer, &shell)?;
134
    csrf(&viewer, &f.csrf_token, &shell)?;
135
    let secret = totp::new_secret();
136
    let uri = totp::uri(&app.config.site.name, &me.account.username, &secret);
137
    let qr = totp::qr_svg(&uri).unwrap_or_default();
138
    Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: Some((secret, uri, qr)), recovery: vec![] }))
139
}
140
141
/// Step two: the first code proves the app holds the secret.
142
pub async fn totp_enable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
143
    let shell = Shell::new(&app, "/settings/security", &viewer);
144
    let me = require(&viewer, &shell)?;
145
    csrf(&viewer, &f.csrf_token, &shell)?;
146
    let now = (db::now_ms() / 1000) as u64;
147
    if f.secret.is_empty() || !totp::verify(&f.secret, &f.code, now) {
148
        let uri = totp::uri(&app.config.site.name, &me.account.username, &f.secret);
149
        let qr = totp::qr_svg(&uri).unwrap_or_default();
150
        return Ok(Page(SecurityPage {
151
            shell,
152
            me: me.account,
153
            notice: None,
154
            error: Some("That code did not match; scan again and enter the current code.".into()),
155
            enrol: Some((f.secret, uri, qr)),
156
            recovery: vec![],
157
        }));
158
    }
159
    let (codes, stored) = totp::new_recovery_codes();
160
    accounts::set_totp(&app.db, me.account.id, Some(&f.secret), &stored).await.map_err(|e| Error::from_db(shell.clone(), e))?;
161
    accounts::audit(&app.db, Some(&me.account), "totp.enabled", &me.account.username, "").await.ok();
162
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
163
    Ok(Page(SecurityPage {
164
        shell,
165
        me: fresh,
166
        notice: Some("Two-factor authentication is on. Keep these recovery codes somewhere safe; each works once.".into()),
167
        error: None,
168
        enrol: None,
169
        recovery: codes,
170
    }))
171
}
172
173
pub async fn totp_disable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
174
    let shell = Shell::new(&app, "/settings/security", &viewer);
175
    let me = require(&viewer, &shell)?;
176
    csrf(&viewer, &f.csrf_token, &shell)?;
177
    if !auth::verify_password(&me.account.password_hash, &f.password) {
178
        return Ok(Page(SecurityPage {
179
            shell,
180
            me: me.account,
181
            notice: None,
182
            error: Some("Your password is wrong.".into()),
183
            enrol: None,
184
            recovery: vec![],
185
        }));
186
    }
187
    accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
188
    accounts::audit(&app.db, Some(&me.account), "totp.disabled", &me.account.username, "").await.ok();
189
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
190
    Ok(Page(SecurityPage {
191
        shell,
192
        me: fresh,
193
        notice: Some("Two-factor authentication is off.".into()),
194
        error: None,
195
        enrol: None,
196
        recovery: vec![],
197
    }))
198
}
199
200
/// Sign out everywhere: a new session salt invalidates every cookie, then
201
/// this browser gets a fresh one.
202
pub async fn sessions_reset(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Response, Error> {
203
    let shell = Shell::new(&app, "/settings/security", &viewer);
204
    let me = require(&viewer, &shell)?;
205
    csrf(&viewer, &f.csrf, &shell)?;
206
    accounts::set_session_salt(&app.db, me.account.id, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
207
    accounts::audit(&app.db, Some(&me.account), "sessions.reset", &me.account.username, "").await.ok();
208
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
209
    Ok(super::session::signed_in(&app, &fresh, "/settings/security"))
210
}
211
212
// ------------------------------------------------------------------- keys
213
214
#[derive(Template)]
215
#[template(path = "settings_keys.html")]
216
pub struct KeysPage {
217
    shell: Shell,
218
    me: accounts::Account,
219
    keys: Vec<accounts::LinkedIdentity>,
220
    notice: Option<String>,
221
    error: Option<String>,
222
    /// A pending challenge: id, hex bytes, identity name.
223
    pending: Option<(i64, String, String)>,
224
}
225
226
async fn keys_page(
227
    app: &Shared,
228
    shell: Shell,
229
    me: accounts::Account,
230
    notice: Option<String>,
231
    error: Option<String>,
232
    pending: Option<(i64, String, String)>,
233
) -> Result<Page<KeysPage>, Error> {
234
    let keys = accounts::identities_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
235
    Ok(Page(KeysPage { shell, me, keys, notice, error, pending }))
236
}
237
238
pub async fn keys(State(app): State<Shared>, viewer: Viewer) -> Result<Page<KeysPage>, Error> {
239
    let shell = Shell::new(&app, "/settings/keys", &viewer);
240
    let me = require(&viewer, &shell)?;
241
    keys_page(&app, shell, me.account, None, None, None).await
242
}
243
244
/// Upload an identity object (`oxy identity export`): the hub issues a
245
/// challenge to sign with that key.
246
pub async fn keys_upload(State(app): State<Shared>, viewer: Viewer, mut multipart: Multipart) -> Result<Page<KeysPage>, Error> {
247
    let shell = Shell::new(&app, "/settings/keys", &viewer);
248
    let me = require(&viewer, &shell)?;
249
    let mut token = String::new();
250
    let mut object: Vec<u8> = Vec::new();
251
    while let Ok(Some(field)) = multipart.next_field().await {
252
        match field.name() {
253
            Some("_csrf") => token = field.text().await.unwrap_or_default(),
254
            Some("identity") => object = field.bytes().await.map(|b| b.to_vec()).unwrap_or_default(),
255
            _ => {}
256
        }
257
    }
258
    csrf(&viewer, &token, &shell)?;
259
    let identity = match Identity::decode(&object) {
260
        Ok(i) => i,
261
        Err(e) => return keys_page(&app, shell, me.account, None, Some(format!("That is not an identity file: {e}")), None).await,
262
    };
263
    let key = identity.hash().to_hex();
264
    if let Some(existing) = accounts::identity_by_key(&app.db, &key).await.map_err(|e| Error::from_db(shell.clone(), e))? {
265
        let msg = if existing.account_id == me.account.id {
266
            "That key is already linked to you."
267
        } else {
268
            "That key is linked to another account."
269
        };
270
        return keys_page(&app, shell, me.account, None, Some(msg.into()), None).await;
271
    }
272
    let bytes = auth::random_hex(32);
273
    let id = accounts::create_challenge(&app.db, me.account.id, &bytes).await.map_err(|e| Error::from_db(shell.clone(), e))?;
274
    sqlx::query("UPDATE challenges SET object = ? WHERE id = ?")
275
        .bind(&object)
276
        .bind(id)
277
        .execute(&app.db)
278
        .await
279
        .map_err(|e| Error::from_db(shell.clone(), e))?;
280
    keys_page(&app, shell, me.account, None, None, Some((id, bytes, identity.name))).await
281
}
282
283
#[derive(Deserialize)]
284
pub struct KeyVerifyForm {
285
    #[serde(rename = "_csrf")]
286
    csrf_token: String,
287
    challenge: i64,
288
    signature: String,
289
}
290
291
pub async fn keys_verify(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyVerifyForm>) -> Result<Page<KeysPage>, Error> {
292
    let shell = Shell::new(&app, "/settings/keys", &viewer);
293
    let me = require(&viewer, &shell)?;
294
    csrf(&viewer, &f.csrf_token, &shell)?;
295
    let object: Option<Vec<u8>> = sqlx::query_scalar("SELECT object FROM challenges WHERE id = ? AND account_id = ?")
296
        .bind(f.challenge)
297
        .bind(me.account.id)
298
        .fetch_optional(&app.db)
299
        .await
300
        .map_err(|e| Error::from_db(shell.clone(), e))?;
301
    let bytes = accounts::take_challenge(&app.db, me.account.id, f.challenge).await.map_err(|e| Error::from_db(shell.clone(), e))?;
302
    let (Some(object), Some(bytes)) = (object, bytes) else {
303
        return keys_page(&app, shell, me.account, None, Some("That challenge has expired; upload the identity again.".into()), None).await;
304
    };
305
    let Ok(identity) = Identity::decode(&object) else {
306
        return keys_page(&app, shell, me.account, None, Some("The stored identity is unreadable.".into()), None).await;
307
    };
308
    let sig_bytes = hex::decode(f.signature.trim()).unwrap_or_default();
309
    let sigs = Signature::decode_list(&sig_bytes).unwrap_or_default();
310
    let challenge = hex::decode(&bytes).unwrap_or_default();
311
    let ok = sigs.first().is_some_and(|sig| oxy_core::identity::verify_challenge(&identity, sig, &challenge).is_ok());
312
    if !ok {
313
        return keys_page(
314
            &app,
315
            shell,
316
            me.account,
317
            None,
318
            Some("The signature does not verify against that identity and challenge.".into()),
319
            None,
320
        )
321
        .await;
322
    }
323
    let key = identity.hash().to_hex();
324
    accounts::link_identity(&app.db, me.account.id, &key, &identity.name, &identity.email, &object)
325
        .await
326
        .map_err(|e| Error::from_db(shell.clone(), e))?;
327
    accounts::audit(&app.db, Some(&me.account), "identity.linked", &key, &identity.name).await.ok();
328
    keys_page(&app, shell, me.account, Some("Key linked. Changes signed with it are now yours on every page.".into()), None, None).await
329
}
330
331
#[derive(Deserialize)]
332
pub struct KeyForm {
333
    #[serde(rename = "_csrf")]
334
    csrf_token: String,
335
    key: String,
336
}
337
338
pub async fn keys_unlink(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyForm>) -> Result<Page<KeysPage>, Error> {
339
    let shell = Shell::new(&app, "/settings/keys", &viewer);
340
    let me = require(&viewer, &shell)?;
341
    csrf(&viewer, &f.csrf_token, &shell)?;
342
    accounts::unlink_identity(&app.db, me.account.id, &f.key).await.map_err(|e| Error::from_db(shell.clone(), e))?;
343
    accounts::audit(&app.db, Some(&me.account), "identity.unlinked", &f.key, "").await.ok();
344
    keys_page(&app, shell, me.account, Some("Key unlinked.".into()), None, None).await
345
}
346
347
// ----------------------------------------------------------------- tokens
348
349
#[derive(Template)]
350
#[template(path = "settings_tokens.html")]
351
pub struct TokensPage {
352
    shell: Shell,
353
    me: accounts::Account,
354
    tokens: Vec<accounts::Token>,
355
    /// A freshly created token, shown once.
356
    fresh: Option<String>,
357
    notice: Option<String>,
358
    error: Option<String>,
359
}
360
361
async fn tokens_page(
362
    app: &Shared,
363
    shell: Shell,
364
    me: accounts::Account,
365
    fresh: Option<String>,
366
    notice: Option<String>,
367
    error: Option<String>,
368
) -> Result<Page<TokensPage>, Error> {
369
    let tokens = accounts::tokens_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
370
    Ok(Page(TokensPage { shell, me, tokens, fresh, notice, error }))
371
}
372
373
pub async fn tokens(State(app): State<Shared>, viewer: Viewer) -> Result<Page<TokensPage>, Error> {
374
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
375
    let me = require(&viewer, &shell)?;
376
    tokens_page(&app, shell, me.account, None, None, None).await
377
}
378
379
#[derive(Deserialize)]
380
pub struct TokenForm {
381
    #[serde(rename = "_csrf")]
382
    csrf_token: String,
383
    name: String,
384
    #[serde(default)]
385
    scopes: Vec<String>,
386
    #[serde(default)]
387
    repos: String,
388
    #[serde(default)]
389
    expires_days: String,
390
}
391
392
pub const SCOPES: &[(&str, &str)] = &[
393
    ("repo:read", "read repositories you can see (clone, fetch, pull)"),
394
    ("repo:write", "push to repositories you can write to"),
395
    ("repo:admin", "change repository settings through the API"),
396
    ("user:read", "read your profile through the API"),
397
];
398
399
pub async fn tokens_create(
400
    State(app): State<Shared>,
401
    viewer: Viewer,
402
    axum_extra::extract::Form(f): axum_extra::extract::Form<TokenForm>,
403
) -> Result<Page<TokensPage>, Error> {
404
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
405
    let me = require(&viewer, &shell)?;
406
    csrf(&viewer, &f.csrf_token, &shell)?;
407
    let name = f.name.trim();
408
    if name.is_empty() {
409
        return tokens_page(&app, shell, me.account, None, None, Some("Give the token a name.".into())).await;
410
    }
411
    let scopes: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| SCOPES.iter().any(|(k, _)| k == s)).collect();
412
    if scopes.is_empty() {
413
        return tokens_page(&app, shell, me.account, None, None, Some("Pick at least one scope.".into())).await;
414
    }
415
    let expires_ms = f.expires_days.trim().parse::<i64>().ok().filter(|d| *d > 0).map(|d| db::now_ms() + d * 86_400_000);
416
    let repos = f.repos.split(',').map(str::trim).filter(|r| !r.is_empty()).collect::<Vec<_>>().join(",");
417
    let (secret, hash) = auth::new_token();
418
    accounts::create_token(&app.db, me.account.id, name, &hash, &scopes.join(","), &repos, expires_ms)
419
        .await
420
        .map_err(|e| Error::from_db(shell.clone(), e))?;
421
    accounts::audit(&app.db, Some(&me.account), "token.created", name, &scopes.join(",")).await.ok();
422
    tokens_page(&app, shell, me.account, Some(secret), Some("Copy the token now; it is not shown again.".into()), None).await
423
}
424
425
pub async fn tokens_delete(
426
    State(app): State<Shared>,
427
    viewer: Viewer,
428
    Path(id): Path<i64>,
429
    Form(f): Form<super::session::CsrfForm>,
430
) -> Result<Page<TokensPage>, Error> {
431
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
432
    let me = require(&viewer, &shell)?;
433
    csrf(&viewer, &f.csrf, &shell)?;
434
    accounts::delete_token(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
435
    accounts::audit(&app.db, Some(&me.account), "token.deleted", &id.to_string(), "").await.ok();
436
    tokens_page(&app, shell, me.account, None, Some("Token deleted.".into()), None).await
437
}