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,
}
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 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))
}
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(),
]
}
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}}}
}
}
}),
),
]
}
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) => (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);
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();
let _ = umbral_sessions::logout(&headers, response.headers_mut()).await;
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()
}