use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
pub mfa_code: Option<String>,
#[serde(default)]
pub remember_me: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MfaVerifyRequest {
pub mfa_token: String,
pub code: String,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "status")]
pub enum LoginResponse {
#[serde(rename = "success")]
Success {
access_token: String,
refresh_token: String,
expires_in: u64,
token_type: &'static str,
},
#[serde(rename = "mfa_required")]
MfaRequired {
mfa_token: String,
mfa_type: MfaType,
#[serde(skip_serializing_if = "Option::is_none")]
backup_codes_remaining: Option<usize>,
},
#[serde(rename = "error")]
Error {
message: String,
},
}
impl LoginResponse {
pub fn success(access_token: String, refresh_token: String, expires_in: u64) -> Self {
Self::Success {
access_token,
refresh_token,
expires_in,
token_type: "Bearer",
}
}
pub fn mfa_required(mfa_token: String, backup_codes_remaining: Option<usize>) -> Self {
Self::MfaRequired {
mfa_token,
mfa_type: MfaType::Totp,
backup_codes_remaining,
}
}
pub fn error(message: impl Into<String>) -> Self {
Self::Error {
message: message.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct LoginHttpResponse(pub LoginResponse);
impl From<LoginResponse> for LoginHttpResponse {
fn from(response: LoginResponse) -> Self {
Self(response)
}
}
impl IntoResponse for LoginHttpResponse {
fn into_response(self) -> Response {
let status = match &self.0 {
LoginResponse::Success { .. } | LoginResponse::MfaRequired { .. } => StatusCode::OK,
LoginResponse::Error { message } if message.contains("temporarily locked") => {
StatusCode::LOCKED
}
LoginResponse::Error { message } if message.contains("verify your email") => {
StatusCode::FORBIDDEN
}
LoginResponse::Error { .. } => StatusCode::UNAUTHORIZED,
};
(status, Json(self.0)).into_response()
}
}
#[cfg(test)]
mod login_http_response_tests {
use super::*;
#[test]
fn maps_login_outcomes_to_http_statuses() {
let cases = [
(
LoginResponse::error("Invalid credentials"),
StatusCode::UNAUTHORIZED,
),
(
LoginResponse::error("Please verify your email before logging in."),
StatusCode::FORBIDDEN,
),
(
LoginResponse::error("Account temporarily locked. Try again later."),
StatusCode::LOCKED,
),
(
LoginResponse::success("access".into(), "refresh".into(), 900),
StatusCode::OK,
),
];
for (response, expected) in cases {
assert_eq!(
LoginHttpResponse(response).into_response().status(),
expected
);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum MfaType {
Totp,
BackupCode,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
pub name: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PasswordResetRequest {
pub email: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PasswordResetComplete {
pub token: String,
pub new_password: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EmailVerifyRequest {
pub token: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ResendVerificationRequest {
pub email: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RefreshRequest {
pub refresh_token: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LogoutRequest {
pub refresh_token: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PasswordChangeRequest {
pub current_password: String,
pub new_password: String,
}