Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +5 −0
| @@ -462,6 +462,11 @@ label.checkbox input { | |||
| 462 | justify-content: center; | 462 | justify-content: center; |
| 463 | margin-top: 1.25rem; | 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 | /* ---- Flash / notices ---- */ | 471 | /* ---- Flash / notices ---- */ |
| 467 | .notice { | 472 | .notice { |
crates/config/src/types.rs +5 −0
| @@ -106,6 +106,10 @@ pub struct Instance { | |||
| 106 | /// Default visibility for newly created repositories. | 106 | /// Default visibility for newly created repositories. |
| 107 | #[serde(default)] | 107 | #[serde(default)] |
| 108 | pub default_visibility: DefaultVisibility, | 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 | impl Default for Instance { | 115 | impl Default for Instance { |
| @@ -117,6 +121,7 @@ impl Default for Instance { | |||
| 117 | default_branch: "main".to_string(), | 121 | default_branch: "main".to_string(), |
| 118 | allow_anonymous: true, | 122 | allow_anonymous: true, |
| 119 | default_visibility: DefaultVisibility::Private, | 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 | pub themes: Vec<ThemeInfo>, | 29 | pub themes: Vec<ThemeInfo>, |
| 30 | /// Whether the theme picker is shown. | 30 | /// Whether the theme picker is shown. |
| 31 | pub allow_theme_choice: bool, | 31 | pub allow_theme_choice: bool, |
| 32 | /// Whether web self-registration is enabled (drives the Sign-up links). | ||
| 33 | pub allow_registration: bool, | ||
| 32 | /// The current request path, for the theme form's return and nav highlighting. | 34 | /// The current request path, for the theme form's return and nav highlighting. |
| 33 | pub path: String, | 35 | pub path: String, |
| 34 | /// The CSRF token for this session, injected into htmx and forms. | 36 | /// The CSRF token for this session, injected into htmx and forms. |
| @@ -113,6 +115,9 @@ fn drawer(chrome: &Chrome) -> Markup { | |||
| 113 | } | 115 | } |
| 114 | } @else { | 116 | } @else { |
| 115 | a href="/login" { (icon(Icon::User)) span { "Sign in" } } | 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 | .route("/explore", get(pages::explore)) | 136 | .route("/explore", get(pages::explore)) |
| 137 | .route("/explore/users", get(pages::explore_users)) | 137 | .route("/explore/users", get(pages::explore_users)) |
| 138 | .route("/login", get(pages::login_form).post(pages::login_submit)) | 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 | .route("/logout", post(pages::logout)) | 143 | .route("/logout", post(pages::logout)) |
| 140 | .route( | 144 | .route( |
| 141 | "/invite/{token}", | 145 | "/invite/{token}", |
crates/web/src/pages.rs +163 −3
| @@ -52,6 +52,7 @@ pub(crate) fn build_chrome( | |||
| 52 | active_scheme: scheme, | 52 | active_scheme: scheme, |
| 53 | themes, | 53 | themes, |
| 54 | allow_theme_choice: state.config.ui.allow_theme_choice, | 54 | allow_theme_choice: state.config.ui.allow_theme_choice, |
| 55 | allow_registration: state.config.instance.allow_registration, | ||
| 55 | path, | 56 | path, |
| 56 | csrf, | 57 | csrf, |
| 57 | }; | 58 | }; |
| @@ -435,12 +436,12 @@ pub async fn login_form( | |||
| 435 | return Redirect::to("/").into_response(); | 436 | return Redirect::to("/").into_response(); |
| 436 | } | 437 | } |
| 437 | let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string()); | 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 | (jar, page(&chrome, "Sign in", body)).into_response() | 440 | (jar, page(&chrome, "Sign in", body)).into_response() |
| 440 | } | 441 | } |
| 441 | 442 | ||
| 442 | /// Render the login form with an optional error notice. | 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 | html! { | 445 | html! { |
| 445 | div class="auth-wrap" { | 446 | div class="auth-wrap" { |
| 446 | div class="auth-card" { | 447 | div class="auth-card" { |
| @@ -457,6 +458,9 @@ fn login_body(csrf: &str, error: Option<&str>) -> Markup { | |||
| 457 | autocomplete="current-password" required; | 458 | autocomplete="current-password" required; |
| 458 | button class="btn btn-primary" type="submit" { "Sign in" } | 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 | let Some(user) = user.filter(|_| ok) else { | 493 | let Some(user) = user.filter(|_| ok) else { |
| 490 | // Re-render the form with a generic error (no username enumeration). | 494 | // Re-render the form with a generic error (no username enumeration). |
| 491 | let (jar, chrome) = build_chrome(&state, jar, None, "/login".to_string()); | 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 | return ( | 501 | return ( |
| 494 | axum::http::StatusCode::UNAUTHORIZED, | 502 | axum::http::StatusCode::UNAUTHORIZED, |
| 495 | jar, | 503 | jar, |
| @@ -536,6 +544,158 @@ async fn open_session( | |||
| 536 | Ok(jar.clone().add(cookie)) | 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 | /// Logout form field. | 699 | /// Logout form field. |
| 540 | #[derive(Debug, Deserialize)] | 700 | #[derive(Debug, Deserialize)] |
| 541 | pub struct CsrfForm { | 701 | pub struct CsrfForm { |
crates/web/src/tests.rs +61 −0
| @@ -468,6 +468,67 @@ async fn settings_page_shows_account_and_tokens() { | |||
| 468 | assert!(html.contains("API tokens"), "tokens section"); | 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 | #[tokio::test] | 532 | #[tokio::test] |
| 472 | async fn settings_requires_authentication() { | 533 | async fn settings_requires_authentication() { |
| 473 | let (_state, app) = app().await; | 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 | default_branch = "main" # default branch for new repositories | 21 | default_branch = "main" # default branch for new repositories |
| 22 | allow_anonymous = true # anonymous browse + clone of public repos | 22 | allow_anonymous = true # anonymous browse + clone of public repos |
| 23 | default_visibility = "private" # new repos: public | internal | private | 23 | default_visibility = "private" # new repos: public | internal | private |
| 24 | allow_registration = false # allow web self-registration (sign-up) | ||
| 24 | 25 | ||
| 25 | [server] | 26 | [server] |
| 26 | address = "0.0.0.0" | 27 | address = "0.0.0.0" |