A new hub gives administrator only to whoever brings its setup code c8684d44d549
On a hub with no accounts, /register made the first account an administrator with no invitation, whatever the registration mode: the fresh GCP hub at gcp.oxygenhub.io was open like this for an hour until Andy stopped it. Now, with no accounts, the hub keeps a random one-time setup code in <data>/setup-code (owner-only, never served) and logs where it is at start, or takes [registration] setup_code (OXYHUB_REGISTRATION_SETUP_CODE) for automation. The first registration must bring it (compared in constant time) and spends it; a sign-in provider never makes the first account; once there are accounts the file is removed. oxygenhub admin user create --admin needs no code. The tests give their hubs a known code; a new test checks the made code, its file's mode, wrong and missing codes, and spending it. Closes #245
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 21:10 UTC
- Signature
- ✔ signed by Claude Code
- Hash
c8684d44d549e0c7c40f0202032904814d318fbb5e75df1c5624225a06dc7668- Depends on
- 0024ccb1b050 10708ab8f54d 145306044896 1870e4fa9808 18e381ea88c2 19bb31ff1f5c 1f78655a0466 2a83f5303458 2bfe5ef66b0c 34beb1a7e376 38833ed78707 3f4b0ea6eabf 3ff52534f69b 42fa95540468 4ac01a732552 62ceb2eec18c 65be9f9c3074 66fd9d6b1452 6a1780558353 6ca0b971ff07 6e7dfa0a42b9 760a7dd30d6f 76f262e1a666 7974adb0be66 7b9f4570daf5 83940d53664f 8933faff2043 8a3334b7a364 9008bf6c2b8c 92574625ee9a 93501da2a162 93be9ce4653e 961c10bee174 9756c67d3aec 9a8e3b4419e0 aa0bfbd11f02 b64e2537d702 b6b7e4cf6b24 d0fbea2eb46e d23464c0ac60 d7d182467382 de505df5cf8d df71c7892d27 e271a37b629a ea85398a51bc ef20fdc14e8b
- Depended on by
- 737de0fac722 e57765dee61a c92a99490cba
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 88 atoms, 1 tree operation, 20 files
Browse the tree at this change
added crates/hub/src/setup.rs +77 −0
| 1 | + | //! The first administrator (oxygenhub#245). A hub with no accounts gives |
|
| 2 | + | //! administrator to the first account registered, so that account must |
|
| 3 | + | //! bring a one-time setup code: the one in `[registration] setup_code` |
|
| 4 | + | //! (for automation), or else a random one this module keeps in |
|
| 5 | + | //! `<data>/setup-code` (owner-only) and logs at start. It is never served |
|
| 6 | + | //! over the web. Registering with it spends it; once there are accounts |
|
| 7 | + | //! there is no code. |
|
| 8 | + | ||
| 9 | + | use crate::db::accounts; |
|
| 10 | + | use crate::Shared; |
|
| 11 | + | use std::path::PathBuf; |
|
| 12 | + | ||
| 13 | + | pub fn path(app: &Shared) -> PathBuf { |
|
| 14 | + | app.config.data.join("setup-code") |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | /// At start: with no accounts, make sure there is a code and say where it |
|
| 18 | + | /// is; with accounts, make sure none is left lying about. |
|
| 19 | + | pub async fn ensure(app: &Shared) -> anyhow::Result<()> { |
|
| 20 | + | let file = path(app); |
|
| 21 | + | if accounts::account_count(&app.db).await? > 0 { |
|
| 22 | + | let _ = std::fs::remove_file(&file); |
|
| 23 | + | return Ok(()); |
|
| 24 | + | } |
|
| 25 | + | let code = match expected(app) { |
|
| 26 | + | Some(code) => code, |
|
| 27 | + | None => { |
|
| 28 | + | let code = crate::auth::random_hex(12); |
|
| 29 | + | write_private(&file, &code)?; |
|
| 30 | + | code |
|
| 31 | + | } |
|
| 32 | + | }; |
|
| 33 | + | tracing::warn!( |
|
| 34 | + | "this hub has no administrator yet: register the first account at {}/register with setup code {code} (also in {}); it works once", |
|
| 35 | + | app.config.base_url(), |
|
| 36 | + | file.display() |
|
| 37 | + | ); |
|
| 38 | + | Ok(()) |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | /// The code the first registration must bring, if there is one. |
|
| 42 | + | pub fn expected(app: &Shared) -> Option<String> { |
|
| 43 | + | let configured = app.config.registration.setup_code.trim(); |
|
| 44 | + | if !configured.is_empty() { |
|
| 45 | + | return Some(configured.to_string()); |
|
| 46 | + | } |
|
| 47 | + | std::fs::read_to_string(path(app)).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | /// Whether `given` is the setup code, compared in constant time. |
|
| 51 | + | pub fn matches(app: &Shared, given: &str) -> bool { |
|
| 52 | + | match expected(app) { |
|
| 53 | + | Some(code) => { |
|
| 54 | + | let (a, b) = (code.as_bytes(), given.trim().as_bytes()); |
|
| 55 | + | a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 |
|
| 56 | + | } |
|
| 57 | + | None => false, |
|
| 58 | + | } |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | /// The first administrator exists: the code is spent. |
|
| 62 | + | pub fn spend(app: &Shared) { |
|
| 63 | + | let _ = std::fs::remove_file(path(app)); |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | fn write_private(file: &std::path::Path, code: &str) -> std::io::Result<()> { |
|
| 67 | + | use std::io::Write; |
|
| 68 | + | let mut options = std::fs::OpenOptions::new(); |
|
| 69 | + | options.write(true).create(true).truncate(true); |
|
| 70 | + | #[cfg(unix)] |
|
| 71 | + | { |
|
| 72 | + | use std::os::unix::fs::OpenOptionsExt; |
|
| 73 | + | options.mode(0o600); |
|
| 74 | + | } |
|
| 75 | + | let mut f = options.open(file)?; |
|
| 76 | + | writeln!(f, "{code}") |
|
| 77 | + | } |
modified crates/hub/src/config.rs +8 −1
| … | 153 unchanged lines |
||
| 154 | 154 | pub struct Registration { |
|
| 155 | 155 | /// `open`, `invite` or `closed`; the admin page can override it. |
|
| 156 | 156 | pub mode: String, |
|
| 157 | + | /// The setup code the first account must bring (oxygenhub#245), for |
|
| 158 | + | /// automation; empty, the hub makes one and keeps it in |
|
| 159 | + | /// `<data>/setup-code`. |
|
| 160 | + | pub setup_code: String, |
|
| 157 | 161 | } |
|
| 158 | 162 | ||
| 159 | 163 | impl Default for Registration { |
|
| 160 | 164 | fn default() -> Self { |
|
| 161 | - | Registration { mode: "invite".into() } |
|
| 165 | + | Registration { mode: "invite".into(), setup_code: String::new() } |
|
| 162 | 166 | } |
|
| 163 | 167 | } |
|
| 164 | 168 | ||
| … | 191 unchanged lines |
||
| 356 | 360 | if let Some(v) = get("OXYHUB_REGISTRATION_MODE") { |
|
| 357 | 361 | self.registration.mode = v; |
|
| 358 | 362 | } |
|
| 363 | + | if let Some(v) = get("OXYHUB_REGISTRATION_SETUP_CODE") { |
|
| 364 | + | self.registration.setup_code = v; |
|
| 365 | + | } |
|
| 359 | 366 | if let Some(v) = get("OXYHUB_AUTH_PUSH_TOKENS") { |
|
| 360 | 367 | self.auth.push_tokens = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect(); |
|
| 361 | 368 | } |
|
| … | 75 unchanged lines |
modified crates/hub/src/lib.rs +2 −0
| … | 31 unchanged lines |
||
| 32 | 32 | pub mod reports; |
|
| 33 | 33 | pub mod repos; |
|
| 34 | 34 | pub mod secrets; |
|
| 35 | + | pub mod setup; |
|
| 35 | 36 | pub mod signin; |
|
| 36 | 37 | pub mod social; |
|
| 37 | 38 | pub mod storage; |
|
| … | 121 unchanged lines |
||
| 159 | 160 | } |
|
| 160 | 161 | } |
|
| 161 | 162 | }); |
|
| 163 | + | setup::ensure(&app).await?; |
|
| 162 | 164 | Ok(app) |
|
| 163 | 165 | } |
|
| 164 | 166 | ||
| … | 144 unchanged lines |
modified crates/hub/src/web/session.rs +11 −0
| … | 245 unchanged lines |
||
| 246 | 246 | password: String, |
|
| 247 | 247 | #[serde(default)] |
|
| 248 | 248 | invite: String, |
|
| 249 | + | /// The first account's setup code (oxygenhub#245). |
|
| 250 | + | #[serde(default)] |
|
| 251 | + | setup: String, |
|
| 249 | 252 | } |
|
| 250 | 253 | ||
| 251 | 254 | pub async fn register(State(app): State<Shared>, viewer: Viewer, Form(f): Form<RegisterForm>) -> Result<Response, Error> { |
|
| … | 18 unchanged lines |
||
| 270 | 273 | } |
|
| 271 | 274 | if let Err(e) = auth::password_ok(&f.password) { |
|
| 272 | 275 | return Ok(fail(e)); |
|
| 276 | + | } |
|
| 277 | + | if first && !crate::setup::matches(&app, &f.setup) { |
|
| 278 | + | return Ok(fail( |
|
| 279 | + | "This hub has no administrator yet. The first account needs the setup code the server logged when it started (also in setup-code in its data directory).", |
|
| 280 | + | )); |
|
| 273 | 281 | } |
|
| 274 | 282 | if !first { |
|
| 275 | 283 | match mode.as_str() { |
|
| … | 20 unchanged lines |
||
| 296 | 304 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 297 | 305 | if !first && mode == "invite" { |
|
| 298 | 306 | accounts::use_invitation(&app.db, f.invite.trim(), account.id).await.ok(); |
|
| 307 | + | } |
|
| 308 | + | if first { |
|
| 309 | + | crate::setup::spend(&app); |
|
| 299 | 310 | } |
|
| 300 | 311 | accounts::audit(&app.db, Some(&account), "register", &username, if first { "first account: administrator" } else { "" }).await.ok(); |
|
| 301 | 312 | if let Err(e) = super::recovery::send_verification(&app, &account).await { |
|
| … | 16 unchanged lines |
modified crates/hub/src/web/signin.rs +5 −0
| … | 223 unchanged lines |
||
| 224 | 224 | let username = signin::free_username(&app.db, &who.login, p.id).await?; |
|
| 225 | 225 | let hash = auth::hash_password(&auth::random_hex(24)).unwrap_or_default(); |
|
| 226 | 226 | let first = accounts::account_count(&app.db).await? == 0; |
|
| 227 | + | // The first account brings the setup code (oxygenhub#245): never one a |
|
| 228 | + | // sign-in provider vouches for. |
|
| 229 | + | if first { |
|
| 230 | + | return Err(sqlx::Error::Protocol("this hub has no administrator yet: register the first account with its setup code".into())); |
|
| 231 | + | } |
|
| 227 | 232 | let account = |
|
| 228 | 233 | accounts::create_account(&app.db, &username, who.email.as_deref().unwrap_or(""), &hash, &auth::random_hex(16), first).await?; |
|
| 229 | 234 | if who.email.is_some() { |
|
| … | 116 unchanged lines |
modified crates/hub/tests/accounts.rs +69 −11
| … | 36 unchanged lines |
||
| 37 | 37 | hermetic(); |
|
| 38 | 38 | let dir = tempfile::tempdir().unwrap(); |
|
| 39 | 39 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 40 | + | // The first account's setup code (oxygenhub#245). |
|
| 41 | + | config.registration.setup_code = "test-setup".into(); |
|
| 40 | 42 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 41 | 43 | let port = listener.local_addr().unwrap().port(); |
|
| 42 | 44 | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| … | 55 unchanged lines |
||
| 98 | 100 | ||
| 99 | 101 | /// Register (or sign in) and return the session cookie value. |
|
| 100 | 102 | async fn register(app: &Shared, user: &str, invite: &str) -> String { |
|
| 101 | - | let r = |
|
| 102 | - | post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&invite={invite}")) |
|
| 103 | - | .await; |
|
| 103 | + | let r = post( |
|
| 104 | + | app, |
|
| 105 | + | "/register", |
|
| 106 | + | None, |
|
| 107 | + | &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup&invite={invite}"), |
|
| 108 | + | ) |
|
| 109 | + | .await; |
|
| 104 | 110 | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 105 | 111 | r.cookie.expect("session cookie") |
|
| 106 | 112 | } |
|
| … | 13 unchanged lines |
||
| 120 | 126 | let me = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap(); |
|
| 121 | 127 | assert!(me.is_admin); |
|
| 122 | 128 | // A second registration needs an invitation. |
|
| 123 | - | let r = post(app, "/register", None, "username=mallory&email=m%40example.com&password=correct-horse-battery&invite=").await; |
|
| 129 | + | let r = post(app, "/register", None, "username=mallory&email=m%40example.com&password=correct-horse-battery&setup=test-setup&invite=") |
|
| 130 | + | .await; |
|
| 124 | 131 | assert_eq!(r.status, StatusCode::OK); |
|
| 125 | 132 | assert!(r.body.contains("invitation"), "{}", r.body); |
|
| 126 | 133 | let admin_page = get(app, "/admin", Some(&andy)).await; |
|
| … | 166 unchanged lines |
||
| 293 | 300 | let code = oxygenhub::auth::totp::current(&secret, now).unwrap(); |
|
| 294 | 301 | let r = post(app, "/settings/security/totp/enable", Some(&fresh), &format!("_csrf={csrf}&secret={secret}&code={code}")).await; |
|
| 295 | 302 | assert!(r.body.contains("Recovery codes"), "{}", r.body); |
|
| 296 | - | let r = post(app, "/login", None, "username=andy&password=correct-horse-battery").await; |
|
| 303 | + | let r = post(app, "/login", None, "username=andy&password=correct-horse-battery&setup=test-setup").await; |
|
| 297 | 304 | assert!(r.body.contains("authenticator"), "second step asked: {}", r.body); |
|
| 298 | 305 | // Submit the second step as a browser would: every field of the |
|
| 299 | 306 | // rendered form, each name once (a repeated name is refused). |
|
| … | 238 unchanged lines |
||
| 538 | 545 | }; |
|
| 539 | 546 | let login = |user: &'static str| { |
|
| 540 | 547 | let app = app.clone(); |
|
| 541 | - | async move { post(&app, "/login", None, &format!("username={user}&password=correct-horse-battery")).await } |
|
| 548 | + | async move { post(&app, "/login", None, &format!("username={user}&password=correct-horse-battery&setup=test-setup")).await } |
|
| 542 | 549 | }; |
|
| 543 | 550 | ||
| 544 | 551 | // Disabled: sign-in says so, the old session no longer counts. |
|
| … | 27 unchanged lines |
||
| 572 | 579 | assert_eq!(get(app, "/settings", Some(&bob)).await.status, StatusCode::SEE_OTHER); |
|
| 573 | 580 | // Its name stays retired. |
|
| 574 | 581 | let code = invite(app.clone(), andy.clone()).await; |
|
| 575 | - | let r = post(app, "/register", None, &format!("username=bob&email=b%40example.com&password=correct-horse-battery&invite={code}")).await; |
|
| 582 | + | let r = post( |
|
| 583 | + | app, |
|
| 584 | + | "/register", |
|
| 585 | + | None, |
|
| 586 | + | &format!("username=bob&email=b%40example.com&password=correct-horse-battery&setup=test-setup&invite={code}"), |
|
| 587 | + | ) |
|
| 588 | + | .await; |
|
| 576 | 589 | assert!(r.body.contains("That name is taken"), "{}", r.body); |
|
| 577 | 590 | // The last admin can be neither disabled nor deleted. |
|
| 578 | 591 | assert!(act("andy", "delete", "andy").await.contains("Not on yourself")); |
|
| … | 355 unchanged lines |
||
| 934 | 947 | let (mock, seen) = mock_provider().await; |
|
| 935 | 948 | let dir = tempfile::tempdir().unwrap(); |
|
| 936 | 949 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 950 | + | // The first account's setup code (oxygenhub#245). |
|
| 951 | + | config.registration.setup_code = "test-setup".into(); |
|
| 937 | 952 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 938 | 953 | let port = listener.local_addr().unwrap().port(); |
|
| 939 | 954 | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| … | 266 unchanged lines |
||
| 1206 | 1221 | hermetic(); |
|
| 1207 | 1222 | let dir = tempfile::tempdir().unwrap(); |
|
| 1208 | 1223 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 1224 | + | // The first account's setup code (oxygenhub#245). |
|
| 1225 | + | config.registration.setup_code = "test-setup".into(); |
|
| 1209 | 1226 | config.server.base_url = "https://hub.example.org".into(); |
|
| 1210 | 1227 | config.registration.mode = "open".into(); |
|
| 1211 | 1228 | let app = oxygenhub::build(config).await.unwrap(); |
|
| … | 25 unchanged lines |
||
| 1237 | 1254 | "POST", |
|
| 1238 | 1255 | "/register", |
|
| 1239 | 1256 | Some(("x-forwarded-proto", "https")), |
|
| 1240 | - | Some("username=andy&email=a%40example.org&password=correct-horse-battery"), |
|
| 1257 | + | Some("username=andy&email=a%40example.org&password=correct-horse-battery&setup=test-setup"), |
|
| 1241 | 1258 | ) |
|
| 1242 | 1259 | .await; |
|
| 1243 | 1260 | let cookie = r.headers().get(header::SET_COOKIE).expect("signed in").to_str().unwrap(); |
|
| … | 5 unchanged lines |
||
| 1249 | 1266 | hermetic(); |
|
| 1250 | 1267 | let dir = tempfile::tempdir().unwrap(); |
|
| 1251 | 1268 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 1269 | + | // The first account's setup code (oxygenhub#245). |
|
| 1270 | + | config.registration.setup_code = "test-setup".into(); |
|
| 1252 | 1271 | config.server.behind_proxy = true; |
|
| 1253 | 1272 | config.registration.mode = "open".into(); |
|
| 1254 | 1273 | let app = oxygenhub::build(config).await.unwrap(); |
|
| … | 11 unchanged lines |
||
| 1266 | 1285 | } |
|
| 1267 | 1286 | }; |
|
| 1268 | 1287 | register(&app, "andy", "").await; |
|
| 1269 | - | let right = || "username=andy&password=correct-horse-battery".to_string(); |
|
| 1288 | + | let right = || "username=andy&password=correct-horse-battery&setup=test-setup".to_string(); |
|
| 1270 | 1289 | ||
| 1271 | 1290 | // An account: five wrong passwords cost nothing, the sixth starts a |
|
| 1272 | 1291 | // wait, and then even the right password waits (told when to retry). |
|
| … | 94 unchanged lines |
||
| 1367 | 1386 | let app = &h.app; |
|
| 1368 | 1387 | let a = register(app, "andy", "").await; |
|
| 1369 | 1388 | assert_eq!(a.len(), 64, "a random id, not the account's name"); |
|
| 1370 | - | let b = post(app, "/login", None, "username=andy&password=correct-horse-battery").await.cookie.expect("signed in again"); |
|
| 1389 | + | let b = |
|
| 1390 | + | post(app, "/login", None, "username=andy&password=correct-horse-battery&setup=test-setup").await.cookie.expect("signed in again"); |
|
| 1371 | 1391 | assert_ne!(a, b, "each sign-in is a new session"); |
|
| 1372 | 1392 | ||
| 1373 | 1393 | let page = get(app, "/settings/security", Some(&a)).await.body; |
|
| … | 15 unchanged lines |
||
| 1389 | 1409 | assert!(!signed_in(a.clone()).await, "idle too long"); |
|
| 1390 | 1410 | ||
| 1391 | 1411 | // Signing out ends the record itself, not just the cookie. |
|
| 1392 | - | let c = post(app, "/login", None, "username=andy&password=correct-horse-battery").await.cookie.unwrap(); |
|
| 1412 | + | let c = post(app, "/login", None, "username=andy&password=correct-horse-battery&setup=test-setup").await.cookie.unwrap(); |
|
| 1393 | 1413 | let csrf = csrf_of(&get(app, "/settings/security", Some(&c)).await.body); |
|
| 1394 | 1414 | post(app, "/logout", Some(&c), &format!("_csrf={csrf}")).await; |
|
| 1395 | 1415 | assert!(!signed_in(c).await, "the old cookie no longer works"); |
|
| … | 7 unchanged lines |
||
| 1403 | 1423 | let (mock, _) = mock_provider().await; |
|
| 1404 | 1424 | let dir = tempfile::tempdir().unwrap(); |
|
| 1405 | 1425 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 1426 | + | // The first account's setup code (oxygenhub#245). |
|
| 1427 | + | config.registration.setup_code = "test-setup".into(); |
|
| 1406 | 1428 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 1407 | 1429 | let port = listener.local_addr().unwrap().port(); |
|
| 1408 | 1430 | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| … | 39 unchanged lines |
||
| 1448 | 1470 | assert_eq!(r.status, StatusCode::BAD_REQUEST); |
|
| 1449 | 1471 | assert!(r.body.contains("That invitation has been used or does not exist."), "{}", r.body); |
|
| 1450 | 1472 | assert_eq!(get(app, "/login/github?invite=not-a.code", None).await.status, StatusCode::BAD_REQUEST); |
|
| 1473 | + | } |
|
| 1474 | + | ||
| 1475 | + | /// A new hub hands administrator only to whoever brings its setup code |
|
| 1476 | + | /// (oxygenhub#245): the one it made, kept owner-only in its data directory, |
|
| 1477 | + | /// never on the web; once spent, the file is gone. |
|
| 1478 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1479 | + | async fn the_first_account_needs_the_setup_code_the_hub_made() { |
|
| 1480 | + | hermetic(); |
|
| 1481 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1482 | + | let config = Config::load(dir.path()).unwrap(); |
|
| 1483 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 1484 | + | let file = dir.path().join("setup-code"); |
|
| 1485 | + | let code = std::fs::read_to_string(&file).expect("an empty hub makes a setup code").trim().to_string(); |
|
| 1486 | + | assert_eq!(code.len(), 24); |
|
| 1487 | + | #[cfg(unix)] |
|
| 1488 | + | { |
|
| 1489 | + | use std::os::unix::fs::PermissionsExt; |
|
| 1490 | + | assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600); |
|
| 1491 | + | } |
|
| 1492 | + | let page = get(&app, "/register", None).await.body; |
|
| 1493 | + | assert!(page.contains("name=\"setup\"") && !page.contains(&code), "asks for the code, never shows it"); |
|
| 1494 | + | ||
| 1495 | + | let form = |setup: &str| format!("username=mallory&email=m%40example.com&password=correct-horse-battery&setup={setup}"); |
|
| 1496 | + | for wrong in ["", "0123456789abcdef01234567"] { |
|
| 1497 | + | let r = post(&app, "/register", None, &form(wrong)).await; |
|
| 1498 | + | assert!(r.cookie.is_none() && r.body.contains("needs the setup code"), "{}", r.body); |
|
| 1499 | + | } |
|
| 1500 | + | assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 0); |
|
| 1501 | + | ||
| 1502 | + | let r = post(&app, "/register", None, &form(&code).replace("mallory", "andy")).await; |
|
| 1503 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 1504 | + | assert!(db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().is_admin); |
|
| 1505 | + | assert!(!file.exists(), "the code is spent"); |
|
| 1506 | + | // The next account is an ordinary one, under the registration mode. |
|
| 1507 | + | let r = post(&app, "/register", None, &form(&code)).await; |
|
| 1508 | + | assert!(r.body.contains("invitation"), "{}", r.body); |
|
| 1451 | 1509 | } |
modified crates/hub/tests/api.rs +200 −62
| … | 41 unchanged lines |
||
| 42 | 42 | hermetic(); |
|
| 43 | 43 | let dir = tempfile::tempdir().unwrap(); |
|
| 44 | 44 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 45 | + | // The first account's setup code (oxygenhub#245). |
|
| 46 | + | config.registration.setup_code = "test-setup".into(); |
|
| 45 | 47 | config.auth.push_tokens = vec!["site".into()]; |
|
| 46 | 48 | config.registration.mode = "open".into(); |
|
| 47 | 49 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| … | 101 unchanged lines |
||
| 149 | 151 | let h = hub().await; |
|
| 150 | 152 | let app = &h.app; |
|
| 151 | 153 | // Accounts and tokens through the pages. |
|
| 152 | - | let r = |
|
| 153 | - | call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await; |
|
| 154 | + | let r = call( |
|
| 155 | + | app, |
|
| 156 | + | "POST", |
|
| 157 | + | "/register", |
|
| 158 | + | None, |
|
| 159 | + | None, |
|
| 160 | + | None, |
|
| 161 | + | Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 162 | + | ) |
|
| 163 | + | .await; |
|
| 154 | 164 | let andy = r.cookie.unwrap(); |
|
| 155 | - | let r = |
|
| 156 | - | call(app, "POST", "/register", None, None, None, Some("username=bob&email=b%40example.com&password=correct-horse-battery")).await; |
|
| 165 | + | let r = call( |
|
| 166 | + | app, |
|
| 167 | + | "POST", |
|
| 168 | + | "/register", |
|
| 169 | + | None, |
|
| 170 | + | None, |
|
| 171 | + | None, |
|
| 172 | + | Some("username=bob&email=b%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 173 | + | ) |
|
| 174 | + | .await; |
|
| 157 | 175 | let bob = r.cookie.unwrap(); |
|
| 158 | 176 | let mint = |cookie: String, scopes: &'static str| { |
|
| 159 | 177 | let app = app.clone(); |
|
| … | 354 unchanged lines |
||
| 514 | 532 | async fn api_issue_triage_and_labels() { |
|
| 515 | 533 | let h = hub().await; |
|
| 516 | 534 | let app = &h.app; |
|
| 517 | - | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 518 | - | .await |
|
| 519 | - | .cookie |
|
| 520 | - | .unwrap(); |
|
| 521 | - | let bob = call(app, "POST", "/register", None, None, None, Some("username=bob&email=b%40example.com&password=correct-horse-battery")) |
|
| 522 | - | .await |
|
| 523 | - | .cookie |
|
| 524 | - | .unwrap(); |
|
| 535 | + | let andy = call( |
|
| 536 | + | app, |
|
| 537 | + | "POST", |
|
| 538 | + | "/register", |
|
| 539 | + | None, |
|
| 540 | + | None, |
|
| 541 | + | None, |
|
| 542 | + | Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 543 | + | ) |
|
| 544 | + | .await |
|
| 545 | + | .cookie |
|
| 546 | + | .unwrap(); |
|
| 547 | + | let bob = call( |
|
| 548 | + | app, |
|
| 549 | + | "POST", |
|
| 550 | + | "/register", |
|
| 551 | + | None, |
|
| 552 | + | None, |
|
| 553 | + | None, |
|
| 554 | + | Some("username=bob&email=b%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 555 | + | ) |
|
| 556 | + | .await |
|
| 557 | + | .cookie |
|
| 558 | + | .unwrap(); |
|
| 525 | 559 | let mint = |cookie: String, scopes: &'static str| { |
|
| 526 | 560 | let app = app.clone(); |
|
| 527 | 561 | async move { |
|
| … | 133 unchanged lines |
||
| 661 | 695 | async fn webhook_formats_editing_and_web_issue_events() { |
|
| 662 | 696 | let h = hub().await; |
|
| 663 | 697 | let app = &h.app; |
|
| 664 | - | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 665 | - | .await |
|
| 666 | - | .cookie |
|
| 667 | - | .unwrap(); |
|
| 698 | + | let andy = call( |
|
| 699 | + | app, |
|
| 700 | + | "POST", |
|
| 701 | + | "/register", |
|
| 702 | + | None, |
|
| 703 | + | None, |
|
| 704 | + | None, |
|
| 705 | + | Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 706 | + | ) |
|
| 707 | + | .await |
|
| 708 | + | .cookie |
|
| 709 | + | .unwrap(); |
|
| 668 | 710 | let page = call(app, "GET", "/settings/tokens", None, Some(&andy), None, None).await; |
|
| 669 | 711 | let r = call( |
|
| 670 | 712 | app, |
|
| … | 84 unchanged lines |
||
| 755 | 797 | async fn api_projects() { |
|
| 756 | 798 | let h = hub().await; |
|
| 757 | 799 | let app = &h.app; |
|
| 758 | - | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 759 | - | .await |
|
| 760 | - | .cookie |
|
| 761 | - | .unwrap(); |
|
| 762 | - | let bob = call(app, "POST", "/register", None, None, None, Some("username=bob&email=b%40example.com&password=correct-horse-battery")) |
|
| 763 | - | .await |
|
| 764 | - | .cookie |
|
| 765 | - | .unwrap(); |
|
| 800 | + | let andy = call( |
|
| 801 | + | app, |
|
| 802 | + | "POST", |
|
| 803 | + | "/register", |
|
| 804 | + | None, |
|
| 805 | + | None, |
|
| 806 | + | None, |
|
| 807 | + | Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 808 | + | ) |
|
| 809 | + | .await |
|
| 810 | + | .cookie |
|
| 811 | + | .unwrap(); |
|
| 812 | + | let bob = call( |
|
| 813 | + | app, |
|
| 814 | + | "POST", |
|
| 815 | + | "/register", |
|
| 816 | + | None, |
|
| 817 | + | None, |
|
| 818 | + | None, |
|
| 819 | + | Some("username=bob&email=b%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 820 | + | ) |
|
| 821 | + | .await |
|
| 822 | + | .cookie |
|
| 823 | + | .unwrap(); |
|
| 766 | 824 | let mint = |cookie: String, scopes: &'static str| { |
|
| 767 | 825 | let app = app.clone(); |
|
| 768 | 826 | async move { |
|
| … | 67 unchanged lines |
||
| 836 | 894 | async fn api_required_checks() { |
|
| 837 | 895 | let h = hub().await; |
|
| 838 | 896 | let app = &h.app; |
|
| 839 | - | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 840 | - | .await |
|
| 841 | - | .cookie |
|
| 842 | - | .unwrap(); |
|
| 897 | + | let andy = call( |
|
| 898 | + | app, |
|
| 899 | + | "POST", |
|
| 900 | + | "/register", |
|
| 901 | + | None, |
|
| 902 | + | None, |
|
| 903 | + | None, |
|
| 904 | + | Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 905 | + | ) |
|
| 906 | + | .await |
|
| 907 | + | .cookie |
|
| 908 | + | .unwrap(); |
|
| 843 | 909 | let page = call(app, "GET", "/settings/tokens", None, Some(&andy), None, None).await; |
|
| 844 | 910 | let csrf = csrf_of(&page.body); |
|
| 845 | 911 | let mint = |scopes: &'static str| { |
|
| … | 42 unchanged lines |
||
| 888 | 954 | async fn flow_is_switched_per_repository_by_site_admins() { |
|
| 889 | 955 | let h = hub().await; |
|
| 890 | 956 | let app = &h.app; |
|
| 891 | - | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 892 | - | .await |
|
| 893 | - | .cookie |
|
| 894 | - | .unwrap(); |
|
| 957 | + | let andy = call( |
|
| 958 | + | app, |
|
| 959 | + | "POST", |
|
| 960 | + | "/register", |
|
| 961 | + | None, |
|
| 962 | + | None, |
|
| 963 | + | None, |
|
| 964 | + | Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 965 | + | ) |
|
| 966 | + | .await |
|
| 967 | + | .cookie |
|
| 968 | + | .unwrap(); |
|
| 895 | 969 | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 896 | 970 | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await; |
|
| 897 | 971 | let row = || async { oxygenhub::db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap() }; |
|
| … | 45 unchanged lines |
||
| 943 | 1017 | async fn bookmarks_are_deleted_by_push_and_on_the_web() { |
|
| 944 | 1018 | let h = hub().await; |
|
| 945 | 1019 | let app = &h.app; |
|
| 946 | - | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 947 | - | .await |
|
| 948 | - | .cookie |
|
| 949 | - | .unwrap(); |
|
| 1020 | + | let andy = call( |
|
| 1021 | + | app, |
|
| 1022 | + | "POST", |
|
| 1023 | + | "/register", |
|
| 1024 | + | None, |
|
| 1025 | + | None, |
|
| 1026 | + | None, |
|
| 1027 | + | Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 1028 | + | ) |
|
| 1029 | + | .await |
|
| 1030 | + | .cookie |
|
| 1031 | + | .unwrap(); |
|
| 950 | 1032 | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 951 | 1033 | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await; |
|
| 952 | 1034 | let url = format!("{}/andy/demo", h.base); |
|
| … | 78 unchanged lines |
||
| 1031 | 1113 | async fn changes_signed_by_unlinked_keys_say_so() { |
|
| 1032 | 1114 | let h = hub().await; |
|
| 1033 | 1115 | let app = &h.app; |
|
| 1034 | - | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 1035 | - | .await |
|
| 1036 | - | .cookie |
|
| 1037 | - | .unwrap(); |
|
| 1116 | + | let andy = call( |
|
| 1117 | + | app, |
|
| 1118 | + | "POST", |
|
| 1119 | + | "/register", |
|
| 1120 | + | None, |
|
| 1121 | + | None, |
|
| 1122 | + | None, |
|
| 1123 | + | Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 1124 | + | ) |
|
| 1125 | + | .await |
|
| 1126 | + | .cookie |
|
| 1127 | + | .unwrap(); |
|
| 1038 | 1128 | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 1039 | 1129 | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await; |
|
| 1040 | 1130 | let url = format!("{}/andy/demo", h.base); |
|
| … | 28 unchanged lines |
||
| 1069 | 1159 | async fn a_function_has_its_own_history() { |
|
| 1070 | 1160 | let h = hub().await; |
|
| 1071 | 1161 | let app = &h.app; |
|
| 1072 | - | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 1073 | - | .await |
|
| 1074 | - | .cookie |
|
| 1075 | - | .unwrap(); |
|
| 1162 | + | let andy = call( |
|
| 1163 | + | app, |
|
| 1164 | + | "POST", |
|
| 1165 | + | "/register", |
|
| 1166 | + | None, |
|
| 1167 | + | None, |
|
| 1168 | + | None, |
|
| 1169 | + | Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 1170 | + | ) |
|
| 1171 | + | .await |
|
| 1172 | + | .cookie |
|
| 1173 | + | .unwrap(); |
|
| 1076 | 1174 | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 1077 | 1175 | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await; |
|
| 1078 | 1176 | let url = format!("{}/andy/demo", h.base); |
|
| … | 33 unchanged lines |
||
| 1112 | 1210 | async fn a_bookmark_shows_as_swimlanes() { |
|
| 1113 | 1211 | let h = hub().await; |
|
| 1114 | 1212 | let app = &h.app; |
|
| 1115 | - | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 1116 | - | .await |
|
| 1117 | - | .cookie |
|
| 1118 | - | .unwrap(); |
|
| 1213 | + | let andy = call( |
|
| 1214 | + | app, |
|
| 1215 | + | "POST", |
|
| 1216 | + | "/register", |
|
| 1217 | + | None, |
|
| 1218 | + | None, |
|
| 1219 | + | None, |
|
| 1220 | + | Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 1221 | + | ) |
|
| 1222 | + | .await |
|
| 1223 | + | .cookie |
|
| 1224 | + | .unwrap(); |
|
| 1119 | 1225 | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 1120 | 1226 | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await; |
|
| 1121 | 1227 | let url = format!("{}/andy/demo", h.base); |
|
| … | 33 unchanged lines |
||
| 1155 | 1261 | async fn a_proposal_board_plans_squashes_and_rebases() { |
|
| 1156 | 1262 | let h = hub().await; |
|
| 1157 | 1263 | let app = &h.app; |
|
| 1158 | - | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 1159 | - | .await |
|
| 1160 | - | .cookie |
|
| 1161 | - | .unwrap(); |
|
| 1264 | + | let andy = call( |
|
| 1265 | + | app, |
|
| 1266 | + | "POST", |
|
| 1267 | + | "/register", |
|
| 1268 | + | None, |
|
| 1269 | + | None, |
|
| 1270 | + | None, |
|
| 1271 | + | Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 1272 | + | ) |
|
| 1273 | + | .await |
|
| 1274 | + | .cookie |
|
| 1275 | + | .unwrap(); |
|
| 1162 | 1276 | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 1163 | 1277 | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await; |
|
| 1164 | 1278 | let url = format!("{}/andy/demo", h.base); |
|
| … | 65 unchanged lines |
||
| 1230 | 1344 | async fn proposals_and_issues_show_their_chain() { |
|
| 1231 | 1345 | let h = hub().await; |
|
| 1232 | 1346 | let app = &h.app; |
|
| 1233 | - | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 1234 | - | .await |
|
| 1235 | - | .cookie |
|
| 1236 | - | .unwrap(); |
|
| 1347 | + | let andy = call( |
|
| 1348 | + | app, |
|
| 1349 | + | "POST", |
|
| 1350 | + | "/register", |
|
| 1351 | + | None, |
|
| 1352 | + | None, |
|
| 1353 | + | None, |
|
| 1354 | + | Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 1355 | + | ) |
|
| 1356 | + | .await |
|
| 1357 | + | .cookie |
|
| 1358 | + | .unwrap(); |
|
| 1237 | 1359 | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 1238 | 1360 | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await; |
|
| 1239 | 1361 | let url = format!("{}/andy/demo", h.base); |
|
| … | 97 unchanged lines |
||
| 1337 | 1459 | async fn device_sign_in() { |
|
| 1338 | 1460 | let h = hub().await; |
|
| 1339 | 1461 | let app = &h.app; |
|
| 1340 | - | let r = |
|
| 1341 | - | call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await; |
|
| 1462 | + | let r = call( |
|
| 1463 | + | app, |
|
| 1464 | + | "POST", |
|
| 1465 | + | "/register", |
|
| 1466 | + | None, |
|
| 1467 | + | None, |
|
| 1468 | + | None, |
|
| 1469 | + | Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 1470 | + | ) |
|
| 1471 | + | .await; |
|
| 1342 | 1472 | let andy = r.cookie.unwrap(); |
|
| 1343 | 1473 | ||
| 1344 | 1474 | // An unknown scope is refused. |
|
| … | 63 unchanged lines |
||
| 1408 | 1538 | async fn api_assistants_at_each_level() { |
|
| 1409 | 1539 | let h = hub().await; |
|
| 1410 | 1540 | let app = &h.app; |
|
| 1411 | - | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 1412 | - | .await |
|
| 1413 | - | .cookie |
|
| 1414 | - | .unwrap(); |
|
| 1541 | + | let andy = call( |
|
| 1542 | + | app, |
|
| 1543 | + | "POST", |
|
| 1544 | + | "/register", |
|
| 1545 | + | None, |
|
| 1546 | + | None, |
|
| 1547 | + | None, |
|
| 1548 | + | Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 1549 | + | ) |
|
| 1550 | + | .await |
|
| 1551 | + | .cookie |
|
| 1552 | + | .unwrap(); |
|
| 1415 | 1553 | let page = call(app, "GET", "/settings/tokens", None, Some(&andy), None, None).await; |
|
| 1416 | 1554 | let csrf = csrf_of(&page.body); |
|
| 1417 | 1555 | let mint = |scopes: &'static str| { |
|
| … | 58 unchanged lines |
modified crates/hub/tests/apt.rs +2 −0
| … | 66 unchanged lines |
||
| 67 | 67 | } |
|
| 68 | 68 | let dir = tempfile::tempdir().unwrap(); |
|
| 69 | 69 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 70 | + | // The first account's setup code (oxygenhub#245). |
|
| 71 | + | config.registration.setup_code = "test-setup".into(); |
|
| 70 | 72 | config.apt.repos = vec!["andy/oxy".into(), "andy/hidden".into()]; |
|
| 71 | 73 | // The signing key, in the hub's data as on Earth. |
|
| 72 | 74 | let home = dir.path().join("apt-gnupg"); |
|
| … | 101 unchanged lines |
modified crates/hub/tests/backups.rs +4 −0
| … | 34 unchanged lines |
||
| 35 | 35 | hermetic(); |
|
| 36 | 36 | let dir = tempfile::tempdir().unwrap(); |
|
| 37 | 37 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 38 | + | // The first account's setup code (oxygenhub#245). |
|
| 39 | + | config.registration.setup_code = "test-setup".into(); |
|
| 38 | 40 | config.auth.push_tokens = vec!["site".into()]; |
|
| 39 | 41 | config.backups.directories = vec![backups.to_path_buf()]; |
|
| 40 | 42 | if let Some(g) = google { |
|
| … | 650 unchanged lines |
||
| 691 | 693 | hermetic(); |
|
| 692 | 694 | let dir = tempfile::tempdir().unwrap(); |
|
| 693 | 695 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 696 | + | // The first account's setup code (oxygenhub#245). |
|
| 697 | + | config.registration.setup_code = "test-setup".into(); |
|
| 694 | 698 | config.auth.push_tokens = vec!["site".into()]; |
|
| 695 | 699 | config.backups.directories = vec![backups.to_path_buf()]; |
|
| 696 | 700 | config.backups.aws_sts_url = aws.to_string(); |
|
| … | 194 unchanged lines |
modified crates/hub/tests/browse.rs +2 −0
| … | 38 unchanged lines |
||
| 39 | 39 | hermetic(); |
|
| 40 | 40 | let dir = tempfile::tempdir().unwrap(); |
|
| 41 | 41 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 42 | + | // The first account's setup code (oxygenhub#245). |
|
| 43 | + | config.registration.setup_code = "test-setup".into(); |
|
| 42 | 44 | config.auth.push_tokens = push_tokens; |
|
| 43 | 45 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 44 | 46 | let port = listener.local_addr().unwrap().port(); |
|
| … | 259 unchanged lines |
modified crates/hub/tests/edits.rs +22 −4
| … | 104 unchanged lines |
||
| 105 | 105 | hermetic(); |
|
| 106 | 106 | let dir = tempfile::tempdir().unwrap(); |
|
| 107 | 107 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 108 | + | // The first account's setup code (oxygenhub#245). |
|
| 109 | + | config.registration.setup_code = "test-setup".into(); |
|
| 108 | 110 | config.auth.push_tokens = vec!["site".into()]; |
|
| 109 | 111 | config.registration.mode = "open".into(); |
|
| 110 | 112 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| … | 5 unchanged lines |
||
| 116 | 118 | let base = format!("http://127.0.0.1:{port}"); |
|
| 117 | 119 | let app = &app; |
|
| 118 | 120 | ||
| 119 | - | let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await; |
|
| 121 | + | let r = |
|
| 122 | + | send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 123 | + | .await; |
|
| 120 | 124 | let andy = r.cookie.unwrap(); |
|
| 121 | 125 | let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id; |
|
| 122 | 126 | let repo = RepoName::parse("andy/demo").unwrap(); |
|
| … | 365 unchanged lines |
||
| 488 | 492 | hermetic(); |
|
| 489 | 493 | let dir = tempfile::tempdir().unwrap(); |
|
| 490 | 494 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 495 | + | // The first account's setup code (oxygenhub#245). |
|
| 496 | + | config.registration.setup_code = "test-setup".into(); |
|
| 491 | 497 | config.auth.push_tokens = vec!["site".into()]; |
|
| 492 | 498 | config.registration.mode = "open".into(); |
|
| 493 | 499 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| … | 4 unchanged lines |
||
| 498 | 504 | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 499 | 505 | let base = format!("http://127.0.0.1:{port}"); |
|
| 500 | 506 | let app = &app; |
|
| 501 | - | let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await; |
|
| 507 | + | let r = |
|
| 508 | + | send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 509 | + | .await; |
|
| 502 | 510 | assert!(r.cookie.is_some()); |
|
| 503 | 511 | let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id; |
|
| 504 | 512 | let repo = RepoName::parse("andy/demo").unwrap(); |
|
| … | 75 unchanged lines |
||
| 580 | 588 | hermetic(); |
|
| 581 | 589 | let dir = tempfile::tempdir().unwrap(); |
|
| 582 | 590 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 591 | + | // The first account's setup code (oxygenhub#245). |
|
| 592 | + | config.registration.setup_code = "test-setup".into(); |
|
| 583 | 593 | config.auth.push_tokens = vec!["site".into()]; |
|
| 584 | 594 | config.registration.mode = "open".into(); |
|
| 585 | 595 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| … | 4 unchanged lines |
||
| 590 | 600 | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 591 | 601 | let base = format!("http://127.0.0.1:{port}"); |
|
| 592 | 602 | let app = &app; |
|
| 593 | - | let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await; |
|
| 603 | + | let r = |
|
| 604 | + | send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 605 | + | .await; |
|
| 594 | 606 | let andy = r.cookie.unwrap(); |
|
| 595 | 607 | let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id; |
|
| 596 | 608 | db::accounts::create_org(&app.db, "doomsday", "Doomsday One", andy_id).await.unwrap(); |
|
| … | 67 unchanged lines |
||
| 664 | 676 | hermetic(); |
|
| 665 | 677 | let dir = tempfile::tempdir().unwrap(); |
|
| 666 | 678 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 679 | + | // The first account's setup code (oxygenhub#245). |
|
| 680 | + | config.registration.setup_code = "test-setup".into(); |
|
| 667 | 681 | config.auth.push_tokens = vec!["site".into()]; |
|
| 668 | 682 | config.registration.mode = "open".into(); |
|
| 669 | 683 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| … | 4 unchanged lines |
||
| 674 | 688 | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 675 | 689 | let base = format!("http://127.0.0.1:{port}"); |
|
| 676 | 690 | let app = &app; |
|
| 677 | - | let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await; |
|
| 691 | + | let r = |
|
| 692 | + | send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 693 | + | .await; |
|
| 678 | 694 | let andy = r.cookie.unwrap(); |
|
| 679 | 695 | let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id; |
|
| 680 | 696 | let repo = RepoName::parse("andy/demo").unwrap(); |
|
| … | 124 unchanged lines |
||
| 805 | 821 | hermetic(); |
|
| 806 | 822 | let dir = tempfile::tempdir().unwrap(); |
|
| 807 | 823 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 824 | + | // The first account's setup code (oxygenhub#245). |
|
| 825 | + | config.registration.setup_code = "test-setup".into(); |
|
| 808 | 826 | config.auth.push_tokens = vec!["site".into()]; |
|
| 809 | 827 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 810 | 828 | let port = listener.local_addr().unwrap().port(); |
|
| … | 35 unchanged lines |
modified crates/hub/tests/flow.rs +9 −7
| … | 40 unchanged lines |
||
| 41 | 41 | hermetic(); |
|
| 42 | 42 | let dir = tempfile::tempdir().unwrap(); |
|
| 43 | 43 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 44 | + | // The first account's setup code (oxygenhub#245). |
|
| 45 | + | config.registration.setup_code = "test-setup".into(); |
|
| 44 | 46 | config.auth.push_tokens = vec!["site".into()]; |
|
| 45 | 47 | config.flow.runner_tokens = vec!["runner-secret".into(), "runner-two".into(), "runner-three".into()]; |
|
| 46 | 48 | config.flow.repos = vec!["andy/*".into()]; |
|
| … | 875 unchanged lines |
||
| 922 | 924 | Request::post("/register").header(header::CONTENT_TYPE, "application/x-www-form-urlencoded").body(Body::from(f)).unwrap() |
|
| 923 | 925 | }; |
|
| 924 | 926 | let res = oxygenhub::router(h.app.clone()) |
|
| 925 | - | .oneshot(form("username=andy&email=a%40example.com&password=correct-horse-battery".into())) |
|
| 927 | + | .oneshot(form("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup".into())) |
|
| 926 | 928 | .await |
|
| 927 | 929 | .unwrap(); |
|
| 928 | 930 | let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string(); |
|
| … | 352 unchanged lines |
||
| 1281 | 1283 | .oneshot( |
|
| 1282 | 1284 | Request::post("/register") |
|
| 1283 | 1285 | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 1284 | - | .body(Body::from("username=carol&email=carol%40example.com&password=correct-horse-battery")) |
|
| 1286 | + | .body(Body::from("username=carol&email=carol%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 1285 | 1287 | .unwrap(), |
|
| 1286 | 1288 | ) |
|
| 1287 | 1289 | .await |
|
| … | 207 unchanged lines |
||
| 1495 | 1497 | .oneshot( |
|
| 1496 | 1498 | Request::post("/register") |
|
| 1497 | 1499 | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 1498 | - | .body(Body::from("username=andy&email=andy%40example.com&password=correct-horse-battery")) |
|
| 1500 | + | .body(Body::from("username=andy&email=andy%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 1499 | 1501 | .unwrap(), |
|
| 1500 | 1502 | ) |
|
| 1501 | 1503 | .await |
|
| … | 485 unchanged lines |
||
| 1987 | 1989 | .oneshot( |
|
| 1988 | 1990 | Request::post("/register") |
|
| 1989 | 1991 | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 1990 | - | .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 1992 | + | .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 1991 | 1993 | .unwrap(), |
|
| 1992 | 1994 | ) |
|
| 1993 | 1995 | .await |
|
| … | 76 unchanged lines |
||
| 2070 | 2072 | .oneshot( |
|
| 2071 | 2073 | Request::post("/register") |
|
| 2072 | 2074 | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 2073 | - | .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 2075 | + | .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 2074 | 2076 | .unwrap(), |
|
| 2075 | 2077 | ) |
|
| 2076 | 2078 | .await |
|
| … | 1046 unchanged lines |
||
| 3123 | 3125 | .oneshot( |
|
| 3124 | 3126 | Request::post("/register") |
|
| 3125 | 3127 | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 3126 | - | .body(Body::from("username=carol&email=carol%40example.com&password=correct-horse-battery")) |
|
| 3128 | + | .body(Body::from("username=carol&email=carol%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 3127 | 3129 | .unwrap(), |
|
| 3128 | 3130 | ) |
|
| 3129 | 3131 | .await |
|
| … | 967 unchanged lines |
||
| 4097 | 4099 | let register = |name: &'static str| { |
|
| 4098 | 4100 | let app = h.app.clone(); |
|
| 4099 | 4101 | async move { |
|
| 4100 | - | let body = format!("username={name}&email={name}%40example.com&password=correct-horse-battery"); |
|
| 4102 | + | let body = format!("username={name}&email={name}%40example.com&password=correct-horse-battery&setup=test-setup"); |
|
| 4101 | 4103 | let res = oxygenhub::router(app) |
|
| 4102 | 4104 | .oneshot( |
|
| 4103 | 4105 | Request::post("/register") |
|
| … | 66 unchanged lines |
modified crates/hub/tests/issues.rs +9 −1
| … | 38 unchanged lines |
||
| 39 | 39 | hermetic(); |
|
| 40 | 40 | let dir = tempfile::tempdir().unwrap(); |
|
| 41 | 41 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 42 | + | // The first account's setup code (oxygenhub#245). |
|
| 43 | + | config.registration.setup_code = "test-setup".into(); |
|
| 42 | 44 | config.auth.push_tokens = vec!["site".into()]; |
|
| 43 | 45 | config.registration.mode = "open".into(); |
|
| 44 | 46 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| … | 47 unchanged lines |
||
| 92 | 94 | } |
|
| 93 | 95 | ||
| 94 | 96 | async fn register(app: &Shared, user: &str) -> String { |
|
| 95 | - | let r = post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery")).await; |
|
| 97 | + | let r = post( |
|
| 98 | + | app, |
|
| 99 | + | "/register", |
|
| 100 | + | None, |
|
| 101 | + | &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 102 | + | ) |
|
| 103 | + | .await; |
|
| 96 | 104 | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 97 | 105 | r.cookie.unwrap() |
|
| 98 | 106 | } |
|
| … | 1085 unchanged lines |
modified crates/hub/tests/maintenance.rs +2 −0
| … | 26 unchanged lines |
||
| 27 | 27 | hermetic(); |
|
| 28 | 28 | let dir = tempfile::tempdir().unwrap(); |
|
| 29 | 29 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 30 | + | // The first account's setup code (oxygenhub#245). |
|
| 31 | + | config.registration.setup_code = "test-setup".into(); |
|
| 30 | 32 | config.auth.push_tokens = vec!["site".into()]; |
|
| 31 | 33 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 32 | 34 | let port = listener.local_addr().unwrap().port(); |
|
| … | 40 unchanged lines |
modified crates/hub/tests/proposals.rs +9 −1
| … | 38 unchanged lines |
||
| 39 | 39 | hermetic(); |
|
| 40 | 40 | let dir = tempfile::tempdir().unwrap(); |
|
| 41 | 41 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 42 | + | // The first account's setup code (oxygenhub#245). |
|
| 43 | + | config.registration.setup_code = "test-setup".into(); |
|
| 42 | 44 | config.auth.push_tokens = vec!["site".into()]; |
|
| 43 | 45 | config.registration.mode = "open".into(); |
|
| 44 | 46 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| … | 63 unchanged lines |
||
| 108 | 110 | } |
|
| 109 | 111 | ||
| 110 | 112 | async fn register(app: &Shared, user: &str) -> String { |
|
| 111 | - | let r = post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery")).await; |
|
| 113 | + | let r = post( |
|
| 114 | + | app, |
|
| 115 | + | "/register", |
|
| 116 | + | None, |
|
| 117 | + | &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 118 | + | ) |
|
| 119 | + | .await; |
|
| 112 | 120 | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 113 | 121 | r.cookie.unwrap() |
|
| 114 | 122 | } |
|
| … | 611 unchanged lines |
modified crates/hub/tests/recovery.rs +5 −3
| … | 30 unchanged lines |
||
| 31 | 31 | let mailbox = dir.path().join("mailbox"); |
|
| 32 | 32 | std::fs::create_dir_all(&mailbox).unwrap(); |
|
| 33 | 33 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 34 | + | // The first account's setup code (oxygenhub#245). |
|
| 35 | + | config.registration.setup_code = "test-setup".into(); |
|
| 34 | 36 | config.server.base_url = "http://hub.test".into(); |
|
| 35 | 37 | config.registration.mode = "open".into(); |
|
| 36 | 38 | if mail { |
|
| … | 78 unchanged lines |
||
| 115 | 117 | async fn verification_and_reset() { |
|
| 116 | 118 | let h = hub(true).await; |
|
| 117 | 119 | let app = &h.app; |
|
| 118 | - | let r = post(app, "/register", None, "username=andy&email=andy%40example.com&password=correct-horse-battery").await; |
|
| 120 | + | let r = post(app, "/register", None, "username=andy&email=andy%40example.com&password=correct-horse-battery&setup=test-setup").await; |
|
| 119 | 121 | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 120 | 122 | let old_session = r.cookie.unwrap(); |
|
| 121 | 123 | ||
| … | 41 unchanged lines |
||
| 163 | 165 | assert!(get(app, &reset, None).await.body.contains("expired or was already used"), "single use"); |
|
| 164 | 166 | let stale = get(app, "/settings", Some(&old_session)).await; |
|
| 165 | 167 | assert!(stale.status.is_redirection() || !stale.body.contains("name=\"_csrf\""), "the old session ended"); |
|
| 166 | - | let wrong = post(app, "/login", None, "username=andy&password=correct-horse-battery").await; |
|
| 168 | + | let wrong = post(app, "/login", None, "username=andy&password=correct-horse-battery&setup=test-setup").await; |
|
| 167 | 169 | assert!(wrong.body.contains("Wrong username or password")); |
|
| 168 | 170 | let signed = post(app, "/login", None, "username=andy&password=new-password-long-enough").await; |
|
| 169 | 171 | assert_eq!(signed.status, StatusCode::SEE_OTHER); |
|
| … | 27 unchanged lines |
||
| 197 | 199 | assert!(again.body.contains("less than two minutes ago"), "{}", again.body); |
|
| 198 | 200 | ||
| 199 | 201 | // An administrator's link, for an account without a verified email. |
|
| 200 | - | post(app, "/register", None, "username=bob&email=&password=bob-password-long-enough").await; |
|
| 202 | + | post(app, "/register", None, "username=bob&email=&password=bob-password-long-enough&setup=test-setup").await; |
|
| 201 | 203 | let admin = get(app, "/admin", Some(&session)).await; |
|
| 202 | 204 | assert!(admin.body.contains("value=\"reset_link\"")); |
|
| 203 | 205 | let r = post(app, "/admin/account", Some(&session), &format!("_csrf={}&username=bob&action=reset_link", csrf_of(&admin.body))).await; |
|
| … | 18 unchanged lines |
modified crates/hub/tests/releases.rs +3 −1
| … | 89 unchanged lines |
||
| 90 | 90 | hermetic(); |
|
| 91 | 91 | let dir = tempfile::tempdir().unwrap(); |
|
| 92 | 92 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 93 | + | // The first account's setup code (oxygenhub#245). |
|
| 94 | + | config.registration.setup_code = "test-setup".into(); |
|
| 93 | 95 | config.auth.push_tokens = vec!["site".into()]; |
|
| 94 | 96 | config.registration.mode = "open".into(); |
|
| 95 | 97 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| … | 11 unchanged lines |
||
| 107 | 109 | None, |
|
| 108 | 110 | None, |
|
| 109 | 111 | Some("application/x-www-form-urlencoded"), |
|
| 110 | - | b"username=andy&email=a%40example.com&password=correct-horse-battery".to_vec(), |
|
| 112 | + | b"username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup".to_vec(), |
|
| 111 | 113 | ) |
|
| 112 | 114 | .await |
|
| 113 | 115 | .cookie |
|
| … | 249 unchanged lines |
modified crates/hub/tests/storage.rs +10 −2
| … | 33 unchanged lines |
||
| 34 | 34 | hermetic(); |
|
| 35 | 35 | let dir = tempfile::tempdir().unwrap(); |
|
| 36 | 36 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 37 | + | // The first account's setup code (oxygenhub#245). |
|
| 38 | + | config.registration.setup_code = "test-setup".into(); |
|
| 37 | 39 | config.auth.push_tokens = vec!["site".into()]; |
|
| 38 | 40 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 39 | 41 | let port = listener.local_addr().unwrap().port(); |
|
| … | 43 unchanged lines |
||
| 83 | 85 | } |
|
| 84 | 86 | ||
| 85 | 87 | async fn andy(app: &Shared) -> String { |
|
| 86 | - | let (st, body, cookie) = |
|
| 87 | - | send(app, "POST", "/register", None, Some("username=andy&email=andy%40example.com&password=correct-horse-battery&invite=")).await; |
|
| 88 | + | let (st, body, cookie) = send( |
|
| 89 | + | app, |
|
| 90 | + | "POST", |
|
| 91 | + | "/register", |
|
| 92 | + | None, |
|
| 93 | + | Some("username=andy&email=andy%40example.com&password=correct-horse-battery&setup=test-setup&invite="), |
|
| 94 | + | ) |
|
| 95 | + | .await; |
|
| 88 | 96 | assert_eq!(st, StatusCode::SEE_OTHER, "{body}"); |
|
| 89 | 97 | cookie.expect("session") |
|
| 90 | 98 | } |
|
| … | 171 unchanged lines |
modified docs/spec.md +1 −1
| … | 168 unchanged lines |
||
| 169 | 169 | ||
| 170 | 170 | ### 5.1 Accounts |
|
| 171 | 171 | ||
| 172 | - | An account has a username (same grammar as an owner name), a display name, an email (verified before it is used for anything), an Argon2id password hash, an optional TOTP secret, a session salt (rotating it signs out every session), and a set of linked Oxygen identities (§5.3). The first account created becomes the site administrator. Registration is open, invitation only, or closed, per configuration. |
|
| 172 | + | An account has a username (same grammar as an owner name), a display name, an email (verified before it is used for anything), an Argon2id password hash, an optional TOTP secret, a session salt (rotating it signs out every session), and a set of linked Oxygen identities (§5.3). The first account created becomes the site administrator, and only with the hub's one-time setup code (oxygenhub#245): with no accounts, the hub keeps a random code in `<data>/setup-code` (owner-only, never served) and logs it at start, or uses `[registration] setup_code` (`OXYHUB_REGISTRATION_SETUP_CODE`) for automation; registering with it spends it, and a sign-in provider never makes the first account. `oxygenhub admin user create --admin` needs no code. Registration is open, invitation only, or closed, per configuration. |
|
| 173 | 173 | ||
| 174 | 174 | Sessions are server-side records (oxygenhub#223): the cookie `oxyhub=<id>` holds 32 random bytes in hex, and the hub keeps only the id's BLAKE3 hash, with the account, the account's session salt at sign-in, the times it was made and last used, and the address and browser it came from. A session is good while its salt is still the account's, it has been used in the last 14 days, and it is under 30 days old; the cookie is `HttpOnly`, `Secure` when served over HTTPS, `SameSite=Lax`. Every sign-in (after the code, with two-factor) makes a new session; signing out deletes its record. Password changes, resets and "sign out everywhere" rotate the salt, which ends every session at once; Settings › Security lists the signed-in browsers and signs any one out. Ended sessions are swept nightly. CSRF tokens are bound to the session id. Sign-in is also slowed against guessing (§15.6, oxygenhub#221). Passkeys (WebAuthn) are planned for 1.0 as a second factor or password replacement. |
|
| 175 | 175 | ||
| … | 584 unchanged lines |
modified templates/register.html +1 −0
| … | 13 unchanged lines |
||
| 14 | 14 | <label for="username">Username</label><input type="text" id="username" name="username" autocomplete="username" required pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}"> |
|
| 15 | 15 | <label for="email">Email</label><input type="email" id="email" name="email" autocomplete="email"> |
|
| 16 | 16 | <label for="password">Password (at least 10 characters)</label><input type="password" id="password" name="password" autocomplete="new-password" required minlength="10"> |
|
| 17 | + | {% if first %}<label for="setup">Setup code <small>logged by the server when it started, and in <code>setup-code</code> in its data directory</small></label><input type="text" id="setup" name="setup" autocomplete="off" required>{% endif %} |
|
| 17 | 18 | {% if !first && mode == "invite" %}<label for="invite">Invitation code</label><input type="text" id="invite" name="invite" value="{{ invite }}" required>{% endif %} |
|
| 18 | 19 | <button type="submit">CREATE ACCOUNT</button> |
|
| 19 | 20 | </form> |
|
| … | 5 unchanged lines |