feat(registraton): add anti enumeration (cannot know if an account already exists)
Important: anti-enumeration is active only if SMTP is defined, welcome email can be used
otherwise it is a classic registration with ok or conflic if account alrady exists
This commit is contained in:
@@ -45,6 +45,24 @@ pub enum OidcCallbackResult {
|
|||||||
/// the same shape as a password login; the optional resource fields tell
|
/// the same shape as a password login; the optional resource fields tell
|
||||||
/// the handler whether to deep-link to the invited resource or fall back
|
/// the handler whether to deep-link to the invited resource or fall back
|
||||||
/// to the generic `/shared-with-me` landing.
|
/// to the generic `/shared-with-me` landing.
|
||||||
|
/// Outcome of a `register` call. The handler maps this to either an
|
||||||
|
/// anti-enumerated uniform 200 (when SMTP is available — there's a
|
||||||
|
/// "check your email" cover story for the user) or the classic
|
||||||
|
/// 201/409 split (when SMTP is unavailable — without the cover story,
|
||||||
|
/// uniform responses would just be misleading UX with no security
|
||||||
|
/// benefit). Either way the service emits the same audit-log entries.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum RegisterResult {
|
||||||
|
/// Boxed to avoid the `large_enum_variant` clippy warning —
|
||||||
|
/// `UserDto` is ~250 bytes, the other variants are zero-sized,
|
||||||
|
/// so a heap-pointer indirection keeps the enum's stack size
|
||||||
|
/// small. `register` is called once per request; the
|
||||||
|
/// allocation cost is negligible.
|
||||||
|
Created(Box<UserDto>),
|
||||||
|
UsernameTaken,
|
||||||
|
EmailTaken,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MagicLinkRedemption {
|
pub struct MagicLinkRedemption {
|
||||||
pub auth: AuthResponseDto,
|
pub auth: AuthResponseDto,
|
||||||
@@ -254,7 +272,18 @@ impl AuthApplicationService {
|
|||||||
state.service.clone()
|
state.service.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn register(&self, dto: RegisterDto) -> Result<UserDto, DomainError> {
|
/// Public registration. Returns one of three outcomes:
|
||||||
|
/// - `Created(user)` — a user was actually created
|
||||||
|
/// - `UsernameTaken` / `EmailTaken` — collision; no DB write
|
||||||
|
///
|
||||||
|
/// The handler decides the HTTP shape based on whether SMTP is
|
||||||
|
/// available (anti-enumeration uniform 200 vs classic 201/409).
|
||||||
|
/// The service emits the same audit-log entries either way — the
|
||||||
|
/// audit channel is the source of truth for the actual outcome.
|
||||||
|
///
|
||||||
|
/// Real failures (DB error, password too short, etc.) surface as
|
||||||
|
/// `Err`.
|
||||||
|
pub async fn register(&self, dto: RegisterDto) -> Result<RegisterResult, DomainError> {
|
||||||
// Username uniqueness (only when a username was supplied — None
|
// Username uniqueness (only when a username was supplied — None
|
||||||
// is the "claim later" path, multiple NULLs are allowed by the
|
// is the "claim later" path, multiple NULLs are allowed by the
|
||||||
// UNIQUE index per Postgres semantics).
|
// UNIQUE index per Postgres semantics).
|
||||||
@@ -265,11 +294,16 @@ impl AuthApplicationService {
|
|||||||
.await
|
.await
|
||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
{
|
||||||
return Err(DomainError::new(
|
tracing::info!(
|
||||||
ErrorKind::AlreadyExists,
|
target: "audit",
|
||||||
"User",
|
event = "auth.register",
|
||||||
format!("User '{}' already exists", username),
|
reason = "username_taken",
|
||||||
));
|
attempted_username = %username,
|
||||||
|
attempted_email = %dto.email,
|
||||||
|
"🛂 register collision: username '{}' already exists",
|
||||||
|
username,
|
||||||
|
);
|
||||||
|
return Ok(RegisterResult::UsernameTaken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if self
|
if self
|
||||||
@@ -278,11 +312,15 @@ impl AuthApplicationService {
|
|||||||
.await
|
.await
|
||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
{
|
||||||
return Err(DomainError::new(
|
tracing::info!(
|
||||||
ErrorKind::AlreadyExists,
|
target: "audit",
|
||||||
"User",
|
event = "auth.register",
|
||||||
format!("Email '{}' is already registered", dto.email),
|
reason = "email_taken",
|
||||||
));
|
attempted_email = %dto.email,
|
||||||
|
"🛂 register collision: email '{}' is already registered",
|
||||||
|
dto.email,
|
||||||
|
);
|
||||||
|
return Ok(RegisterResult::EmailTaken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY: Public registration ALWAYS creates regular users.
|
// SECURITY: Public registration ALWAYS creates regular users.
|
||||||
@@ -308,7 +346,6 @@ impl AuthApplicationService {
|
|||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let was_passwordless = password_hash.is_none();
|
|
||||||
|
|
||||||
let user = User::new(
|
let user = User::new(
|
||||||
dto.email.clone(),
|
dto.email.clone(),
|
||||||
@@ -330,7 +367,6 @@ impl AuthApplicationService {
|
|||||||
|
|
||||||
// Save user
|
// Save user
|
||||||
let created_user = self.user_storage.create_user(user).await?;
|
let created_user = self.user_storage.create_user(user).await?;
|
||||||
let _ = was_passwordless; // handler dispatches the welcome mail on this path
|
|
||||||
|
|
||||||
// Lifecycle: HomeFolderLifecycleHook handles personal-folder
|
// Lifecycle: HomeFolderLifecycleHook handles personal-folder
|
||||||
// creation (was inlined here pre-PR 3); audit log + future
|
// creation (was inlined here pre-PR 3); audit log + future
|
||||||
@@ -339,8 +375,17 @@ impl AuthApplicationService {
|
|||||||
lc.dispatch_created(&created_user).await;
|
lc.dispatch_created(&created_user).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("User registered: {}", created_user.id());
|
tracing::info!(
|
||||||
Ok(UserDto::from(created_user))
|
target: "audit",
|
||||||
|
event = "auth.register",
|
||||||
|
reason = "created",
|
||||||
|
user_id = %created_user.id(),
|
||||||
|
username = %created_user.display_for_audit(),
|
||||||
|
email = %created_user.email(),
|
||||||
|
is_external = false,
|
||||||
|
"🛂 user registered",
|
||||||
|
);
|
||||||
|
Ok(RegisterResult::Created(Box::new(UserDto::from(created_user))))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the first admin user during initial system setup.
|
/// Create the first admin user during initial system setup.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use crate::application::dtos::user_dto::{
|
|||||||
AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto,
|
AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto,
|
||||||
OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto,
|
OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto,
|
||||||
};
|
};
|
||||||
use crate::application::services::auth_application_service::OidcCallbackResult;
|
use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult};
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::interfaces::api::cookie_auth;
|
use crate::interfaces::api::cookie_auth;
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
@@ -64,15 +64,39 @@ pub fn setup_route() -> Router<Arc<AppState>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Register a new user account.
|
/// Register a new user account.
|
||||||
|
///
|
||||||
|
/// **Response shape depends on SMTP availability**:
|
||||||
|
///
|
||||||
|
/// - **SMTP configured** (`magic_link_invite_service` is wired): the
|
||||||
|
/// endpoint returns a **uniform 200** for both success and collision
|
||||||
|
/// (anti-enumeration). The "Registration request received" message
|
||||||
|
/// covers both branches honestly because successful email-only
|
||||||
|
/// signups receive a welcome magic-link. Real outcome recorded in
|
||||||
|
/// the `audit` channel as `auth.register` with `reason` one of
|
||||||
|
/// `created`, `email_taken`, `username_taken`.
|
||||||
|
/// - **SMTP not configured**: there is no welcome-mail cover story, so
|
||||||
|
/// the classic `201 + UserDto` on success and `409` on collision
|
||||||
|
/// apply. Anti-enumeration would just be misleading UX (telling the
|
||||||
|
/// user to check an email that will never arrive). Email-only
|
||||||
|
/// signup is **503** in this mode because the user would otherwise
|
||||||
|
/// be stranded with an account they can't log into.
|
||||||
|
///
|
||||||
|
/// **Instance-wide policy stays visible** in both modes: when
|
||||||
|
/// registration is disabled by the admin or password registration is
|
||||||
|
/// disabled in OIDC-only mode, the endpoint returns **403** with a
|
||||||
|
/// clear message. These are instance-wide settings, not per-user
|
||||||
|
/// oracles — legitimate users deserve an actionable error.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/auth/register",
|
path = "/api/auth/register",
|
||||||
request_body = RegisterDto,
|
request_body = RegisterDto,
|
||||||
responses(
|
responses(
|
||||||
(status = 201, description = "User registered successfully", body = UserDto),
|
(status = 200, description = "Uniform registration response (SMTP configured, anti-enumeration mode)"),
|
||||||
(status = 400, description = "Validation error"),
|
(status = 201, description = "User registered successfully (SMTP not configured)", body = UserDto),
|
||||||
(status = 403, description = "Registration disabled"),
|
(status = 400, description = "Validation error (malformed request body)"),
|
||||||
(status = 409, description = "Username or email already taken"),
|
(status = 403, description = "Registration disabled (admin setting or OIDC-only mode)"),
|
||||||
|
(status = 409, description = "Username or email already taken (SMTP not configured)"),
|
||||||
|
(status = 503, description = "Email-only signup requires SMTP to be configured"),
|
||||||
),
|
),
|
||||||
tag = "auth"
|
tag = "auth"
|
||||||
)]
|
)]
|
||||||
@@ -80,15 +104,13 @@ pub async fn register(
|
|||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(dto): Json<RegisterDto>,
|
Json(dto): Json<RegisterDto>,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
// Display the supplied identifier in operational logs without
|
// Uniform 200 response used in anti-enumeration mode (SMTP wired).
|
||||||
// panicking on the None branch — the user may have registered
|
let uniform_ok = || {
|
||||||
// email-only with no username yet.
|
let payload = serde_json::json!({
|
||||||
let log_identifier = dto
|
"message": "Registration request received.",
|
||||||
.username
|
});
|
||||||
.as_deref()
|
(StatusCode::OK, Json(payload)).into_response()
|
||||||
.unwrap_or(dto.email.as_str())
|
};
|
||||||
.to_string();
|
|
||||||
tracing::info!("Registration attempt for: {}", log_identifier);
|
|
||||||
|
|
||||||
// Verify auth service exists
|
// Verify auth service exists
|
||||||
let auth_service = match state.auth_service.as_ref() {
|
let auth_service = match state.auth_service.as_ref() {
|
||||||
@@ -101,9 +123,9 @@ pub async fn register(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Block password registration when OIDC-only mode is active. The
|
// Block password registration when OIDC-only mode is active.
|
||||||
// email-only signup path is allowed because it doesn't store a
|
// Email-only signup still works in OIDC-only mode (no password
|
||||||
// password — the user later authenticates via magic-link.
|
// stored; the user authenticates via magic-link).
|
||||||
if dto.password.is_some()
|
if dto.password.is_some()
|
||||||
&& auth_service
|
&& auth_service
|
||||||
.auth_application_service
|
.auth_application_service
|
||||||
@@ -116,7 +138,7 @@ pub async fn register(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if public registration has been disabled by the admin
|
// Admin disabled public registration globally — surface 403.
|
||||||
if let Some(admin_svc) = state.admin_settings_service.as_ref()
|
if let Some(admin_svc) = state.admin_settings_service.as_ref()
|
||||||
&& !admin_svc.get_registration_enabled().await
|
&& !admin_svc.get_registration_enabled().await
|
||||||
{
|
{
|
||||||
@@ -127,45 +149,81 @@ pub async fn register(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Email-only signup requires SMTP. Without it the welcome mail
|
||||||
|
// can't be dispatched and the user is stranded with no way to log
|
||||||
|
// in. 503 is the right response: instance-wide policy, no per-user
|
||||||
|
// oracle leaked.
|
||||||
|
let smtp_enabled = state.magic_link_invite_service.is_some();
|
||||||
|
if dto.password.is_none() && !smtp_enabled {
|
||||||
|
return Err(AppError::new(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Email-only registration requires SMTP to be configured on this server.",
|
||||||
|
"SmtpRequired",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let was_passwordless = dto.password.is_none();
|
let was_passwordless = dto.password.is_none();
|
||||||
let email = dto.email.clone();
|
let email = dto.email.clone();
|
||||||
|
|
||||||
// Registration logic (duplicate checks, hashing, user creation) is
|
let result = match auth_service.auth_application_service.register(dto).await {
|
||||||
// all inside the service layer.
|
Ok(r) => r,
|
||||||
let user = match auth_service.auth_application_service.register(dto).await {
|
|
||||||
Ok(u) => u,
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::error!("Registration failed for {}: {}", log_identifier, err);
|
tracing::error!("Registration failed: {}", err);
|
||||||
return Err(err.into());
|
return Err(err.into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tracing::info!("Registration successful for: {}", log_identifier);
|
|
||||||
|
|
||||||
// Email-only signup: dispatch a welcome magic-link so the user can
|
match result {
|
||||||
// land their first session without a password. Best-effort — SMTP
|
RegisterResult::Created(user) => {
|
||||||
// failures don't fail the registration. Response shape is uniform
|
// Email-only signup: dispatch the welcome magic-link.
|
||||||
// (200 + anti-enumeration message) so the user is told to check
|
// Best-effort — SMTP failures don't roll back the user.
|
||||||
// their email regardless of whether SMTP was actually wired.
|
if was_passwordless
|
||||||
if was_passwordless {
|
&& let Some(invite) = state.magic_link_invite_service.as_ref()
|
||||||
if let Some(invite) = state.magic_link_invite_service.as_ref()
|
&& let Err(e) = invite.send_login_link(&email).await
|
||||||
&& let Err(e) = invite.send_login_link(&email).await
|
{
|
||||||
{
|
tracing::warn!(
|
||||||
tracing::warn!(
|
target: "audit",
|
||||||
target: "audit",
|
event = "auth.register_welcome_mail_failed",
|
||||||
event = "auth.register_welcome_mail_failed",
|
user_id = %user.id,
|
||||||
user_id = %user.id,
|
email = %email,
|
||||||
email = %email,
|
error = %e,
|
||||||
error = %e,
|
"register: welcome magic-link send failed (user created)",
|
||||||
"register: welcome magic-link send failed (user created)",
|
);
|
||||||
);
|
}
|
||||||
|
if smtp_enabled {
|
||||||
|
// Anti-enumeration mode: hide success-vs-collision behind
|
||||||
|
// the uniform "check your email" cover story.
|
||||||
|
Ok(uniform_ok())
|
||||||
|
} else {
|
||||||
|
// Classic mode: clear 201 + UserDto so the frontend can
|
||||||
|
// log the user in directly with the password they just
|
||||||
|
// submitted. Unbox the DTO for the JSON serialisation.
|
||||||
|
Ok((StatusCode::CREATED, Json(*user)).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RegisterResult::UsernameTaken => {
|
||||||
|
if smtp_enabled {
|
||||||
|
Ok(uniform_ok())
|
||||||
|
} else {
|
||||||
|
Err(AppError::new(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"Username is already taken",
|
||||||
|
"UsernameTaken",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RegisterResult::EmailTaken => {
|
||||||
|
if smtp_enabled {
|
||||||
|
Ok(uniform_ok())
|
||||||
|
} else {
|
||||||
|
Err(AppError::new(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"Email is already registered",
|
||||||
|
"EmailTaken",
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let payload = serde_json::json!({
|
|
||||||
"message": "Check your email for a sign-in link to complete registration.",
|
|
||||||
});
|
|
||||||
return Ok((StatusCode::OK, Json(payload)).into_response());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(user)).into_response())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticate with username and password.
|
/// Authenticate with username and password.
|
||||||
|
|||||||
+82
-11
@@ -25,8 +25,11 @@ alice_token: jsonpath "$.access_token"
|
|||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 2 — Classic registration (with password) still works.
|
# Step 2 — Classic registration (with password). PR 20 anti-
|
||||||
# Returns 201 + UserDto (existing behaviour, unchanged).
|
# enumeration mode (SMTP wired) returns a uniform 200
|
||||||
|
# regardless of success or collision. No UserDto in
|
||||||
|
# the response — the frontend logs the user in
|
||||||
|
# separately to get a session.
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
POST {{base_url}}/api/auth/register
|
POST {{base_url}}/api/auth/register
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
@@ -36,13 +39,23 @@ Content-Type: application/json
|
|||||||
"password": "TestPassword1!"
|
"password": "TestPassword1!"
|
||||||
}
|
}
|
||||||
|
|
||||||
HTTP 201
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$.username" == "charlie"
|
jsonpath "$.message" contains "request received"
|
||||||
jsonpath "$.email" == "charlie@example.com"
|
|
||||||
jsonpath "$.is_external" == false
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 2b — Log in as charlie to confirm registration succeeded
|
||||||
|
# AND to capture her user_id for cleanup.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "charlie", "password": "TestPassword1!" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
[Captures]
|
[Captures]
|
||||||
charlie_user_id: jsonpath "$.id"
|
charlie_token: jsonpath "$.access_token"
|
||||||
|
charlie_user_id: jsonpath "$.user.id"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@@ -58,7 +71,7 @@ Content-Type: application/json
|
|||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$.message" contains "sign-in link"
|
jsonpath "$.message" contains "request received"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@@ -109,7 +122,7 @@ pr18_user_id: jsonpath "$.id"
|
|||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 7 — Dave can request another magic-link (he has no
|
# Step 7 — The new user can request another magic-link (no
|
||||||
# password configured → eligible). Anti-enumeration
|
# password configured → eligible). Anti-enumeration
|
||||||
# 200 either way.
|
# 200 either way.
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@@ -123,8 +136,66 @@ jsonpath "$.message" contains "sign-in link"
|
|||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Cleanup — admin deletes charlie + dave so the DB-clean sweep
|
# Step 8 — PR 20 anti-enumeration: register with charlie's
|
||||||
# at run.sh end sees no stragglers.
|
# email AGAIN (different password). Response is the
|
||||||
|
# same uniform 200 — attacker can't tell from the
|
||||||
|
# HTTP shape whether the email was already taken.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "charlie-imposter",
|
||||||
|
"email": "charlie@example.com",
|
||||||
|
"password": "AttackerPassword99!"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.message" contains "request received"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 9 — Verify the collision was silently suppressed: the
|
||||||
|
# attacker's password does NOT work (the original
|
||||||
|
# row is intact, no rewrite happened).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "charlie@example.com", "password": "AttackerPassword99!" }
|
||||||
|
|
||||||
|
HTTP 403
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 10 — Charlie's original password still works — the
|
||||||
|
# collision didn't touch her account.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "charlie", "password": "TestPassword1!" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 11 — Username collision (different email): same uniform
|
||||||
|
# 200, no new user, audit `username_taken`.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "charlie",
|
||||||
|
"email": "charlie-other@example.com",
|
||||||
|
"password": "AttackerPassword99!"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.message" contains "request received"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Cleanup — admin deletes both test users.
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
DELETE {{base_url}}/api/admin/users/{{charlie_user_id}}
|
DELETE {{base_url}}/api/admin/users/{{charlie_user_id}}
|
||||||
Authorization: Bearer {{alice_token}}
|
Authorization: Bearer {{alice_token}}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ RUST_LOG="warn,audit=info"
|
|||||||
# grow up limits for tests
|
# grow up limits for tests
|
||||||
OXICLOUD_RATE_LIMIT_REFRESH_MAX=360
|
OXICLOUD_RATE_LIMIT_REFRESH_MAX=360
|
||||||
OXICLOUD_RATE_LIMIT_LOGIN_MAX=360
|
OXICLOUD_RATE_LIMIT_LOGIN_MAX=360
|
||||||
|
OXICLOUD_RATE_LIMIT_REGISTER_MAX=360
|
||||||
|
|
||||||
# Magic-link / external-users flow (PR 9). The mock SMTP captures every
|
# Magic-link / external-users flow (PR 9). The mock SMTP captures every
|
||||||
# outbound message in-process so external_users.hurl can retrieve the
|
# outbound message in-process so external_users.hurl can retrieve the
|
||||||
|
|||||||
Reference in New Issue
Block a user