feat(auth): add comprehensive authentication system with 2FA support
- Add new auth module with captcha, login, logout, register, and email verification endpoints - Implement two-factor authentication with TOTP enable, disable, verify, and backup codes regeneration - Create RSA public key endpoint for secure password encryption - Add user profile management with get current user and email retrieval - Integrate OpenAPI documentation for all authentication endpoints - Implement password reset functionality with email verification flow - Add comprehensive API response structures with proper error handling - Configure all auth routes under /api/v1/auth scope with proper tagging
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::captcha::{CaptchaQuery, CaptchaResponse};
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/auth/captcha",
|
||||||
|
tag = "Auth",
|
||||||
|
operation_id = "authGetCaptcha",
|
||||||
|
summary = "Get captcha image",
|
||||||
|
description = "Generate a one-time captcha image and store the plaintext captcha in the current session. Captchas are used for sensitive entry points such as login and sending registration email codes. Set rsa=true to return the current session RSA public key at the same time and reduce frontend initialization requests. The captcha is consumed after either successful or failed validation, so clients must fetch a new one after failure.",
|
||||||
|
params(
|
||||||
|
("w" = u32, Query, description = "Captcha image width; allowed range is 80..=400.", example = 160),
|
||||||
|
("h" = u32, Query, description = "Captcha image height; allowed range is 30..=200.", example = 64),
|
||||||
|
("dark" = bool, Query, description = "Whether to generate a dark-mode captcha.", example = false),
|
||||||
|
("rsa" = bool, Query, description = "Whether to include the RSA public key in the response.", example = true)
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Captcha generated successfully. The base64 field is image data that can be used directly as img.src.", body = ApiResponse<CaptchaResponse>),
|
||||||
|
(status = 400, description = "Invalid captcha size.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Session write failed or RSA initialization failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
query: web::Query<CaptchaQuery>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let data = service
|
||||||
|
.auth
|
||||||
|
.auth_captcha(&session, query.into_inner())
|
||||||
|
.await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::totp::Disable2FAParams;
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/2fa/disable",
|
||||||
|
tag = "Auth / 2FA",
|
||||||
|
operation_id = "authDisableTwoFactor",
|
||||||
|
summary = "Disable two-factor authentication",
|
||||||
|
description = "Disable TOTP two-factor authentication for the current signed-in user. This requires verifying both the current password and a valid TOTP code or backup code. password must be encrypted with the current session RSA public key; a successfully verified backup code is consumed.",
|
||||||
|
request_body(
|
||||||
|
content = Disable2FAParams,
|
||||||
|
description = "TOTP/backup code and the current password encrypted with RSA.",
|
||||||
|
content_type = "application/json"
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "2FA has been disabled.", body = ApiEmptyResponse),
|
||||||
|
(status = 400, description = "2FA is not enabled, the verification code is incorrect, the password is incorrect, or RSA decryption failed.", body = ApiErrorResponse),
|
||||||
|
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database write failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
params: web::Json<Disable2FAParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
service
|
||||||
|
.auth
|
||||||
|
.auth_2fa_disable(&session, params.into_inner())
|
||||||
|
.await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("two-factor authentication disabled")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::totp::Enable2FAResponse;
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/2fa/enable",
|
||||||
|
tag = "Auth / 2FA",
|
||||||
|
operation_id = "authPrepareTwoFactorEnable",
|
||||||
|
summary = "Initialize two-factor authentication setup",
|
||||||
|
description = "Generate a new TOTP secret, otpauth QR-code URI, and 10 one-time backup codes for the current signed-in user, and save them in a not-yet-enabled state. Clients must guide the user to scan the QR code and call /auth/2fa/verify with a dynamic code before 2FA is actually enabled. Backup codes are returned in plaintext only once in this response; frontends must remind users to store them securely.",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "2FA setup initialized successfully. Returns the secret, QR-code URI, and backup codes.", body = ApiResponse<Enable2FAResponse>),
|
||||||
|
(status = 400, description = "The current user has already enabled 2FA.", body = ApiErrorResponse),
|
||||||
|
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database write or backup code hashing failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let data = service.auth.auth_2fa_enable(&session).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::totp::Get2FAStatusResponse;
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/auth/2fa/status",
|
||||||
|
tag = "Auth / 2FA",
|
||||||
|
operation_id = "authGetTwoFactorStatus",
|
||||||
|
summary = "Get two-factor authentication status",
|
||||||
|
description = "Read the current signed-in user's TOTP two-factor authentication status, including whether it is enabled, the authentication method, and whether backup codes are still available.",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Read successfully.", body = ApiResponse<Get2FAStatusResponse>),
|
||||||
|
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let data = service.auth.auth_2fa_status(&session).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::email::EmailResponse;
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/auth/email",
|
||||||
|
tag = "Auth",
|
||||||
|
operation_id = "authGetPrimaryEmail",
|
||||||
|
summary = "Get current user verified email",
|
||||||
|
description = "Return the verified primary email for the current signed-in user. If no verified email is bound to the account, the email field is null.",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Read successfully.", body = ApiResponse<EmailResponse>),
|
||||||
|
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let data = service.auth.auth_get_email(&session).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::login::LoginParams;
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/login",
|
||||||
|
tag = "Auth",
|
||||||
|
operation_id = "authLogin",
|
||||||
|
summary = "Account login",
|
||||||
|
description = "Log in using a username or verified email. password must be a Base64 ciphertext encrypted with the public key returned by /auth/rsa; the first login attempt must include captcha. If the account has TOTP enabled, the first successful password check returns 400/two-factor required and records pending verification state in the session. Then submit username, password, and totp_code again in the same session to complete login. On success, the session is renewed, the current user is bound, and temporary RSA keys are cleared.",
|
||||||
|
request_body(
|
||||||
|
content = LoginParams,
|
||||||
|
description = "Login parameters. username accepts a username or email; password is an RSA-OAEP-SHA256 encrypted ciphertext; captcha is the captcha stored in the current session; totp_code is required only during the second verification step.",
|
||||||
|
content_type = "application/json"
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Login succeeded. The server establishes login state through the session cookie.", body = ApiEmptyResponse),
|
||||||
|
(status = 400, description = "Captcha error, RSA decryption failure, or missing/incorrect TOTP.", body = ApiErrorResponse),
|
||||||
|
(status = 404, description = "User does not exist or password is incorrect; to reduce enumeration risk, incorrect passwords are also treated as user-not-found.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database, cache, or session write failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
params: web::Json<LoginParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
service
|
||||||
|
.auth
|
||||||
|
.auth_login(params.into_inner(), session)
|
||||||
|
.await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("login successful")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/logout",
|
||||||
|
tag = "Auth",
|
||||||
|
operation_id = "authLogout",
|
||||||
|
summary = "Log out",
|
||||||
|
description = "Clear the user identity and all temporary authentication data from the current session, including captcha, temporary RSA keys, and pending 2FA state. This endpoint is idempotent: unauthenticated users also receive a success response.",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Logged out successfully, or the session was already unauthenticated.", body = ApiEmptyResponse),
|
||||||
|
(status = 500, description = "Session persistence failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
service.auth.auth_logout(&session).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("logout successful")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::me::ContextMe;
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/auth/me",
|
||||||
|
tag = "Auth",
|
||||||
|
operation_id = "authGetCurrentUser",
|
||||||
|
summary = "Get current signed-in user context",
|
||||||
|
description = "Return the current user's basic profile, preferred language, timezone, and notification summary using the user_uid bound to the session. This endpoint is typically used to restore the login state when the frontend app starts.",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "The current session is authenticated. Returns the user context.", body = ApiResponse<ContextMe>),
|
||||||
|
(status = 401, description = "The current session is unauthenticated or the login state has expired.", body = ApiErrorResponse),
|
||||||
|
(status = 404, description = "The user in the session no longer exists, has been disabled, or has been deleted.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let data = service.auth.auth_me(session).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
pub mod captcha;
|
||||||
|
pub mod disable_2fa;
|
||||||
|
pub mod enable_2fa;
|
||||||
|
pub mod get_2fa_status;
|
||||||
|
pub mod get_email;
|
||||||
|
pub mod login;
|
||||||
|
pub mod logout;
|
||||||
|
pub mod me;
|
||||||
|
pub mod regenerate_2fa_backup_codes;
|
||||||
|
pub mod register;
|
||||||
|
pub mod register_email_code;
|
||||||
|
pub mod request_email_change;
|
||||||
|
pub mod request_reset_password;
|
||||||
|
pub mod rsa;
|
||||||
|
pub mod verify_2fa;
|
||||||
|
pub mod verify_email;
|
||||||
|
pub mod verify_reset_password;
|
||||||
|
|
||||||
|
use actix_web::web;
|
||||||
|
|
||||||
|
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||||
|
cfg.service(
|
||||||
|
web::scope("/auth")
|
||||||
|
.route("/rsa", web::get().to(rsa::handle))
|
||||||
|
.route("/captcha", web::get().to(captcha::handle))
|
||||||
|
.route("/login", web::post().to(login::handle))
|
||||||
|
.route("/logout", web::post().to(logout::handle))
|
||||||
|
.route("/me", web::get().to(me::handle))
|
||||||
|
.route(
|
||||||
|
"/register/email-code",
|
||||||
|
web::post().to(register_email_code::handle),
|
||||||
|
)
|
||||||
|
.route("/register", web::post().to(register::handle))
|
||||||
|
.route("/email", web::get().to(get_email::handle))
|
||||||
|
.route(
|
||||||
|
"/email/change",
|
||||||
|
web::post().to(request_email_change::handle),
|
||||||
|
)
|
||||||
|
.route("/email/verify", web::post().to(verify_email::handle))
|
||||||
|
.route(
|
||||||
|
"/reset-password",
|
||||||
|
web::post().to(request_reset_password::handle),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/reset-password/verify",
|
||||||
|
web::post().to(verify_reset_password::handle),
|
||||||
|
)
|
||||||
|
.route("/2fa/status", web::get().to(get_2fa_status::handle))
|
||||||
|
.route("/2fa/enable", web::post().to(enable_2fa::handle))
|
||||||
|
.route("/2fa/verify", web::post().to(verify_2fa::handle))
|
||||||
|
.route("/2fa/disable", web::post().to(disable_2fa::handle))
|
||||||
|
.route(
|
||||||
|
"/2fa/backup-codes/regenerate",
|
||||||
|
web::post().to(regenerate_2fa_backup_codes::handle),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct Regenerate2FABackupCodesRequest {
|
||||||
|
/// Current account password encrypted with the session RSA public key.
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct Regenerate2FABackupCodesResponse {
|
||||||
|
/// Newly generated one-time backup codes. Old backup codes become invalid.
|
||||||
|
pub backup_codes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/2fa/backup-codes/regenerate",
|
||||||
|
tag = "Auth / 2FA",
|
||||||
|
operation_id = "authRegenerateTwoFactorBackupCodes",
|
||||||
|
summary = "Regenerate 2FA backup codes",
|
||||||
|
description = "After verifying the current password, generate a new set of backup codes for a user with 2FA enabled and replace the old backup codes. password must be encrypted with the current session RSA public key. Backup codes are returned in plaintext only once in this response; clients must prompt users to store them securely.",
|
||||||
|
request_body(
|
||||||
|
content = Regenerate2FABackupCodesRequest,
|
||||||
|
description = "The current account password encrypted with RSA.",
|
||||||
|
content_type = "application/json"
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Backup codes have been regenerated; old backup codes are immediately invalidated.", body = ApiResponse<Regenerate2FABackupCodesResponse>),
|
||||||
|
(status = 400, description = "2FA is not enabled, the password is incorrect, or RSA decryption failed.", body = ApiErrorResponse),
|
||||||
|
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database write or backup code hashing failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
params: web::Json<Regenerate2FABackupCodesRequest>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let backup_codes = service
|
||||||
|
.auth
|
||||||
|
.auth_2fa_regenerate_backup_codes(&session, params.into_inner().password)
|
||||||
|
.await?;
|
||||||
|
Ok(
|
||||||
|
HttpResponse::Ok().json(ApiResponse::new(Regenerate2FABackupCodesResponse {
|
||||||
|
backup_codes,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::users::User;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::register::RegisterParams;
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct RegisterResponse {
|
||||||
|
/// Newly created user id.
|
||||||
|
pub id: Uuid,
|
||||||
|
/// Unique username used for login and profile URL.
|
||||||
|
pub username: String,
|
||||||
|
/// Display name initialized from username.
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
/// Avatar URL; usually absent right after registration.
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<User> for RegisterResponse {
|
||||||
|
fn from(user: User) -> Self {
|
||||||
|
Self {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
|
avatar_url: user.avatar_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/register",
|
||||||
|
tag = "Auth",
|
||||||
|
operation_id = "authRegister",
|
||||||
|
summary = "Register a new account",
|
||||||
|
description = "Create an account after validating username, email, password, captcha, and email verification code. password must be encrypted with the current session RSA public key; captcha and email_code are one-time credentials. On successful registration, the new user is written to the session and does not need to log in again.",
|
||||||
|
request_body(
|
||||||
|
content = RegisterParams,
|
||||||
|
description = "Registration parameters. email_code comes from /auth/register/email-code; password is a Base64 ciphertext encrypted with RSA-OAEP-SHA256.",
|
||||||
|
content_type = "application/json"
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Registration succeeded; the current session is automatically signed in as the new user.", body = ApiResponse<RegisterResponse>),
|
||||||
|
(status = 400, description = "Captcha error, email verification code error, weak password, RSA decryption failure, or missing required fields.", body = ApiErrorResponse),
|
||||||
|
(status = 409, description = "The username or email is already in use.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database transaction, password hashing, cache, or session write failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
params: web::Json<RegisterParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let user = service
|
||||||
|
.auth
|
||||||
|
.auth_register(params.into_inner(), &session)
|
||||||
|
.await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiResponse::new(RegisterResponse::from(user))))
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::register::{RegisterEmailCodeParams, RegisterEmailCodeResponse};
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/register/email-code",
|
||||||
|
tag = "Auth",
|
||||||
|
operation_id = "authSendRegisterEmailCode",
|
||||||
|
summary = "Send registration email verification code",
|
||||||
|
description = "After validating the captcha in the current session, send a 6-digit registration code to the target email address. The endpoint checks whether a verified email already exists and applies a per-email cooldown to prevent email bombing. The code is valid for 10 minutes by default.",
|
||||||
|
request_body(
|
||||||
|
content = RegisterEmailCodeParams,
|
||||||
|
description = "The target email address and captcha from the current session.",
|
||||||
|
content_type = "application/json"
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "The verification email has been queued for delivery. Returns the code expiration time.", body = ApiResponse<RegisterEmailCodeResponse>),
|
||||||
|
(status = 400, description = "The captcha is incorrect, the email is empty, or requests are too frequent.", body = ApiErrorResponse),
|
||||||
|
(status = 409, description = "The email is already used by another verified account.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Cache write failed or the email service is unavailable.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
params: web::Json<RegisterEmailCodeParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let data = service
|
||||||
|
.auth
|
||||||
|
.auth_register_email_code(params.into_inner(), &session)
|
||||||
|
.await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::email::EmailChangeRequest;
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/email/change",
|
||||||
|
tag = "Auth",
|
||||||
|
operation_id = "authRequestEmailChange",
|
||||||
|
summary = "Request login email change",
|
||||||
|
description = "After verifying the current user password, send a confirmation link to the new email address. password must be encrypted with the current session RSA public key. The token in the confirmation link is valid for 1 hour by default; the actual email switch is completed by calling /auth/email/verify.",
|
||||||
|
request_body(
|
||||||
|
content = EmailChangeRequest,
|
||||||
|
description = "The new email address and encrypted current account password.",
|
||||||
|
content_type = "application/json"
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "The confirmation email has been queued for delivery.", body = ApiEmptyResponse),
|
||||||
|
(status = 400, description = "The new email is empty, the password is incorrect, or RSA decryption failed.", body = ApiErrorResponse),
|
||||||
|
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
|
||||||
|
(status = 409, description = "The new email is already in use.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Cache, email service, or database read failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
params: web::Json<EmailChangeRequest>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
service
|
||||||
|
.auth
|
||||||
|
.auth_email_change_request(&session, params.into_inner())
|
||||||
|
.await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("email change verification sent")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::reset_pass::ResetPasswordRequest;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/reset-password",
|
||||||
|
tag = "Auth",
|
||||||
|
operation_id = "authRequestPasswordReset",
|
||||||
|
summary = "Request password reset email",
|
||||||
|
description = "Submit an email address to send a password reset link if it belongs to an active user. To prevent user enumeration, the business logic attempts to return success whether the email exists, rate limits are triggered, or email delivery fails. Internally, the endpoint enforces a 60-second cooldown and a daily limit of 5 requests per email.",
|
||||||
|
request_body(
|
||||||
|
content = ResetPasswordRequest,
|
||||||
|
description = "The email address that should receive the password reset link.",
|
||||||
|
content_type = "application/json"
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "The request has been accepted; if the email exists, a reset email will be sent.", body = ApiEmptyResponse),
|
||||||
|
(status = 500, description = "Rare unrecoverable server-side error.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
params: web::Json<ResetPasswordRequest>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
service
|
||||||
|
.auth
|
||||||
|
.auth_reset_password_request(params.into_inner())
|
||||||
|
.await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("password reset request accepted")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::rsa::RsaResponse;
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/auth/rsa",
|
||||||
|
tag = "Auth",
|
||||||
|
operation_id = "authGetRsaPublicKey",
|
||||||
|
summary = "Get login form RSA public key",
|
||||||
|
description = "Generate or reuse a temporary RSA-2048 key pair for the current browser session and return the public key in PKCS#1 PEM format. Clients should use this public key to encrypt sensitive fields such as passwords with RSA-OAEP-SHA256 before submitting login, registration, password reset, or 2FA disable requests. The private key is encrypted with AEAD and stored only in the server-side session; it is never returned to clients.",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Return the RSA public key available for the current session; if an unexpired key already exists in the session, reuse the existing public key.", body = ApiResponse<RsaResponse>),
|
||||||
|
(status = 500, description = "APP_SESSION_SECRET is missing, RSA generation failed, or session write failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let data = service.auth.auth_rsa(&session).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::totp::Verify2FAParams;
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/2fa/verify",
|
||||||
|
tag = "Auth / 2FA",
|
||||||
|
operation_id = "authVerifyAndEnableTwoFactor",
|
||||||
|
summary = "Verify and enable two-factor authentication",
|
||||||
|
description = "After initializing with /auth/2fa/enable, submit the 6-digit TOTP code generated by the authenticator app. On success, the current user's 2FA status is set to enabled. A small clock drift of one 30-second window before or after is allowed.",
|
||||||
|
request_body(
|
||||||
|
content = Verify2FAParams,
|
||||||
|
description = "The 6-digit TOTP code generated by the authenticator app.",
|
||||||
|
content_type = "application/json"
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "2FA has been enabled.", body = ApiEmptyResponse),
|
||||||
|
(status = 400, description = "2FA has not been initialized, is already enabled, or the verification code is incorrect.", body = ApiErrorResponse),
|
||||||
|
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database write failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
params: web::Json<Verify2FAParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
service
|
||||||
|
.auth
|
||||||
|
.auth_2fa_verify_and_enable(&session, params.into_inner())
|
||||||
|
.await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("two-factor authentication enabled")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::email::EmailVerifyRequest;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/email/verify",
|
||||||
|
tag = "Auth",
|
||||||
|
operation_id = "authVerifyEmailChange",
|
||||||
|
summary = "Confirm email change",
|
||||||
|
description = "Complete an email change using the token from the confirmation email. The endpoint checks again whether the target email is already taken, then marks old emails as unverified and inserts the new verified primary email in a transaction.",
|
||||||
|
request_body(
|
||||||
|
content = EmailVerifyRequest,
|
||||||
|
description = "Email change confirmation token.",
|
||||||
|
content_type = "application/json"
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Email changed successfully.", body = ApiEmptyResponse),
|
||||||
|
(status = 400, description = "The token is empty.", body = ApiErrorResponse),
|
||||||
|
(status = 404, description = "The token is invalid or expired.", body = ApiErrorResponse),
|
||||||
|
(status = 409, description = "The target email was taken by another account before confirmation.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database transaction failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
params: web::Json<EmailVerifyRequest>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
service.auth.auth_email_verify(params.into_inner()).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("email verified")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::service::auth::reset_pass::ResetPasswordVerifyParams;
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/reset-password/verify",
|
||||||
|
tag = "Auth",
|
||||||
|
operation_id = "authVerifyPasswordReset",
|
||||||
|
summary = "Confirm password reset",
|
||||||
|
description = "Set a new password using the token from the password reset email. password must be encrypted with the current session RSA public key; the new password is strength-checked and rehashed with Argon2id. The token is deleted immediately after successful use; expired or missing tokens fail.",
|
||||||
|
request_body(
|
||||||
|
content = ResetPasswordVerifyParams,
|
||||||
|
description = "The reset token and new password encrypted with RSA.",
|
||||||
|
content_type = "application/json"
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Password reset succeeded.", body = ApiEmptyResponse),
|
||||||
|
(status = 400, description = "The token is invalid or expired, RSA decryption failed, or the password is too weak.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database update or password hashing failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
params: web::Json<ResetPasswordVerifyParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
service
|
||||||
|
.auth
|
||||||
|
.auth_reset_password_verify(&session, params.into_inner())
|
||||||
|
.await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("password reset successful")))
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod auth;
|
||||||
pub mod openapi;
|
pub mod openapi;
|
||||||
pub mod response;
|
pub mod response;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
|||||||
@@ -1,4 +1,85 @@
|
|||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
use crate::api::auth::regenerate_2fa_backup_codes::{
|
||||||
|
Regenerate2FABackupCodesRequest, Regenerate2FABackupCodesResponse,
|
||||||
|
};
|
||||||
|
use crate::api::auth::register::RegisterResponse;
|
||||||
|
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse, ApiResponse};
|
||||||
|
use crate::service::auth::captcha::{CaptchaQuery, CaptchaResponse};
|
||||||
|
use crate::service::auth::email::{EmailChangeRequest, EmailResponse, EmailVerifyRequest};
|
||||||
|
use crate::service::auth::login::LoginParams;
|
||||||
|
use crate::service::auth::me::ContextMe;
|
||||||
|
use crate::service::auth::register::{
|
||||||
|
RegisterEmailCodeParams, RegisterEmailCodeResponse, RegisterParams,
|
||||||
|
};
|
||||||
|
use crate::service::auth::reset_pass::{ResetPasswordRequest, ResetPasswordVerifyParams};
|
||||||
|
use crate::service::auth::rsa::RsaResponse;
|
||||||
|
use crate::service::auth::totp::{
|
||||||
|
Disable2FAParams, Enable2FAResponse, Get2FAStatusResponse, Verify2FAParams,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
info(
|
||||||
|
title = "AppKS API",
|
||||||
|
version = "0.1.0",
|
||||||
|
description = "AppKS collaborative development platform HTTP API. Auth endpoints use server-side sessions backed by Redis and a signed/encrypted session cookie. Sensitive password fields are RSA-OAEP-SHA256 encrypted per session before transmission."
|
||||||
|
),
|
||||||
|
tags(
|
||||||
|
(name = "Auth", description = "Authentication, registration, session and email security endpoints."),
|
||||||
|
(name = "Auth / 2FA", description = "TOTP two-factor authentication management endpoints.")
|
||||||
|
),
|
||||||
|
paths(
|
||||||
|
crate::api::auth::rsa::handle,
|
||||||
|
crate::api::auth::captcha::handle,
|
||||||
|
crate::api::auth::login::handle,
|
||||||
|
crate::api::auth::logout::handle,
|
||||||
|
crate::api::auth::me::handle,
|
||||||
|
crate::api::auth::register_email_code::handle,
|
||||||
|
crate::api::auth::register::handle,
|
||||||
|
crate::api::auth::get_email::handle,
|
||||||
|
crate::api::auth::request_email_change::handle,
|
||||||
|
crate::api::auth::verify_email::handle,
|
||||||
|
crate::api::auth::request_reset_password::handle,
|
||||||
|
crate::api::auth::verify_reset_password::handle,
|
||||||
|
crate::api::auth::get_2fa_status::handle,
|
||||||
|
crate::api::auth::enable_2fa::handle,
|
||||||
|
crate::api::auth::verify_2fa::handle,
|
||||||
|
crate::api::auth::disable_2fa::handle,
|
||||||
|
crate::api::auth::regenerate_2fa_backup_codes::handle
|
||||||
|
),
|
||||||
|
components(schemas(
|
||||||
|
ApiEmptyResponse,
|
||||||
|
ApiErrorResponse,
|
||||||
|
ApiResponse<RsaResponse>,
|
||||||
|
ApiResponse<CaptchaResponse>,
|
||||||
|
ApiResponse<ContextMe>,
|
||||||
|
ApiResponse<RegisterEmailCodeResponse>,
|
||||||
|
ApiResponse<RegisterResponse>,
|
||||||
|
ApiResponse<EmailResponse>,
|
||||||
|
ApiResponse<Get2FAStatusResponse>,
|
||||||
|
ApiResponse<Enable2FAResponse>,
|
||||||
|
ApiResponse<Regenerate2FABackupCodesResponse>,
|
||||||
|
RsaResponse,
|
||||||
|
CaptchaQuery,
|
||||||
|
CaptchaResponse,
|
||||||
|
LoginParams,
|
||||||
|
ContextMe,
|
||||||
|
RegisterEmailCodeParams,
|
||||||
|
RegisterEmailCodeResponse,
|
||||||
|
RegisterParams,
|
||||||
|
RegisterResponse,
|
||||||
|
EmailResponse,
|
||||||
|
EmailChangeRequest,
|
||||||
|
EmailVerifyRequest,
|
||||||
|
ResetPasswordRequest,
|
||||||
|
ResetPasswordVerifyParams,
|
||||||
|
Get2FAStatusResponse,
|
||||||
|
Enable2FAResponse,
|
||||||
|
Verify2FAParams,
|
||||||
|
Disable2FAParams,
|
||||||
|
Regenerate2FABackupCodesRequest,
|
||||||
|
Regenerate2FABackupCodesResponse
|
||||||
|
))
|
||||||
|
)]
|
||||||
pub struct OpenApiDoc;
|
pub struct OpenApiDoc;
|
||||||
|
|||||||
@@ -2,14 +2,22 @@ use serde::Serialize;
|
|||||||
|
|
||||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
pub struct ApiResponse<T: Serialize> {
|
pub struct ApiResponse<T: Serialize> {
|
||||||
|
/// Business payload returned by the endpoint.
|
||||||
pub data: T,
|
pub data: T,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
pub struct ApiEmptyResponse {
|
pub struct ApiEmptyResponse {
|
||||||
|
/// Human-readable success message.
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct ApiErrorResponse {
|
||||||
|
/// Stable, client-safe error message.
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
pub struct ApiListResponse<T: Serialize> {
|
pub struct ApiListResponse<T: Serialize> {
|
||||||
pub data: Vec<T>,
|
pub data: Vec<T>,
|
||||||
@@ -17,3 +25,41 @@ pub struct ApiListResponse<T: Serialize> {
|
|||||||
pub page: i64,
|
pub page: i64,
|
||||||
pub per_page: i64,
|
pub per_page: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<T: Serialize> ApiResponse<T> {
|
||||||
|
pub fn new(data: T) -> Self {
|
||||||
|
Self { data }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiEmptyResponse {
|
||||||
|
pub fn ok(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Serialize> ApiListResponse<T> {
|
||||||
|
pub fn new(data: Vec<T>, total: i64, page: i64, per_page: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
per_page,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
data: vec![],
|
||||||
|
total: 0,
|
||||||
|
page: 0,
|
||||||
|
per_page: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<T: Serialize> From<(Vec<T>, i64, i64, i64)> for ApiListResponse<T> {
|
||||||
|
fn from(value: (Vec<T>, i64, i64, i64)) -> Self {
|
||||||
|
Self::new(value.0, value.1, value.2, value.3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
use actix_web::web;
|
use actix_web::web;
|
||||||
use actix_web::web::scope;
|
use actix_web::web::scope;
|
||||||
|
|
||||||
|
use crate::api::auth;
|
||||||
|
|
||||||
pub fn init_routes(cfg: &mut web::ServiceConfig) {
|
pub fn init_routes(cfg: &mut web::ServiceConfig) {
|
||||||
cfg.service(scope("/api/v1"));
|
cfg.service(scope("/api/v1").configure(auth::configure));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pub struct UpdateWikiPageParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RepoService {
|
impl RepoService {
|
||||||
/// 列出仓库的所有 wiki 页面
|
/// List all wiki pages in a repository.
|
||||||
pub async fn wiki_list_pages(
|
pub async fn wiki_list_pages(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
@@ -69,7 +69,7 @@ impl RepoService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取单个 wiki 页面
|
/// Get a single wiki page.
|
||||||
pub async fn wiki_get_page(
|
pub async fn wiki_get_page(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
@@ -94,7 +94,7 @@ impl RepoService {
|
|||||||
.ok_or_else(|| AppError::NotFound("Wiki page not found".into()))
|
.ok_or_else(|| AppError::NotFound("Wiki page not found".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建 wiki 页面
|
/// Create a wiki page.
|
||||||
pub async fn wiki_create_page(
|
pub async fn wiki_create_page(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
@@ -160,7 +160,7 @@ impl RepoService {
|
|||||||
Ok(page)
|
Ok(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新 wiki 页面
|
/// Update a wiki page.
|
||||||
pub async fn wiki_update_page(
|
pub async fn wiki_update_page(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
@@ -231,7 +231,7 @@ impl RepoService {
|
|||||||
Ok(updated)
|
Ok(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除 wiki 页面(软删除)
|
/// Delete a wiki page using a soft delete.
|
||||||
pub async fn wiki_delete_page(
|
pub async fn wiki_delete_page(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
@@ -259,7 +259,7 @@ impl RepoService {
|
|||||||
ensure_affected(result.rows_affected(), "wiki page not found")
|
ensure_affected(result.rows_affected(), "wiki page not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 回滚到历史版本
|
/// Revert a wiki page to a historical version.
|
||||||
pub async fn wiki_revert_to_version(
|
pub async fn wiki_revert_to_version(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::session::Session;
|
|||||||
use super::util::clamp_limit_offset;
|
use super::util::clamp_limit_offset;
|
||||||
|
|
||||||
impl RepoService {
|
impl RepoService {
|
||||||
/// 获取页面的修订历史
|
/// Get the revision history for a page.
|
||||||
pub async fn wiki_get_revisions(
|
pub async fn wiki_get_revisions(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
@@ -34,7 +34,7 @@ impl RepoService {
|
|||||||
.map_err(AppError::Database)
|
.map_err(AppError::Database)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取特定版本的修订详情
|
/// Get details for a specific revision version.
|
||||||
pub async fn wiki_get_revision(
|
pub async fn wiki_get_revision(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
@@ -60,7 +60,7 @@ impl RepoService {
|
|||||||
.ok_or_else(|| AppError::NotFound("Revision not found".into()))
|
.ok_or_else(|| AppError::NotFound("Revision not found".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 比较两个版本的差异
|
/// Compare two revision versions.
|
||||||
pub async fn wiki_compare_revisions(
|
pub async fn wiki_compare_revisions(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
|
|||||||
Reference in New Issue
Block a user