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, util::username_exists, validate_password, }; #[derive(Serialize, ToSchema)] #[schema(description = "Authorization token information")] struct Token { #[schema(examples("eyJ0eXAiO..."))] token: String, expiration_date: DateTime, } #[derive(Deserialize, ToSchema)] #[schema(description = "Body of the account registration request")] 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 = "Body of the login request")] 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 = "Body of the change account password request")] struct ChangePasswordRequest { #[schema(examples("secret-password"))] old_password: String, #[schema(examples("super-secret-password"))] new_password: String, } #[derive(Deserialize, ToSchema)] #[schema(description = "Body of the change account username request")] struct ChangeUsernameRequest { #[schema(examples("ivanov_ivan", "john_doe"))] new_username: String, } #[derive(Deserialize, ToSchema)] #[schema(description = "Body of the change account name request")] struct ChangeNameRequest { #[schema(examples("John", "Иван"))] first_name: Option, #[schema(examples("Doe", "Иванов"))] last_name: Option, } #[derive(Deserialize, ToSchema)] #[schema(description = "Body of the delete account request")] struct DeleteUserRequest { #[schema(examples("secret-password"))] password: String, } #[utoipa::path( get, path = "/me", tag = ACCOUNT, summary = "Get me", description = "Get your account information", 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", description = "Register a new account", request_body = RegisterRequest, responses( ( status = 200, body = SuccessResponse, description = "Success response with your account data" ), GlobalResponses ), )] async fn register( State(state): State, ApiJson(req): ApiJson, ) -> ApiResult { if username_exists(&req.username, &state.db).await? { return Err(ClientError::UsernameIsTaken { 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", description = "Get auth data", request_body = LoginRequest, responses( ( status = 200, body = SuccessResponse, description = "Success response with auth token data" ), GlobalResponses ), )] 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 expiration_date = 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: expiration_date.timestamp(), }, &state.secret, ) .map_err(|e| ServerError::Token(e.to_string()))?; Ok(SuccessResponse::ok(Token { token, expiration_date, })) } #[utoipa::path( patch, path = "/update/password", tag = ACCOUNT, summary = "Change password", description = "Change your account password", request_body = ChangePasswordRequest, responses( ( status = 200, body = SuccessResponse, description = "Success response with the changed account data" ), GlobalResponses ), security(("auth" = [])) )] async fn update_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( patch, path = "/update/username", tag = ACCOUNT, summary = "Change username", description = "Change your account username", request_body = ChangeUsernameRequest, responses( ( status = 200, body = SuccessResponse, description = "Success response with the changed account data" ), GlobalResponses ), security(("auth" = [])) )] async fn update_username( State(state): State, Auth(user): Auth, ApiJson(req): ApiJson, ) -> ApiResult { if username_exists(&req.new_username, &state.db).await? { return Err(ClientError::UsernameIsTaken { 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( patch, path = "/update/name", tag = ACCOUNT, summary = "Change name", description = "Change your account first or last name", request_body = ChangeNameRequest, responses( ( status = 200, body = SuccessResponse, description = "Success response with the changed account data" ), GlobalResponses ), security(("auth" = [])) )] async fn update_name( State(state): State, Auth(user): Auth, ApiJson(req): ApiJson, ) -> ApiResult { let mut active_user = user.into_active_model(); if let Some(first_name) = req.first_name { active_user.first_name = Set(first_name); } if let Some(last_name) = req.last_name { active_user.last_name = Set(last_name); } let user = active_user.update(&state.db).await?; Ok(SuccessResponse::ok(user.into())) } #[utoipa::path( delete, path = "/delete", tag = ACCOUNT, summary = "Delete", description = "Delete your account", request_body = DeleteUserRequest, responses( ( status = 200, body = SuccessResponse, description = "Success response with the 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!(update_password)) .routes(routes!(update_username)) .routes(routes!(update_name)) .routes(routes!(delete)) }