use std::{net::SocketAddr, sync::Arc};
use axum::{
Json, Router,
extract::{ConnectInfo, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::post,
};
use serde::Deserialize;
use super::LocalPasswordAuthenticator;
use crate::{
audit::logger::{AuditEventType, SecretType, get_audit_logger},
error::AuthError,
rate_limiting::RateLimiters,
session::{SessionStore, unix_now},
session_bearer::SessionBearerAuthenticator,
};
const SESSION_TTL_SECS: u64 = 3_600;
#[derive(Clone)]
pub struct LocalPasswordRouteState {
pub authenticator: Arc<LocalPasswordAuthenticator>,
pub session_store: Arc<dyn SessionStore>,
pub rate_limiters: Arc<RateLimiters>,
}
#[derive(Debug, Deserialize)]
pub struct CredentialsRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct ResetStartRequest {
pub email: String,
}
#[derive(Debug, Deserialize)]
pub struct ResetConfirmRequest {
pub token: String,
pub new_password: String,
}
#[allow(clippy::missing_panics_doc)] pub fn local_password_routes(state: Arc<LocalPasswordRouteState>) -> Router {
Router::new()
.route("/auth/v1/password/signup", post(password_signup))
.route("/auth/v1/password/login", post(password_login))
.route("/auth/v1/password/reset", post(password_reset_start))
.route("/auth/v1/password/reset/confirm", post(password_reset_confirm))
.with_state(state)
}
fn json_error(status: StatusCode, error: &str, message: &str) -> Response {
(status, Json(serde_json::json!({ "error": error, "message": message }))).into_response()
}
fn rate_limit(state: &LocalPasswordRouteState, addr: SocketAddr, op: &str) -> Option<Response> {
let client_ip = addr.ip().to_string();
if state.rate_limiters.auth_start.check(&client_ip).is_ok() {
return None;
}
let retry_after = state.rate_limiters.auth_start.clone_config().window_secs;
get_audit_logger().log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
None,
op,
"rate limited",
);
Some(
(
StatusCode::TOO_MANY_REQUESTS,
[(axum::http::header::RETRY_AFTER, retry_after.to_string())],
Json(serde_json::json!({
"error": "rate_limited",
"message": "Too many attempts; please retry later"
})),
)
.into_response(),
)
}
async fn issue_session(state: &LocalPasswordRouteState, user_id: &str) -> Response {
let Ok(now) = unix_now() else {
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "internal error");
};
match state.session_store.create_session(user_id, now + SESSION_TTL_SECS).await {
Ok(tokens) => {
get_audit_logger().log_success(
AuditEventType::AuthSuccess,
SecretType::SessionToken,
Some(user_id.to_string()),
"local_password",
);
(StatusCode::OK, Json(tokens)).into_response()
},
Err(e) => {
tracing::error!(error = %e, "session creation failed after local-password auth");
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"session_failed",
"session could not be created",
)
},
}
}
pub async fn password_signup(
State(state): State<Arc<LocalPasswordRouteState>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(req): Json<CredentialsRequest>,
) -> Response {
if let Some(limited) = rate_limit(&state, addr, "password_signup") {
return limited;
}
match state.authenticator.signup(&req.email, &req.password).await {
Ok(user_id) => issue_session(&state, &user_id).await,
Err(e @ AuthError::InvalidRegistration { .. }) => {
json_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_registration", &e.to_string())
},
Err(AuthError::EmailAlreadyRegistered) => json_error(
StatusCode::CONFLICT,
"already_registered",
"that email already has a password credential",
),
Err(e) => {
tracing::error!(error = %e, "local-password signup failed");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "signup_failed", "signup failed")
},
}
}
pub async fn password_login(
State(state): State<Arc<LocalPasswordRouteState>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(req): Json<CredentialsRequest>,
) -> Response {
if let Some(limited) = rate_limit(&state, addr, "password_login") {
return limited;
}
match state.authenticator.login(&req.email, &req.password).await {
Ok(user_id) => issue_session(&state, &user_id).await,
Err(e) => {
get_audit_logger().log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
None,
"password_login",
&e.to_string(),
);
json_error(StatusCode::UNAUTHORIZED, "invalid_credentials", "invalid email or password")
},
}
}
pub async fn password_reset_start(
State(state): State<Arc<LocalPasswordRouteState>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(req): Json<ResetStartRequest>,
) -> Response {
if let Some(limited) = rate_limit(&state, addr, "password_reset_start") {
return limited;
}
if let Err(e) = state.authenticator.start_password_reset(&req.email).await {
tracing::error!(error = %e, "password reset start failed");
}
(
StatusCode::ACCEPTED,
Json(serde_json::json!({
"message": "If that address has an account, a reset link has been sent."
})),
)
.into_response()
}
pub async fn password_reset_confirm(
State(state): State<Arc<LocalPasswordRouteState>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(req): Json<ResetConfirmRequest>,
) -> Response {
if let Some(limited) = rate_limit(&state, addr, "password_reset_confirm") {
return limited;
}
match state.authenticator.confirm_password_reset(&req.token, &req.new_password).await {
Ok(()) => (StatusCode::OK, Json(serde_json::json!({ "message": "Password updated." })))
.into_response(),
Err(e @ AuthError::InvalidRegistration { .. }) => {
json_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_password", &e.to_string())
},
Err(e) => {
get_audit_logger().log_failure(
AuditEventType::AuthFailure,
SecretType::CsrfToken,
None,
"password_reset_confirm",
&e.to_string(),
);
json_error(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid_token",
"invalid, expired, or already-used reset token",
)
},
}
}
#[derive(Clone)]
pub struct EmailVerificationRouteState {
pub authenticator: Arc<LocalPasswordAuthenticator>,
pub session_bearer: Arc<SessionBearerAuthenticator>,
pub rate_limiters: Arc<RateLimiters>,
}
#[derive(Debug, Deserialize)]
pub struct EmailVerifyConfirmRequest {
pub token: String,
}
#[allow(clippy::missing_panics_doc)] pub fn email_verification_routes(state: Arc<EmailVerificationRouteState>) -> Router {
Router::new()
.route("/auth/v1/email/verify/start", post(email_verify_start))
.route("/auth/v1/email/verify/confirm", post(email_verify_confirm))
.with_state(state)
}
fn verify_rate_limit(
state: &EmailVerificationRouteState,
addr: SocketAddr,
op: &str,
) -> Option<Response> {
let client_ip = addr.ip().to_string();
if state.rate_limiters.auth_start.check(&client_ip).is_ok() {
return None;
}
let retry_after = state.rate_limiters.auth_start.clone_config().window_secs;
get_audit_logger().log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
None,
op,
"rate limited",
);
Some(
(
StatusCode::TOO_MANY_REQUESTS,
[(axum::http::header::RETRY_AFTER, retry_after.to_string())],
Json(serde_json::json!({
"error": "rate_limited",
"message": "Too many attempts; please retry later"
})),
)
.into_response(),
)
}
fn caller(
state: &EmailVerificationRouteState,
headers: &HeaderMap,
op: &str,
) -> Result<String, Box<Response>> {
state.session_bearer.subject(headers).map_err(|e| {
get_audit_logger().log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
None,
op,
&e.to_string(),
);
Box::new(
(
StatusCode::UNAUTHORIZED,
[(axum::http::header::WWW_AUTHENTICATE, "Bearer")],
Json(serde_json::json!({
"error": "unauthenticated",
"message": "a valid session is required"
})),
)
.into_response(),
)
})
}
pub async fn email_verify_start(
State(state): State<Arc<EmailVerificationRouteState>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Response {
if let Some(limited) = verify_rate_limit(&state, addr, "email_verify_start") {
return limited;
}
let user_id = match caller(&state, &headers, "email_verify_start") {
Ok(user_id) => user_id,
Err(unauthorized) => return *unauthorized,
};
if let Err(e) = state.authenticator.start_email_verification(&user_id).await {
tracing::error!(error = %e, "email verification start failed");
}
(
StatusCode::ACCEPTED,
Json(serde_json::json!({
"message": "If this account has an unverified address, a verification link has been \
sent to it."
})),
)
.into_response()
}
pub async fn email_verify_confirm(
State(state): State<Arc<EmailVerificationRouteState>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<EmailVerifyConfirmRequest>,
) -> Response {
if let Some(limited) = verify_rate_limit(&state, addr, "email_verify_confirm") {
return limited;
}
let user_id = match caller(&state, &headers, "email_verify_confirm") {
Ok(user_id) => user_id,
Err(unauthorized) => return *unauthorized,
};
match state.authenticator.confirm_email_verification(&user_id, &req.token).await {
Ok(verified) => (
StatusCode::OK,
Json(serde_json::json!({
"verified": true,
"email": verified.email,
"user_id": verified.user_id,
})),
)
.into_response(),
Err(e @ AuthError::EmailClaimedByAnotherAccount) => {
json_error(StatusCode::CONFLICT, "email_claimed_by_another_account", &e.to_string())
},
Err(e) => {
get_audit_logger().log_failure(
AuditEventType::AuthFailure,
SecretType::CsrfToken,
Some(user_id),
"email_verify_confirm",
&e.to_string(),
);
json_error(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid_token",
"invalid, expired, or already-used verification token",
)
},
}
}
#[cfg(test)]
mod routes_tests;