Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +5 −0
| @@ -462,6 +462,11 @@ label.checkbox input { | |||
| 462 | 462 | justify-content: center; | |
| 463 | 463 | margin-top: 1.25rem; | |
| 464 | 464 | } | |
| 465 | + | .auth-alt { | |
| 466 | + | margin: 1rem 0 0; | |
| 467 | + | text-align: center; | |
| 468 | + | font-size: 0.9em; | |
| 469 | + | } | |
| 465 | 470 | ||
| 466 | 471 | /* ---- Flash / notices ---- */ | |
| 467 | 472 | .notice { | |
crates/config/src/types.rs +5 −0
| @@ -106,6 +106,10 @@ pub struct Instance { | |||
| 106 | 106 | /// Default visibility for newly created repositories. | |
| 107 | 107 | #[serde(default)] | |
| 108 | 108 | pub default_visibility: DefaultVisibility, | |
| 109 | + | /// Allow web self-registration (sign-up). Off by default; admin invites work | |
| 110 | + | /// regardless. | |
| 111 | + | #[serde(default)] | |
| 112 | + | pub allow_registration: bool, | |
| 109 | 113 | } | |
| 110 | 114 | ||
| 111 | 115 | impl Default for Instance { | |
| @@ -117,6 +121,7 @@ impl Default for Instance { | |||
| 117 | 121 | default_branch: "main".to_string(), | |
| 118 | 122 | allow_anonymous: true, | |
| 119 | 123 | default_visibility: DefaultVisibility::Private, | |
| 124 | + | allow_registration: false, | |
| 120 | 125 | } | |
| 121 | 126 | } | |
| 122 | 127 | } | |
crates/web/src/layout.rs +5 −0
| @@ -29,6 +29,8 @@ pub struct Chrome { | |||
| 29 | 29 | pub themes: Vec<ThemeInfo>, | |
| 30 | 30 | /// Whether the theme picker is shown. | |
| 31 | 31 | pub allow_theme_choice: bool, | |
| 32 | + | /// Whether web self-registration is enabled (drives the Sign-up links). | |
| 33 | + | pub allow_registration: bool, | |
| 32 | 34 | /// The current request path, for the theme form's return and nav highlighting. | |
| 33 | 35 | pub path: String, | |
| 34 | 36 | /// The CSRF token for this session, injected into htmx and forms. | |
| @@ -113,6 +115,9 @@ fn drawer(chrome: &Chrome) -> Markup { | |||
| 113 | 115 | } | |
| 114 | 116 | } @else { | |
| 115 | 117 | a href="/login" { (icon(Icon::User)) span { "Sign in" } } | |
| 118 | + | @if chrome.allow_registration { | |
| 119 | + | a href="/register" { (icon(Icon::Plus)) span { "Sign up" } } | |
| 120 | + | } | |
| 116 | 121 | } | |
| 117 | 122 | } | |
| 118 | 123 | } | |
crates/web/src/lib.rs +4 −0
| @@ -136,6 +136,10 @@ pub fn build_router(state: AppState) -> Router { | |||
| 136 | 136 | .route("/explore", get(pages::explore)) | |
| 137 | 137 | .route("/explore/users", get(pages::explore_users)) | |
| 138 | 138 | .route("/login", get(pages::login_form).post(pages::login_submit)) | |
| 139 | + | .route( | |
| 140 | + | "/register", | |
| 141 | + | get(pages::register_form).post(pages::register_submit), | |
| 142 | + | ) | |
| 139 | 143 | .route("/logout", post(pages::logout)) | |
| 140 | 144 | .route( | |
| 141 | 145 | "/invite/{token}", | |
crates/web/src/pages.rs +163 −3
| @@ -52,6 +52,7 @@ pub(crate) fn build_chrome( | |||
| 52 | 52 | active_scheme: scheme, | |
| 53 | 53 | themes, | |
| 54 | 54 | allow_theme_choice: state.config.ui.allow_theme_choice, | |
| 55 | + | allow_registration: state.config.instance.allow_registration, | |
| 55 | 56 | path, | |
| 56 | 57 | csrf, | |
| 57 | 58 | }; | |
| @@ -435,12 +436,12 @@ pub async fn login_form( | |||
| 435 | 436 | return Redirect::to("/").into_response(); | |
| 436 | 437 | } | |
| 437 | 438 | let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string()); | |
| 438 | - | let body = login_body(&chrome.csrf, None); | |
| 439 | + | let body = login_body(&chrome.csrf, None, chrome.allow_registration); | |
| 439 | 440 | (jar, page(&chrome, "Sign in", body)).into_response() | |
| 440 | 441 | } | |
| 441 | 442 | ||
| 442 | 443 | /// Render the login form with an optional error notice. | |
| 443 | - | fn login_body(csrf: &str, error: Option<&str>) -> Markup { | |
| 444 | + | fn login_body(csrf: &str, error: Option<&str>, allow_registration: bool) -> Markup { | |
| 444 | 445 | html! { | |
| 445 | 446 | div class="auth-wrap" { | |
| 446 | 447 | div class="auth-card" { | |
| @@ -457,6 +458,9 @@ fn login_body(csrf: &str, error: Option<&str>) -> Markup { | |||
| 457 | 458 | autocomplete="current-password" required; | |
| 458 | 459 | button class="btn btn-primary" type="submit" { "Sign in" } | |
| 459 | 460 | } | |
| 461 | + | @if allow_registration { | |
| 462 | + | p class="muted auth-alt" { "New here? " a href="/register" { "Create an account" } } | |
| 463 | + | } | |
| 460 | 464 | } | |
| 461 | 465 | } | |
| 462 | 466 | } | |
| @@ -489,7 +493,11 @@ pub async fn login_submit( | |||
| 489 | 493 | let Some(user) = user.filter(|_| ok) else { | |
| 490 | 494 | // Re-render the form with a generic error (no username enumeration). | |
| 491 | 495 | let (jar, chrome) = build_chrome(&state, jar, None, "/login".to_string()); | |
| 492 | - | let body = login_body(&chrome.csrf, Some("Incorrect username or password.")); | |
| 496 | + | let body = login_body( | |
| 497 | + | &chrome.csrf, | |
| 498 | + | Some("Incorrect username or password."), | |
| 499 | + | chrome.allow_registration, | |
| 500 | + | ); | |
| 493 | 501 | return ( | |
| 494 | 502 | axum::http::StatusCode::UNAUTHORIZED, | |
| 495 | 503 | jar, | |
| @@ -536,6 +544,158 @@ async fn open_session( | |||
| 536 | 544 | Ok(jar.clone().add(cookie)) | |
| 537 | 545 | } | |
| 538 | 546 | ||
| 547 | + | /// Sign-up form fields. | |
| 548 | + | #[derive(Debug, Deserialize)] | |
| 549 | + | pub struct RegisterForm { | |
| 550 | + | /// Desired username. | |
| 551 | + | #[serde(default)] | |
| 552 | + | username: String, | |
| 553 | + | /// Email address. | |
| 554 | + | #[serde(default)] | |
| 555 | + | email: String, | |
| 556 | + | /// Password. | |
| 557 | + | #[serde(default)] | |
| 558 | + | password: String, | |
| 559 | + | /// Confirmation. | |
| 560 | + | #[serde(default)] | |
| 561 | + | confirm: String, | |
| 562 | + | /// CSRF token. | |
| 563 | + | #[serde(rename = "_csrf")] | |
| 564 | + | csrf: String, | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | /// `GET /register` — the sign-up form, or `404` when registration is disabled. | |
| 568 | + | pub async fn register_form( | |
| 569 | + | State(state): State<AppState>, | |
| 570 | + | MaybeUser(user): MaybeUser, | |
| 571 | + | jar: CookieJar, | |
| 572 | + | uri: Uri, | |
| 573 | + | ) -> Response { | |
| 574 | + | if !state.config.instance.allow_registration { | |
| 575 | + | return AppError::NotFound.into_response(); | |
| 576 | + | } | |
| 577 | + | if user.is_some() { | |
| 578 | + | return Redirect::to("/").into_response(); | |
| 579 | + | } | |
| 580 | + | let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string()); | |
| 581 | + | let body = register_body(&chrome.csrf, None, "", ""); | |
| 582 | + | (jar, page(&chrome, "Sign up", body)).into_response() | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | /// Render the sign-up form with an optional error and prefilled fields. | |
| 586 | + | fn register_body(csrf: &str, error: Option<&str>, username: &str, email: &str) -> Markup { | |
| 587 | + | html! { | |
| 588 | + | div class="auth-wrap" { | |
| 589 | + | div class="auth-card" { | |
| 590 | + | h1 { "Sign up" } | |
| 591 | + | @if let Some(err) = error { | |
| 592 | + | div class="notice notice-error" { (err) } | |
| 593 | + | } | |
| 594 | + | form method="post" action="/register" { | |
| 595 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 596 | + | label for="username" { "Username" } | |
| 597 | + | input type="text" id="username" name="username" value=(username) | |
| 598 | + | autocomplete="username" required; | |
| 599 | + | label for="email" { "Email" } | |
| 600 | + | input type="email" id="email" name="email" value=(email) | |
| 601 | + | autocomplete="email" required; | |
| 602 | + | label for="password" { "Password" } | |
| 603 | + | input type="password" id="password" name="password" | |
| 604 | + | autocomplete="new-password" required minlength="8"; | |
| 605 | + | label for="confirm" { "Confirm password" } | |
| 606 | + | input type="password" id="confirm" name="confirm" | |
| 607 | + | autocomplete="new-password" required; | |
| 608 | + | button class="btn btn-primary" type="submit" { "Create account" } | |
| 609 | + | } | |
| 610 | + | p class="muted auth-alt" { "Already have an account? " a href="/login" { "Sign in" } } | |
| 611 | + | } | |
| 612 | + | } | |
| 613 | + | } | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | /// `POST /register` — create an account and sign in. | |
| 617 | + | pub async fn register_submit( | |
| 618 | + | State(state): State<AppState>, | |
| 619 | + | jar: CookieJar, | |
| 620 | + | headers: HeaderMap, | |
| 621 | + | Form(form): Form<RegisterForm>, | |
| 622 | + | ) -> Response { | |
| 623 | + | if !state.config.instance.allow_registration { | |
| 624 | + | return AppError::NotFound.into_response(); | |
| 625 | + | } | |
| 626 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 627 | + | return AppError::Forbidden.into_response(); | |
| 628 | + | } | |
| 629 | + | let username = form.username.trim().to_string(); | |
| 630 | + | let email = form.email.trim().to_string(); | |
| 631 | + | ||
| 632 | + | let reject = |msg: &str, username: &str, email: &str| { | |
| 633 | + | let (jar, chrome) = build_chrome(&state, jar.clone(), None, "/register".to_string()); | |
| 634 | + | let body = register_body(&chrome.csrf, Some(msg), username, email); | |
| 635 | + | ( | |
| 636 | + | axum::http::StatusCode::BAD_REQUEST, | |
| 637 | + | jar, | |
| 638 | + | page(&chrome, "Sign up", body), | |
| 639 | + | ) | |
| 640 | + | .into_response() | |
| 641 | + | }; | |
| 642 | + | ||
| 643 | + | if !email.contains('@') { | |
| 644 | + | return reject("Enter a valid email address.", &username, &email); | |
| 645 | + | } | |
| 646 | + | if form.password != form.confirm { | |
| 647 | + | return reject("Passwords do not match.", &username, &email); | |
| 648 | + | } | |
| 649 | + | if form.password.len() < 8 { | |
| 650 | + | return reject("Password must be at least 8 characters.", &username, &email); | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | let params = auth::HashParams { | |
| 654 | + | m_cost: state.config.auth.argon2_m_cost, | |
| 655 | + | t_cost: state.config.auth.argon2_t_cost, | |
| 656 | + | p_cost: state.config.auth.argon2_p_cost, | |
| 657 | + | }; | |
| 658 | + | let Ok(hash) = auth::hash_password(&form.password, params) else { | |
| 659 | + | return AppError::internal("password hashing failed").into_response(); | |
| 660 | + | }; | |
| 661 | + | ||
| 662 | + | let created = state | |
| 663 | + | .store | |
| 664 | + | .create_user(store::NewUser { | |
| 665 | + | username: username.clone(), | |
| 666 | + | email: email.clone(), | |
| 667 | + | display_name: None, | |
| 668 | + | password_hash: Some(hash), | |
| 669 | + | is_admin: false, | |
| 670 | + | must_change_password: false, | |
| 671 | + | }) | |
| 672 | + | .await; | |
| 673 | + | let user = match created { | |
| 674 | + | Ok(u) => u, | |
| 675 | + | Err(store::StoreError::Conflict { field, .. }) => { | |
| 676 | + | let msg = if field == "email" { | |
| 677 | + | "That email is already in use." | |
| 678 | + | } else { | |
| 679 | + | "That username is already taken." | |
| 680 | + | }; | |
| 681 | + | return reject(msg, &username, &email); | |
| 682 | + | } | |
| 683 | + | Err(store::StoreError::Name(_)) => { | |
| 684 | + | return reject( | |
| 685 | + | "Invalid username: use letters, digits, '-', and '_'.", | |
| 686 | + | &username, | |
| 687 | + | &email, | |
| 688 | + | ); | |
| 689 | + | } | |
| 690 | + | Err(err) => return AppError::from(err).into_response(), | |
| 691 | + | }; | |
| 692 | + | ||
| 693 | + | match open_session(&state, &jar, &user, &headers).await { | |
| 694 | + | Ok(jar) => (jar, Redirect::to("/")).into_response(), | |
| 695 | + | Err(err) => err.into_response(), | |
| 696 | + | } | |
| 697 | + | } | |
| 698 | + | ||
| 539 | 699 | /// Logout form field. | |
| 540 | 700 | #[derive(Debug, Deserialize)] | |
| 541 | 701 | pub struct CsrfForm { | |
crates/web/src/tests.rs +61 −0
| @@ -468,6 +468,67 @@ async fn settings_page_shows_account_and_tokens() { | |||
| 468 | 468 | assert!(html.contains("API tokens"), "tokens section"); | |
| 469 | 469 | } | |
| 470 | 470 | ||
| 471 | + | /// An app with self-registration enabled (and no seeded user). | |
| 472 | + | async fn app_with_registration() -> Router { | |
| 473 | + | let store = Store::connect("sqlite::memory:", 1).await.unwrap(); | |
| 474 | + | store.migrate().await.unwrap(); | |
| 475 | + | let mut config = Config::default(); | |
| 476 | + | config.instance.name = "Forge".to_string(); | |
| 477 | + | config.instance.allow_registration = true; | |
| 478 | + | config.auth.cookie_secure = false; | |
| 479 | + | let state = AppState::build(Arc::new(config), store, "test-secret".to_string()).unwrap(); | |
| 480 | + | crate::build_router(state) | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | #[tokio::test] | |
| 484 | + | async fn registration_is_404_when_disabled() { | |
| 485 | + | let (_state, app) = app().await; | |
| 486 | + | let res = app.oneshot(get("/register")).await.unwrap(); | |
| 487 | + | assert_eq!(res.status(), StatusCode::NOT_FOUND); | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | #[tokio::test] | |
| 491 | + | async fn registration_creates_an_account_and_signs_in() { | |
| 492 | + | let app = app_with_registration().await; | |
| 493 | + | ||
| 494 | + | let form = app.clone().oneshot(get("/register")).await.unwrap(); | |
| 495 | + | assert_eq!(form.status(), StatusCode::OK); | |
| 496 | + | let cookie = form | |
| 497 | + | .headers() | |
| 498 | + | .get(header::SET_COOKIE) | |
| 499 | + | .unwrap() | |
| 500 | + | .to_str() | |
| 501 | + | .unwrap() | |
| 502 | + | .to_string(); | |
| 503 | + | let token = cookie | |
| 504 | + | .split("fabrica_csrf=") | |
| 505 | + | .nth(1) | |
| 506 | + | .and_then(|s| s.split(';').next()) | |
| 507 | + | .unwrap() | |
| 508 | + | .to_string(); | |
| 509 | + | assert!(body_string(form).await.contains("Create account")); | |
| 510 | + | ||
| 511 | + | let req = Request::builder() | |
| 512 | + | .method("POST") | |
| 513 | + | .uri("/register") | |
| 514 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | |
| 515 | + | .header(header::COOKIE, format!("fabrica_csrf={token}")) | |
| 516 | + | .body(Body::from(format!( | |
| 517 | + | "username=newbie&email=new@example.com&password=secret12&confirm=secret12&_csrf={token}" | |
| 518 | + | ))) | |
| 519 | + | .unwrap(); | |
| 520 | + | let res = app.oneshot(req).await.unwrap(); | |
| 521 | + | assert_eq!(res.status(), StatusCode::SEE_OTHER, "redirect on success"); | |
| 522 | + | let set = res | |
| 523 | + | .headers() | |
| 524 | + | .get_all(header::SET_COOKIE) | |
| 525 | + | .iter() | |
| 526 | + | .filter_map(|v| v.to_str().ok()) | |
| 527 | + | .collect::<Vec<_>>() | |
| 528 | + | .join("\n"); | |
| 529 | + | assert!(set.contains("fabrica_session="), "signed in: {set:?}"); | |
| 530 | + | } | |
| 531 | + | ||
| 471 | 532 | #[tokio::test] | |
| 472 | 533 | async fn settings_requires_authentication() { | |
| 473 | 534 | let (_state, app) = app().await; | |
fabrica.example.toml +1 −0
| @@ -21,6 +21,7 @@ description = "A small, private forge." # one line, shown on the landing pag | |||
| 21 | 21 | default_branch = "main" # default branch for new repositories | |
| 22 | 22 | allow_anonymous = true # anonymous browse + clone of public repos | |
| 23 | 23 | default_visibility = "private" # new repos: public | internal | private | |
| 24 | + | allow_registration = false # allow web self-registration (sign-up) | |
| 24 | 25 | ||
| 25 | 26 | [server] | |
| 26 | 27 | address = "0.0.0.0" | |