use axum::extract::State; use chrono::{DateTime, Duration, Utc}; use entity::users::{self}; use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, IntoActiveModel, ModelTrait, QueryFilter, }; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::{ ApiResult, AppState, ClientError, GlobalResponses, JwtClaims, ServerError, SuccessResponse, create_jwt, create_password, extract::{ApiJson, Auth}, models::Account, tags::ACCOUNT, validate_password, }; #[derive(Serialize, ToSchema)] #[schema(description = "Authorization token information")] struct Token { token: String, expired_at: DateTime, } #[derive(Deserialize, ToSchema)] #[schema(description = "Account register data")] struct RegisterRequest { #[schema(examples("john_doe", "ivanov_ivan"))] username: String, #[schema(examples("secret-password"))] password: String, #[schema(examples("John", "Иван"))] first_name: String, #[schema(examples("Doe", "Иванов"))] last_name: String, } #[derive(Deserialize, ToSchema, Default)] #[serde(rename_all = "UPPERCASE")] #[schema(description = "Account token life time")] enum TokenLifetime { Day = 1, #[default] Week = 7, Month = 31, } #[derive(Deserialize, ToSchema)] #[schema(description = "Account login data")] struct LoginRequest { #[schema(examples("john_doe", "ivanov_ivan"))] username: String, #[schema(examples("secret-password"))] password: String, #[serde(default)] #[schema(default = "WEEK")] token_lifetime: TokenLifetime, } #[derive(Deserialize, ToSchema)] #[schema(description = "Change account password data")] struct ChangePasswordRequest { #[schema(examples("secret-password"))] old_password: String, #[schema(examples("super-secret-password"))] new_password: String, } #[derive(Deserialize, ToSchema)] #[schema(description = "Change account password data")] struct ChangeUsernameRequest { #[schema(examples("ivanov_ivan", "john_doe"))] new_username: String, } #[derive(Deserialize, ToSchema)] #[schema(description = "Change account name data")] #[serde(rename_all = "snake_case")] enum ChangeNameRequest { #[schema(examples("John", "Иван"))] FirstName(String), #[schema(examples("Doe", "Иванов"))] LastName(String), } #[derive(Deserialize, ToSchema)] #[schema(description = "Account delete data")] struct DeleteUserRequest { #[schema(examples("secret-password"))] password: String, } #[utoipa::path( get, path = "/me", tag = ACCOUNT, summary = "Get me", responses( ( status = 200, body = SuccessResponse, description = "Success response with your account data" ), GlobalResponses ), security(("auth" = [])), )] async fn me(Auth(user): Auth) -> ApiResult { return Ok(SuccessResponse::ok(user.into())); } #[utoipa::path( post, path = "/register", tag = ACCOUNT, summary = "Register", responses( ( status = 200, body = SuccessResponse, description = "Success response with created account data" ), GlobalResponses ), request_body = RegisterRequest )] async fn register( State(state): State, ApiJson(req): ApiJson, ) -> ApiResult { let user_exists = users::Entity::find() .filter(users::Column::Username.eq(&req.username)) .one(&state.db) .await? .is_some(); if user_exists { return Err(ClientError::UserAlreadyExists { username: req.username, } .into()); } let user = users::ActiveModel { username: Set(req.username), password_hash: Set(create_password(&req.password)?), password_issue_date: Set(Utc::now().naive_utc()), first_name: Set(req.first_name), last_name: Set(req.last_name), ..Default::default() } .insert(&state.db) .await?; Ok(SuccessResponse::ok(user.into())) } #[utoipa::path( post, path = "/login", tag = ACCOUNT, summary = "Login", responses( ( status = 200, body = SuccessResponse, description = "Success response with auth token data" ), GlobalResponses ), request_body = LoginRequest )] async fn login( State(state): State, ApiJson(req): ApiJson, ) -> ApiResult { let user = users::Entity::find() .filter(users::Column::Username.eq(&req.username)) .one(&state.db) .await? .ok_or(ClientError::InvalidPassword)?; if !validate_password(&req.password, &user.password_hash)? { return Err(ClientError::InvalidPassword.into()); } let expired_at = Utc::now() + Duration::days(req.token_lifetime as i64); let token = create_jwt( &JwtClaims { sub: user.id, iat: user.password_issue_date.and_utc().timestamp(), exp: expired_at.timestamp(), }, &state.secret, ) .map_err(|e| ServerError::Token(e.to_string()))?; Ok(SuccessResponse::ok(Token { token, expired_at })) } #[utoipa::path( put, path = "/change/password", tag = ACCOUNT, summary = "Change password", request_body = ChangePasswordRequest, responses( ( status = 200, body = SuccessResponse, description = "Success response with changed account data" ), GlobalResponses ), security(("auth" = [])) )] async fn change_password( State(state): State, Auth(user): Auth, ApiJson(req): ApiJson, ) -> ApiResult { if !validate_password(&req.old_password, &user.password_hash)? { return Err(ClientError::InvalidPassword.into()); } let mut active_user = user.into_active_model(); active_user.password_hash = Set(create_password(&req.new_password)?); active_user.password_issue_date = Set(Utc::now().naive_utc()); let user = active_user.update(&state.db).await?; Ok(SuccessResponse::ok(user.into())) } #[utoipa::path( put, path = "/change/username", tag = ACCOUNT, summary = "Change username", request_body = ChangeUsernameRequest, responses( ( status = 200, body = SuccessResponse, description = "Success response with changed account data" ), GlobalResponses ), security(("auth" = [])) )] async fn change_username( State(state): State, Auth(user): Auth, ApiJson(req): ApiJson, ) -> ApiResult { let user_exists = users::Entity::find() .filter(users::Column::Username.eq(&req.new_username)) .one(&state.db) .await? .is_some(); if user_exists { return Err(ClientError::UserAlreadyExists { username: req.new_username, } .into()); } let mut active_user = user.into_active_model(); active_user.username = Set(req.new_username); let user = active_user.update(&state.db).await?; Ok(SuccessResponse::ok(user.into())) } #[utoipa::path( put, path = "/change/name", tag = ACCOUNT, summary = "Change name", request_body = ChangeNameRequest, responses( ( status = 200, body = SuccessResponse, description = "Success response with changed account data" ), GlobalResponses ), security(("auth" = [])) )] async fn change_name( State(state): State, Auth(user): Auth, ApiJson(req): ApiJson, ) -> ApiResult { let mut active_user = user.into_active_model(); match req { ChangeNameRequest::FirstName(new_first_name) => { active_user.first_name = Set(new_first_name); } ChangeNameRequest::LastName(new_last_name) => { active_user.first_name = Set(new_last_name); } } let user = active_user.update(&state.db).await?; Ok(SuccessResponse::ok(Account { id: user.id, username: user.username, first_name: user.first_name, last_name: user.last_name, })) } #[utoipa::path( delete, path = "/delete", tag = ACCOUNT, summary = "Delete", request_body = DeleteUserRequest, responses( ( status = 200, body = SuccessResponse, description = "Success response with deleted account data" ), GlobalResponses ), security(("auth" = [])) )] async fn delete( State(state): State, Auth(user): Auth, ApiJson(req): ApiJson, ) -> ApiResult { if !validate_password(&req.password, &user.password_hash)? { return Err(ClientError::InvalidPassword.into()); } user.clone().delete(&state.db).await?; Ok(SuccessResponse::ok(user.into())) } pub fn router() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(me)) .routes(routes!(register)) .routes(routes!(login)) .routes(routes!(change_password)) .routes(routes!(change_username)) .routes(routes!(change_name)) .routes(routes!(delete)) }