use crate::token::AuthToken;
use crate::{AuthUser, OptionalIdentity, auth_user};
use serde::{Deserialize, Serialize};
use umbral::web::{HeaderMap, IntoResponse, Json, Response, Router, StatusCode, post};
#[derive(Debug, Deserialize)]
struct RegisterIn {
username: String,
email: String,
password: String,
}
#[derive(Debug, Deserialize)]
struct LoginIn {
username: String,
password: String,
}
#[derive(Debug, Serialize)]
struct UserOut {
id: i64,
username: String,
email: String,
is_staff: bool,
is_superuser: bool,
}
impl From<&AuthUser> for UserOut {
fn from(u: &AuthUser) -> Self {
Self {
id: u.id,
username: u.username.clone(),
email: u.email.clone(),
is_staff: u.is_staff,
is_superuser: u.is_superuser,
}
}
}
#[derive(Debug, Serialize)]
struct LoginOut {
user: UserOut,
token: String,
}
#[derive(Debug, Serialize)]
struct ErrorOut {
error: &'static str,
detail: String,
}
#[derive(Debug, Deserialize)]
struct VerifyEmailIn {
email: String,
code: String,
}
#[derive(Debug, Deserialize)]
struct EmailOnlyIn {
email: String,
}
#[derive(Debug, Deserialize)]
struct ResetIn {
token: String,
new_password: String,
}
pub(crate) fn client_ip(headers: &HeaderMap) -> String {
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
if let Some(first) = xff.split(',').next() {
let ip = first.trim();
if !ip.is_empty() {
return ip.to_string();
}
}
}
if let Some(real) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
let ip = real.trim();
if !ip.is_empty() {
return ip.to_string();
}
}
"unknown".to_string()
}
fn err(status: StatusCode, error: &'static str, detail: impl Into<String>) -> Response {
(
status,
Json(ErrorOut {
error,
detail: detail.into(),
}),
)
.into_response()
}
pub(crate) fn reset_url_base(headers: &HeaderMap) -> String {
let host = headers
.get("host")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim());
let Some(host) = host else {
return "/auth/reset".to_string();
};
let proto = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim())
.unwrap_or("https");
format!("{proto}://{host}/auth/reset")
}
pub(crate) fn build_router(prefix: &str) -> Router {
Router::new()
.route(&format!("{prefix}/register"), post(register))
.route(&format!("{prefix}/login"), post(login))
.route(&format!("{prefix}/logout"), post(logout))
.route(&format!("{prefix}/me"), umbral::web::get(me))
.route(&format!("{prefix}/verify-email"), post(verify_email_h))
.route(
&format!("{prefix}/resend-verification"),
post(resend_verification_h),
)
.route(
&format!("{prefix}/password-forgot"),
post(password_forgot_h),
)
.route(&format!("{prefix}/password-reset"), post(password_reset_h))
}
pub(crate) fn declared_routes(prefix: &str) -> Vec<umbral::routes::RouteSpec> {
vec![
("POST", format!("{prefix}/register")).into(),
("POST", format!("{prefix}/login")).into(),
("POST", format!("{prefix}/logout")).into(),
("GET", format!("{prefix}/me")).into(),
("POST", format!("{prefix}/verify-email")).into(),
("POST", format!("{prefix}/resend-verification")).into(),
("POST", format!("{prefix}/password-forgot")).into(),
("POST", format!("{prefix}/password-reset")).into(),
]
}
pub(crate) fn openapi_paths(prefix: &str) -> Vec<(String, serde_json::Value)> {
use serde_json::json;
let tag = "auth";
let register_body = json!({
"type": "object",
"required": ["username", "email", "password"],
"properties": {
"username": {"type": "string", "example": "alice"},
"email": {"type": "string", "format": "email", "example": "alice@example.com"},
"password": {"type": "string", "format": "password"},
}
});
let login_body = json!({
"type": "object",
"required": ["username", "password"],
"properties": {
"username": {"type": "string", "example": "alice"},
"password": {"type": "string", "format": "password"},
}
});
let user_response = json!({
"type": "object",
"properties": {
"id": {"type": "integer", "format": "int64"},
"username": {"type": "string"},
"email": {"type": "string", "format": "email"},
"is_staff": {"type": "boolean"},
"is_superuser": {"type": "boolean"},
}
});
let login_response = json!({
"type": "object",
"properties": {
"user": user_response.clone(),
"token": {"type": "string", "description": "Opaque bearer token. Shown ONCE."},
}
});
let error_response = json!({
"type": "object",
"properties": {
"error": {"type": "string"},
"detail": {"type": "string"},
}
});
vec![
(
format!("{prefix}/register"),
json!({
"post": {
"tags": [tag],
"operationId": "auth_register",
"summary": "Create a new user.",
"description": "Returns the user shape (no password_hash). 409 on duplicate username/email; 400 on missing fields.",
"requestBody": {
"required": true,
"content": {"application/json": {"schema": register_body}}
},
"responses": {
"201": {"description": "User created.", "content": {"application/json": {"schema": user_response.clone()}}},
"400": {"description": "Invalid input.", "content": {"application/json": {"schema": error_response.clone()}}},
"409": {"description": "Username or email already exists.", "content": {"application/json": {"schema": error_response.clone()}}}
}
}
}),
),
(
format!("{prefix}/login"),
json!({
"post": {
"tags": [tag],
"operationId": "auth_login",
"summary": "Verify credentials, mint a bearer token, set a session cookie.",
"description": "Returns `{user, token}` and a `Set-Cookie` header. Browsers can ignore `token`; CLI / mobile can ignore the cookie.",
"requestBody": {
"required": true,
"content": {"application/json": {"schema": login_body}}
},
"responses": {
"200": {"description": "Logged in.", "content": {"application/json": {"schema": login_response}}},
"401": {"description": "Invalid credentials.", "content": {"application/json": {"schema": error_response.clone()}}}
}
}
}),
),
(
format!("{prefix}/logout"),
json!({
"post": {
"tags": [tag],
"operationId": "auth_logout",
"summary": "Clear the session cookie + destroy the session row.",
"description": "Does NOT revoke bearer tokens — those stay valid until explicitly revoked.",
"responses": {
"204": {"description": "Session cleared."}
}
}
}),
),
(
format!("{prefix}/me"),
json!({
"get": {
"tags": [tag],
"operationId": "auth_me",
"summary": "Return the current user.",
"description": "Resolves via session cookie first, then bearer token. 401 if neither yields an active user.",
"responses": {
"200": {"description": "Authenticated user.", "content": {"application/json": {"schema": user_response}}},
"401": {"description": "Not authenticated.", "content": {"application/json": {"schema": error_response.clone()}}}
}
}
}),
),
(
format!("{prefix}/verify-email"),
json!({
"post": {
"tags": [tag],
"operationId": "auth_verify_email",
"summary": "Verify an email address with a 6-digit code.",
"description": "JSON `{email, code}` → 204 on success. 400 (generic) on any failure (unknown email, no active challenge, wrong code, attempt cap) — no enumeration.",
"requestBody": {
"required": true,
"content": {"application/json": {"schema": json!({
"type": "object",
"required": ["email", "code"],
"properties": {
"email": {"type": "string", "format": "email"},
"code": {"type": "string", "example": "483920"}
}
})}}
},
"responses": {
"204": {"description": "Email verified."},
"400": {"description": "Invalid or expired code.", "content": {"application/json": {"schema": error_response.clone()}}}
}
}
}),
),
(
format!("{prefix}/resend-verification"),
json!({
"post": {
"tags": [tag],
"operationId": "auth_resend_verification",
"summary": "Re-issue an email-verification code.",
"description": "JSON `{email}` → always 202. Unknown emails and already-verified users receive the same response as a pending user (no enumeration). The verification mail is sent best-effort.",
"requestBody": {
"required": true,
"content": {"application/json": {"schema": json!({
"type": "object",
"required": ["email"],
"properties": {
"email": {"type": "string", "format": "email"}
}
})}}
},
"responses": {
"202": {"description": "Request accepted (mail sent if the address is known and unverified)."}
}
}
}),
),
(
format!("{prefix}/password-forgot"),
json!({
"post": {
"tags": [tag],
"operationId": "auth_password_forgot",
"summary": "Issue a password-reset link.",
"description": "JSON `{email}` → always 202. Unknown emails receive the same response as known ones (no enumeration). The reset link is sent best-effort.",
"requestBody": {
"required": true,
"content": {"application/json": {"schema": json!({
"type": "object",
"required": ["email"],
"properties": {
"email": {"type": "string", "format": "email"}
}
})}}
},
"responses": {
"202": {"description": "Request accepted (reset link sent if the address matches a known account)."}
}
}
}),
),
(
format!("{prefix}/password-reset"),
json!({
"post": {
"tags": [tag],
"operationId": "auth_password_reset",
"summary": "Consume a password-reset token.",
"description": "JSON `{token, new_password}` → 204 on success. 400 (generic) on any failure (unknown / expired / already-used token, weak password).",
"requestBody": {
"required": true,
"content": {"application/json": {"schema": json!({
"type": "object",
"required": ["token", "new_password"],
"properties": {
"token": {"type": "string", "description": "Opaque reset token from the emailed link."},
"new_password": {"type": "string", "format": "password"}
}
})}}
},
"responses": {
"204": {"description": "Password updated."},
"400": {"description": "Invalid, expired, or already-used token; or weak password.", "content": {"application/json": {"schema": error_response}}}
}
}
}),
),
]
}
async fn register(headers: HeaderMap, Json(body): Json<RegisterIn>) -> Response {
let ip = client_ip(&headers);
if !crate::register_throttle_check(&ip) {
return err(
StatusCode::TOO_MANY_REQUESTS,
"rate_limited",
"too many registration attempts; try again later",
);
}
if body.username.is_empty() || body.email.is_empty() || body.password.is_empty() {
return err(
StatusCode::BAD_REQUEST,
"invalid_input",
"username, email and password are required",
);
}
if let Err(reasons) = crate::validate_password(
&body.password,
&crate::PasswordContext::new(Some(&body.username), Some(&body.email)),
) {
return err(StatusCode::BAD_REQUEST, "weak_password", reasons.join(" "));
}
match crate::create_user(&body.username, &body.email, &body.password).await {
Ok(user) => {
if crate::verified_email_required() {
if let Err(e) = crate::start_email_verification(&user).await {
tracing::warn!(
user_id = user.id,
"umbral-auth: require_verified_email: auto-send on register failed: {e}"
);
}
}
(StatusCode::CREATED, Json(UserOut::from(&user))).into_response()
}
Err(e) => {
let msg = format!("{e}");
let status = if msg.to_lowercase().contains("unique") {
StatusCode::CONFLICT
} else {
StatusCode::BAD_REQUEST
};
err(status, "create_failed", msg)
}
}
}
async fn login(headers: HeaderMap, Json(body): Json<LoginIn>) -> Response {
let ip = client_ip(&headers);
if !crate::login_throttle_check(&ip, &body.username) {
return err(
StatusCode::TOO_MANY_REQUESTS,
"rate_limited",
"too many login attempts; try again later",
);
}
let user: AuthUser = match crate::authenticate(&body.username, &body.password).await {
Ok(u) => u,
Err(_) => {
return err(
StatusCode::UNAUTHORIZED,
"invalid_credentials",
"username or password is incorrect",
);
}
};
crate::login_throttle_clear(&ip, &body.username);
if crate::verified_email_required() && user.email_verified_at.is_none() {
return err(
StatusCode::FORBIDDEN,
"email_not_verified",
"verify your email before logging in",
);
}
let (_token_row, plaintext) = match AuthToken::create_for(&user, "login").await {
Ok(t) => t,
Err(e) => {
return err(
StatusCode::INTERNAL_SERVER_ERROR,
"token_failed",
format!("{e}"),
);
}
};
let body = LoginOut {
user: UserOut::from(&user),
token: plaintext.0,
};
let mut response = Json(body).into_response();
if let Err(e) = crate::login_with_request(&headers, response.headers_mut(), &user).await {
return err(
StatusCode::INTERNAL_SERVER_ERROR,
"session_failed",
format!("{e}"),
);
}
response
}
async fn logout(headers: HeaderMap) -> Response {
let mut response = StatusCode::NO_CONTENT.into_response();
if let Err(e) = crate::logout(&headers, response.headers_mut()).await {
tracing::error!("umbral-auth: logout session error: {e}");
}
response
}
async fn me(OptionalIdentity(id): OptionalIdentity) -> Response {
let Some(id) = id else {
return err(
StatusCode::UNAUTHORIZED,
"not_authenticated",
"send a session cookie or a Bearer token",
);
};
let Ok(auth_user_id) = id.user_id.parse::<i64>() else {
return err(
StatusCode::UNAUTHORIZED,
"not_authenticated",
"session user id does not match the AuthUser PK shape",
);
};
let user: AuthUser = match AuthUser::objects()
.filter(auth_user::ID.eq(auth_user_id) & auth_user::IS_ACTIVE.eq(true))
.first()
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return err(
StatusCode::UNAUTHORIZED,
"not_authenticated",
"user record went away between auth and lookup",
);
}
Err(e) => {
return err(
StatusCode::INTERNAL_SERVER_ERROR,
"lookup_failed",
format!("{e}"),
);
}
};
Json(UserOut::from(&user)).into_response()
}
async fn verify_email_h(headers: HeaderMap, Json(b): Json<VerifyEmailIn>) -> Response {
let ip = client_ip(&headers);
if !crate::email_action_throttle_check(&ip, &b.email) {
return err(
StatusCode::TOO_MANY_REQUESTS,
"rate_limited",
"too many requests; try again later",
);
}
match crate::verify_email(&b.email, &b.code).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(_) => err(
StatusCode::BAD_REQUEST,
"invalid_code",
"verification failed",
),
}
}
async fn resend_verification_h(headers: HeaderMap, Json(b): Json<EmailOnlyIn>) -> Response {
let ip = client_ip(&headers);
if !crate::email_action_throttle_check(&ip, &b.email) {
return err(
StatusCode::TOO_MANY_REQUESTS,
"rate_limited",
"too many requests; try again later",
);
}
if let Ok(Some(u)) = AuthUser::objects()
.filter(auth_user::EMAIL.eq(b.email.clone()) & auth_user::EMAIL_VERIFIED_AT.is_null())
.first()
.await
{
let _ = crate::start_email_verification(&u).await;
}
StatusCode::ACCEPTED.into_response()
}
async fn password_forgot_h(headers: HeaderMap, Json(b): Json<EmailOnlyIn>) -> Response {
let ip = client_ip(&headers);
if !crate::email_action_throttle_check(&ip, &b.email) {
return err(
StatusCode::TOO_MANY_REQUESTS,
"rate_limited",
"too many requests; try again later",
);
}
let base = reset_url_base(&headers);
let _ = crate::start_password_reset(&b.email, &base).await;
StatusCode::ACCEPTED.into_response()
}
async fn password_reset_h(Json(b): Json<ResetIn>) -> Response {
match crate::reset_password(&b.token, &b.new_password).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(_) => err(
StatusCode::BAD_REQUEST,
"reset_failed",
"could not reset password",
),
}
}