use axum::Json; use axum::extract::State; use axum::http::HeaderMap; use export::rest::{AppResponseData, NoData}; use jwt::Session; use jwt::share::session::require_uid; use kenauth::proto::common::v1::MfaStatus; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::errors::ApiError; use crate::services::AuthService; use crate::share::rpc; #[derive(Debug, Serialize, ToSchema)] pub struct TotpStatusResp { pub enabled: bool, } #[derive(Debug, Serialize, ToSchema)] pub struct TotpSetupResp { pub secret: String, pub otpauth_uri: String, } #[derive(Debug, Deserialize, ToSchema)] pub struct TotpEnableCmd { pub code: String, pub password: String, } #[derive(Debug, Deserialize, ToSchema)] pub struct TotpCodeCmd { pub code: String, } #[derive(Debug, Deserialize, ToSchema)] pub struct RegenerateRecoveryCodesCmd { pub password: String, } #[derive(Debug, Serialize, ToSchema)] pub struct RecoveryCodesResp { pub total: u32, pub used: u32, pub codes: Vec, } #[utoipa::path( get, path = "/auth/2fa/status", tag = "auth", description = "查询当前账号是否已启用两步验证。", responses( (status = 200, description = "two-factor status", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn totp_status( State(service): State, session: Session, ) -> Result, ApiError> { let uid = require_uid(&session)?; let client = rpc::client(&service.state)?; let status = client .get_mfa_status(uid) .await .map_err(|error| ApiError::internal(error.to_string()))?; let enabled = status .factors .iter() .any(|factor| factor.status == MfaStatus::Enabled as i32); Ok(AppResponseData::ok(TotpStatusResp { enabled })) } #[utoipa::path( post, path = "/auth/2fa/setup", tag = "auth", description = "生成新的 TOTP 密钥并返回 otpauth URI,密钥明文只返回一次。", responses( (status = 200, description = "totp secret provisioned", body = AppResponseData), (status = 400, description = "request failed validation", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn totp_setup( State(service): State, session: Session, headers: HeaderMap, ) -> Result, ApiError> { let uid = require_uid(&session)?; let client = rpc::client(&service.state)?; let configured = client .configure_totp(uid, "TOTP", &rpc::client_ip(&headers)) .await .map_err(|error| ApiError::bad_request(error.to_string()))?; Ok(AppResponseData::ok(TotpSetupResp { secret: configured.secret, otpauth_uri: configured.otpauth_uri, })) } #[utoipa::path( post, path = "/auth/2fa/enable", tag = "auth", description = "用动态码与账号口令确认启用两步验证,返回一次性恢复码。", request_body = TotpEnableCmd, responses( (status = 200, description = "two-factor enabled and recovery codes issued", body = AppResponseData), (status = 400, description = "request failed validation", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn totp_enable( State(service): State, session: Session, headers: HeaderMap, Json(cmd): Json, ) -> Result, ApiError> { let uid = require_uid(&session)?; let client = rpc::client(&service.state)?; let enabled = client .enable_totp( uid, cmd.code.trim(), &rpc::digest(&cmd.password), &rpc::client_ip(&headers), ) .await .map_err(|error| ApiError::bad_request(error.to_string()))?; let total = enabled.recovery_codes.len() as u32; Ok(AppResponseData::ok(RecoveryCodesResp { total, used: 0, codes: enabled.recovery_codes, })) } #[utoipa::path( post, path = "/auth/2fa/disable", tag = "auth", description = "用动态码或一枚恢复码关闭两步验证,并吊销全部恢复码。", request_body = TotpCodeCmd, responses( (status = 200, description = "two-factor disabled", body = AppResponseData), (status = 400, description = "request failed validation", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn totp_disable( State(service): State, session: Session, headers: HeaderMap, Json(cmd): Json, ) -> Result, ApiError> { let uid = require_uid(&session)?; let client = rpc::client(&service.state)?; client .disable_totp(uid, cmd.code.trim(), &rpc::client_ip(&headers)) .await .map_err(|error| ApiError::bad_request(error.to_string()))?; Ok(AppResponseData::empty()) } #[utoipa::path( get, path = "/auth/2fa/recovery-codes", tag = "auth", description = "查看恢复码批次用量。明文只在生成时返回一次,这里不会回显。", responses( (status = 200, description = "recovery code usage", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn recovery_codes( State(service): State, session: Session, ) -> Result, ApiError> { let uid = require_uid(&session)?; let client = rpc::client(&service.state)?; let status = client .get_mfa_status(uid) .await .map_err(|error| ApiError::internal(error.to_string()))?; Ok(AppResponseData::ok(RecoveryCodesResp { total: status.recovery_codes_total, used: status.recovery_codes_used, codes: Vec::new(), })) } #[utoipa::path( put, path = "/auth/2fa/recovery-codes", tag = "auth", description = "校验账号口令后重建恢复码批次,返回新的一批明文。", request_body = RegenerateRecoveryCodesCmd, responses( (status = 200, description = "recovery codes regenerated", body = AppResponseData), (status = 400, description = "request failed validation", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn regenerate_recovery_codes( State(service): State, session: Session, headers: HeaderMap, Json(cmd): Json, ) -> Result, ApiError> { let uid = require_uid(&session)?; let client = rpc::client(&service.state)?; let regenerated = client .regenerate_recovery_codes(uid, &rpc::digest(&cmd.password), &rpc::client_ip(&headers)) .await .map_err(|error| ApiError::bad_request(error.to_string()))?; let total = regenerated.recovery_codes.len() as u32; Ok(AppResponseData::ok(RecoveryCodesResp { total, used: 0, codes: regenerated.recovery_codes, })) }