feat(auth): ALLOW_SELF_REGISTER toggle + public /auth/config endpoint (0.42.0)
Lets operators run a closed-membership deployment by setting
ALLOW_SELF_REGISTER=false (default true, so existing deploys are
unaffected). When off, POST /auth/register returns 403 forbidden. The
rate-limit token is consumed BEFORE the disabled check so the timing
doesn't distinguish enabled-but-rejected from disabled — closes the
toggle-state probe channel.
New public GET /auth/config returns { self_register_enabled: bool }
so the frontend can render its register affordances correctly
without conflating "disabled" with "rate-limited" (which a probe
attempt would).
Frontend: a lightweight reactive `authConfig` store loads the flag
once on root-layout mount (and again on /register direct navigation,
which bypasses the layout's onMount). Header hides the Register link
when the toggle is off; /register renders a "self-registration is
disabled — ask an administrator" notice instead of the form.
Admin-create endpoint that pairs with this toggle is intentionally
not in this PR — it lands as the next branch (feat/admin-user-create).
The toggle alone is independently useful for deployments that want
to lock down enrollment without yet wiring an admin UI.
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.41.2"
|
||||
version = "0.42.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.41.2"
|
||||
version = "0.42.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::repo;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/auth/config", get(auth_config))
|
||||
.route("/auth/register", post(register))
|
||||
.route("/auth/login", post(login))
|
||||
.route("/auth/logout", post(logout))
|
||||
@@ -41,6 +42,21 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/auth/tokens/:id", delete(delete_token))
|
||||
}
|
||||
|
||||
/// Public, unauthenticated. Exposes anonymous-relevant auth policy
|
||||
/// (currently just whether self-registration is open) so the frontend
|
||||
/// can render its login / register affordances correctly without a
|
||||
/// probe request that would conflate "disabled" with "rate-limited".
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthConfigResponse {
|
||||
pub self_register_enabled: bool,
|
||||
}
|
||||
|
||||
async fn auth_config(State(state): State<AppState>) -> Json<AuthConfigResponse> {
|
||||
Json(AuthConfigResponse {
|
||||
self_register_enabled: state.auth.allow_self_register,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Credentials {
|
||||
pub username: String,
|
||||
@@ -82,7 +98,14 @@ async fn register(
|
||||
jar: CookieJar,
|
||||
Json(input): Json<Credentials>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
// Rate limit before the disabled check so an operator who flips
|
||||
// the toggle can't be probed for the toggle state via timing —
|
||||
// disabled and enabled paths both consume a token, and disabled
|
||||
// returns 403 instead of running argon2.
|
||||
check_auth_rate_limit(&state, "register")?;
|
||||
if !state.auth.allow_self_register {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let username = input.username.trim();
|
||||
validate_username(username)?;
|
||||
validate_password(&input.password)?;
|
||||
|
||||
@@ -13,6 +13,12 @@ pub struct AuthConfig {
|
||||
pub cookie_domain: Option<String>,
|
||||
pub session_ttl_days: i64,
|
||||
pub rate_limit: crate::auth::rate_limit::RateLimitConfig,
|
||||
/// When `false`, `POST /auth/register` returns 403
|
||||
/// `registration_disabled` and the frontend hides its register
|
||||
/// affordance. Admins can still mint accounts via
|
||||
/// `POST /admin/users`. Defaults to `true` (open registration)
|
||||
/// for backward compatibility.
|
||||
pub allow_self_register: bool,
|
||||
}
|
||||
|
||||
impl Default for AuthConfig {
|
||||
@@ -26,6 +32,7 @@ impl Default for AuthConfig {
|
||||
// to the [`PRODUCTION_PER_SEC`]/[`PRODUCTION_BURST`]
|
||||
// defaults.
|
||||
rate_limit: crate::auth::rate_limit::RateLimitConfig::default(),
|
||||
allow_self_register: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,6 +157,7 @@ impl Config {
|
||||
crate::auth::rate_limit::PRODUCTION_BURST.into(),
|
||||
) as u32,
|
||||
},
|
||||
allow_self_register: env_bool("ALLOW_SELF_REGISTER", true),
|
||||
},
|
||||
upload: UploadConfig {
|
||||
max_request_bytes: env_usize("MAX_REQUEST_BYTES", 200 * 1024 * 1024),
|
||||
|
||||
@@ -765,3 +765,44 @@ async fn create_token_rejects_name_over_64_chars(pool: PgPool) {
|
||||
assert_eq!(body["error"]["code"], "validation_failed");
|
||||
assert!(body["error"]["details"]["name"].is_string());
|
||||
}
|
||||
|
||||
// ---- self-register toggle + /auth/config -----------------------------------
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn auth_config_reports_self_register_enabled_by_default(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get("/api/v1/auth/config"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["self_register_enabled"], true);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn auth_config_reflects_self_register_disabled(pool: PgPool) {
|
||||
let h = common::harness_with_self_register_disabled(pool);
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get("/api/v1/auth/config"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["self_register_enabled"], false);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn register_returns_403_when_self_register_disabled(pool: PgPool) {
|
||||
let h = common::harness_with_self_register_disabled(pool);
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["code"], "forbidden");
|
||||
}
|
||||
|
||||
@@ -78,6 +78,20 @@ fn harness_with_auth_config(
|
||||
Harness { app: router(state), _storage_dir: storage_dir }
|
||||
}
|
||||
|
||||
/// Like [`harness`] but flips `ALLOW_SELF_REGISTER` off so the
|
||||
/// register-disabled test exercises the 403 branch in
|
||||
/// `api::auth::register`.
|
||||
pub fn harness_with_self_register_disabled(pool: PgPool) -> Harness {
|
||||
let storage_dir = tempfile::tempdir().expect("tempdir");
|
||||
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
|
||||
let auth = AuthConfig {
|
||||
cookie_secure: false,
|
||||
allow_self_register: false,
|
||||
..AuthConfig::default()
|
||||
};
|
||||
harness_with_auth_config(pool, storage, storage_dir, auth)
|
||||
}
|
||||
|
||||
/// Like [`harness`] but configures a tight auth rate limit. Used by
|
||||
/// the brute-force-rate-limiting test.
|
||||
pub fn harness_with_auth_rate_limit(
|
||||
|
||||
Reference in New Issue
Block a user