fabrica

hanna/fabrica

feat(web): optional self-registration with a disable toggle

be214ee · hanna committed on 2026-07-25

Add instance.allow_registration (off by default). When enabled, /register
offers a sign-up form that creates the account directly and signs in;
Sign-up links appear on the login page and in the drawer. When disabled, the
routes 404. (Captcha is deferred: it needs an outbound HTTP verifier and
loads external JS on the page, which conflicts with the no-network-requests
principle — a documented opt-in for later.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +244 −3UnifiedSplit
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}
465470
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}
110114
111impl Default for Instance {115impl 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}
441442
442/// Render the login form with an optional error notice.443/// Render the login form with an optional error notice.
443fn login_body(csrf: &str, error: Option<&str>) -> Markup {444fn 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}
538546
547/// Sign-up form fields.
548#[derive(Debug, Deserialize)]
549pub 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.
568pub 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.
586fn 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.
617pub 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)]
541pub struct CsrfForm {701pub 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}
470470
471/// An app with self-registration enabled (and no seeded user).
472async 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]
484async 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]
491async 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]
472async fn settings_requires_authentication() {533async 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
21default_branch = "main" # default branch for new repositories21default_branch = "main" # default branch for new repositories
22allow_anonymous = true # anonymous browse + clone of public repos22allow_anonymous = true # anonymous browse + clone of public repos
23default_visibility = "private" # new repos: public | internal | private23default_visibility = "private" # new repos: public | internal | private
24allow_registration = false # allow web self-registration (sign-up)
2425
25[server]26[server]
26address = "0.0.0.0"27address = "0.0.0.0"