From 8c4d22ea565adaf5cde0564b5eb402b17915f344 Mon Sep 17 00:00:00 2001 From: yupix Date: Wed, 20 May 2026 08:01:31 +0000 Subject: [PATCH 01/23] feat(backend): add email verification functionality with token management and user email verification status --- apps/backend/.env.example | 1 + apps/backend/src/entities/README.md | 4 +- apps/backend/src/entities/scopes.rs | 2 +- apps/backend/src/entities/users.rs | 3 + apps/backend/src/handlers/auth.rs | 225 +++++++++++++++++-- apps/backend/src/handlers/labels.rs | 18 +- apps/backend/src/handlers/personal_tokens.rs | 40 ++-- apps/backend/src/openapi/mod.rs | 9 +- apps/backend/src/openapi/responses.rs | 93 ++++++-- apps/backend/src/routes/auth.rs | 2 + apps/backend/src/settings.rs | 7 + apps/backend/src/utils/auth.rs | 73 ++++++ apps/backend/src/utils/db.rs | 13 ++ apps/backend/src/utils/email_verification.rs | 124 ++++++++++ apps/backend/src/utils/mod.rs | 2 + 15 files changed, 555 insertions(+), 61 deletions(-) create mode 100644 apps/backend/src/utils/db.rs create mode 100644 apps/backend/src/utils/email_verification.rs diff --git a/apps/backend/.env.example b/apps/backend/.env.example index b2afe8a..92a6e94 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -6,3 +6,4 @@ smtp_port=587 smtp_username=your_smtp_username smtp_password=your_smtp_password smtp_from=no-reply@example.com +email_verification_app_url=http://localhost:3000 diff --git a/apps/backend/src/entities/README.md b/apps/backend/src/entities/README.md index 2d8944c..a2310f1 100644 --- a/apps/backend/src/entities/README.md +++ b/apps/backend/src/entities/README.md @@ -1,3 +1,3 @@ -# entities Module +# entities -このモジュールは、データベースのエンティティを定義します。各エンティティは、SeaORMのマクロを使用して定義されており、データベースのテーブル構造に対応しています。また、OpenAPIドキュメント生成のために、`utoipa::ToSchema`トレイトも実装しています。これにより、APIエンドポイントで使用されるデータ構造が明確になり、クライアントとのインターフェースが一貫性を持つようになります。 \ No newline at end of file +API や永続化で使うモデルをまとめるモジュールです。レスポンス用の `@ToSchema` 付きモデルもここに置きます。 diff --git a/apps/backend/src/entities/scopes.rs b/apps/backend/src/entities/scopes.rs index 3726f8c..3510918 100644 --- a/apps/backend/src/entities/scopes.rs +++ b/apps/backend/src/entities/scopes.rs @@ -29,7 +29,7 @@ pub enum Scope { AdminAll, } -/// JSON カラム用の `Vec` ラッパ(SeaORM エンティティ向け)。 +/// アクセストークン等に付与する権限スコープのリスト。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult, ToSchema)] #[serde(transparent)] pub struct ScopeList(pub Vec); diff --git a/apps/backend/src/entities/users.rs b/apps/backend/src/entities/users.rs index 9f8801a..d6c185c 100644 --- a/apps/backend/src/entities/users.rs +++ b/apps/backend/src/entities/users.rs @@ -17,8 +17,11 @@ pub struct Model { #[sea_orm(nullable)] #[schema(nullable)] pub avatar_url: Option, + #[sea_orm(unique)] #[schema(value_type = String, format="email")] pub email: String, + /// メールアドレスの確認が済んでいるかどうか + pub email_verified: bool, #[schema(ignore)] #[serde(skip_serializing)] pub password_hash: String, diff --git a/apps/backend/src/handlers/auth.rs b/apps/backend/src/handlers/auth.rs index b6f6c4d..27c0361 100644 --- a/apps/backend/src/handlers/auth.rs +++ b/apps/backend/src/handlers/auth.rs @@ -1,17 +1,25 @@ -use axum::{Json, extract::State}; +use axum::{Json, extract::State, http::StatusCode}; use axum_session::Session; use axum_session_redispool::SessionRedisPool; use axum_valid::Valid; use sea_orm::prelude::Uuid; -use sea_orm::{ActiveValue::Set, EntityTrait}; +use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait}; use sea_orm::{ColumnTrait, QueryFilter}; use serde::Deserialize; use validator::Validate; use crate::entities; use crate::extractors::{AuthUser, CurrentUser}; -use crate::openapi::{CredentialErrors, InternalOnlyError, SessionAuthErrors, UnauthorizedErrors}; -use crate::utils::auth::{AuthError, create_password_hash, verify_password}; +use crate::openapi::{ + CredentialErrors, RegisterErrors, ResendVerificationErrors, SessionAuthErrors, + UnauthorizedErrors, VerifyEmailErrors, +}; +use crate::settings::Settings; +use crate::utils::auth::{ + AuthError, create_password_hash, generate_email_verification_token, verify_password, +}; +use crate::utils::db::is_postgres_unique_violation; +use crate::utils::{email_verification, smtp::SmtpClient}; use crate::{AppState, entities::users}; #[derive(Validate, Debug, Deserialize, utoipa::ToSchema)] @@ -28,9 +36,10 @@ pub struct LoginRequest { #[utoipa::path( post, path = "/login", + summary = "ログイン", request_body = LoginRequest, responses( - (status = 200, description = "Login successful", body = String), + (status = 204, description = "ログインに成功しました(本文なし)"), CredentialErrors, ) )] @@ -38,20 +47,23 @@ pub async fn login( session: Session, State(state): State, Valid(Json(payload)): Valid>, -) -> Result, AuthError> { +) -> Result { let LoginRequest { email, password } = payload; let user = users::Entity::find() .filter(users::Column::Email.eq(email)) .one(&state.db) .await? - .ok_or(AuthError::Forbidden)?; - if verify_password(&password, &user.password_hash)? { - session.set("user_id", user.id); - Ok(Json("Login successful".to_string())) - } else { - Err(AuthError::Forbidden) + .ok_or(AuthError::InvalidCredentials)?; + if !verify_password(&password, &user.password_hash)? { + return Err(AuthError::InvalidCredentials); + } + if !user.email_verified { + return Err(AuthError::EmailNotVerified); } + + session.set("user_id", user.id); + Ok(StatusCode::NO_CONTENT) } #[derive(Validate, Debug, Deserialize, utoipa::ToSchema)] @@ -71,17 +83,21 @@ pub struct RegisterRequest { #[utoipa::path( post, path = "/register", + summary = "新規登録", request_body = RegisterRequest, responses( - (status = 200, description = "Register successful", body = String), - InternalOnlyError, + ( + status = 201, + description = "アカウントが作成されました。続けて送信されたメールで認証してください。", + body = String + ), + RegisterErrors, ) )] pub async fn register( - session: Session, State(state): State, Valid(Json(payload)): Valid>, -) -> Result, AuthError> { +) -> Result<(StatusCode, Json), AuthError> { let RegisterRequest { username, email, @@ -89,6 +105,7 @@ pub async fn register( } = payload; let password_hash = create_password_hash(&password)?; + let verification_token = generate_email_verification_token(); let user_id = Uuid::new_v4(); let user = users::ActiveModel { @@ -96,25 +113,153 @@ pub async fn register( username: Set(username), bio: Set(Some(String::new())), avatar_url: Set(None), - email: Set(email), + email: Set(email.clone()), + email_verified: Set(false), password_hash: Set(password_hash), }; users::Entity::insert(user.clone()) .exec(&state.db) .await - .map_err(|e| AuthError::Internal(anyhow::anyhow!("insert user: {e}")))?; + .map_err(|e| { + if is_postgres_unique_violation(&e) { + AuthError::DuplicateEmail + } else { + AuthError::Internal(anyhow::anyhow!("insert user: {e}")) + } + })?; - session.set("user_id", user_id); - Ok(Json("Register successful".to_string())) + email_verification::store_token(&state.redis_client, user_id, &verification_token) + .await + .map_err(|e| AuthError::Internal(anyhow::anyhow!("redis store verification token: {e}")))?; + + send_verification_email( + &state.smtp_client, + &email, + &state.settings, + &verification_token, + ) + .await?; + Ok(( + StatusCode::CREATED, + Json("Register successful".to_string()), + )) +} + +/// メールでの本人確認時に送信する情報。 +#[derive(Validate, Debug, Deserialize, utoipa::ToSchema)] +pub struct VerifyEmailRequest { + /// メールまたはアプリにお知らせした認証用文字列です。 + #[validate(length(min = 1))] + pub token: String, +} + +#[axum::debug_handler] +#[utoipa::path( + post, + path = "/verify-email", + summary = "メールアドレスの確認", + request_body = VerifyEmailRequest, + responses( + ( + status = 200, + description = "メールアドレスの確認が完了しました", + body = String + ), + VerifyEmailErrors, + ) +)] +pub async fn verify_email( + State(state): State, + Valid(Json(payload)): Valid>, +) -> Result, AuthError> { + let user_id = + email_verification::consume_token(&state.redis_client, &payload.token) + .await + .map_err(|e| AuthError::Internal(anyhow::anyhow!("redis consume verification token: {e}")))? + .ok_or(AuthError::InvalidVerificationToken)?; + + let user = users::Entity::find_by_id(user_id) + .one(&state.db) + .await? + .ok_or_else(|| { + AuthError::Internal(anyhow::anyhow!( + "email verification token referenced missing user" + )) + })?; + + if user.email_verified { + return Ok(Json("Email already verified".to_string())); + } + + let mut active: users::ActiveModel = user.into(); + active.email_verified = Set(true); + active.update(&state.db).await?; + + Ok(Json("Email verified".to_string())) +} + +#[derive(Validate, Debug, Deserialize, utoipa::ToSchema)] +pub struct ResendVerificationRequest { + #[schema(value_type = String, format="email")] + #[validate(email)] + pub email: String, +} + +#[axum::debug_handler] +#[utoipa::path( + post, + path = "/resend-verification-email", + summary = "認証メールの再送", + request_body = ResendVerificationRequest, + responses( + (status = 200, description = "認証メールを送信しました", body = String), + ResendVerificationErrors, + ) +)] +pub async fn resend_verification_email( + State(state): State, + Valid(Json(payload)): Valid>, +) -> Result, AuthError> { + let email = payload.email.trim().to_string(); + + if !email_verification::try_acquire_resend_slot(&state.redis_client, &email) + .await + .map_err(|e| AuthError::Internal(anyhow::anyhow!("redis resend cooldown: {e}")))? + { + return Err(AuthError::TooManyRequests); + } + + let user = users::Entity::find() + .filter(users::Column::Email.eq(email.clone())) + .one(&state.db) + .await? + .ok_or(AuthError::UserNotFound)?; + + if user.email_verified { + return Err(AuthError::EmailAlreadyVerified); + } + + let token = generate_email_verification_token(); + email_verification::store_token(&state.redis_client, user.id, &token) + .await + .map_err(|e| AuthError::Internal(anyhow::anyhow!("redis store verification token: {e}")))?; + + send_verification_email(&state.smtp_client, &email, &state.settings, &token).await?; + + Ok(Json(format!( + "確認メールを再送しました(同一メールアドレスへの再送は{}秒に1回までです)。", + email_verification::RESEND_COOLDOWN_SECS + ))) } #[axum::debug_handler] #[utoipa::path( get, path = "/me", + summary = "ログイン中ユーザー情報", responses( - (status = 200, description = "Current user info", body = entities::users::Model), + (status = 200, description = "現在のアカウント情報", body = entities::users::Model), SessionAuthErrors, ) )] @@ -129,8 +274,9 @@ pub async fn me( #[utoipa::path( post, path = "/logout", + summary = "ログアウト", responses( - (status = 200, description = "Logout successful", body = String), + (status = 204, description = "ログアウトしました(本文なし)"), UnauthorizedErrors, ) )] @@ -138,7 +284,38 @@ pub async fn logout( session: Session, State(_): State, _auth: AuthUser, -) -> Result, AuthError> { +) -> Result { session.remove("user_id"); - Ok(Json("Logout successful".to_string())) + Ok(StatusCode::NO_CONTENT) +} + +fn build_verify_url(settings: &Settings, token: &str) -> String { + format!( + "{}/verify-email?token={}", + settings.email_verification_app_url.trim_end_matches('/'), + token + ) +} + +async fn send_verification_email( + smtp: &SmtpClient, + email: &str, + settings: &Settings, + token: &str, +) -> Result<(), AuthError> { + let verify_url = build_verify_url(settings, token); + let mins = email_verification::TOKEN_TTL_SECS / 60; + smtp.send_email( + email, + "メール認証", + &format!( + "以下のリンクからアプリを開き、表示に従ってメールアドレスの確認を完了してください(有効期限は約{mins}分です)。\n{verify_url}", + ), + Some(&format!( + "

以下のリンクからアプリを開き、表示に従ってメールアドレスの確認を完了してください(有効期限は約{mins}分です)。

{verify_url}

", + )), + ) + .await + .map_err(|e| AuthError::Internal(anyhow::anyhow!("send verification email: {e}")))?; + Ok(()) } diff --git a/apps/backend/src/handlers/labels.rs b/apps/backend/src/handlers/labels.rs index d3da409..f458b75 100644 --- a/apps/backend/src/handlers/labels.rs +++ b/apps/backend/src/handlers/labels.rs @@ -1,20 +1,30 @@ use axum::{Json, extract::State}; use sea_orm::EntityTrait; +use crate::openapi::InternalOnlyError; +use crate::utils::auth::AuthError; use crate::{AppState, entities}; #[axum::debug_handler] #[utoipa::path( get, path = "/", + summary = "ラベル一覧", responses( - (status = 200, description = "Labels list", body = [entities::labels::Model]) + ( + status = 200, + description = "すべてのラベル", + body = [entities::labels::Model] + ), + InternalOnlyError, ) )] -pub async fn get_labels(State(state): State) -> Json> { +pub async fn get_labels( + State(state): State, +) -> Result>, AuthError> { let labels = entities::labels::Entity::find() .all(&state.db) .await - .unwrap_or_default(); - Json(labels) + .map_err(AuthError::from)?; + Ok(Json(labels)) } diff --git a/apps/backend/src/handlers/personal_tokens.rs b/apps/backend/src/handlers/personal_tokens.rs index b9eeebe..cd2bd50 100644 --- a/apps/backend/src/handlers/personal_tokens.rs +++ b/apps/backend/src/handlers/personal_tokens.rs @@ -7,18 +7,20 @@ use crate::dto::personal_tokens::{CreatePersonalTokenResponse, PersonalTokenResp use crate::openapi::SessionAuthErrors; #[derive(Validate, Debug, Deserialize, utoipa::ToSchema)] -struct CreatePersonalTokenRequest { - // フィールドは後で定義 -} +struct CreatePersonalTokenRequest {} -// 対象ユーザーの新しいパーソナルアクセストークンを作成する #[axum::debug_handler] #[utoipa::path( post, path = "/", + summary = "パーソナルアクセストークンを発行", request_body = CreatePersonalTokenRequest, responses( - (status = 200, description = "Personal token created", body = CreatePersonalTokenResponse), + ( + status = 200, + description = "発行したトークンの情報", + body = CreatePersonalTokenResponse + ), SessionAuthErrors, ) )] @@ -26,14 +28,18 @@ pub async fn create_personal_token() { todo!() } -// 対象ユーザーの特定のパーソナルアクセストークンを取得する #[axum::debug_handler] #[utoipa::path( get, path = "/{id}", - params(("id" = Uuid, Path, description = "Personal token ID")), + summary = "指定したトークンを参照", + params(("id" = Uuid, Path, description = "トークンの識別子")), responses( - (status = 200, description = "Personal token found", body = PersonalTokenResponse), + ( + status = 200, + description = "トークンの状態", + body = PersonalTokenResponse + ), SessionAuthErrors, ) )] @@ -41,14 +47,18 @@ pub async fn get_personal_token(Path(_id): Path) { todo!() } -// 対象ユーザーの特定のパーソナルアクセストークンを失効させる #[axum::debug_handler] #[utoipa::path( delete, path = "/{id}", - params(("id" = Uuid, Path, description = "Personal token ID")), + summary = "指定したトークンを取り消し", + params(("id" = Uuid, Path, description = "トークンの識別子")), responses( - (status = 200, description = "Personal token revoked", body = PersonalTokenResponse), + ( + status = 200, + description = "取り消し後の状態", + body = PersonalTokenResponse + ), SessionAuthErrors, ) )] @@ -56,13 +66,17 @@ pub async fn revoke_personal_token(Path(_id): Path) { todo!() } -// 対象ユーザーの全てのパーソナルアクセストークンを失効させる #[axum::debug_handler] #[utoipa::path( delete, path = "/", + summary = "すべての個人用トークンを取り消し", responses( - (status = 200, description = "All personal tokens revoked", body = [PersonalTokenResponse]), + ( + status = 200, + description = "現在アクティブなトークンの一覧(空になり得ます)", + body = [PersonalTokenResponse] + ), SessionAuthErrors, ) )] diff --git a/apps/backend/src/openapi/mod.rs b/apps/backend/src/openapi/mod.rs index 9904f85..4a8a318 100644 --- a/apps/backend/src/openapi/mod.rs +++ b/apps/backend/src/openapi/mod.rs @@ -1,4 +1,4 @@ -//! OpenAPI 用の共通型。 +//! OpenAPI コンポーネント登録。 pub mod responses; @@ -6,9 +6,12 @@ use utoipa::openapi::OpenApi; use utoipa::{PartialSchema, ToSchema}; pub use crate::utils::auth::ServerError; -pub use responses::{CredentialErrors, InternalOnlyError, SessionAuthErrors, UnauthorizedErrors}; +pub use responses::{ + CredentialErrors, InternalOnlyError, RegisterErrors, ResendVerificationErrors, + SessionAuthErrors, UnauthorizedErrors, VerifyEmailErrors, +}; -/// `IntoResponses` 経由で参照されるが path body からは収集されないスキーマを登録する。 +/// スキーマのうち、ハンドラだけでは OpenAPI に載らないものを登録する。 pub fn register_schemas(openapi: &mut OpenApi) { let components = openapi .components diff --git a/apps/backend/src/openapi/responses.rs b/apps/backend/src/openapi/responses.rs index b86e675..0109127 100644 --- a/apps/backend/src/openapi/responses.rs +++ b/apps/backend/src/openapi/responses.rs @@ -1,4 +1,4 @@ -//! OpenAPI ドキュメント専用のレスポンス型(実行時には `AuthError` を使用)。 +//! OpenAPI 用の共通レスポンス型(ランタイムの `IntoResponse` とは別定義)。 #![allow(dead_code)] @@ -6,36 +6,101 @@ use utoipa::IntoResponses; use crate::utils::auth::ServerError; -/// セッション認証必須 API の共通エラー(401 / 403 / 500) #[derive(IntoResponses)] pub enum SessionAuthErrors { - #[response(status = 401, description = "Unauthorized")] + #[response(status = 401, description = "ログインまたはセッションが必要です")] Unauthorized(#[to_schema] ServerError), - #[response(status = 403, description = "Forbidden")] + #[response(status = 403, description = "この操作は許可されていません")] Forbidden(#[to_schema] ServerError), - #[response(status = 500, description = "Internal server error")] + #[response( + status = 500, + description = "サーバー側で問題が発生しました。時間をおいて再度お試しください" + )] Internal(#[to_schema] ServerError), } -/// ログイン等、認証前 API のエラー(403 / 500) #[derive(IntoResponses)] pub enum CredentialErrors { - #[response(status = 403, description = "Forbidden")] - Forbidden(#[to_schema] ServerError), - #[response(status = 500, description = "Internal server error")] + #[response( + status = 401, + description = "メールアドレスまたはパスワードが正しくありません" + )] + InvalidCredentials(#[to_schema] ServerError), + #[response( + status = 403, + description = "メールアドレスの確認が済んでいないためログインできません" + )] + EmailNotVerified(#[to_schema] ServerError), + #[response( + status = 500, + description = "サーバー側で問題が発生しました。時間をおいて再度お試しください" + )] + Internal(#[to_schema] ServerError), +} + +#[derive(IntoResponses)] +pub enum RegisterErrors { + #[response( + status = 409, + description = "このメールアドレスはすでに登録されています" + )] + Conflict(#[to_schema] ServerError), + #[response( + status = 500, + description = "サーバー側で問題が発生しました。時間をおいて再度お試しください" + )] + Internal(#[to_schema] ServerError), +} + +#[derive(IntoResponses)] +pub enum VerifyEmailErrors { + #[response( + status = 400, + description = "認証用リンクが無効か、または有効期限切れです" + )] + InvalidToken(#[to_schema] ServerError), + #[response( + status = 500, + description = "サーバー側で問題が発生しました。時間をおいて再度お試しください" + )] Internal(#[to_schema] ServerError), } -/// 認証必須だが 403 を返さない API のエラー(401 / 500) #[derive(IntoResponses)] pub enum UnauthorizedErrors { - #[response(status = 401, description = "Unauthorized")] + #[response(status = 401, description = "ログインまたはセッションが必要です")] Unauthorized(#[to_schema] ServerError), - #[response(status = 500, description = "Internal server error")] + #[response( + status = 500, + description = "サーバー側で問題が発生しました。時間をおいて再度お試しください" + )] Internal(#[to_schema] ServerError), } -/// 内部エラーのみ(500) #[derive(IntoResponses)] -#[response(status = 500, description = "Internal server error")] +#[response( + status = 500, + description = "サーバー側で問題が発生しました。時間をおいて再度お試しください" +)] pub struct InternalOnlyError(#[to_schema] ServerError); + +#[derive(IntoResponses)] +pub enum ResendVerificationErrors { + #[response( + status = 404, + description = "入力されたメールアドレスのアカウントが見つかりませんでした" + )] + NotFound(#[to_schema] ServerError), + #[response( + status = 409, + description = "このアカウントではメール認証はもう完了しています" + )] + Conflict(#[to_schema] ServerError), + #[response(status = 429, description = "しばらくしてから再度お試しください")] + TooManyRequests(#[to_schema] ServerError), + #[response( + status = 500, + description = "サーバー側で問題が発生しました。時間をおいて再度お試しください" + )] + Internal(#[to_schema] ServerError), +} diff --git a/apps/backend/src/routes/auth.rs b/apps/backend/src/routes/auth.rs index 6a8169f..8b2c3dc 100644 --- a/apps/backend/src/routes/auth.rs +++ b/apps/backend/src/routes/auth.rs @@ -8,6 +8,8 @@ pub fn routes() -> OpenApiRouter { // routes!マクロは一つのエンドポイントのメソッドをまとめてルーティングするためのマクロっぽい...?同じメソッドを複数定義しようとするとエラーになる。 .routes(routes!(crate::handlers::auth::login)) .routes(routes!(crate::handlers::auth::register)) + .routes(routes!(crate::handlers::auth::verify_email)) + .routes(routes!(crate::handlers::auth::resend_verification_email)) .routes(routes!(crate::handlers::auth::logout)) .routes(routes!(crate::handlers::auth::me)) } diff --git a/apps/backend/src/settings.rs b/apps/backend/src/settings.rs index a1ec837..bb0e8bd 100644 --- a/apps/backend/src/settings.rs +++ b/apps/backend/src/settings.rs @@ -13,12 +13,19 @@ pub struct Settings { pub smtp_username: String, pub smtp_password: String, pub smtp_from: String, + /// 認証メールに載せるリンクのベース URL(例: `http://localhost:3000`)。末尾に `/verify-email?token=…` を付与します。 + #[serde(default = "default_email_verification_app_url")] + pub email_verification_app_url: String, } fn default_allow_origin() -> String { "http://localhost:3000".to_string() } +fn default_email_verification_app_url() -> String { + default_allow_origin() +} + pub fn load_settings() -> Result { dotenvy::dotenv().ok(); let settings = Config::builder() diff --git a/apps/backend/src/utils/auth.rs b/apps/backend/src/utils/auth.rs index 78fe0d1..7e9a3da 100644 --- a/apps/backend/src/utils/auth.rs +++ b/apps/backend/src/utils/auth.rs @@ -21,8 +21,11 @@ use thiserror::Error; use tracing::debug; use utoipa::ToSchema; +/// API 共通のエラー応答ボディ。 + #[derive(Serialize, ToSchema)] pub struct ServerError { + #[schema(example = "invalid-credentials")] pub message: String, } @@ -32,8 +35,22 @@ pub enum AuthError { Internal(#[from] anyhow::Error), #[error("unauthorized")] Unauthorized, + #[error("invalid credentials")] + InvalidCredentials, #[error("forbidden")] Forbidden, + #[error("email not verified")] + EmailNotVerified, + #[error("invalid verification token")] + InvalidVerificationToken, + #[error("no such user")] + UserNotFound, + #[error("email already verified")] + EmailAlreadyVerified, + #[error("duplicate email")] + DuplicateEmail, + #[error("too many requests")] + TooManyRequests, } impl From for AuthError { @@ -62,6 +79,13 @@ impl IntoResponse for AuthError { }), ) .into_response(), + AuthError::InvalidCredentials => ( + StatusCode::UNAUTHORIZED, + Json(ServerError { + message: "invalid-credentials".into(), + }), + ) + .into_response(), AuthError::Forbidden => ( StatusCode::FORBIDDEN, Json(ServerError { @@ -69,6 +93,48 @@ impl IntoResponse for AuthError { }), ) .into_response(), + AuthError::EmailNotVerified => ( + StatusCode::FORBIDDEN, + Json(ServerError { + message: "email-not-verified".into(), + }), + ) + .into_response(), + AuthError::InvalidVerificationToken => ( + StatusCode::BAD_REQUEST, + Json(ServerError { + message: "invalid-verification-token".into(), + }), + ) + .into_response(), + AuthError::UserNotFound => ( + StatusCode::NOT_FOUND, + Json(ServerError { + message: "not-found".into(), + }), + ) + .into_response(), + AuthError::EmailAlreadyVerified => ( + StatusCode::CONFLICT, + Json(ServerError { + message: "email-already-verified".into(), + }), + ) + .into_response(), + AuthError::DuplicateEmail => ( + StatusCode::CONFLICT, + Json(ServerError { + message: "email-already-exists".into(), + }), + ) + .into_response(), + AuthError::TooManyRequests => ( + StatusCode::TOO_MANY_REQUESTS, + Json(ServerError { + message: "too-many-requests".into(), + }), + ) + .into_response(), } } } @@ -123,6 +189,13 @@ pub fn verify_password(password: &str, password_hash: &str) -> Result String { + let mut buf = [0u8; 32]; + OsRng.fill_bytes(&mut buf); + URL_SAFE_NO_PAD.encode(buf) +} + // --- Personal token helpers --- type HmacSha256 = Hmac; diff --git a/apps/backend/src/utils/db.rs b/apps/backend/src/utils/db.rs new file mode 100644 index 0000000..649b61e --- /dev/null +++ b/apps/backend/src/utils/db.rs @@ -0,0 +1,13 @@ +//! データベース制約との照合用ヘルパ。 + +use sea_orm::DbErr; + +/// 一意制約違反(重複キーなど)として扱ってよさそうなエラーか。 +pub fn is_postgres_unique_violation(err: &DbErr) -> bool { + let s = err.to_string(); + s.contains("23505") + || { + let l = s.to_ascii_lowercase(); + l.contains("duplicate") && l.contains("unique") + } +} diff --git a/apps/backend/src/utils/email_verification.rs b/apps/backend/src/utils/email_verification.rs new file mode 100644 index 0000000..f509d8a --- /dev/null +++ b/apps/backend/src/utils/email_verification.rs @@ -0,0 +1,124 @@ +//! メール認証トークンを Redis に保持する(TTL で有効期限)。 + +use uuid::Uuid; + +use super::redis::RedisConnection; + +/// 認証リンクの有効期限(秒)。約 15 分。 +pub const TOKEN_TTL_SECS: u64 = 15 * 60; +/// 認証メール再送のクールダウン(秒)。 +pub const RESEND_COOLDOWN_SECS: u64 = 60; + +const KEY_TOKEN: &str = "email_verify:t:"; +const KEY_USER: &str = "email_verify:u:"; +const KEY_RESEND: &str = "email_verify:resend:e:"; + +pub async fn store_token( + redis: &RedisConnection, + user_id: Uuid, + token: &str, +) -> Result<(), anyhow::Error> { + let mut conn = redis + .conn + .acquire() + .await + .map_err(|e| anyhow::anyhow!("redis acquire failed: {e}"))?; + + let user_key = format!("{KEY_USER}{user_id}"); + + let old_token: Option = redis::cmd("GET") + .arg(&user_key) + .query_async(&mut conn) + .await + .map_err(|e| anyhow::anyhow!("redis GET user->token: {e}"))?; + + if let Some(ref t) = old_token { + let _: () = redis::cmd("DEL") + .arg(format!("{KEY_TOKEN}{t}")) + .query_async(&mut conn) + .await + .map_err(|e| anyhow::anyhow!("redis DEL old token: {e}"))?; + } + + let token_key = format!("{KEY_TOKEN}{token}"); + let _: () = redis::cmd("SET") + .arg(&token_key) + .arg(user_id.to_string()) + .arg("EX") + .arg(TOKEN_TTL_SECS) + .query_async(&mut conn) + .await + .map_err(|e| anyhow::anyhow!("redis SET token: {e}"))?; + + let _: () = redis::cmd("SET") + .arg(&user_key) + .arg(token) + .arg("EX") + .arg(TOKEN_TTL_SECS) + .query_async(&mut conn) + .await + .map_err(|e| anyhow::anyhow!("redis SET user->token: {e}"))?; + + Ok(()) +} + +/// GETDEL でトークンを消費し、対応するユーザー ID を返す。無効・期限切れなら `None`。 +pub async fn consume_token( + redis: &RedisConnection, + token: &str, +) -> Result, anyhow::Error> { + let mut conn = redis + .conn + .acquire() + .await + .map_err(|e| anyhow::anyhow!("redis acquire failed: {e}"))?; + + let token_key = format!("{KEY_TOKEN}{token}"); + let raw: Option = redis::cmd("GETDEL") + .arg(&token_key) + .query_async(&mut conn) + .await + .map_err(|e| anyhow::anyhow!("redis GETDEL token: {e}"))?; + + let Some(s) = raw else { + return Ok(None); + }; + + let uid = Uuid::parse_str(s.trim()) + .map_err(|e| anyhow::anyhow!("invalid user id in redis: {e}"))?; + + let user_key = format!("{KEY_USER}{uid}"); + let _: () = redis::cmd("DEL") + .arg(&user_key) + .query_async(&mut conn) + .await + .map_err(|e| anyhow::anyhow!("redis DEL user->token: {e}"))?; + + Ok(Some(uid)) +} + +/// メールアドレス単位で再送クールダウンを取る。取れたら `true`、取れなければレート制限で `false`。 +pub async fn try_acquire_resend_slot(redis: &RedisConnection, email: &str) -> Result { + let mut conn = redis + .conn + .acquire() + .await + .map_err(|e| anyhow::anyhow!("redis acquire failed: {e}"))?; + + let key = format!( + "{KEY_RESEND}{}", + email.trim().to_ascii_lowercase() + ); + + let set_ok: Option = redis::cmd("SET") + .arg(&key) + .arg("1") + .arg("NX") + .arg("EX") + .arg(RESEND_COOLDOWN_SECS) + .query_async(&mut conn) + .await + .map_err(|e| anyhow::anyhow!("redis SET NX resend cooldown: {e}"))?; + + Ok(set_ok.is_some()) +} diff --git a/apps/backend/src/utils/mod.rs b/apps/backend/src/utils/mod.rs index 99be336..4cff38c 100644 --- a/apps/backend/src/utils/mod.rs +++ b/apps/backend/src/utils/mod.rs @@ -1,3 +1,5 @@ pub mod auth; +pub mod db; +pub mod email_verification; pub mod redis; pub mod smtp; From 88ec75f387d2c4b82f60a966c2183d8a843b9b32 Mon Sep 17 00:00:00 2001 From: yupix Date: Wed, 20 May 2026 16:13:08 +0000 Subject: [PATCH 02/23] =?UTF-8?q?refactor(backend):=20SeaORM=E3=81=AEv2?= =?UTF-8?q?=E3=81=A7=E6=8E=A8=E5=A5=A8=E3=81=95=E3=82=8C=E3=81=A6=E3=81=84?= =?UTF-8?q?=E3=82=8B=E3=82=A8=E3=83=A9=E3=83=BC=E3=83=8F=E3=83=B3=E3=83=89?= =?UTF-8?q?=E3=83=AA=E3=83=B3=E3=82=B0=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/utils/db.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/utils/db.rs b/apps/backend/src/utils/db.rs index 649b61e..6555eb5 100644 --- a/apps/backend/src/utils/db.rs +++ b/apps/backend/src/utils/db.rs @@ -4,10 +4,8 @@ use sea_orm::DbErr; /// 一意制約違反(重複キーなど)として扱ってよさそうなエラーか。 pub fn is_postgres_unique_violation(err: &DbErr) -> bool { - let s = err.to_string(); - s.contains("23505") - || { - let l = s.to_ascii_lowercase(); - l.contains("duplicate") && l.contains("unique") - } -} + matches!( + err.sql_err(), + Some(sea_orm::SqlErr::UniqueConstraintViolation(_)) + ) +} \ No newline at end of file From f184165b5e3004d3167e26bf737f38ebced744cb Mon Sep 17 00:00:00 2001 From: yupix Date: Wed, 20 May 2026 16:21:00 +0000 Subject: [PATCH 03/23] =?UTF-8?q?refactor(backend):=20=E3=83=88=E3=83=BC?= =?UTF-8?q?=E3=82=AF=E3=83=B3=E3=81=AE=E6=9B=B4=E6=96=B0=E3=82=92LuaSccrip?= =?UTF-8?q?t=E3=81=A7=E5=8E=9F=E5=A7=8B=E7=9A=84=E3=81=AB=E5=AE=9F?= =?UTF-8?q?=E8=A1=8C=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/utils/email_verification.rs | 92 +++++++++++--------- 1 file changed, 52 insertions(+), 40 deletions(-) diff --git a/apps/backend/src/utils/email_verification.rs b/apps/backend/src/utils/email_verification.rs index f509d8a..6ae6079 100644 --- a/apps/backend/src/utils/email_verification.rs +++ b/apps/backend/src/utils/email_verification.rs @@ -1,9 +1,46 @@ //! メール認証トークンを Redis に保持する(TTL で有効期限)。 +//! +//! `user->token` と `token->user` は Lua で原子的に更新し、再送時の旧トークン無効化と +//! 消費時の逆マッピング削除が同時リクエストでも崩れないようにする。 + +use std::sync::LazyLock; use uuid::Uuid; use super::redis::RedisConnection; +/// 再送時: 旧 token キー削除 → 新 token/user キー SET を一括実行。 +static STORE_TOKEN_SCRIPT: LazyLock = LazyLock::new(|| { + redis::Script::new( + r#" + local old_token = redis.call('GET', KEYS[1]) + if old_token then + redis.call('DEL', ARGV[1] .. old_token) + end + redis.call('SET', KEYS[2], ARGV[2], 'EX', tonumber(ARGV[4])) + redis.call('SET', KEYS[1], ARGV[3], 'EX', tonumber(ARGV[4])) + return 1 + "#, + ) +}); + +/// 消費時: GETDEL 後、user->token が当該トークンのときだけ user キーを削除。 +static CONSUME_TOKEN_SCRIPT: LazyLock = LazyLock::new(|| { + redis::Script::new( + r#" + local user_id = redis.call('GETDEL', KEYS[1]) + if not user_id then + return nil + end + local user_key = ARGV[1] .. user_id + if redis.call('GET', user_key) == ARGV[2] then + redis.call('DEL', user_key) + end + return user_id + "#, + ) +}); + /// 認証リンクの有効期限(秒)。約 15 分。 pub const TOKEN_TTL_SECS: u64 = 15 * 60; /// 認証メール再送のクールダウン(秒)。 @@ -25,39 +62,19 @@ pub async fn store_token( .map_err(|e| anyhow::anyhow!("redis acquire failed: {e}"))?; let user_key = format!("{KEY_USER}{user_id}"); - - let old_token: Option = redis::cmd("GET") - .arg(&user_key) - .query_async(&mut conn) - .await - .map_err(|e| anyhow::anyhow!("redis GET user->token: {e}"))?; - - if let Some(ref t) = old_token { - let _: () = redis::cmd("DEL") - .arg(format!("{KEY_TOKEN}{t}")) - .query_async(&mut conn) - .await - .map_err(|e| anyhow::anyhow!("redis DEL old token: {e}"))?; - } - let token_key = format!("{KEY_TOKEN}{token}"); - let _: () = redis::cmd("SET") - .arg(&token_key) - .arg(user_id.to_string()) - .arg("EX") - .arg(TOKEN_TTL_SECS) - .query_async(&mut conn) - .await - .map_err(|e| anyhow::anyhow!("redis SET token: {e}"))?; + let ttl = TOKEN_TTL_SECS.to_string(); - let _: () = redis::cmd("SET") - .arg(&user_key) + let _: i32 = STORE_TOKEN_SCRIPT + .key(&user_key) + .key(&token_key) + .arg(KEY_TOKEN) + .arg(user_id.to_string()) .arg(token) - .arg("EX") - .arg(TOKEN_TTL_SECS) - .query_async(&mut conn) + .arg(&ttl) + .invoke_async(&mut conn) .await - .map_err(|e| anyhow::anyhow!("redis SET user->token: {e}"))?; + .map_err(|e| anyhow::anyhow!("redis store_token script: {e}"))?; Ok(()) } @@ -74,11 +91,13 @@ pub async fn consume_token( .map_err(|e| anyhow::anyhow!("redis acquire failed: {e}"))?; let token_key = format!("{KEY_TOKEN}{token}"); - let raw: Option = redis::cmd("GETDEL") - .arg(&token_key) - .query_async(&mut conn) + let raw: Option = CONSUME_TOKEN_SCRIPT + .key(&token_key) + .arg(KEY_USER) + .arg(token) + .invoke_async(&mut conn) .await - .map_err(|e| anyhow::anyhow!("redis GETDEL token: {e}"))?; + .map_err(|e| anyhow::anyhow!("redis consume_token script: {e}"))?; let Some(s) = raw else { return Ok(None); @@ -87,13 +106,6 @@ pub async fn consume_token( let uid = Uuid::parse_str(s.trim()) .map_err(|e| anyhow::anyhow!("invalid user id in redis: {e}"))?; - let user_key = format!("{KEY_USER}{uid}"); - let _: () = redis::cmd("DEL") - .arg(&user_key) - .query_async(&mut conn) - .await - .map_err(|e| anyhow::anyhow!("redis DEL user->token: {e}"))?; - Ok(Some(uid)) } From 835e83f9c2eabe05a312ada1738439df7f3dc411 Mon Sep 17 00:00:00 2001 From: yupix Date: Wed, 20 May 2026 16:25:43 +0000 Subject: [PATCH 04/23] feat(backend): add urlencoding dependency and use it for token encoding in email verification URL --- apps/backend/Cargo.lock | 7 +++++++ apps/backend/Cargo.toml | 1 + apps/backend/src/handlers/auth.rs | 3 ++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/backend/Cargo.lock b/apps/backend/Cargo.lock index 25ff67d..d507184 100644 --- a/apps/backend/Cargo.lock +++ b/apps/backend/Cargo.lock @@ -664,6 +664,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "urlencoding", "utoipa", "utoipa-axum", "utoipa-scalar", @@ -4901,6 +4902,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8-zero" version = "0.8.1" diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index 5da8cf6..16d56a9 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -38,6 +38,7 @@ thiserror = "2.0.10" base64 = "0.22.1" redis_pool = "0.10.0" redis = { version = "1.2.1", features = ["tokio-comp"] } +urlencoding = "2.1" hmac = "0.13" sha2 = "0.11" rand = "0.10" diff --git a/apps/backend/src/handlers/auth.rs b/apps/backend/src/handlers/auth.rs index 27c0361..f9b7b81 100644 --- a/apps/backend/src/handlers/auth.rs +++ b/apps/backend/src/handlers/auth.rs @@ -290,10 +290,11 @@ pub async fn logout( } fn build_verify_url(settings: &Settings, token: &str) -> String { + let encoded = urlencoding::encode(token); format!( "{}/verify-email?token={}", settings.email_verification_app_url.trim_end_matches('/'), - token + encoded ) } From 7c5e840f57a3ba3c32dbb2276fc50dcdde133d7e Mon Sep 17 00:00:00 2001 From: yupix Date: Wed, 20 May 2026 16:46:46 +0000 Subject: [PATCH 05/23] feat(backend): implement verification email outbox for transactional email delivery --- apps/backend/.env.example | 2 + apps/backend/Cargo.lock | 1 + apps/backend/Cargo.toml | 1 + apps/backend/src/entities/mod.rs | 1 + .../src/entities/verification_email_outbox.rs | 53 ++++++ apps/backend/src/handlers/auth.rs | 61 ++----- apps/backend/src/server.rs | 13 +- apps/backend/src/utils/mod.rs | 2 + .../src/utils/verification_email_delivery.rs | 36 ++++ .../src/utils/verification_email_outbox.rs | 158 ++++++++++++++++++ 10 files changed, 279 insertions(+), 49 deletions(-) create mode 100644 apps/backend/src/entities/verification_email_outbox.rs create mode 100644 apps/backend/src/utils/verification_email_delivery.rs create mode 100644 apps/backend/src/utils/verification_email_outbox.rs diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 92a6e94..bfde054 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,3 +1,5 @@ +# 未指定時は info,sqlx=warn(2秒/30秒ポーリングの空 SELECT をログに出さない) +# RUST_LOG=info,sqlx=warn database_url=postgresql://username:password@host:port/db_name redis_url=redis://127.0.0.1:6379 sentry_dsn= diff --git a/apps/backend/Cargo.lock b/apps/backend/Cargo.lock index d507184..047f149 100644 --- a/apps/backend/Cargo.lock +++ b/apps/backend/Cargo.lock @@ -643,6 +643,7 @@ dependencies = [ "axum_session", "axum_session_redispool", "base64", + "chrono", "config", "dotenvy", "hmac 0.13.0", diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index 16d56a9..033f7fe 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -34,6 +34,7 @@ sentry = { version = "0.48.2", features = ["tower-axum-matched-path"] } tower = "0.5.2" argon2 = "0.5.3" anyhow = "1.0.102" +chrono = { version = "0.4", default-features = false, features = ["clock"] } thiserror = "2.0.10" base64 = "0.22.1" redis_pool = "0.10.0" diff --git a/apps/backend/src/entities/mod.rs b/apps/backend/src/entities/mod.rs index 577a8df..288150f 100644 --- a/apps/backend/src/entities/mod.rs +++ b/apps/backend/src/entities/mod.rs @@ -3,3 +3,4 @@ pub mod personal_tokens; pub mod scopes; pub mod tenants; pub mod users; +pub mod verification_email_outbox; diff --git a/apps/backend/src/entities/verification_email_outbox.rs b/apps/backend/src/entities/verification_email_outbox.rs new file mode 100644 index 0000000..e38707f --- /dev/null +++ b/apps/backend/src/entities/verification_email_outbox.rs @@ -0,0 +1,53 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(20))")] +pub enum OutboxStatus { + #[sea_orm(string_value = "pending")] + Pending, + #[sea_orm(string_value = "sent")] + Sent, + #[sea_orm(string_value = "failed")] + Failed, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "verification_email_outbox")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(indexed)] + pub user_id: Uuid, + pub email: String, + /// 送信完了後はクリアする(平文トークンを永続保持しない) + #[sea_orm(nullable)] + pub token: Option, + #[sea_orm(indexed)] + pub status: OutboxStatus, + pub attempts: i32, + #[sea_orm(nullable)] + pub last_error: Option, + pub created_at: DateTimeWithTimeZone, + #[sea_orm(nullable)] + pub sent_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UserId", + to = "super::users::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/backend/src/handlers/auth.rs b/apps/backend/src/handlers/auth.rs index f9b7b81..90e4ab4 100644 --- a/apps/backend/src/handlers/auth.rs +++ b/apps/backend/src/handlers/auth.rs @@ -3,7 +3,7 @@ use axum_session::Session; use axum_session_redispool::SessionRedisPool; use axum_valid::Valid; use sea_orm::prelude::Uuid; -use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait}; +use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait, TransactionTrait}; use sea_orm::{ColumnTrait, QueryFilter}; use serde::Deserialize; use validator::Validate; @@ -14,12 +14,11 @@ use crate::openapi::{ CredentialErrors, RegisterErrors, ResendVerificationErrors, SessionAuthErrors, UnauthorizedErrors, VerifyEmailErrors, }; -use crate::settings::Settings; use crate::utils::auth::{ AuthError, create_password_hash, generate_email_verification_token, verify_password, }; use crate::utils::db::is_postgres_unique_violation; -use crate::utils::{email_verification, smtp::SmtpClient}; +use crate::utils::{email_verification, verification_email_outbox}; use crate::{AppState, entities::users}; #[derive(Validate, Debug, Deserialize, utoipa::ToSchema)] @@ -118,8 +117,9 @@ pub async fn register( password_hash: Set(password_hash), }; + let txn = state.db.begin().await?; users::Entity::insert(user.clone()) - .exec(&state.db) + .exec(&txn) .await .map_err(|e| { if is_postgres_unique_violation(&e) { @@ -129,17 +129,15 @@ pub async fn register( } })?; - email_verification::store_token(&state.redis_client, user_id, &verification_token) + verification_email_outbox::enqueue(&txn, user_id, email.clone(), verification_token) .await - .map_err(|e| AuthError::Internal(anyhow::anyhow!("redis store verification token: {e}")))?; + .map_err(|e| AuthError::Internal(anyhow::anyhow!("enqueue verification email: {e}")))?; - send_verification_email( - &state.smtp_client, - &email, - &state.settings, - &verification_token, - ) - .await?; + txn.commit() + .await + .map_err(|e| AuthError::Internal(anyhow::anyhow!("commit register transaction: {e}")))?; + + verification_email_outbox::wake_worker(state); Ok(( StatusCode::CREATED, Json("Register successful".to_string()), @@ -241,11 +239,11 @@ pub async fn resend_verification_email( } let token = generate_email_verification_token(); - email_verification::store_token(&state.redis_client, user.id, &token) + verification_email_outbox::enqueue(&state.db, user.id, email.clone(), token) .await - .map_err(|e| AuthError::Internal(anyhow::anyhow!("redis store verification token: {e}")))?; + .map_err(|e| AuthError::Internal(anyhow::anyhow!("enqueue verification email: {e}")))?; - send_verification_email(&state.smtp_client, &email, &state.settings, &token).await?; + verification_email_outbox::wake_worker(state); Ok(Json(format!( "確認メールを再送しました(同一メールアドレスへの再送は{}秒に1回までです)。", @@ -289,34 +287,3 @@ pub async fn logout( Ok(StatusCode::NO_CONTENT) } -fn build_verify_url(settings: &Settings, token: &str) -> String { - let encoded = urlencoding::encode(token); - format!( - "{}/verify-email?token={}", - settings.email_verification_app_url.trim_end_matches('/'), - encoded - ) -} - -async fn send_verification_email( - smtp: &SmtpClient, - email: &str, - settings: &Settings, - token: &str, -) -> Result<(), AuthError> { - let verify_url = build_verify_url(settings, token); - let mins = email_verification::TOKEN_TTL_SECS / 60; - smtp.send_email( - email, - "メール認証", - &format!( - "以下のリンクからアプリを開き、表示に従ってメールアドレスの確認を完了してください(有効期限は約{mins}分です)。\n{verify_url}", - ), - Some(&format!( - "

以下のリンクからアプリを開き、表示に従ってメールアドレスの確認を完了してください(有効期限は約{mins}分です)。

{verify_url}

", - )), - ) - .await - .map_err(|e| AuthError::Internal(anyhow::anyhow!("send verification email: {e}")))?; - Ok(()) -} diff --git a/apps/backend/src/server.rs b/apps/backend/src/server.rs index 5a4ff9e..538f241 100644 --- a/apps/backend/src/server.rs +++ b/apps/backend/src/server.rs @@ -9,14 +9,18 @@ use tower_http::cors::{AllowHeaders, CorsLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use utoipa_scalar::{Scalar, Servable}; -use crate::{AppState, middlewares::logging::logging_middleware}; +use crate::{ + AppState, middlewares::logging::logging_middleware, + utils::verification_email_outbox, +}; pub async fn run(state: AppState) -> Result<(), Box> { tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new( - std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + std::env::var("RUST_LOG") + .unwrap_or_else(|_| "info,sqlx=warn".into()), )) .with(tracing_subscriber::fmt::layer()) .init(); @@ -54,6 +58,11 @@ pub async fn run(state: AppState) -> Result<(), Box> { .allow_headers(AllowHeaders::mirror_request()) .allow_credentials(true); + let worker_state = state.clone(); + tokio::spawn(async move { + verification_email_outbox::run_worker(worker_state).await; + }); + let app = router .merge(Scalar::with_url("/scalar", openapi.clone())) .with_state(state) diff --git a/apps/backend/src/utils/mod.rs b/apps/backend/src/utils/mod.rs index 4cff38c..7d84a2a 100644 --- a/apps/backend/src/utils/mod.rs +++ b/apps/backend/src/utils/mod.rs @@ -3,3 +3,5 @@ pub mod db; pub mod email_verification; pub mod redis; pub mod smtp; +pub mod verification_email_delivery; +pub mod verification_email_outbox; diff --git a/apps/backend/src/utils/verification_email_delivery.rs b/apps/backend/src/utils/verification_email_delivery.rs new file mode 100644 index 0000000..abd9977 --- /dev/null +++ b/apps/backend/src/utils/verification_email_delivery.rs @@ -0,0 +1,36 @@ +//! 認証メール本文の組み立てと SMTP 送信。 + +use crate::settings::Settings; +use crate::utils::{email_verification, smtp::SmtpClient}; + +pub fn build_verify_url(settings: &Settings, token: &str) -> String { + let encoded = urlencoding::encode(token); + format!( + "{}/verify-email?token={}", + settings.email_verification_app_url.trim_end_matches('/'), + encoded + ) +} + +pub async fn send_verification_email( + smtp: &SmtpClient, + email: &str, + settings: &Settings, + token: &str, +) -> Result<(), anyhow::Error> { + let verify_url = build_verify_url(settings, token); + let mins = email_verification::TOKEN_TTL_SECS / 60; + smtp.send_email( + email, + "メール認証", + &format!( + "以下のリンクからアプリを開き、表示に従ってメールアドレスの確認を完了してください(有効期限は約{mins}分です)。\n{verify_url}", + ), + Some(&format!( + "

以下のリンクからアプリを開き、表示に従ってメールアドレスの確認を完了してください(有効期限は約{mins}分です)。

{verify_url}

", + )), + ) + .await + .map_err(|e| anyhow::anyhow!("send verification email: {e}"))?; + Ok(()) +} diff --git a/apps/backend/src/utils/verification_email_outbox.rs b/apps/backend/src/utils/verification_email_outbox.rs new file mode 100644 index 0000000..5fc89a2 --- /dev/null +++ b/apps/backend/src/utils/verification_email_outbox.rs @@ -0,0 +1,158 @@ +//! 認証メール送信の transactional outbox。 +//! +//! 登録・再送は DB に outbox 行を書き込むだけにし、Redis トークン保存と SMTP は +//! バックグラウンドワーカーが再試行可能な形で実行する。 + +use std::time::Duration; + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, + QueryOrder, QuerySelect, +}; +use tracing::warn; +use uuid::Uuid; + +use crate::entities::verification_email_outbox::{ + self, Entity as OutboxEntity, Model as OutboxModel, OutboxStatus, +}; +use crate::utils::{email_verification, verification_email_delivery}; +use crate::AppState; + +pub const MAX_ATTEMPTS: i32 = 8; +const BATCH_SIZE: u64 = 16; +/// 未処理行があるときのポーリング間隔 +const POLL_INTERVAL_ACTIVE: Duration = Duration::from_secs(2); +/// outbox が空のときのポーリング間隔(空 SELECT のログ・DB 負荷を抑える) +const POLL_INTERVAL_IDLE: Duration = Duration::from_secs(30); + +/// ユーザー作成と同一トランザクション内で呼ぶ。 +pub async fn enqueue( + db: &C, + user_id: Uuid, + email: String, + token: String, +) -> Result<(), sea_orm::DbErr> { + let row = verification_email_outbox::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user_id), + email: Set(email), + token: Set(Some(token)), + status: Set(OutboxStatus::Pending), + attempts: Set(0), + last_error: Set(None), + created_at: Set(Utc::now().into()), + sent_at: Set(None), + }; + OutboxEntity::insert(row).exec(db).await?; + Ok(()) +} + +/// 直近で enqueue した分をできるだけ早く処理する。 +pub fn wake_worker(state: AppState) { + tokio::spawn(async move { + if let Err(e) = process_pending(&state).await { + warn!("verification email outbox wake failed: {e:#}"); + } + }); +} + +/// 起動時からポーリングで未送信行を処理する。 +pub async fn run_worker(state: AppState) { + let mut idle = true; + loop { + let delay = if idle { + POLL_INTERVAL_IDLE + } else { + POLL_INTERVAL_ACTIVE + }; + tokio::time::sleep(delay).await; + + match process_pending(&state).await { + Ok(n) => idle = n == 0, + Err(e) => { + warn!("verification email outbox poll failed: {e:#}"); + idle = false; + } + } + } +} + +/// 処理対象にした outbox 行数(0 ならキューは空) +pub async fn process_pending(state: &AppState) -> Result { + let rows = OutboxEntity::find() + .filter(verification_email_outbox::Column::Status.eq(OutboxStatus::Pending)) + .filter(verification_email_outbox::Column::Attempts.lt(MAX_ATTEMPTS)) + .order_by_asc(verification_email_outbox::Column::CreatedAt) + .limit(BATCH_SIZE) + .all(&state.db) + .await?; + + let n = rows.len(); + for row in rows { + if let Err(e) = process_one(state, row).await { + warn!("verification email outbox item failed: {e:#}"); + } + } + Ok(n) +} + +async fn process_one(state: &AppState, row: OutboxModel) -> Result<(), anyhow::Error> { + let Some(token) = row.token.clone() else { + mark_failed(&state.db, row.id, "missing token").await?; + return Ok(()); + }; + + let delivery = async { + email_verification::store_token(&state.redis_client, row.user_id, &token).await?; + verification_email_delivery::send_verification_email( + &state.smtp_client, + &row.email, + &state.settings, + &token, + ) + .await + } + .await; + + match delivery { + Ok(()) => { + let mut active: verification_email_outbox::ActiveModel = row.into(); + active.status = Set(OutboxStatus::Sent); + active.token = Set(None); + active.sent_at = Set(Some(Utc::now().into())); + active.last_error = Set(None); + active.update(&state.db).await?; + } + Err(e) => { + let attempts = row.attempts + 1; + let mut active: verification_email_outbox::ActiveModel = row.into(); + active.attempts = Set(attempts); + active.last_error = Set(Some(e.to_string())); + if attempts >= MAX_ATTEMPTS { + active.status = Set(OutboxStatus::Failed); + active.token = Set(None); + } + active.update(&state.db).await?; + } + } + + Ok(()) +} + +async fn mark_failed( + db: &sea_orm::DatabaseConnection, + id: Uuid, + reason: &str, +) -> Result<(), anyhow::Error> { + let row = OutboxEntity::find_by_id(id) + .one(db) + .await? + .ok_or_else(|| anyhow::anyhow!("outbox row {id} not found"))?; + let mut active: verification_email_outbox::ActiveModel = row.into(); + active.status = Set(OutboxStatus::Failed); + active.token = Set(None); + active.last_error = Set(Some(reason.to_string())); + active.update(db).await?; + Ok(()) +} From b6914786990d425645830449854cb799f12642fa Mon Sep 17 00:00:00 2001 From: yupix Date: Wed, 20 May 2026 16:51:32 +0000 Subject: [PATCH 06/23] =?UTF-8?q?chore(front):=20openapi.json=E3=81=8B?= =?UTF-8?q?=E3=82=89api=E5=91=BC=E3=81=B3=E5=87=BA=E3=81=97=E7=94=A8?= =?UTF-8?q?=E3=81=AE=E9=96=A2=E6=95=B0=E3=82=92=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/app/utils/openapi.json | 433 +++++++++++++++--- .../utils/openapi/.openapi-generator/FILES | 8 +- .../app/utils/openapi/apis/DefaultApi.ts | 172 ++++++- .../openapi/docs/CrateEntitiesUsersModel.md | 2 + .../app/utils/openapi/docs/DefaultApi.md | 255 ++++++++--- .../utils/openapi/docs/Login401Response.md | 35 ++ .../openapi/docs/ResendVerificationRequest.md | 34 ++ .../app/utils/openapi/docs/ServerError.md | 3 +- .../utils/openapi/docs/VerifyEmailRequest.md | 35 ++ .../openapi/models/CrateEntitiesUsersModel.ts | 9 + .../models/CreatePersonalTokenResponse.ts | 2 +- .../utils/openapi/models/Login401Response.ts | 66 +++ .../openapi/models/PersonalTokenResponse.ts | 2 +- .../models/ResendVerificationRequest.ts | 66 +++ .../app/utils/openapi/models/ServerError.ts | 2 +- .../openapi/models/VerifyEmailRequest.ts | 66 +++ .../app/utils/openapi/models/index.ts | 4 +- 17 files changed, 1043 insertions(+), 151 deletions(-) create mode 100644 apps/frontend/app/utils/openapi/docs/Login401Response.md create mode 100644 apps/frontend/app/utils/openapi/docs/ResendVerificationRequest.md create mode 100644 apps/frontend/app/utils/openapi/docs/VerifyEmailRequest.md create mode 100644 apps/frontend/app/utils/openapi/models/Login401Response.ts create mode 100644 apps/frontend/app/utils/openapi/models/ResendVerificationRequest.ts create mode 100644 apps/frontend/app/utils/openapi/models/VerifyEmailRequest.ts diff --git a/apps/frontend/app/utils/openapi.json b/apps/frontend/app/utils/openapi.json index 6a95bf1..122075b 100644 --- a/apps/frontend/app/utils/openapi.json +++ b/apps/frontend/app/utils/openapi.json @@ -16,6 +16,7 @@ "paths": { "/v1/auth/login": { "post": { + "summary": "ログイン", "operationId": "login", "requestBody": { "content": { @@ -28,28 +29,43 @@ "required": true }, "responses": { - "200": { - "description": "Login successful", + "204": { + "description": "ログインに成功しました(本文なし)" + }, + "401": { + "description": "メールアドレスまたはパスワードが正しくありません", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "type": "object", + "description": "API 共通のエラー応答ボディ。", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "example": "invalid-credentials" + } + } } } } }, "403": { - "description": "Forbidden", + "description": "メールアドレスの確認が済んでいないためログインできません", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -57,17 +73,19 @@ } }, "500": { - "description": "Internal server error", + "description": "サーバー側で問題が発生しました。時間をおいて再度お試しください", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -79,30 +97,26 @@ }, "/v1/auth/logout": { "post": { + "summary": "ログアウト", "operationId": "logout", "responses": { - "200": { - "description": "Logout successful", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } + "204": { + "description": "ログアウトしました(本文なし)" }, "401": { - "description": "Unauthorized", + "description": "ログインまたはセッションが必要です", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -110,17 +124,19 @@ } }, "500": { - "description": "Internal server error", + "description": "サーバー側で問題が発生しました。時間をおいて再度お試しください", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -132,10 +148,11 @@ }, "/v1/auth/me": { "get": { + "summary": "ログイン中ユーザー情報", "operationId": "me", "responses": { "200": { - "description": "Current user info", + "description": "現在のアカウント情報", "content": { "application/json": { "schema": { @@ -145,17 +162,19 @@ } }, "401": { - "description": "Unauthorized", + "description": "ログインまたはセッションが必要です", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -163,17 +182,19 @@ } }, "403": { - "description": "Forbidden", + "description": "この操作は許可されていません", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -181,17 +202,19 @@ } }, "500": { - "description": "Internal server error", + "description": "サーバー側で問題が発生しました。時間をおいて再度お試しください", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -203,6 +226,7 @@ }, "/v1/auth/register": { "post": { + "summary": "新規登録", "operationId": "register", "requestBody": { "content": { @@ -214,9 +238,77 @@ }, "required": true }, + "responses": { + "201": { + "description": "アカウントが作成されました。続けて送信されたメールで認証してください。", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "409": { + "description": "このメールアドレスはすでに登録されています", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "API 共通のエラー応答ボディ。", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "example": "invalid-credentials" + } + } + } + } + } + }, + "500": { + "description": "サーバー側で問題が発生しました。時間をおいて再度お試しください", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "API 共通のエラー応答ボディ。", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "example": "invalid-credentials" + } + } + } + } + } + } + } + } + }, + "/v1/auth/resend-verification-email": { + "post": { + "summary": "認証メールの再送", + "operationId": "resend_verification_email", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResendVerificationRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Register successful", + "description": "認証メールを送信しました", "content": { "text/plain": { "schema": { @@ -225,18 +317,148 @@ } } }, + "404": { + "description": "入力されたメールアドレスのアカウントが見つかりませんでした", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "API 共通のエラー応答ボディ。", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "example": "invalid-credentials" + } + } + } + } + } + }, + "409": { + "description": "このアカウントではメール認証はもう完了しています", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "API 共通のエラー応答ボディ。", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "example": "invalid-credentials" + } + } + } + } + } + }, + "429": { + "description": "しばらくしてから再度お試しください", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "API 共通のエラー応答ボディ。", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "example": "invalid-credentials" + } + } + } + } + } + }, "500": { - "description": "Internal server error", + "description": "サーバー側で問題が発生しました。時間をおいて再度お試しください", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" + } + } + } + } + } + } + } + } + }, + "/v1/auth/verify-email": { + "post": { + "summary": "メールアドレスの確認", + "operationId": "verify_email", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyEmailRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "メールアドレスの確認が完了しました", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "認証用リンクが無効か、または有効期限切れです", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "API 共通のエラー応答ボディ。", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "example": "invalid-credentials" + } + } + } + } + } + }, + "500": { + "description": "サーバー側で問題が発生しました。時間をおいて再度お試しください", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "API 共通のエラー応答ボディ。", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "example": "invalid-credentials" } } } @@ -248,10 +470,11 @@ }, "/v1/labels": { "get": { + "summary": "ラベル一覧", "operationId": "get_labels", "responses": { "200": { - "description": "Labels list", + "description": "すべてのラベル", "content": { "application/json": { "schema": { @@ -262,12 +485,33 @@ } } } + }, + "500": { + "description": "サーバー側で問題が発生しました。時間をおいて再度お試しください", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "API 共通のエラー応答ボディ。", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "example": "invalid-credentials" + } + } + } + } + } } } } }, "/v1/personal_tokens": { "post": { + "summary": "パーソナルアクセストークンを発行", "operationId": "create_personal_token", "requestBody": { "content": { @@ -281,7 +525,7 @@ }, "responses": { "200": { - "description": "Personal token created", + "description": "発行したトークンの情報", "content": { "application/json": { "schema": { @@ -291,17 +535,19 @@ } }, "401": { - "description": "Unauthorized", + "description": "ログインまたはセッションが必要です", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -309,17 +555,19 @@ } }, "403": { - "description": "Forbidden", + "description": "この操作は許可されていません", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -327,17 +575,19 @@ } }, "500": { - "description": "Internal server error", + "description": "サーバー側で問題が発生しました。時間をおいて再度お試しください", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -347,10 +597,11 @@ } }, "delete": { + "summary": "すべての個人用トークンを取り消し", "operationId": "revoke_all_personal_tokens", "responses": { "200": { - "description": "All personal tokens revoked", + "description": "現在アクティブなトークンの一覧(空になり得ます)", "content": { "application/json": { "schema": { @@ -363,17 +614,19 @@ } }, "401": { - "description": "Unauthorized", + "description": "ログインまたはセッションが必要です", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -381,17 +634,19 @@ } }, "403": { - "description": "Forbidden", + "description": "この操作は許可されていません", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -399,17 +654,19 @@ } }, "500": { - "description": "Internal server error", + "description": "サーバー側で問題が発生しました。時間をおいて再度お試しください", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -421,12 +678,13 @@ }, "/v1/personal_tokens/{id}": { "get": { + "summary": "指定したトークンを参照", "operationId": "get_personal_token", "parameters": [ { "name": "id", "in": "path", - "description": "Personal token ID", + "description": "トークンの識別子", "required": true, "schema": { "type": "string", @@ -436,7 +694,7 @@ ], "responses": { "200": { - "description": "Personal token found", + "description": "トークンの状態", "content": { "application/json": { "schema": { @@ -446,17 +704,19 @@ } }, "401": { - "description": "Unauthorized", + "description": "ログインまたはセッションが必要です", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -464,17 +724,19 @@ } }, "403": { - "description": "Forbidden", + "description": "この操作は許可されていません", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -482,17 +744,19 @@ } }, "500": { - "description": "Internal server error", + "description": "サーバー側で問題が発生しました。時間をおいて再度お試しください", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -502,12 +766,13 @@ } }, "delete": { + "summary": "指定したトークンを取り消し", "operationId": "revoke_personal_token", "parameters": [ { "name": "id", "in": "path", - "description": "Personal token ID", + "description": "トークンの識別子", "required": true, "schema": { "type": "string", @@ -517,7 +782,7 @@ ], "responses": { "200": { - "description": "Personal token revoked", + "description": "取り消し後の状態", "content": { "application/json": { "schema": { @@ -527,17 +792,19 @@ } }, "401": { - "description": "Unauthorized", + "description": "ログインまたはセッションが必要です", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -545,17 +812,19 @@ } }, "403": { - "description": "Forbidden", + "description": "この操作は許可されていません", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -563,17 +832,19 @@ } }, "500": { - "description": "Internal server error", + "description": "サーバー側で問題が発生しました。時間をおいて再度お試しください", "content": { "application/json": { "schema": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" } } } @@ -732,6 +1003,18 @@ } } }, + "ResendVerificationRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + }, "Scope": { "type": "string", "enum": [ @@ -745,16 +1028,31 @@ "items": { "$ref": "#/components/schemas/Scope" }, - "description": "JSON カラム用の `Vec` ラッパ(SeaORM エンティティ向け)。" + "description": "アクセストークン等に付与する権限スコープのリスト。" }, "ServerError": { "type": "object", + "description": "API 共通のエラー応答ボディ。", "required": [ "message" ], "properties": { "message": { - "type": "string" + "type": "string", + "example": "invalid-credentials" + } + } + }, + "VerifyEmailRequest": { + "type": "object", + "description": "メールでの本人確認時に送信する情報。", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "メールまたはアプリにお知らせした認証用文字列です。" } } }, @@ -793,7 +1091,8 @@ "required": [ "id", "username", - "email" + "email", + "email_verified" ], "properties": { "avatar_url": { @@ -812,6 +1111,10 @@ "type": "string", "format": "email" }, + "email_verified": { + "type": "boolean", + "description": "メールアドレスの確認が済んでいるかどうか" + }, "id": { "type": "string", "format": "uuid" diff --git a/apps/frontend/app/utils/openapi/.openapi-generator/FILES b/apps/frontend/app/utils/openapi/.openapi-generator/FILES index 59c6441..184dfb2 100644 --- a/apps/frontend/app/utils/openapi/.openapi-generator/FILES +++ b/apps/frontend/app/utils/openapi/.openapi-generator/FILES @@ -4,21 +4,25 @@ docs/CrateEntitiesLabelsModel.md docs/CrateEntitiesUsersModel.md docs/CreatePersonalTokenResponse.md docs/DefaultApi.md -docs/Login403Response.md +docs/Login401Response.md docs/LoginRequest.md docs/PersonalTokenResponse.md docs/RegisterRequest.md +docs/ResendVerificationRequest.md docs/Scope.md docs/ServerError.md +docs/VerifyEmailRequest.md index.ts models/CrateEntitiesLabelsModel.ts models/CrateEntitiesUsersModel.ts models/CreatePersonalTokenResponse.ts -models/Login403Response.ts +models/Login401Response.ts models/LoginRequest.ts models/PersonalTokenResponse.ts models/RegisterRequest.ts +models/ResendVerificationRequest.ts models/Scope.ts models/ServerError.ts +models/VerifyEmailRequest.ts models/index.ts runtime.ts diff --git a/apps/frontend/app/utils/openapi/apis/DefaultApi.ts b/apps/frontend/app/utils/openapi/apis/DefaultApi.ts index fa356df..5796604 100644 --- a/apps/frontend/app/utils/openapi/apis/DefaultApi.ts +++ b/apps/frontend/app/utils/openapi/apis/DefaultApi.ts @@ -29,10 +29,10 @@ import { CreatePersonalTokenResponseToJSON, } from '../models/CreatePersonalTokenResponse'; import { - type Login403Response, - Login403ResponseFromJSON, - Login403ResponseToJSON, -} from '../models/Login403Response'; + type Login401Response, + Login401ResponseFromJSON, + Login401ResponseToJSON, +} from '../models/Login401Response'; import { type LoginRequest, LoginRequestFromJSON, @@ -48,6 +48,16 @@ import { RegisterRequestFromJSON, RegisterRequestToJSON, } from '../models/RegisterRequest'; +import { + type ResendVerificationRequest, + ResendVerificationRequestFromJSON, + ResendVerificationRequestToJSON, +} from '../models/ResendVerificationRequest'; +import { + type VerifyEmailRequest, + VerifyEmailRequestFromJSON, + VerifyEmailRequestToJSON, +} from '../models/VerifyEmailRequest'; export interface CreatePersonalTokenRequest { body: object; @@ -65,10 +75,18 @@ export interface RegisterOperationRequest { registerRequest: RegisterRequest; } +export interface ResendVerificationEmailRequest { + resendVerificationRequest: ResendVerificationRequest; +} + export interface RevokePersonalTokenRequest { id: string; } +export interface VerifyEmailOperationRequest { + verifyEmailRequest: VerifyEmailRequest; +} + /** * */ @@ -104,6 +122,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * パーソナルアクセストークンを発行 */ async createPersonalTokenRaw(requestParameters: CreatePersonalTokenRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const requestOptions = await this.createPersonalTokenRequestOpts(requestParameters); @@ -113,6 +132,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * パーソナルアクセストークンを発行 */ async createPersonalToken(requestParameters: CreatePersonalTokenRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { const response = await this.createPersonalTokenRaw(requestParameters, initOverrides); @@ -139,6 +159,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * ラベル一覧 */ async getLabelsRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>> { const requestOptions = await this.getLabelsRequestOpts(); @@ -148,6 +169,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * ラベル一覧 */ async getLabels(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const response = await this.getLabelsRaw(initOverrides); @@ -182,6 +204,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * 指定したトークンを参照 */ async getPersonalTokenRaw(requestParameters: GetPersonalTokenRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const requestOptions = await this.getPersonalTokenRequestOpts(requestParameters); @@ -191,6 +214,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * 指定したトークンを参照 */ async getPersonalToken(requestParameters: GetPersonalTokenRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { const response = await this.getPersonalTokenRaw(requestParameters, initOverrides); @@ -227,23 +251,20 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * ログイン */ - async loginRaw(requestParameters: LoginOperationRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + async loginRaw(requestParameters: LoginOperationRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const requestOptions = await this.loginRequestOpts(requestParameters); const response = await this.request(requestOptions, initOverrides); - if (this.isJsonMime(response.headers.get('content-type'))) { - return new runtime.JSONApiResponse(response); - } else { - return new runtime.TextApiResponse(response) as any; - } + return new runtime.VoidApiResponse(response); } /** + * ログイン */ - async login(requestParameters: LoginOperationRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { - const response = await this.loginRaw(requestParameters, initOverrides); - return await response.value(); + async login(requestParameters: LoginOperationRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + await this.loginRaw(requestParameters, initOverrides); } /** @@ -266,23 +287,20 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * ログアウト */ - async logoutRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + async logoutRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const requestOptions = await this.logoutRequestOpts(); const response = await this.request(requestOptions, initOverrides); - if (this.isJsonMime(response.headers.get('content-type'))) { - return new runtime.JSONApiResponse(response); - } else { - return new runtime.TextApiResponse(response) as any; - } + return new runtime.VoidApiResponse(response); } /** + * ログアウト */ - async logout(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { - const response = await this.logoutRaw(initOverrides); - return await response.value(); + async logout(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + await this.logoutRaw(initOverrides); } /** @@ -305,6 +323,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * ログイン中ユーザー情報 */ async meRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const requestOptions = await this.meRequestOpts(); @@ -314,6 +333,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * ログイン中ユーザー情報 */ async me(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { const response = await this.meRaw(initOverrides); @@ -350,6 +370,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * 新規登録 */ async registerRaw(requestParameters: RegisterOperationRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const requestOptions = await this.registerRequestOpts(requestParameters); @@ -363,12 +384,64 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * 新規登録 */ async register(requestParameters: RegisterOperationRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { const response = await this.registerRaw(requestParameters, initOverrides); return await response.value(); } + /** + * Creates request options for resendVerificationEmail without sending the request + */ + async resendVerificationEmailRequestOpts(requestParameters: ResendVerificationEmailRequest): Promise { + if (requestParameters['resendVerificationRequest'] == null) { + throw new runtime.RequiredError( + 'resendVerificationRequest', + 'Required parameter "resendVerificationRequest" was null or undefined when calling resendVerificationEmail().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + + let urlPath = `/v1/auth/resend-verification-email`; + + return { + path: urlPath, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: ResendVerificationRequestToJSON(requestParameters['resendVerificationRequest']), + }; + } + + /** + * 認証メールの再送 + */ + async resendVerificationEmailRaw(requestParameters: ResendVerificationEmailRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const requestOptions = await this.resendVerificationEmailRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + if (this.isJsonMime(response.headers.get('content-type'))) { + return new runtime.JSONApiResponse(response); + } else { + return new runtime.TextApiResponse(response) as any; + } + } + + /** + * 認証メールの再送 + */ + async resendVerificationEmail(requestParameters: ResendVerificationEmailRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.resendVerificationEmailRaw(requestParameters, initOverrides); + return await response.value(); + } + /** * Creates request options for revokeAllPersonalTokens without sending the request */ @@ -389,6 +462,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * すべての個人用トークンを取り消し */ async revokeAllPersonalTokensRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>> { const requestOptions = await this.revokeAllPersonalTokensRequestOpts(); @@ -398,6 +472,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * すべての個人用トークンを取り消し */ async revokeAllPersonalTokens(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const response = await this.revokeAllPersonalTokensRaw(initOverrides); @@ -432,6 +507,7 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * 指定したトークンを取り消し */ async revokePersonalTokenRaw(requestParameters: RevokePersonalTokenRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const requestOptions = await this.revokePersonalTokenRequestOpts(requestParameters); @@ -441,10 +517,62 @@ export class DefaultApi extends runtime.BaseAPI { } /** + * 指定したトークンを取り消し */ async revokePersonalToken(requestParameters: RevokePersonalTokenRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { const response = await this.revokePersonalTokenRaw(requestParameters, initOverrides); return await response.value(); } + /** + * Creates request options for verifyEmail without sending the request + */ + async verifyEmailRequestOpts(requestParameters: VerifyEmailOperationRequest): Promise { + if (requestParameters['verifyEmailRequest'] == null) { + throw new runtime.RequiredError( + 'verifyEmailRequest', + 'Required parameter "verifyEmailRequest" was null or undefined when calling verifyEmail().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + + let urlPath = `/v1/auth/verify-email`; + + return { + path: urlPath, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: VerifyEmailRequestToJSON(requestParameters['verifyEmailRequest']), + }; + } + + /** + * メールアドレスの確認 + */ + async verifyEmailRaw(requestParameters: VerifyEmailOperationRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const requestOptions = await this.verifyEmailRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + if (this.isJsonMime(response.headers.get('content-type'))) { + return new runtime.JSONApiResponse(response); + } else { + return new runtime.TextApiResponse(response) as any; + } + } + + /** + * メールアドレスの確認 + */ + async verifyEmail(requestParameters: VerifyEmailOperationRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.verifyEmailRaw(requestParameters, initOverrides); + return await response.value(); + } + } diff --git a/apps/frontend/app/utils/openapi/docs/CrateEntitiesUsersModel.md b/apps/frontend/app/utils/openapi/docs/CrateEntitiesUsersModel.md index faf05a3..f458f49 100644 --- a/apps/frontend/app/utils/openapi/docs/CrateEntitiesUsersModel.md +++ b/apps/frontend/app/utils/openapi/docs/CrateEntitiesUsersModel.md @@ -9,6 +9,7 @@ Name | Type `avatarUrl` | string `bio` | string `email` | string +`emailVerified` | boolean `id` | string `username` | string @@ -22,6 +23,7 @@ const example = { "avatarUrl": null, "bio": null, "email": null, + "emailVerified": null, "id": null, "username": null, } satisfies CrateEntitiesUsersModel diff --git a/apps/frontend/app/utils/openapi/docs/DefaultApi.md b/apps/frontend/app/utils/openapi/docs/DefaultApi.md index 6457303..17c5b63 100644 --- a/apps/frontend/app/utils/openapi/docs/DefaultApi.md +++ b/apps/frontend/app/utils/openapi/docs/DefaultApi.md @@ -4,15 +4,17 @@ All URIs are relative to *http://localhost* | Method | HTTP request | Description | |------------- | ------------- | -------------| -| [**createPersonalToken**](DefaultApi.md#createpersonaltoken) | **POST** /v1/personal_tokens | | -| [**getLabels**](DefaultApi.md#getlabels) | **GET** /v1/labels | | -| [**getPersonalToken**](DefaultApi.md#getpersonaltoken) | **GET** /v1/personal_tokens/{id} | | -| [**login**](DefaultApi.md#loginoperation) | **POST** /v1/auth/login | | -| [**logout**](DefaultApi.md#logout) | **POST** /v1/auth/logout | | -| [**me**](DefaultApi.md#me) | **GET** /v1/auth/me | | -| [**register**](DefaultApi.md#registeroperation) | **POST** /v1/auth/register | | -| [**revokeAllPersonalTokens**](DefaultApi.md#revokeallpersonaltokens) | **DELETE** /v1/personal_tokens | | -| [**revokePersonalToken**](DefaultApi.md#revokepersonaltoken) | **DELETE** /v1/personal_tokens/{id} | | +| [**createPersonalToken**](DefaultApi.md#createpersonaltoken) | **POST** /v1/personal_tokens | パーソナルアクセストークンを発行 | +| [**getLabels**](DefaultApi.md#getlabels) | **GET** /v1/labels | ラベル一覧 | +| [**getPersonalToken**](DefaultApi.md#getpersonaltoken) | **GET** /v1/personal_tokens/{id} | 指定したトークンを参照 | +| [**login**](DefaultApi.md#loginoperation) | **POST** /v1/auth/login | ログイン | +| [**logout**](DefaultApi.md#logout) | **POST** /v1/auth/logout | ログアウト | +| [**me**](DefaultApi.md#me) | **GET** /v1/auth/me | ログイン中ユーザー情報 | +| [**register**](DefaultApi.md#registeroperation) | **POST** /v1/auth/register | 新規登録 | +| [**resendVerificationEmail**](DefaultApi.md#resendverificationemail) | **POST** /v1/auth/resend-verification-email | 認証メールの再送 | +| [**revokeAllPersonalTokens**](DefaultApi.md#revokeallpersonaltokens) | **DELETE** /v1/personal_tokens | すべての個人用トークンを取り消し | +| [**revokePersonalToken**](DefaultApi.md#revokepersonaltoken) | **DELETE** /v1/personal_tokens/{id} | 指定したトークンを取り消し | +| [**verifyEmail**](DefaultApi.md#verifyemailoperation) | **POST** /v1/auth/verify-email | メールアドレスの確認 | @@ -20,7 +22,7 @@ All URIs are relative to *http://localhost* > CreatePersonalTokenResponse createPersonalToken(body) - +パーソナルアクセストークンを発行 ### Example @@ -76,10 +78,10 @@ No authorization required ### HTTP response details | Status code | Description | Response headers | |-------------|-------------|------------------| -| **200** | Personal token created | - | -| **401** | Unauthorized | - | -| **403** | Forbidden | - | -| **500** | Internal server error | - | +| **200** | 発行したトークンの情報 | - | +| **401** | ログインまたはセッションが必要です | - | +| **403** | この操作は許可されていません | - | +| **500** | サーバー側で問題が発生しました。時間をおいて再度お試しください | - | [[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) @@ -88,7 +90,7 @@ No authorization required > Array<CrateEntitiesLabelsModel> getLabels() - +ラベル一覧 ### Example @@ -136,7 +138,8 @@ No authorization required ### HTTP response details | Status code | Description | Response headers | |-------------|-------------|------------------| -| **200** | Labels list | - | +| **200** | すべてのラベル | - | +| **500** | サーバー側で問題が発生しました。時間をおいて再度お試しください | - | [[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) @@ -145,7 +148,7 @@ No authorization required > PersonalTokenResponse getPersonalToken(id) - +指定したトークンを参照 ### Example @@ -161,7 +164,7 @@ async function example() { const api = new DefaultApi(); const body = { - // string | Personal token ID + // string | トークンの識別子 id: 38400000-8cf0-11bd-b23e-10b96e4ef00d, } satisfies GetPersonalTokenRequest; @@ -182,7 +185,7 @@ example().catch(console.error); | Name | Type | Description | Notes | |------------- | ------------- | ------------- | -------------| -| **id** | `string` | Personal token ID | [Defaults to `undefined`] | +| **id** | `string` | トークンの識別子 | [Defaults to `undefined`] | ### Return type @@ -201,19 +204,19 @@ No authorization required ### HTTP response details | Status code | Description | Response headers | |-------------|-------------|------------------| -| **200** | Personal token found | - | -| **401** | Unauthorized | - | -| **403** | Forbidden | - | -| **500** | Internal server error | - | +| **200** | トークンの状態 | - | +| **401** | ログインまたはセッションが必要です | - | +| **403** | この操作は許可されていません | - | +| **500** | サーバー側で問題が発生しました。時間をおいて再度お試しください | - | [[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) ## login -> string login(loginRequest) - +> login(loginRequest) +ログイン ### Example @@ -254,7 +257,7 @@ example().catch(console.error); ### Return type -**string** +`void` (Empty response body) ### Authorization @@ -263,24 +266,25 @@ No authorization required ### HTTP request headers - **Content-Type**: `application/json` -- **Accept**: `text/plain`, `application/json` +- **Accept**: `application/json` ### HTTP response details | Status code | Description | Response headers | |-------------|-------------|------------------| -| **200** | Login successful | - | -| **403** | Forbidden | - | -| **500** | Internal server error | - | +| **204** | ログインに成功しました(本文なし) | - | +| **401** | メールアドレスまたはパスワードが正しくありません | - | +| **403** | メールアドレスの確認が済んでいないためログインできません | - | +| **500** | サーバー側で問題が発生しました。時間をおいて再度お試しください | - | [[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) ## logout -> string logout() - +> logout() +ログアウト ### Example @@ -313,7 +317,7 @@ This endpoint does not need any parameter. ### Return type -**string** +`void` (Empty response body) ### Authorization @@ -322,15 +326,15 @@ No authorization required ### HTTP request headers - **Content-Type**: Not defined -- **Accept**: `text/plain`, `application/json` +- **Accept**: `application/json` ### HTTP response details | Status code | Description | Response headers | |-------------|-------------|------------------| -| **200** | Logout successful | - | -| **401** | Unauthorized | - | -| **500** | Internal server error | - | +| **204** | ログアウトしました(本文なし) | - | +| **401** | ログインまたはセッションが必要です | - | +| **500** | サーバー側で問題が発生しました。時間をおいて再度お試しください | - | [[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) @@ -339,7 +343,7 @@ No authorization required > CrateEntitiesUsersModel me() - +ログイン中ユーザー情報 ### Example @@ -387,10 +391,10 @@ No authorization required ### HTTP response details | Status code | Description | Response headers | |-------------|-------------|------------------| -| **200** | Current user info | - | -| **401** | Unauthorized | - | -| **403** | Forbidden | - | -| **500** | Internal server error | - | +| **200** | 現在のアカウント情報 | - | +| **401** | ログインまたはセッションが必要です | - | +| **403** | この操作は許可されていません | - | +| **500** | サーバー側で問題が発生しました。時間をおいて再度お試しください | - | [[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) @@ -399,7 +403,7 @@ No authorization required > string register(registerRequest) - +新規登録 ### Example @@ -455,8 +459,78 @@ No authorization required ### HTTP response details | Status code | Description | Response headers | |-------------|-------------|------------------| -| **200** | Register successful | - | -| **500** | Internal server error | - | +| **201** | アカウントが作成されました。続けて送信されたメールで認証してください。 | - | +| **409** | このメールアドレスはすでに登録されています | - | +| **500** | サーバー側で問題が発生しました。時間をおいて再度お試しください | - | + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + + +## resendVerificationEmail + +> string resendVerificationEmail(resendVerificationRequest) + +認証メールの再送 + +### Example + +```ts +import { + Configuration, + DefaultApi, +} from ''; +import type { ResendVerificationEmailRequest } from ''; + +async function example() { + console.log("🚀 Testing SDK..."); + const api = new DefaultApi(); + + const body = { + // ResendVerificationRequest + resendVerificationRequest: ..., + } satisfies ResendVerificationEmailRequest; + + try { + const data = await api.resendVerificationEmail(body); + console.log(data); + } catch (error) { + console.error(error); + } +} + +// Run the test +example().catch(console.error); +``` + +### Parameters + + +| Name | Type | Description | Notes | +|------------- | ------------- | ------------- | -------------| +| **resendVerificationRequest** | [ResendVerificationRequest](ResendVerificationRequest.md) | | | + +### Return type + +**string** + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: `application/json` +- **Accept**: `text/plain`, `application/json` + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +| **200** | 認証メールを送信しました | - | +| **404** | 入力されたメールアドレスのアカウントが見つかりませんでした | - | +| **409** | このアカウントではメール認証はもう完了しています | - | +| **429** | しばらくしてから再度お試しください | - | +| **500** | サーバー側で問題が発生しました。時間をおいて再度お試しください | - | [[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) @@ -465,7 +539,7 @@ No authorization required > Array<PersonalTokenResponse> revokeAllPersonalTokens() - +すべての個人用トークンを取り消し ### Example @@ -513,10 +587,10 @@ No authorization required ### HTTP response details | Status code | Description | Response headers | |-------------|-------------|------------------| -| **200** | All personal tokens revoked | - | -| **401** | Unauthorized | - | -| **403** | Forbidden | - | -| **500** | Internal server error | - | +| **200** | 現在アクティブなトークンの一覧(空になり得ます) | - | +| **401** | ログインまたはセッションが必要です | - | +| **403** | この操作は許可されていません | - | +| **500** | サーバー側で問題が発生しました。時間をおいて再度お試しください | - | [[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) @@ -525,7 +599,7 @@ No authorization required > PersonalTokenResponse revokePersonalToken(id) - +指定したトークンを取り消し ### Example @@ -541,7 +615,7 @@ async function example() { const api = new DefaultApi(); const body = { - // string | Personal token ID + // string | トークンの識別子 id: 38400000-8cf0-11bd-b23e-10b96e4ef00d, } satisfies RevokePersonalTokenRequest; @@ -562,7 +636,7 @@ example().catch(console.error); | Name | Type | Description | Notes | |------------- | ------------- | ------------- | -------------| -| **id** | `string` | Personal token ID | [Defaults to `undefined`] | +| **id** | `string` | トークンの識別子 | [Defaults to `undefined`] | ### Return type @@ -581,10 +655,77 @@ No authorization required ### HTTP response details | Status code | Description | Response headers | |-------------|-------------|------------------| -| **200** | Personal token revoked | - | -| **401** | Unauthorized | - | -| **403** | Forbidden | - | -| **500** | Internal server error | - | +| **200** | 取り消し後の状態 | - | +| **401** | ログインまたはセッションが必要です | - | +| **403** | この操作は許可されていません | - | +| **500** | サーバー側で問題が発生しました。時間をおいて再度お試しください | - | + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + + +## verifyEmail + +> string verifyEmail(verifyEmailRequest) + +メールアドレスの確認 + +### Example + +```ts +import { + Configuration, + DefaultApi, +} from ''; +import type { VerifyEmailOperationRequest } from ''; + +async function example() { + console.log("🚀 Testing SDK..."); + const api = new DefaultApi(); + + const body = { + // VerifyEmailRequest + verifyEmailRequest: ..., + } satisfies VerifyEmailOperationRequest; + + try { + const data = await api.verifyEmail(body); + console.log(data); + } catch (error) { + console.error(error); + } +} + +// Run the test +example().catch(console.error); +``` + +### Parameters + + +| Name | Type | Description | Notes | +|------------- | ------------- | ------------- | -------------| +| **verifyEmailRequest** | [VerifyEmailRequest](VerifyEmailRequest.md) | | | + +### Return type + +**string** + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: `application/json` +- **Accept**: `text/plain`, `application/json` + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +| **200** | メールアドレスの確認が完了しました | - | +| **400** | 認証用リンクが無効か、または有効期限切れです | - | +| **500** | サーバー側で問題が発生しました。時間をおいて再度お試しください | - | [[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) diff --git a/apps/frontend/app/utils/openapi/docs/Login401Response.md b/apps/frontend/app/utils/openapi/docs/Login401Response.md new file mode 100644 index 0000000..d8114dd --- /dev/null +++ b/apps/frontend/app/utils/openapi/docs/Login401Response.md @@ -0,0 +1,35 @@ + +# Login401Response + +API 共通のエラー応答ボディ。 + +## Properties + +Name | Type +------------ | ------------- +`message` | string + +## Example + +```typescript +import type { Login401Response } from '' + +// TODO: Update the object below with actual values +const example = { + "message": invalid-credentials, +} satisfies Login401Response + +console.log(example) + +// Convert the instance to a JSON string +const exampleJSON: string = JSON.stringify(example) +console.log(exampleJSON) + +// Parse the JSON string back to an object +const exampleParsed = JSON.parse(exampleJSON) as Login401Response +console.log(exampleParsed) +``` + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + + diff --git a/apps/frontend/app/utils/openapi/docs/ResendVerificationRequest.md b/apps/frontend/app/utils/openapi/docs/ResendVerificationRequest.md new file mode 100644 index 0000000..21622af --- /dev/null +++ b/apps/frontend/app/utils/openapi/docs/ResendVerificationRequest.md @@ -0,0 +1,34 @@ + +# ResendVerificationRequest + + +## Properties + +Name | Type +------------ | ------------- +`email` | string + +## Example + +```typescript +import type { ResendVerificationRequest } from '' + +// TODO: Update the object below with actual values +const example = { + "email": null, +} satisfies ResendVerificationRequest + +console.log(example) + +// Convert the instance to a JSON string +const exampleJSON: string = JSON.stringify(example) +console.log(exampleJSON) + +// Parse the JSON string back to an object +const exampleParsed = JSON.parse(exampleJSON) as ResendVerificationRequest +console.log(exampleParsed) +``` + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + + diff --git a/apps/frontend/app/utils/openapi/docs/ServerError.md b/apps/frontend/app/utils/openapi/docs/ServerError.md index 019ba7b..cd624be 100644 --- a/apps/frontend/app/utils/openapi/docs/ServerError.md +++ b/apps/frontend/app/utils/openapi/docs/ServerError.md @@ -1,6 +1,7 @@ # ServerError +API 共通のエラー応答ボディ。 ## Properties @@ -15,7 +16,7 @@ import type { ServerError } from '' // TODO: Update the object below with actual values const example = { - "message": null, + "message": invalid-credentials, } satisfies ServerError console.log(example) diff --git a/apps/frontend/app/utils/openapi/docs/VerifyEmailRequest.md b/apps/frontend/app/utils/openapi/docs/VerifyEmailRequest.md new file mode 100644 index 0000000..005568a --- /dev/null +++ b/apps/frontend/app/utils/openapi/docs/VerifyEmailRequest.md @@ -0,0 +1,35 @@ + +# VerifyEmailRequest + +メールでの本人確認時に送信する情報。 + +## Properties + +Name | Type +------------ | ------------- +`token` | string + +## Example + +```typescript +import type { VerifyEmailRequest } from '' + +// TODO: Update the object below with actual values +const example = { + "token": null, +} satisfies VerifyEmailRequest + +console.log(example) + +// Convert the instance to a JSON string +const exampleJSON: string = JSON.stringify(example) +console.log(exampleJSON) + +// Parse the JSON string back to an object +const exampleParsed = JSON.parse(exampleJSON) as VerifyEmailRequest +console.log(exampleParsed) +``` + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + + diff --git a/apps/frontend/app/utils/openapi/models/CrateEntitiesUsersModel.ts b/apps/frontend/app/utils/openapi/models/CrateEntitiesUsersModel.ts index f8ef07f..d18fc78 100644 --- a/apps/frontend/app/utils/openapi/models/CrateEntitiesUsersModel.ts +++ b/apps/frontend/app/utils/openapi/models/CrateEntitiesUsersModel.ts @@ -37,6 +37,12 @@ export interface CrateEntitiesUsersModel { * @memberof CrateEntitiesUsersModel */ email: string; + /** + * メールアドレスの確認が済んでいるかどうか + * @type {boolean} + * @memberof CrateEntitiesUsersModel + */ + emailVerified: boolean; /** * * @type {string} @@ -56,6 +62,7 @@ export interface CrateEntitiesUsersModel { */ export function instanceOfCrateEntitiesUsersModel(value: object): value is CrateEntitiesUsersModel { if (!('email' in value) || value['email'] === undefined) return false; + if (!('emailVerified' in value) || value['emailVerified'] === undefined) return false; if (!('id' in value) || value['id'] === undefined) return false; if (!('username' in value) || value['username'] === undefined) return false; return true; @@ -74,6 +81,7 @@ export function CrateEntitiesUsersModelFromJSONTyped(json: any, ignoreDiscrimina 'avatarUrl': json['avatar_url'] == null ? undefined : json['avatar_url'], 'bio': json['bio'] == null ? undefined : json['bio'], 'email': json['email'], + 'emailVerified': json['email_verified'], 'id': json['id'], 'username': json['username'], }; @@ -93,6 +101,7 @@ export function CrateEntitiesUsersModelToJSONTyped(value?: CrateEntitiesUsersMod 'avatar_url': value['avatarUrl'], 'bio': value['bio'], 'email': value['email'], + 'email_verified': value['emailVerified'], 'id': value['id'], 'username': value['username'], }; diff --git a/apps/frontend/app/utils/openapi/models/CreatePersonalTokenResponse.ts b/apps/frontend/app/utils/openapi/models/CreatePersonalTokenResponse.ts index 2d09102..99c3047 100644 --- a/apps/frontend/app/utils/openapi/models/CreatePersonalTokenResponse.ts +++ b/apps/frontend/app/utils/openapi/models/CreatePersonalTokenResponse.ts @@ -58,7 +58,7 @@ export interface CreatePersonalTokenResponse { */ revoked: boolean; /** - * JSON カラム用の `Vec` ラッパ(SeaORM エンティティ向け)。 + * アクセストークン等に付与する権限スコープのリスト。 * @type {Array} * @memberof CreatePersonalTokenResponse */ diff --git a/apps/frontend/app/utils/openapi/models/Login401Response.ts b/apps/frontend/app/utils/openapi/models/Login401Response.ts new file mode 100644 index 0000000..8e962d3 --- /dev/null +++ b/apps/frontend/app/utils/openapi/models/Login401Response.ts @@ -0,0 +1,66 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * utoipa-axum + * Utoipa\'s axum bindings for seamless integration for the two + * + * The version of the OpenAPI document: 0.2.0 + * Contact: juha7kukkonen@gmail.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * API 共通のエラー応答ボディ。 + * @export + * @interface Login401Response + */ +export interface Login401Response { + /** + * + * @type {string} + * @memberof Login401Response + */ + message: string; +} + +/** + * Check if a given object implements the Login401Response interface. + */ +export function instanceOfLogin401Response(value: object): value is Login401Response { + if (!('message' in value) || value['message'] === undefined) return false; + return true; +} + +export function Login401ResponseFromJSON(json: any): Login401Response { + return Login401ResponseFromJSONTyped(json, false); +} + +export function Login401ResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): Login401Response { + if (json == null) { + return json; + } + return { + + 'message': json['message'], + }; +} + +export function Login401ResponseToJSON(json: any): Login401Response { + return Login401ResponseToJSONTyped(json, false); +} + +export function Login401ResponseToJSONTyped(value?: Login401Response | null, ignoreDiscriminator: boolean = false): any { + if (value == null) { + return value; + } + + return { + + 'message': value['message'], + }; +} + diff --git a/apps/frontend/app/utils/openapi/models/PersonalTokenResponse.ts b/apps/frontend/app/utils/openapi/models/PersonalTokenResponse.ts index 89a2339..d19b792 100644 --- a/apps/frontend/app/utils/openapi/models/PersonalTokenResponse.ts +++ b/apps/frontend/app/utils/openapi/models/PersonalTokenResponse.ts @@ -58,7 +58,7 @@ export interface PersonalTokenResponse { */ revoked: boolean; /** - * JSON カラム用の `Vec` ラッパ(SeaORM エンティティ向け)。 + * アクセストークン等に付与する権限スコープのリスト。 * @type {Array} * @memberof PersonalTokenResponse */ diff --git a/apps/frontend/app/utils/openapi/models/ResendVerificationRequest.ts b/apps/frontend/app/utils/openapi/models/ResendVerificationRequest.ts new file mode 100644 index 0000000..7199b19 --- /dev/null +++ b/apps/frontend/app/utils/openapi/models/ResendVerificationRequest.ts @@ -0,0 +1,66 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * utoipa-axum + * Utoipa\'s axum bindings for seamless integration for the two + * + * The version of the OpenAPI document: 0.2.0 + * Contact: juha7kukkonen@gmail.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * + * @export + * @interface ResendVerificationRequest + */ +export interface ResendVerificationRequest { + /** + * + * @type {string} + * @memberof ResendVerificationRequest + */ + email: string; +} + +/** + * Check if a given object implements the ResendVerificationRequest interface. + */ +export function instanceOfResendVerificationRequest(value: object): value is ResendVerificationRequest { + if (!('email' in value) || value['email'] === undefined) return false; + return true; +} + +export function ResendVerificationRequestFromJSON(json: any): ResendVerificationRequest { + return ResendVerificationRequestFromJSONTyped(json, false); +} + +export function ResendVerificationRequestFromJSONTyped(json: any, ignoreDiscriminator: boolean): ResendVerificationRequest { + if (json == null) { + return json; + } + return { + + 'email': json['email'], + }; +} + +export function ResendVerificationRequestToJSON(json: any): ResendVerificationRequest { + return ResendVerificationRequestToJSONTyped(json, false); +} + +export function ResendVerificationRequestToJSONTyped(value?: ResendVerificationRequest | null, ignoreDiscriminator: boolean = false): any { + if (value == null) { + return value; + } + + return { + + 'email': value['email'], + }; +} + diff --git a/apps/frontend/app/utils/openapi/models/ServerError.ts b/apps/frontend/app/utils/openapi/models/ServerError.ts index ee71881..4598c1f 100644 --- a/apps/frontend/app/utils/openapi/models/ServerError.ts +++ b/apps/frontend/app/utils/openapi/models/ServerError.ts @@ -14,7 +14,7 @@ import { mapValues } from '../runtime'; /** - * + * API 共通のエラー応答ボディ。 * @export * @interface ServerError */ diff --git a/apps/frontend/app/utils/openapi/models/VerifyEmailRequest.ts b/apps/frontend/app/utils/openapi/models/VerifyEmailRequest.ts new file mode 100644 index 0000000..7e27bbe --- /dev/null +++ b/apps/frontend/app/utils/openapi/models/VerifyEmailRequest.ts @@ -0,0 +1,66 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * utoipa-axum + * Utoipa\'s axum bindings for seamless integration for the two + * + * The version of the OpenAPI document: 0.2.0 + * Contact: juha7kukkonen@gmail.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * メールでの本人確認時に送信する情報。 + * @export + * @interface VerifyEmailRequest + */ +export interface VerifyEmailRequest { + /** + * メールまたはアプリにお知らせした認証用文字列です。 + * @type {string} + * @memberof VerifyEmailRequest + */ + token: string; +} + +/** + * Check if a given object implements the VerifyEmailRequest interface. + */ +export function instanceOfVerifyEmailRequest(value: object): value is VerifyEmailRequest { + if (!('token' in value) || value['token'] === undefined) return false; + return true; +} + +export function VerifyEmailRequestFromJSON(json: any): VerifyEmailRequest { + return VerifyEmailRequestFromJSONTyped(json, false); +} + +export function VerifyEmailRequestFromJSONTyped(json: any, ignoreDiscriminator: boolean): VerifyEmailRequest { + if (json == null) { + return json; + } + return { + + 'token': json['token'], + }; +} + +export function VerifyEmailRequestToJSON(json: any): VerifyEmailRequest { + return VerifyEmailRequestToJSONTyped(json, false); +} + +export function VerifyEmailRequestToJSONTyped(value?: VerifyEmailRequest | null, ignoreDiscriminator: boolean = false): any { + if (value == null) { + return value; + } + + return { + + 'token': value['token'], + }; +} + diff --git a/apps/frontend/app/utils/openapi/models/index.ts b/apps/frontend/app/utils/openapi/models/index.ts index 3a13d95..3c0e2f2 100644 --- a/apps/frontend/app/utils/openapi/models/index.ts +++ b/apps/frontend/app/utils/openapi/models/index.ts @@ -3,9 +3,11 @@ export * from './CrateEntitiesLabelsModel'; export * from './CrateEntitiesUsersModel'; export * from './CreatePersonalTokenResponse'; -export * from './Login403Response'; +export * from './Login401Response'; export * from './LoginRequest'; export * from './PersonalTokenResponse'; export * from './RegisterRequest'; +export * from './ResendVerificationRequest'; export * from './Scope'; export * from './ServerError'; +export * from './VerifyEmailRequest'; From 46c9b87277588d81136bf9c69b29d8d116b73c60 Mon Sep 17 00:00:00 2001 From: yupix Date: Wed, 20 May 2026 16:59:12 +0000 Subject: [PATCH 07/23] feat(backend): add email_verification_app_url to settings with validation and update dependencies --- apps/backend/.env.example | 1 + apps/backend/Cargo.lock | 1 + apps/backend/Cargo.toml | 1 + apps/backend/src/settings.rs | 103 ++++++++++++++++++++++++++++++++--- 4 files changed, 97 insertions(+), 9 deletions(-) diff --git a/apps/backend/.env.example b/apps/backend/.env.example index bfde054..a600d48 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -8,4 +8,5 @@ smtp_port=587 smtp_username=your_smtp_username smtp_password=your_smtp_password smtp_from=no-reply@example.com +# 必須。認証メールのリンク先(本番では実際のフロント URL を指定) email_verification_app_url=http://localhost:3000 diff --git a/apps/backend/Cargo.lock b/apps/backend/Cargo.lock index 047f149..212e375 100644 --- a/apps/backend/Cargo.lock +++ b/apps/backend/Cargo.lock @@ -665,6 +665,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", "urlencoding", "utoipa", "utoipa-axum", diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index 033f7fe..438fc3c 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -39,6 +39,7 @@ thiserror = "2.0.10" base64 = "0.22.1" redis_pool = "0.10.0" redis = { version = "1.2.1", features = ["tokio-comp"] } +url = "2.5" urlencoding = "2.1" hmac = "0.13" sha2 = "0.11" diff --git a/apps/backend/src/settings.rs b/apps/backend/src/settings.rs index bb0e8bd..ab88187 100644 --- a/apps/backend/src/settings.rs +++ b/apps/backend/src/settings.rs @@ -1,7 +1,8 @@ use config::{Config, Environment}; use serde::Deserialize; +use validator::Validate; -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, Validate)] pub struct Settings { pub database_url: String, pub redis_url: String, @@ -13,8 +14,13 @@ pub struct Settings { pub smtp_username: String, pub smtp_password: String, pub smtp_from: String, - /// 認証メールに載せるリンクのベース URL(例: `http://localhost:3000`)。末尾に `/verify-email?token=…` を付与します。 - #[serde(default = "default_email_verification_app_url")] + /// 認証メールに載せるリンクのベース URL(必須。例: `https://app.example.com`)。 + /// 末尾に `/verify-email?token=…` を付与する。未設定・不正な値では起動しない。 + #[validate(length(min = 1, message = "email_verification_app_url is required"))] + #[validate(custom( + function = "validate_email_verification_app_url", + message = "email_verification_app_url must be a valid http or https base URL" + ))] pub email_verification_app_url: String, } @@ -22,17 +28,96 @@ fn default_allow_origin() -> String { "http://localhost:3000".to_string() } -fn default_email_verification_app_url() -> String { - default_allow_origin() -} - pub fn load_settings() -> Result { dotenvy::dotenv().ok(); let settings = Config::builder() .add_source(Environment::default()) .build()?; - settings + let settings: Settings = settings .try_deserialize() - .map_err(|e| anyhow::anyhow!("failed to deserialize settings: {e}")) + .map_err(|e| anyhow::anyhow!("failed to deserialize settings: {e}"))?; + + settings + .validate() + .map_err(|e| anyhow::anyhow!("invalid settings: {e}"))?; + + Ok(settings) +} + +/// 絶対 URL の http(s) ベースのみ許可(`http:/host` のような scheme 直後1スラッシュは拒否)。 +fn validate_email_verification_app_url(raw: &str) -> Result<(), validator::ValidationError> { + let url = raw.trim(); + if url.is_empty() { + return Err(validator::ValidationError::new("required")); + } + + // `http:/localhost` は url クレートではパースできるがベース URL として不正 + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(validator::ValidationError::new("http_or_https")); + } + + let parsed = url::Url::parse(url).map_err(|_| validator::ValidationError::new("url"))?; + + if parsed.cannot_be_a_base() { + return Err(validator::ValidationError::new("not_absolute")); + } + + let Some(host) = parsed.host_str() else { + return Err(validator::ValidationError::new("host")); + }; + + if host.is_empty() { + return Err(validator::ValidationError::new("host")); + } + + // scheme 直後は `//` 必須(`http:/foo` を弾く) + let after_scheme = url + .strip_prefix(parsed.scheme()) + .and_then(|s| s.strip_prefix(':')) + .unwrap_or(""); + if !after_scheme.starts_with("//") { + return Err(validator::ValidationError::new("authority")); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn check(url: &str) -> bool { + Settings { + database_url: String::new(), + redis_url: String::new(), + sentry_dsn: None, + allow_origin: String::new(), + smtp_host: String::new(), + smtp_port: 0, + smtp_username: String::new(), + smtp_password: String::new(), + smtp_from: String::new(), + email_verification_app_url: url.to_string(), + } + .validate() + .is_ok() + } + + #[test] + fn accepts_valid_base_urls() { + assert!(check("http://localhost:3000")); + assert!(check("https://app.example.com")); + } + + #[test] + fn rejects_single_slash_after_scheme() { + assert!(!check("http:/localhost:3000")); + assert!(!check("https:/example.com")); + } + + #[test] + fn rejects_missing_slashes() { + assert!(!check("http:localhost:3000")); + } } From 9b59eaf9de8cdd478ba7ee706a4a4cf143de7d43 Mon Sep 17 00:00:00 2001 From: yupix Date: Thu, 21 May 2026 00:34:50 +0000 Subject: [PATCH 08/23] feat(backend): enhance verification email outbox with processing status and transaction handling --- .../src/entities/verification_email_outbox.rs | 6 ++ apps/backend/src/handlers/auth.rs | 42 ++++++----- apps/backend/src/utils/db.rs | 31 +++++++- .../src/utils/verification_email_outbox.rs | 74 ++++++++++++++++--- 4 files changed, 120 insertions(+), 33 deletions(-) diff --git a/apps/backend/src/entities/verification_email_outbox.rs b/apps/backend/src/entities/verification_email_outbox.rs index e38707f..fa547ab 100644 --- a/apps/backend/src/entities/verification_email_outbox.rs +++ b/apps/backend/src/entities/verification_email_outbox.rs @@ -5,6 +5,9 @@ use sea_orm::entity::prelude::*; pub enum OutboxStatus { #[sea_orm(string_value = "pending")] Pending, + /// ワーカーが送信中(他ワーカーとの二重処理防止) + #[sea_orm(string_value = "processing")] + Processing, #[sea_orm(string_value = "sent")] Sent, #[sea_orm(string_value = "failed")] @@ -28,6 +31,9 @@ pub struct Model { #[sea_orm(nullable)] pub last_error: Option, pub created_at: DateTimeWithTimeZone, + /// ワーカーが processing にした時刻(クラッシュ後の再取得用) + #[sea_orm(nullable)] + pub claimed_at: Option, #[sea_orm(nullable)] pub sent_at: Option, } diff --git a/apps/backend/src/handlers/auth.rs b/apps/backend/src/handlers/auth.rs index 90e4ab4..ead0522 100644 --- a/apps/backend/src/handlers/auth.rs +++ b/apps/backend/src/handlers/auth.rs @@ -3,7 +3,7 @@ use axum_session::Session; use axum_session_redispool::SessionRedisPool; use axum_valid::Valid; use sea_orm::prelude::Uuid; -use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait, TransactionTrait}; +use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait}; use sea_orm::{ColumnTrait, QueryFilter}; use serde::Deserialize; use validator::Validate; @@ -17,7 +17,7 @@ use crate::openapi::{ use crate::utils::auth::{ AuthError, create_password_hash, generate_email_verification_token, verify_password, }; -use crate::utils::db::is_postgres_unique_violation; +use crate::utils::db::{is_postgres_unique_violation, with_transaction}; use crate::utils::{email_verification, verification_email_outbox}; use crate::{AppState, entities::users}; @@ -117,25 +117,29 @@ pub async fn register( password_hash: Set(password_hash), }; - let txn = state.db.begin().await?; - users::Entity::insert(user.clone()) - .exec(&txn) - .await - .map_err(|e| { - if is_postgres_unique_violation(&e) { - AuthError::DuplicateEmail - } else { - AuthError::Internal(anyhow::anyhow!("insert user: {e}")) - } - })?; + with_transaction::<(), AuthError, _>(&state.db, |txn| { + Box::pin(async move { + users::Entity::insert(user.clone()) + .exec(txn) + .await + .map_err(|e| { + if is_postgres_unique_violation(&e) { + AuthError::DuplicateEmail + } else { + AuthError::Internal(anyhow::anyhow!("insert user: {e}")) + } + })?; - verification_email_outbox::enqueue(&txn, user_id, email.clone(), verification_token) - .await - .map_err(|e| AuthError::Internal(anyhow::anyhow!("enqueue verification email: {e}")))?; + verification_email_outbox::enqueue(txn, user_id, email.clone(), verification_token) + .await + .map_err(|e| { + AuthError::Internal(anyhow::anyhow!("enqueue verification email: {e}")) + })?; - txn.commit() - .await - .map_err(|e| AuthError::Internal(anyhow::anyhow!("commit register transaction: {e}")))?; + Ok(()) + }) + }) + .await?; verification_email_outbox::wake_worker(state); Ok(( diff --git a/apps/backend/src/utils/db.rs b/apps/backend/src/utils/db.rs index 6555eb5..c76de04 100644 --- a/apps/backend/src/utils/db.rs +++ b/apps/backend/src/utils/db.rs @@ -1,6 +1,12 @@ -//! データベース制約との照合用ヘルパ。 +//! データベースまわりの共通ヘルパ。 +//! +//! トランザクションは SeaORM の [`TransactionTrait::transaction`] を使う。 +//! 公式: -use sea_orm::DbErr; +use std::future::Future; +use std::pin::Pin; + +use sea_orm::{DatabaseConnection, DbErr, TransactionError, TransactionTrait}; /// 一意制約違反(重複キーなど)として扱ってよさそうなエラーか。 pub fn is_postgres_unique_violation(err: &DbErr) -> bool { @@ -8,4 +14,23 @@ pub fn is_postgres_unique_violation(err: &DbErr) -> bool { err.sql_err(), Some(sea_orm::SqlErr::UniqueConstraintViolation(_)) ) -} \ No newline at end of file +} + +/// [`DatabaseConnection::transaction`] の薄いラッパ。 +/// +/// クロージャはドキュメントどおり `Box::pin(async move { ... })` を返す。 +/// `Ok` で commit、`Err` で rollback する。 +pub async fn with_transaction(db: &DatabaseConnection, f: F) -> Result +where + F: for<'c> FnOnce( + &'c sea_orm::DatabaseTransaction, + ) -> Pin> + Send + 'c>> + + Send, + T: Send, + E: std::fmt::Display + std::fmt::Debug + Send + From, +{ + db.transaction(f).await.map_err(|e| match e { + TransactionError::Connection(err) => E::from(err), + TransactionError::Transaction(err) => err, + }) +} diff --git a/apps/backend/src/utils/verification_email_outbox.rs b/apps/backend/src/utils/verification_email_outbox.rs index 5fc89a2..1161dff 100644 --- a/apps/backend/src/utils/verification_email_outbox.rs +++ b/apps/backend/src/utils/verification_email_outbox.rs @@ -5,17 +5,19 @@ use std::time::Duration; -use chrono::Utc; +use chrono::{Duration as ChronoDuration, Utc}; use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, - QueryOrder, QuerySelect, + ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, ConnectionTrait, EntityTrait, + QueryFilter, QueryOrder, QuerySelect, }; +use sea_orm::sea_query::{LockBehavior, LockType}; use tracing::warn; use uuid::Uuid; use crate::entities::verification_email_outbox::{ self, Entity as OutboxEntity, Model as OutboxModel, OutboxStatus, }; +use crate::utils::db::with_transaction; use crate::utils::{email_verification, verification_email_delivery}; use crate::AppState; @@ -25,6 +27,8 @@ const BATCH_SIZE: u64 = 16; const POLL_INTERVAL_ACTIVE: Duration = Duration::from_secs(2); /// outbox が空のときのポーリング間隔(空 SELECT のログ・DB 負荷を抑える) const POLL_INTERVAL_IDLE: Duration = Duration::from_secs(30); +/// processing のまま固まった行を再取得するまでの猶予 +const STALE_PROCESSING: ChronoDuration = ChronoDuration::minutes(10); /// ユーザー作成と同一トランザクション内で呼ぶ。 pub async fn enqueue( @@ -42,6 +46,7 @@ pub async fn enqueue( attempts: Set(0), last_error: Set(None), created_at: Set(Utc::now().into()), + claimed_at: Set(None), sent_at: Set(None), }; OutboxEntity::insert(row).exec(db).await?; @@ -80,14 +85,7 @@ pub async fn run_worker(state: AppState) { /// 処理対象にした outbox 行数(0 ならキューは空) pub async fn process_pending(state: &AppState) -> Result { - let rows = OutboxEntity::find() - .filter(verification_email_outbox::Column::Status.eq(OutboxStatus::Pending)) - .filter(verification_email_outbox::Column::Attempts.lt(MAX_ATTEMPTS)) - .order_by_asc(verification_email_outbox::Column::CreatedAt) - .limit(BATCH_SIZE) - .all(&state.db) - .await?; - + let rows = claim_pending_rows(&state.db).await?; let n = rows.len(); for row in rows { if let Err(e) = process_one(state, row).await { @@ -97,6 +95,55 @@ pub async fn process_pending(state: &AppState) -> Result { Ok(n) } +/// `FOR UPDATE SKIP LOCKED` で行を確保し、同一トランザクション内で processing にする。 +async fn claim_pending_rows( + db: &sea_orm::DatabaseConnection, +) -> Result, anyhow::Error> { + with_transaction(db, |txn| { + Box::pin(async move { + let stale_before: sea_orm::prelude::DateTimeWithTimeZone = + (Utc::now() - STALE_PROCESSING).into(); + + let claimable = Condition::any() + .add(verification_email_outbox::Column::Status.eq(OutboxStatus::Pending)) + .add( + Condition::all() + .add( + verification_email_outbox::Column::Status + .eq(OutboxStatus::Processing), + ) + .add( + Condition::any() + .add( + verification_email_outbox::Column::ClaimedAt.lt(stale_before), + ) + .add(verification_email_outbox::Column::ClaimedAt.is_null()), + ), + ); + + let rows = OutboxEntity::find() + .filter(claimable) + .filter(verification_email_outbox::Column::Attempts.lt(MAX_ATTEMPTS)) + .order_by_asc(verification_email_outbox::Column::CreatedAt) + .limit(BATCH_SIZE) + .lock_with_behavior(LockType::Update, LockBehavior::SkipLocked) + .all(txn) + .await?; + + let now: sea_orm::prelude::DateTimeWithTimeZone = Utc::now().into(); + let mut claimed = Vec::with_capacity(rows.len()); + for row in rows { + let mut active: verification_email_outbox::ActiveModel = row.into(); + active.status = Set(OutboxStatus::Processing); + active.claimed_at = Set(Some(now)); + claimed.push(active.update(txn).await?); + } + Ok(claimed) + }) + }) + .await +} + async fn process_one(state: &AppState, row: OutboxModel) -> Result<(), anyhow::Error> { let Some(token) = row.token.clone() else { mark_failed(&state.db, row.id, "missing token").await?; @@ -120,6 +167,7 @@ async fn process_one(state: &AppState, row: OutboxModel) -> Result<(), anyhow::E let mut active: verification_email_outbox::ActiveModel = row.into(); active.status = Set(OutboxStatus::Sent); active.token = Set(None); + active.claimed_at = Set(None); active.sent_at = Set(Some(Utc::now().into())); active.last_error = Set(None); active.update(&state.db).await?; @@ -129,9 +177,12 @@ async fn process_one(state: &AppState, row: OutboxModel) -> Result<(), anyhow::E let mut active: verification_email_outbox::ActiveModel = row.into(); active.attempts = Set(attempts); active.last_error = Set(Some(e.to_string())); + active.claimed_at = Set(None); if attempts >= MAX_ATTEMPTS { active.status = Set(OutboxStatus::Failed); active.token = Set(None); + } else { + active.status = Set(OutboxStatus::Pending); } active.update(&state.db).await?; } @@ -152,6 +203,7 @@ async fn mark_failed( let mut active: verification_email_outbox::ActiveModel = row.into(); active.status = Set(OutboxStatus::Failed); active.token = Set(None); + active.claimed_at = Set(None); active.last_error = Set(Some(reason.to_string())); active.update(db).await?; Ok(()) From 3c682227d79c4daf109e104b06eb78561f238329 Mon Sep 17 00:00:00 2001 From: yupix Date: Thu, 21 May 2026 00:36:18 +0000 Subject: [PATCH 09/23] refactor(backend): streamline email worker processing logic and improve idle state handling --- .../src/utils/verification_email_outbox.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/backend/src/utils/verification_email_outbox.rs b/apps/backend/src/utils/verification_email_outbox.rs index 1161dff..7cdfd19 100644 --- a/apps/backend/src/utils/verification_email_outbox.rs +++ b/apps/backend/src/utils/verification_email_outbox.rs @@ -63,16 +63,11 @@ pub fn wake_worker(state: AppState) { } /// 起動時からポーリングで未送信行を処理する。 +/// +/// 各サイクルは「処理 → 待機」の順。再起動直後の pending を最大30秒待たせない。 pub async fn run_worker(state: AppState) { - let mut idle = true; + let mut idle; loop { - let delay = if idle { - POLL_INTERVAL_IDLE - } else { - POLL_INTERVAL_ACTIVE - }; - tokio::time::sleep(delay).await; - match process_pending(&state).await { Ok(n) => idle = n == 0, Err(e) => { @@ -80,6 +75,13 @@ pub async fn run_worker(state: AppState) { idle = false; } } + + let delay = if idle { + POLL_INTERVAL_IDLE + } else { + POLL_INTERVAL_ACTIVE + }; + tokio::time::sleep(delay).await; } } From 883f35e5dd680c0766ef0ccb4ffea8ce7c27ad04 Mon Sep 17 00:00:00 2001 From: yupix Date: Thu, 21 May 2026 00:57:49 +0000 Subject: [PATCH 10/23] feat(backend): introduce centralized error handling with AppError and ServerError types --- apps/backend/src/error.rs | 52 +++++++++++++++++++++++++++ apps/backend/src/handlers/labels.rs | 10 +++--- apps/backend/src/lib.rs | 1 + apps/backend/src/openapi/mod.rs | 2 +- apps/backend/src/openapi/responses.rs | 2 +- apps/backend/src/utils/auth.rs | 18 ++-------- 6 files changed, 61 insertions(+), 24 deletions(-) create mode 100644 apps/backend/src/error.rs diff --git a/apps/backend/src/error.rs b/apps/backend/src/error.rs new file mode 100644 index 0000000..8ac3e1a --- /dev/null +++ b/apps/backend/src/error.rs @@ -0,0 +1,52 @@ +//! ハンドラ共通の API エラー型。 + +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Serialize; +use thiserror::Error; +use tracing::debug; +use utoipa::ToSchema; + +/// API 共通のエラー応答ボディ。 +#[derive(Serialize, ToSchema)] +pub struct ServerError { + #[schema(example = "internal-error")] + pub message: String, +} + +/// 認証・認可以外の一般ハンドラ向けエラー。 +#[derive(Debug, Error)] +pub enum AppError { + #[error("internal error")] + Internal(#[from] anyhow::Error), +} + +impl From for AppError { + fn from(err: sea_orm::DbErr) -> Self { + AppError::Internal(err.into()) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + match self { + AppError::Internal(e) => { + debug!("app error: {:#?}", e); + internal_server_error().into_response() + } + } + } +} + +/// 500 + `internal-error`(`AuthError` / `AppError` で共用)。 +pub fn internal_server_error() -> (StatusCode, Json) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ServerError { + message: "internal-error".into(), + }), + ) +} diff --git a/apps/backend/src/handlers/labels.rs b/apps/backend/src/handlers/labels.rs index f458b75..f6fa27f 100644 --- a/apps/backend/src/handlers/labels.rs +++ b/apps/backend/src/handlers/labels.rs @@ -1,8 +1,8 @@ use axum::{Json, extract::State}; use sea_orm::EntityTrait; +use crate::error::AppError; use crate::openapi::InternalOnlyError; -use crate::utils::auth::AuthError; use crate::{AppState, entities}; #[axum::debug_handler] @@ -21,10 +21,8 @@ use crate::{AppState, entities}; )] pub async fn get_labels( State(state): State, -) -> Result>, AuthError> { - let labels = entities::labels::Entity::find() - .all(&state.db) - .await - .map_err(AuthError::from)?; +) -> Result>, AppError> { + // DB 障害時は 500 を返す + let labels = entities::labels::Entity::find().all(&state.db).await?; Ok(Json(labels)) } diff --git a/apps/backend/src/lib.rs b/apps/backend/src/lib.rs index 9daecef..1f0d302 100644 --- a/apps/backend/src/lib.rs +++ b/apps/backend/src/lib.rs @@ -5,6 +5,7 @@ use crate::{ use sea_orm::DatabaseConnection; pub mod dto; +pub mod error; pub mod entities; pub mod extractors; pub mod handlers; diff --git a/apps/backend/src/openapi/mod.rs b/apps/backend/src/openapi/mod.rs index 4a8a318..1dd5b9d 100644 --- a/apps/backend/src/openapi/mod.rs +++ b/apps/backend/src/openapi/mod.rs @@ -5,7 +5,7 @@ pub mod responses; use utoipa::openapi::OpenApi; use utoipa::{PartialSchema, ToSchema}; -pub use crate::utils::auth::ServerError; +pub use crate::error::ServerError; pub use responses::{ CredentialErrors, InternalOnlyError, RegisterErrors, ResendVerificationErrors, SessionAuthErrors, UnauthorizedErrors, VerifyEmailErrors, diff --git a/apps/backend/src/openapi/responses.rs b/apps/backend/src/openapi/responses.rs index 0109127..3b8aac6 100644 --- a/apps/backend/src/openapi/responses.rs +++ b/apps/backend/src/openapi/responses.rs @@ -4,7 +4,7 @@ use utoipa::IntoResponses; -use crate::utils::auth::ServerError; +use crate::error::ServerError; #[derive(IntoResponses)] pub enum SessionAuthErrors { diff --git a/apps/backend/src/utils/auth.rs b/apps/backend/src/utils/auth.rs index 7e9a3da..9ba0eea 100644 --- a/apps/backend/src/utils/auth.rs +++ b/apps/backend/src/utils/auth.rs @@ -14,20 +14,12 @@ use axum::{ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use hmac::{Hmac, KeyInit, Mac}; -use serde::Serialize; use sha2::Sha256; use subtle::ConstantTimeEq; use thiserror::Error; use tracing::debug; -use utoipa::ToSchema; -/// API 共通のエラー応答ボディ。 - -#[derive(Serialize, ToSchema)] -pub struct ServerError { - #[schema(example = "invalid-credentials")] - pub message: String, -} +use crate::error::{ServerError, internal_server_error}; #[derive(Debug, Error)] pub enum AuthError { @@ -64,13 +56,7 @@ impl IntoResponse for AuthError { match self { AuthError::Internal(e) => { debug!("auth error: {:#?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ServerError { - message: "internal-error".into(), - }), - ) - .into_response() + internal_server_error().into_response() } AuthError::Unauthorized => ( StatusCode::UNAUTHORIZED, From 1371d4b80e1f723353964976fd8983d60860144e Mon Sep 17 00:00:00 2001 From: yupix Date: Thu, 21 May 2026 00:58:13 +0000 Subject: [PATCH 11/23] =?UTF-8?q?feat(backend):=20=E3=82=BF=E3=82=A4?= =?UTF-8?q?=E3=83=9F=E3=83=B3=E3=82=B0=E6=94=BB=E6=92=83=E3=81=AE=E5=AF=BE?= =?UTF-8?q?=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/handlers/auth.rs | 17 +++++++++++++---- apps/backend/src/utils/auth.rs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/handlers/auth.rs b/apps/backend/src/handlers/auth.rs index ead0522..46b72df 100644 --- a/apps/backend/src/handlers/auth.rs +++ b/apps/backend/src/handlers/auth.rs @@ -15,7 +15,8 @@ use crate::openapi::{ UnauthorizedErrors, VerifyEmailErrors, }; use crate::utils::auth::{ - AuthError, create_password_hash, generate_email_verification_token, verify_password, + AuthError, DUMMY_PASSWORD_HASH, create_password_hash, generate_email_verification_token, + verify_password, }; use crate::utils::db::{is_postgres_unique_violation, with_transaction}; use crate::utils::{email_verification, verification_email_outbox}; @@ -52,11 +53,19 @@ pub async fn login( let user = users::Entity::find() .filter(users::Column::Email.eq(email)) .one(&state.db) - .await? - .ok_or(AuthError::InvalidCredentials)?; - if !verify_password(&password, &user.password_hash)? { + .await?; + + let password_hash = user + .as_ref() + .map(|u| u.password_hash.as_str()) + .unwrap_or(DUMMY_PASSWORD_HASH); + + if !verify_password(&password, password_hash)? { return Err(AuthError::InvalidCredentials); } + + let user = user.ok_or(AuthError::InvalidCredentials)?; + if !user.email_verified { return Err(AuthError::EmailNotVerified); } diff --git a/apps/backend/src/utils/auth.rs b/apps/backend/src/utils/auth.rs index 9ba0eea..2f56bda 100644 --- a/apps/backend/src/utils/auth.rs +++ b/apps/backend/src/utils/auth.rs @@ -165,6 +165,11 @@ pub fn create_password_hash(password: &str) -> Result { Ok(hash.to_string()) } +/// 存在しないユーザー向けのダミーハッシュ。ログイン時に常に Argon2 検証を走らせ、 +/// メールアドレスの有無による応答時間差(タイミング攻撃)を抑える。 +pub const DUMMY_PASSWORD_HASH: &str = + "$argon2id$v=19$m=131072,t=3,p=2$0UUArODQDWduujvFlpWtKg$GDp6SlCwV4PIue/EfTr+nJVjlFnycyxtCfnJMnjlIjU"; + pub fn verify_password(password: &str, password_hash: &str) -> Result { let parsed_hash = PasswordHash::new(password_hash) .map_err(|e| AuthError::Internal(anyhow::anyhow!("invalid password hash: {e}")))?; @@ -225,3 +230,14 @@ pub fn verify_personal_token(token: &str, stored_hash: &str) -> Result Date: Thu, 21 May 2026 02:19:38 +0000 Subject: [PATCH 12/23] feat(backend): implement verification email job processing with Apalis and PostgreSQL --- apps/backend/.env.example | 3 + apps/backend/Cargo.lock | 178 +++++++++++++++ apps/backend/Cargo.toml | 4 + apps/backend/src/entities/mod.rs | 1 - .../src/entities/verification_email_outbox.rs | 59 ----- apps/backend/src/handlers/auth.rs | 36 +-- apps/backend/src/jobs/mod.rs | 24 ++ apps/backend/src/jobs/verification_email.rs | 79 +++++++ apps/backend/src/lib.rs | 9 + apps/backend/src/main.rs | 7 + apps/backend/src/server.rs | 134 +++++++++-- apps/backend/src/settings.rs | 8 + apps/backend/src/utils/mod.rs | 1 - .../src/utils/verification_email_outbox.rs | 212 ------------------ 14 files changed, 445 insertions(+), 310 deletions(-) delete mode 100644 apps/backend/src/entities/verification_email_outbox.rs create mode 100644 apps/backend/src/jobs/mod.rs create mode 100644 apps/backend/src/jobs/verification_email.rs delete mode 100644 apps/backend/src/utils/verification_email_outbox.rs diff --git a/apps/backend/.env.example b/apps/backend/.env.example index a600d48..453c333 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -10,3 +10,6 @@ smtp_password=your_smtp_password smtp_from=no-reply@example.com # 必須。認証メールのリンク先(本番では実際のフロント URL を指定) email_verification_app_url=http://localhost:3000 +# 認証メール Apalis ワーカー並列度(本番では 2 以上を検討) +# verification_email_worker_concurrency=2 +# ジョブ管理 UI: http://localhost:3400/ (/jobs は / へリダイレクト。本番はネットワーク制限を推奨) diff --git a/apps/backend/Cargo.lock b/apps/backend/Cargo.lock index 212e375..538efd6 100644 --- a/apps/backend/Cargo.lock +++ b/apps/backend/Cargo.lock @@ -261,6 +261,120 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "apalis" +version = "1.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7780d1e7082500a4fdb463b0a6fc1c00e4012cd9b2af101c26fcabbb2f390f2c" +dependencies = [ + "apalis-core", + "futures-util", + "pin-project", + "thiserror 2.0.18", + "tower", + "tracing", +] + +[[package]] +name = "apalis-board" +version = "1.0.0-rc.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b220330c3885c58fcc88129feeecd510b83b486371c4ccac8e3aef85b44d763b" +dependencies = [ + "apalis-board-api", +] + +[[package]] +name = "apalis-board-api" +version = "1.0.0-rc.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873de5584baefd4502afd3c09ff597956f3048a2d87eabede67b60b7c1e97306" +dependencies = [ + "apalis-board-types", + "apalis-core", + "axum", + "futures", + "include_dir", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "apalis-board-types" +version = "1.0.0-rc.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6864e55559e5f817aa02b25cc0123ac6946e32aacac740d9899bb0db0f61293a" +dependencies = [ + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "apalis-codec" +version = "0.1.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c506e7f00c7c9c38eeb02290b3ec6328695f0614a257faefaeb8e8286746a665" +dependencies = [ + "apalis-core", + "serde", + "serde_json", +] + +[[package]] +name = "apalis-core" +version = "1.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797af42a40f6bc297365f2fed187b74d089c63641f57ce2a5e0f629db560cb47" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "futures-timer", + "futures-util", + "pin-project", + "serde", + "thiserror 2.0.18", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "apalis-postgres" +version = "1.0.0-rc.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "270ece456602fa1f80cf6205a67cafde642c766a3f1bb89bd83b7af104a9c74e" +dependencies = [ + "apalis-codec", + "apalis-core", + "apalis-sql", + "futures", + "pin-project", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "ulid", +] + +[[package]] +name = "apalis-sql" +version = "1.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b555912820da093b004a055105af258df116edcea761db6b759d01aabac2ec" +dependencies = [ + "apalis-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", +] + [[package]] name = "arcstr" version = "1.2.0" @@ -637,6 +751,9 @@ name = "backend" version = "0.1.0" dependencies = [ "anyhow", + "apalis", + "apalis-board", + "apalis-postgres", "argon2", "axum", "axum-valid", @@ -656,6 +773,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "sqlx", "strum", "strum_macros", "subtle", @@ -1557,6 +1675,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.32" @@ -2066,6 +2190,25 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -4677,6 +4820,7 @@ dependencies = [ "pin-project-lite", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -4756,6 +4900,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -4766,12 +4920,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -4798,6 +4955,17 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "serde", + "web-time", +] + [[package]] name = "uname" version = "0.1.1" @@ -5170,6 +5338,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-root-certs" version = "1.0.7" diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index 438fc3c..71d969d 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -48,3 +48,7 @@ subtle = "2.4" strum = { version = "0.28.0", features = ["derive"] } strum_macros = "0.28" lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport","tokio1", "tokio1-rustls-tls"] } +apalis = { version = "1.0.0-rc.9", features = ["limit", "retry", "tracing"] } +apalis-postgres = { version = "1.0.0-rc.8", default-features = false, features = ["migrate", "tokio-comp", "time"] } +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "time"] } +apalis-board = { version = "1.0.0-rc.8", features = ["axum", "ui", "events"] } diff --git a/apps/backend/src/entities/mod.rs b/apps/backend/src/entities/mod.rs index 288150f..577a8df 100644 --- a/apps/backend/src/entities/mod.rs +++ b/apps/backend/src/entities/mod.rs @@ -3,4 +3,3 @@ pub mod personal_tokens; pub mod scopes; pub mod tenants; pub mod users; -pub mod verification_email_outbox; diff --git a/apps/backend/src/entities/verification_email_outbox.rs b/apps/backend/src/entities/verification_email_outbox.rs deleted file mode 100644 index fa547ab..0000000 --- a/apps/backend/src/entities/verification_email_outbox.rs +++ /dev/null @@ -1,59 +0,0 @@ -use sea_orm::entity::prelude::*; - -#[derive(Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum)] -#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(20))")] -pub enum OutboxStatus { - #[sea_orm(string_value = "pending")] - Pending, - /// ワーカーが送信中(他ワーカーとの二重処理防止) - #[sea_orm(string_value = "processing")] - Processing, - #[sea_orm(string_value = "sent")] - Sent, - #[sea_orm(string_value = "failed")] - Failed, -} - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] -#[sea_orm(table_name = "verification_email_outbox")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - #[sea_orm(indexed)] - pub user_id: Uuid, - pub email: String, - /// 送信完了後はクリアする(平文トークンを永続保持しない) - #[sea_orm(nullable)] - pub token: Option, - #[sea_orm(indexed)] - pub status: OutboxStatus, - pub attempts: i32, - #[sea_orm(nullable)] - pub last_error: Option, - pub created_at: DateTimeWithTimeZone, - /// ワーカーが processing にした時刻(クラッシュ後の再取得用) - #[sea_orm(nullable)] - pub claimed_at: Option, - #[sea_orm(nullable)] - pub sent_at: Option, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::users::Entity", - from = "Column::UserId", - to = "super::users::Column::Id", - on_update = "NoAction", - on_delete = "Cascade" - )] - Users, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Users.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/backend/src/handlers/auth.rs b/apps/backend/src/handlers/auth.rs index 46b72df..e456fe6 100644 --- a/apps/backend/src/handlers/auth.rs +++ b/apps/backend/src/handlers/auth.rs @@ -18,8 +18,10 @@ use crate::utils::auth::{ AuthError, DUMMY_PASSWORD_HASH, create_password_hash, generate_email_verification_token, verify_password, }; +use crate::jobs::VerificationEmailJob; +use crate::jobs::verification_email; use crate::utils::db::{is_postgres_unique_violation, with_transaction}; -use crate::utils::{email_verification, verification_email_outbox}; +use crate::utils::email_verification; use crate::{AppState, entities::users}; #[derive(Validate, Debug, Deserialize, utoipa::ToSchema)] @@ -139,18 +141,21 @@ pub async fn register( } })?; - verification_email_outbox::enqueue(txn, user_id, email.clone(), verification_token) - .await - .map_err(|e| { - AuthError::Internal(anyhow::anyhow!("enqueue verification email: {e}")) - })?; - Ok(()) }) }) .await?; - verification_email_outbox::wake_worker(state); + verification_email::enqueue( + state.verification_email_storage.as_ref(), + VerificationEmailJob { + user_id, + email: email.clone(), + token: verification_token, + }, + ) + .await + .map_err(|e| AuthError::Internal(anyhow::anyhow!("enqueue verification email: {e}")))?; Ok(( StatusCode::CREATED, Json("Register successful".to_string()), @@ -252,11 +257,16 @@ pub async fn resend_verification_email( } let token = generate_email_verification_token(); - verification_email_outbox::enqueue(&state.db, user.id, email.clone(), token) - .await - .map_err(|e| AuthError::Internal(anyhow::anyhow!("enqueue verification email: {e}")))?; - - verification_email_outbox::wake_worker(state); + verification_email::enqueue( + state.verification_email_storage.as_ref(), + VerificationEmailJob { + user_id: user.id, + email: email.clone(), + token, + }, + ) + .await + .map_err(|e| AuthError::Internal(anyhow::anyhow!("enqueue verification email: {e}")))?; Ok(Json(format!( "確認メールを再送しました(同一メールアドレスへの再送は{}秒に1回までです)。", diff --git a/apps/backend/src/jobs/mod.rs b/apps/backend/src/jobs/mod.rs new file mode 100644 index 0000000..fc0b36f --- /dev/null +++ b/apps/backend/src/jobs/mod.rs @@ -0,0 +1,24 @@ +//! Apalis バックグラウンドジョブ + +pub mod verification_email; + +use std::sync::Arc; + +use apalis_postgres::PgPool; + +use crate::settings::Settings; + +pub use verification_email::{ + VerificationEmailJob, VerificationEmailStorage, QUEUE_NAME, MAX_RETRIES, +}; + +pub async fn setup_pool(database_url: &str) -> Result { + PgPool::connect(database_url).await +} + +pub async fn setup_verification_email_storage( + pool: &PgPool, + settings: &Settings, +) -> Result, sqlx::Error> { + verification_email::setup(pool, settings).await +} diff --git a/apps/backend/src/jobs/verification_email.rs b/apps/backend/src/jobs/verification_email.rs new file mode 100644 index 0000000..c9c8632 --- /dev/null +++ b/apps/backend/src/jobs/verification_email.rs @@ -0,0 +1,79 @@ +//! 認証メール送信ジョブ(Apalis + PostgreSQL) + +use std::sync::Arc; +use std::time::Duration; + +use apalis::prelude::{ + BackoffConfig, BoxDynError, Data, IntervalStrategy, StrategyBuilder, TaskSink, +}; +use apalis_postgres::{Config, JsonCodec, PostgresStorage, PgPool}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::utils::{email_verification, verification_email_delivery}; +use crate::{AppState, settings::Settings}; + +pub const QUEUE_NAME: &str = "verification_email"; +pub const MAX_RETRIES: usize = 8; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerificationEmailJob { + pub user_id: Uuid, + pub email: String, + pub token: String, +} + +pub type VerificationEmailStorage = PostgresStorage< + VerificationEmailJob, + apalis_postgres::CompactType, + JsonCodec, + apalis_postgres::PgNotify, +>; + +pub fn build_storage(pool: &PgPool, _settings: &Settings) -> VerificationEmailStorage { + let config = Config::new(QUEUE_NAME).with_poll_interval( + StrategyBuilder::new() + .apply( + IntervalStrategy::new(Duration::from_secs(2)) + .with_backoff(BackoffConfig::default()), + ) + .build(), + ); + PostgresStorage::new_with_notify(pool, &config) +} + +pub async fn setup( + pool: &PgPool, + settings: &Settings, +) -> Result, sqlx::Error> { + PostgresStorage::setup(pool).await?; + Ok(Arc::new(build_storage(pool, settings))) +} + +pub async fn enqueue( + storage: &VerificationEmailStorage, + job: VerificationEmailJob, +) -> Result<(), anyhow::Error> { + let mut storage = storage.clone(); + storage + .push(job) + .await + .map_err(|e| anyhow::anyhow!("push verification email job: {e}"))?; + Ok(()) +} + +pub async fn process(job: VerificationEmailJob, state: Data) -> Result<(), BoxDynError> { + email_verification::store_token(&state.redis_client, job.user_id, &job.token).await?; + verification_email_delivery::send_verification_email( + &state.smtp_client, + &job.email, + &state.settings, + &job.token, + ) + .await?; + Ok(()) +} + +pub fn worker_concurrency(settings: &Settings) -> usize { + settings.verification_email_worker_concurrency.max(1) +} diff --git a/apps/backend/src/lib.rs b/apps/backend/src/lib.rs index 1f0d302..23077b5 100644 --- a/apps/backend/src/lib.rs +++ b/apps/backend/src/lib.rs @@ -7,6 +7,7 @@ use sea_orm::DatabaseConnection; pub mod dto; pub mod error; pub mod entities; +pub mod jobs; pub mod extractors; pub mod handlers; pub mod openapi; @@ -16,10 +17,18 @@ pub mod settings; pub mod utils; pub mod middlewares; +use std::sync::Arc; + +use apalis_postgres::PgPool; + +use crate::jobs::VerificationEmailStorage; + #[derive(Clone)] pub struct AppState { pub settings: Settings, pub db: DatabaseConnection, + pub pg_pool: PgPool, pub redis_client: RedisConnection, pub smtp_client: SmtpClient, + pub verification_email_storage: Arc, } diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index 0061c3b..8de1ca3 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -33,11 +33,18 @@ async fn main() -> Result<(), Box> { })?; let redis_client = backend::utils::redis::RedisConnection::new(&settings.redis_url); redis_client.ping().await?; + + let pg_pool = backend::jobs::setup_pool(&settings.database_url).await?; + let verification_email_storage = + backend::jobs::setup_verification_email_storage(&pg_pool, &settings).await?; + let state = AppState { settings, db, + pg_pool, redis_client, smtp_client, + verification_email_storage, }; run(state).await?; diff --git a/apps/backend/src/server.rs b/apps/backend/src/server.rs index 538f241..d58cc0f 100644 --- a/apps/backend/src/server.rs +++ b/apps/backend/src/server.rs @@ -1,35 +1,57 @@ + +use apalis::layers::retry::RetryPolicy; +use apalis::layers::WorkerBuilderExt; +use apalis::prelude::WorkerBuilder; +use apalis_board::axum::{ + framework::{ApiBuilder, RegisterRoute}, + sse::{TracingBroadcaster, TracingSubscriber}, + ui::ServeUI, +}; use axum::{ - body::Body, http::{HeaderValue, Method, Request}, middleware, routing::get + Extension, Router, + body::Body, + http::{HeaderValue, Method, Request}, + middleware, + response::Redirect, + routing::get, }; use axum_session::{SameSite, SessionConfig, SessionLayer, SessionStore}; use axum_session_redispool::SessionRedisPool; use sentry::integrations::tower::NewSentryLayer; +use tokio::sync::watch; use tower::ServiceBuilder; use tower_http::cors::{AllowHeaders, CorsLayer}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use tracing::{info, warn}; +use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt}; use utoipa_scalar::{Scalar, Servable}; use crate::{ - AppState, middlewares::logging::logging_middleware, - utils::verification_email_outbox, + AppState, + jobs::verification_email::{self, MAX_RETRIES, QUEUE_NAME}, + middlewares::logging::logging_middleware, }; - - pub async fn run(state: AppState) -> Result<(), Box> { - tracing_subscriber::registry() + let broadcaster = TracingBroadcaster::create(); + let board_tracing = TracingSubscriber::new(&broadcaster).layer().with_filter( + tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info,sqlx=warn".into()), + ), + ); + + tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new( - std::env::var("RUST_LOG") - .unwrap_or_else(|_| "info,sqlx=warn".into()), + std::env::var("RUST_LOG").unwrap_or_else(|_| "info,sqlx=warn".into()), )) .with(tracing_subscriber::fmt::layer()) + .with(board_tracing) .init(); let is_prod = std::env::var("RUST_ENV").unwrap_or_default() == "production"; let settings = &state.settings; let session_config = SessionConfig::default() - .with_secure(is_prod) // 本番では secure=true にする + .with_secure(is_prod) .with_cookie_same_site(if is_prod { SameSite::None } else { @@ -44,47 +66,111 @@ pub async fn run(state: AppState) -> Result<(), Box> { .unwrap(); let (router, mut openapi) = utoipa_axum::router::OpenApiRouter::new() - .route("/", get(|| async { "Hello, world!" })) .merge(crate::routes::create_routes()) .split_for_parts(); crate::openapi::register_schemas(&mut openapi); - // Allow credentials and mirror the request origin/headers so we don't send - // wildcard `*` which is disallowed when `Access-Control-Allow-Credentials` is true. let cors = CorsLayer::new() .allow_origin(settings.allow_origin.parse::()?) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) .allow_headers(AllowHeaders::mirror_request()) .allow_credentials(true); + let email_storage = state.verification_email_storage.as_ref().clone(); + let board_api = ApiBuilder::new(Router::new()) + .register(email_storage) + .build(); + + let email_worker_storage = state.verification_email_storage.as_ref().clone(); let worker_state = state.clone(); - tokio::spawn(async move { - verification_email_outbox::run_worker(worker_state).await; + let worker_concurrency = verification_email::worker_concurrency(settings); + let email_worker = WorkerBuilder::new(format!("{QUEUE_NAME}-worker")) + .backend(email_worker_storage) + .retry(RetryPolicy::retries(MAX_RETRIES)) + .enable_tracing() + .concurrency(worker_concurrency) + .data(worker_state) + .build(verification_email::process); + + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let worker_shutdown = shutdown_rx.clone(); + let worker_handle = tokio::spawn(async move { + email_worker + .run_until(wait_for_shutdown(worker_shutdown)) + .await }); - let app = router + let api = router .merge(Scalar::with_url("/scalar", openapi.clone())) .with_state(state) .layer(cors) .layer(middleware::from_fn(logging_middleware)) .layer(SessionLayer::new(session_store)) - .layer(ServiceBuilder::new().layer(NewSentryLayer::>::new_from_top())); // Bind a new Hub per request, to ensure correct error <> request correlation + .layer(ServiceBuilder::new().layer(NewSentryLayer::>::new_from_top())); + + // apalis-board の UI はビルド時に API=/api/v1・静的ファイル=/ 直下を前提とする。 + // /jobs にネストすると JS/WASM が 404 になり真っ白になる。 + let app = Router::new() + .merge(api) + .nest("/api/v1", board_api) + .route("/jobs", get(|| async { Redirect::permanent("/") })) + .route("/jobs/", get(|| async { Redirect::permanent("/") })) + .layer(Extension(broadcaster)) + .fallback_service(ServeUI::new()); let addr = "0.0.0.0:3400"; let listener = tokio::net::TcpListener::bind(addr).await?; - println!("Listening on http://{addr}"); + info!("Listening on http://{addr}"); + info!("Apalis board: http://{addr}/"); axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) + .with_graceful_shutdown(async move { + shutdown_signal_inner().await; + let _ = shutdown_tx.send(true); + info!("shutting down HTTP server; Apalis workers finishing in-flight jobs"); + }) .await?; + match worker_handle.await { + Ok(Ok(())) => info!("verification email worker stopped"), + Ok(Err(e)) => warn!("verification email worker error: {e}"), + Err(e) => warn!("verification email worker join error: {e}"), + } + + Ok(()) +} + +async fn wait_for_shutdown(mut shutdown: watch::Receiver) -> Result<(), std::io::Error> { + while !*shutdown.borrow() { + if shutdown.changed().await.is_err() { + break; + } + } Ok(()) } -async fn shutdown_signal() { - tokio::signal::ctrl_c() - .await - .expect("failed to install Ctrl+C handler"); - println!("Shutting down..."); +async fn shutdown_signal_inner() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + use tokio::signal::unix::{SignalKind, signal}; + + let mut sigterm = + signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); + sigterm.recv().await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + () = ctrl_c => warn!("received Ctrl+C"), + () = terminate => warn!("received SIGTERM"), + } } diff --git a/apps/backend/src/settings.rs b/apps/backend/src/settings.rs index ab88187..737dc20 100644 --- a/apps/backend/src/settings.rs +++ b/apps/backend/src/settings.rs @@ -22,6 +22,13 @@ pub struct Settings { message = "email_verification_app_url must be a valid http or https base URL" ))] pub email_verification_app_url: String, + /// 認証メール Apalis ワーカーの並列度 + #[serde(default = "default_verification_email_worker_concurrency")] + pub verification_email_worker_concurrency: usize, +} + +fn default_verification_email_worker_concurrency() -> usize { + 1 } fn default_allow_origin() -> String { @@ -99,6 +106,7 @@ mod tests { smtp_password: String::new(), smtp_from: String::new(), email_verification_app_url: url.to_string(), + verification_email_worker_concurrency: 1, } .validate() .is_ok() diff --git a/apps/backend/src/utils/mod.rs b/apps/backend/src/utils/mod.rs index 7d84a2a..344dfbb 100644 --- a/apps/backend/src/utils/mod.rs +++ b/apps/backend/src/utils/mod.rs @@ -4,4 +4,3 @@ pub mod email_verification; pub mod redis; pub mod smtp; pub mod verification_email_delivery; -pub mod verification_email_outbox; diff --git a/apps/backend/src/utils/verification_email_outbox.rs b/apps/backend/src/utils/verification_email_outbox.rs deleted file mode 100644 index 7cdfd19..0000000 --- a/apps/backend/src/utils/verification_email_outbox.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! 認証メール送信の transactional outbox。 -//! -//! 登録・再送は DB に outbox 行を書き込むだけにし、Redis トークン保存と SMTP は -//! バックグラウンドワーカーが再試行可能な形で実行する。 - -use std::time::Duration; - -use chrono::{Duration as ChronoDuration, Utc}; -use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, ConnectionTrait, EntityTrait, - QueryFilter, QueryOrder, QuerySelect, -}; -use sea_orm::sea_query::{LockBehavior, LockType}; -use tracing::warn; -use uuid::Uuid; - -use crate::entities::verification_email_outbox::{ - self, Entity as OutboxEntity, Model as OutboxModel, OutboxStatus, -}; -use crate::utils::db::with_transaction; -use crate::utils::{email_verification, verification_email_delivery}; -use crate::AppState; - -pub const MAX_ATTEMPTS: i32 = 8; -const BATCH_SIZE: u64 = 16; -/// 未処理行があるときのポーリング間隔 -const POLL_INTERVAL_ACTIVE: Duration = Duration::from_secs(2); -/// outbox が空のときのポーリング間隔(空 SELECT のログ・DB 負荷を抑える) -const POLL_INTERVAL_IDLE: Duration = Duration::from_secs(30); -/// processing のまま固まった行を再取得するまでの猶予 -const STALE_PROCESSING: ChronoDuration = ChronoDuration::minutes(10); - -/// ユーザー作成と同一トランザクション内で呼ぶ。 -pub async fn enqueue( - db: &C, - user_id: Uuid, - email: String, - token: String, -) -> Result<(), sea_orm::DbErr> { - let row = verification_email_outbox::ActiveModel { - id: Set(Uuid::new_v4()), - user_id: Set(user_id), - email: Set(email), - token: Set(Some(token)), - status: Set(OutboxStatus::Pending), - attempts: Set(0), - last_error: Set(None), - created_at: Set(Utc::now().into()), - claimed_at: Set(None), - sent_at: Set(None), - }; - OutboxEntity::insert(row).exec(db).await?; - Ok(()) -} - -/// 直近で enqueue した分をできるだけ早く処理する。 -pub fn wake_worker(state: AppState) { - tokio::spawn(async move { - if let Err(e) = process_pending(&state).await { - warn!("verification email outbox wake failed: {e:#}"); - } - }); -} - -/// 起動時からポーリングで未送信行を処理する。 -/// -/// 各サイクルは「処理 → 待機」の順。再起動直後の pending を最大30秒待たせない。 -pub async fn run_worker(state: AppState) { - let mut idle; - loop { - match process_pending(&state).await { - Ok(n) => idle = n == 0, - Err(e) => { - warn!("verification email outbox poll failed: {e:#}"); - idle = false; - } - } - - let delay = if idle { - POLL_INTERVAL_IDLE - } else { - POLL_INTERVAL_ACTIVE - }; - tokio::time::sleep(delay).await; - } -} - -/// 処理対象にした outbox 行数(0 ならキューは空) -pub async fn process_pending(state: &AppState) -> Result { - let rows = claim_pending_rows(&state.db).await?; - let n = rows.len(); - for row in rows { - if let Err(e) = process_one(state, row).await { - warn!("verification email outbox item failed: {e:#}"); - } - } - Ok(n) -} - -/// `FOR UPDATE SKIP LOCKED` で行を確保し、同一トランザクション内で processing にする。 -async fn claim_pending_rows( - db: &sea_orm::DatabaseConnection, -) -> Result, anyhow::Error> { - with_transaction(db, |txn| { - Box::pin(async move { - let stale_before: sea_orm::prelude::DateTimeWithTimeZone = - (Utc::now() - STALE_PROCESSING).into(); - - let claimable = Condition::any() - .add(verification_email_outbox::Column::Status.eq(OutboxStatus::Pending)) - .add( - Condition::all() - .add( - verification_email_outbox::Column::Status - .eq(OutboxStatus::Processing), - ) - .add( - Condition::any() - .add( - verification_email_outbox::Column::ClaimedAt.lt(stale_before), - ) - .add(verification_email_outbox::Column::ClaimedAt.is_null()), - ), - ); - - let rows = OutboxEntity::find() - .filter(claimable) - .filter(verification_email_outbox::Column::Attempts.lt(MAX_ATTEMPTS)) - .order_by_asc(verification_email_outbox::Column::CreatedAt) - .limit(BATCH_SIZE) - .lock_with_behavior(LockType::Update, LockBehavior::SkipLocked) - .all(txn) - .await?; - - let now: sea_orm::prelude::DateTimeWithTimeZone = Utc::now().into(); - let mut claimed = Vec::with_capacity(rows.len()); - for row in rows { - let mut active: verification_email_outbox::ActiveModel = row.into(); - active.status = Set(OutboxStatus::Processing); - active.claimed_at = Set(Some(now)); - claimed.push(active.update(txn).await?); - } - Ok(claimed) - }) - }) - .await -} - -async fn process_one(state: &AppState, row: OutboxModel) -> Result<(), anyhow::Error> { - let Some(token) = row.token.clone() else { - mark_failed(&state.db, row.id, "missing token").await?; - return Ok(()); - }; - - let delivery = async { - email_verification::store_token(&state.redis_client, row.user_id, &token).await?; - verification_email_delivery::send_verification_email( - &state.smtp_client, - &row.email, - &state.settings, - &token, - ) - .await - } - .await; - - match delivery { - Ok(()) => { - let mut active: verification_email_outbox::ActiveModel = row.into(); - active.status = Set(OutboxStatus::Sent); - active.token = Set(None); - active.claimed_at = Set(None); - active.sent_at = Set(Some(Utc::now().into())); - active.last_error = Set(None); - active.update(&state.db).await?; - } - Err(e) => { - let attempts = row.attempts + 1; - let mut active: verification_email_outbox::ActiveModel = row.into(); - active.attempts = Set(attempts); - active.last_error = Set(Some(e.to_string())); - active.claimed_at = Set(None); - if attempts >= MAX_ATTEMPTS { - active.status = Set(OutboxStatus::Failed); - active.token = Set(None); - } else { - active.status = Set(OutboxStatus::Pending); - } - active.update(&state.db).await?; - } - } - - Ok(()) -} - -async fn mark_failed( - db: &sea_orm::DatabaseConnection, - id: Uuid, - reason: &str, -) -> Result<(), anyhow::Error> { - let row = OutboxEntity::find_by_id(id) - .one(db) - .await? - .ok_or_else(|| anyhow::anyhow!("outbox row {id} not found"))?; - let mut active: verification_email_outbox::ActiveModel = row.into(); - active.status = Set(OutboxStatus::Failed); - active.token = Set(None); - active.claimed_at = Set(None); - active.last_error = Set(Some(reason.to_string())); - active.update(db).await?; - Ok(()) -} From 8c3837cfc5f59e4c74316fdb2a3c684a3fbb30c1 Mon Sep 17 00:00:00 2001 From: yupix Date: Thu, 21 May 2026 02:20:10 +0000 Subject: [PATCH 13/23] feat(backend): enhance personal_tokens model with ToSchema and remove unused scopes --- apps/backend/src/entities/personal_tokens.rs | 26 +++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/entities/personal_tokens.rs b/apps/backend/src/entities/personal_tokens.rs index a8aaada..c183efb 100644 --- a/apps/backend/src/entities/personal_tokens.rs +++ b/apps/backend/src/entities/personal_tokens.rs @@ -1,31 +1,33 @@ -use crate::entities::scopes::ScopeList; use sea_orm::entity::prelude::*; +use utoipa::ToSchema; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +use crate::entities::scopes::Scope; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, ToSchema)] #[sea_orm(table_name = "personal_tokens")] +#[schema(as=crate::entities::personal_tokens::Model)] pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] + #[sea_orm(primary_key, auto_increment = false)] // auto_incrementを無効にする + #[schema(value_type = String, format="uuid")] // OpenAPIでUUIDとして扱うための属性 pub id: Uuid, pub name: String, pub token_last_four: String, #[sea_orm(indexed)] + #[schema(ignore)] + #[serde(skip_serializing)] pub token_hash: String, + #[schema(value_type = String, format="date-time", nullable)] pub expires_at: Option, + #[schema(value_type = String, format="date-time", nullable)] pub last_used_at: Option, pub revoked: bool, + #[schema(value_type = String, format="uuid")] pub user_id: Uuid, - pub scopes: ScopeList, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { - #[sea_orm( - belongs_to = "super::users::Entity", - from = "Column::UserId", - to = "super::users::Column::Id", - on_update = "NoAction", - on_delete = "Cascade" - )] + #[sea_orm(belongs_to = "super::users::Entity", from = "Column::UserId", to = "super::users::Column::Id", on_update = "NoAction", on_delete = "Cascade")] Users, } @@ -35,4 +37,4 @@ impl Related for Entity { } } -impl ActiveModelBehavior for ActiveModel {} +impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file From 2db4f55928216cb7636e9a2ef460d23bd4438c9f Mon Sep 17 00:00:00 2001 From: yupix Date: Thu, 21 May 2026 02:48:22 +0000 Subject: [PATCH 14/23] feat(backend): implement user registration and email verification flow with associated API requests --- .vscode/extensions.json | 3 +- .../bruno/01-register-verify/01-register.bru | 36 ++++++++++++++++ .../01-register-verify/02-verify-email.bru | 37 ++++++++++++++++ .../03-login-unverified.bru | 27 ++++++++++++ .../04-resend-verification.bru | 30 +++++++++++++ .../bruno/01-register-verify/folder.bru | 9 ++++ apps/backend/bruno/02-login/01-login.bru | 34 +++++++++++++++ apps/backend/bruno/02-login/02-me.bru | 31 ++++++++++++++ apps/backend/bruno/02-login/03-logout.bru | 18 ++++++++ apps/backend/bruno/02-login/folder.bru | 8 ++++ .../backend/bruno/03-apalis/01-list-tasks.bru | 30 +++++++++++++ apps/backend/bruno/03-apalis/folder.bru | 8 ++++ apps/backend/bruno/README.md | 42 +++++++++++++++++++ apps/backend/bruno/bruno.json | 6 +++ apps/backend/bruno/collection.bru | 3 ++ apps/backend/bruno/environments/local.bru | 7 ++++ 16 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 apps/backend/bruno/01-register-verify/01-register.bru create mode 100644 apps/backend/bruno/01-register-verify/02-verify-email.bru create mode 100644 apps/backend/bruno/01-register-verify/03-login-unverified.bru create mode 100644 apps/backend/bruno/01-register-verify/04-resend-verification.bru create mode 100644 apps/backend/bruno/01-register-verify/folder.bru create mode 100644 apps/backend/bruno/02-login/01-login.bru create mode 100644 apps/backend/bruno/02-login/02-me.bru create mode 100644 apps/backend/bruno/02-login/03-logout.bru create mode 100644 apps/backend/bruno/02-login/folder.bru create mode 100644 apps/backend/bruno/03-apalis/01-list-tasks.bru create mode 100644 apps/backend/bruno/03-apalis/folder.bru create mode 100644 apps/backend/bruno/README.md create mode 100644 apps/backend/bruno/bruno.json create mode 100644 apps/backend/bruno/collection.bru create mode 100644 apps/backend/bruno/environments/local.bru diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d815b3b..f571182 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,6 +6,7 @@ "tamasfe.even-better-toml", "bradlc.vscode-tailwindcss", "vue.volar", - "github.vscode-github-actions" + "github.vscode-github-actions", + "bruno-api-client.bruno" ] } diff --git a/apps/backend/bruno/01-register-verify/01-register.bru b/apps/backend/bruno/01-register-verify/01-register.bru new file mode 100644 index 0000000..07ad203 --- /dev/null +++ b/apps/backend/bruno/01-register-verify/01-register.bru @@ -0,0 +1,36 @@ +meta { + name: 1. 新規登録 + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/v1/auth/register + body: json + auth: none +} + +headers { + content-type: application/json +} + +body:json { + { + "username": "{{username}}", + "email": "{{email}}", + "password": "{{password}}" + } +} + +vars:pre-request { + baseUrl: http://localhost:3400 + username: yupix + email: "{{email}}" + password: "{{password}}" +} + +tests { + test("should return 201", function() { + expect(res.status).to.equal(201); + }); +} diff --git a/apps/backend/bruno/01-register-verify/02-verify-email.bru b/apps/backend/bruno/01-register-verify/02-verify-email.bru new file mode 100644 index 0000000..f6aa183 --- /dev/null +++ b/apps/backend/bruno/01-register-verify/02-verify-email.bru @@ -0,0 +1,37 @@ +meta { + name: 2. メールアドレス確認 + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/v1/auth/verify-email + body: json + auth: none +} + +headers { + content-type: application/json +} + +body:json { + { + "token": "{{verificationToken}}" + } +} + +vars:pre-request { + verificationToken: "{{verificationToken}}" + baseUrl: "{{baseUrl}}" +} + +tests { + test("should return 200", function() { + expect(res.status).to.equal(200); + }); +} + +docs { + 事前に Env の verificationToken を設定すること。 + メール例: http://localhost:3000/verify-email?token=XXXX +} diff --git a/apps/backend/bruno/01-register-verify/03-login-unverified.bru b/apps/backend/bruno/01-register-verify/03-login-unverified.bru new file mode 100644 index 0000000..fb9d505 --- /dev/null +++ b/apps/backend/bruno/01-register-verify/03-login-unverified.bru @@ -0,0 +1,27 @@ +meta { + name: 3. (確認)未認証ではログイン不可 + type: http + seq: 3 +} + +post { + url: {{baseUrl}}/v1/auth/login + body: json + auth: none +} + +headers { + content-type: application/json +} + +body:json { + { + "email": "{{email}}", + "password": "{{password}}" + } +} + +docs { + メール確認前に実行すると 403 email-not-verified が返る想定。 + 確認後は 4. ログイン を使う。 +} diff --git a/apps/backend/bruno/01-register-verify/04-resend-verification.bru b/apps/backend/bruno/01-register-verify/04-resend-verification.bru new file mode 100644 index 0000000..9b214fe --- /dev/null +++ b/apps/backend/bruno/01-register-verify/04-resend-verification.bru @@ -0,0 +1,30 @@ +meta { + name: (任意)認証メール再送 + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/v1/auth/resend-verification-email + body: json + auth: none +} + +headers { + content-type: application/json +} + +body:json { + { + "email": "{{email}}" + } +} + +vars:pre-request { + baseUrl: http://localhost:3400 +} + +docs { + 同一メールアドレスは 60 秒に 1 回まで。 +} +​ \ No newline at end of file diff --git a/apps/backend/bruno/01-register-verify/folder.bru b/apps/backend/bruno/01-register-verify/folder.bru new file mode 100644 index 0000000..d6060eb --- /dev/null +++ b/apps/backend/bruno/01-register-verify/folder.bru @@ -0,0 +1,9 @@ +meta { + name: 1. 登録〜メール確認 + seq: 1 +} + +docs { + 登録 → verificationToken 設定 → メール確認まで。 + token は認証メールの verify-email?token=... または Apalis Board (/) から取得。 +} diff --git a/apps/backend/bruno/02-login/01-login.bru b/apps/backend/bruno/02-login/01-login.bru new file mode 100644 index 0000000..f678706 --- /dev/null +++ b/apps/backend/bruno/02-login/01-login.bru @@ -0,0 +1,34 @@ +meta { + name: 4. ログイン + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/v1/auth/login + body: json + auth: none +} + +headers { + content-type: application/json +} + +body:json { + { + "email": "{{email}}", + "password": "{{password}}" + } +} + +vars:pre-request { + baseUrl: "{{baseUrl}}" + email: "{{email}}" +} + +tests { + test("should return 204", function() { + expect(res.status).to.equal(204); + }); +} +​ \ No newline at end of file diff --git a/apps/backend/bruno/02-login/02-me.bru b/apps/backend/bruno/02-login/02-me.bru new file mode 100644 index 0000000..0104d3b --- /dev/null +++ b/apps/backend/bruno/02-login/02-me.bru @@ -0,0 +1,31 @@ +meta { + name: 5. 自分の情報 + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/v1/auth/me + body: none + auth: none +} + + +vars:pre-request { + baseUrl: http://localhost:3400 +} + +tests { + test("should return 200", function() { + expect(res.status).to.equal(200); + }); + + test("email should be verified", function() { + expect(res.body.email_verified).to.equal(true); + }); +} + +docs { + 直前のログインで保存されたセッション Cookie が必要。 +} +​ \ No newline at end of file diff --git a/apps/backend/bruno/02-login/03-logout.bru b/apps/backend/bruno/02-login/03-logout.bru new file mode 100644 index 0000000..9c49c4f --- /dev/null +++ b/apps/backend/bruno/02-login/03-logout.bru @@ -0,0 +1,18 @@ +meta { + name: 6. ログアウト + type: http + seq: 3 +} + +post { + url: {{baseUrl}}/v1/auth/logout + body: none + auth: none +} + +tests { + test("should return 204", function() { + expect(res.status).to.equal(204); + }); +} +​ \ No newline at end of file diff --git a/apps/backend/bruno/02-login/folder.bru b/apps/backend/bruno/02-login/folder.bru new file mode 100644 index 0000000..208ce06 --- /dev/null +++ b/apps/backend/bruno/02-login/folder.bru @@ -0,0 +1,8 @@ +meta { + name: 2. ログイン確認 + seq: 2 +} + +docs { + メール確認後に実行。Cookie セッションが自動保存される(Bruno の Cookie を有効に)。 +} diff --git a/apps/backend/bruno/03-apalis/01-list-tasks.bru b/apps/backend/bruno/03-apalis/01-list-tasks.bru new file mode 100644 index 0000000..450cd3a --- /dev/null +++ b/apps/backend/bruno/03-apalis/01-list-tasks.bru @@ -0,0 +1,30 @@ +meta { + name: 認証メールジョブ一覧 + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/v1/queues/verification_email/tasks?limit=10 + body: none + auth: none +} + +script:post-response { + const tasks = res.body; + if (!Array.isArray(tasks) || tasks.length === 0) { + console.log("ジョブが空です。メールの token を Env に貼るか、再送後に再実行してください。"); + return; + } + const latest = tasks[tasks.length - 1]; + let args = latest.args ?? latest.data; + if (typeof args === "string") { + try { args = JSON.parse(args); } catch (_) {} + } + if (args && args.token) { + bru.setVar("verificationToken", args.token); + console.log("verificationToken を設定しました"); + } else { + console.log("token が見つかりません。Board (/) またはメールを確認してください。"); + } +} diff --git a/apps/backend/bruno/03-apalis/folder.bru b/apps/backend/bruno/03-apalis/folder.bru new file mode 100644 index 0000000..680e48e --- /dev/null +++ b/apps/backend/bruno/03-apalis/folder.bru @@ -0,0 +1,8 @@ +meta { + name: (参考)Apalis Board API + seq: 3 +} + +docs { + ジョブ一覧から token を取る場合。処理が速いと空配列になることがある。 +} diff --git a/apps/backend/bruno/README.md b/apps/backend/bruno/README.md new file mode 100644 index 0000000..4cbc861 --- /dev/null +++ b/apps/backend/bruno/README.md @@ -0,0 +1,42 @@ +# Bruno: 登録〜メール確認 + +バックエンド (`http://localhost:3400`) の認証フローを [Bruno](https://www.usebruno.com/) で試すコレクションです。 + +## 開き方 + +1. Bruno で **Open Collection** を選ぶ +2. このフォルダ `apps/backend/bruno` を指定する +3. 右上の環境で **local** を選択する + +## 実行順(推奨) + +| 順 | リクエスト | 期待 | +|----|-----------|------| +| 1 | `1. 新規登録` | 201 | +| — | `verificationToken` を Env に設定(下記) | | +| 2 | `2. メールアドレス確認` | 200 | +| 3 | `4. ログイン` | 204(Cookie 保存) | +| 4 | `5. 自分の情報` | 200、`email_verified: true` | + +### `verificationToken` の取り方 + +1. 認証メールの `http://localhost:3000/verify-email?token=XXXX` の `XXXX` +2. Apalis Board `http://localhost:3400/` のジョブ引数 +3. `(参考)認証メールジョブ一覧` を実行(取れた場合は post-response で Env に自動設定) + +取れないときは `(任意)認証メール再送` → 上記を繰り返す(60 秒クールダウン)。 + +## 環境変数 (`environments/local.bru`) + +| 変数 | 例 | +|------|-----| +| `baseUrl` | `http://localhost:3400` | +| `email` | 未登録のメールアドレス | +| `password` | 8 文字以上 | +| `username` | 3 文字以上 | +| `verificationToken` | メール等からコピー | + +## 注意 + +- ログインは **Cookie セッション** — Bruno で Cookie を有効にし、同じコレクションで続けて実行する +- 同じ `email` で再テストする場合は未使用アドレスに変えるか DB をリセットする diff --git a/apps/backend/bruno/bruno.json b/apps/backend/bruno/bruno.json new file mode 100644 index 0000000..abfcd59 --- /dev/null +++ b/apps/backend/bruno/bruno.json @@ -0,0 +1,6 @@ +{ + "version": "1", + "name": "Auth: 登録〜メール確認", + "type": "collection", + "ignore": ["node_modules", ".git"] +} diff --git a/apps/backend/bruno/collection.bru b/apps/backend/bruno/collection.bru new file mode 100644 index 0000000..474c95e --- /dev/null +++ b/apps/backend/bruno/collection.bru @@ -0,0 +1,3 @@ +meta { + name: Auth: 登録〜メール確認 +} diff --git a/apps/backend/bruno/environments/local.bru b/apps/backend/bruno/environments/local.bru new file mode 100644 index 0000000..52c0493 --- /dev/null +++ b/apps/backend/bruno/environments/local.bru @@ -0,0 +1,7 @@ +vars { + baseUrl: http://localhost:3400 + email: test-user@example.com + password: password123 + username: testuser + verificationToken: +} From 43e51875121b70d1c5a981204211f7f7ad520f07 Mon Sep 17 00:00:00 2001 From: yupix Date: Thu, 21 May 2026 03:12:59 +0000 Subject: [PATCH 15/23] feat(backend): update personal_tokens model to include ScopeList for enhanced scope management --- apps/backend/src/entities/personal_tokens.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/entities/personal_tokens.rs b/apps/backend/src/entities/personal_tokens.rs index c183efb..3c50c32 100644 --- a/apps/backend/src/entities/personal_tokens.rs +++ b/apps/backend/src/entities/personal_tokens.rs @@ -1,7 +1,7 @@ use sea_orm::entity::prelude::*; use utoipa::ToSchema; -use crate::entities::scopes::Scope; +use crate::entities::scopes::ScopeList; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, ToSchema)] #[sea_orm(table_name = "personal_tokens")] @@ -23,6 +23,7 @@ pub struct Model { pub revoked: bool, #[schema(value_type = String, format="uuid")] pub user_id: Uuid, + pub scopes: ScopeList, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] From 6e6877515772cce174e0e1d95018055f0ac028e3 Mon Sep 17 00:00:00 2001 From: yupix Date: Fri, 22 May 2026 07:43:57 +0000 Subject: [PATCH 16/23] =?UTF-8?q?feat(backend):=20=E3=83=A1=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=82=A2=E3=83=89=E3=83=AC=E3=82=B9=E3=82=92=E6=AD=A3?= =?UTF-8?q?=E8=A6=8F=E5=8C=96=E3=81=97=E3=81=A6=E4=BF=9D=E5=AD=98=E3=81=99?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/handlers/auth.rs | 7 +++-- apps/backend/src/utils/email.rs | 27 ++++++++++++++++++++ apps/backend/src/utils/email_verification.rs | 6 ++--- apps/backend/src/utils/mod.rs | 1 + 4 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 apps/backend/src/utils/email.rs diff --git a/apps/backend/src/handlers/auth.rs b/apps/backend/src/handlers/auth.rs index e456fe6..8c41f94 100644 --- a/apps/backend/src/handlers/auth.rs +++ b/apps/backend/src/handlers/auth.rs @@ -21,6 +21,7 @@ use crate::utils::auth::{ use crate::jobs::VerificationEmailJob; use crate::jobs::verification_email; use crate::utils::db::{is_postgres_unique_violation, with_transaction}; +use crate::utils::email::normalize_email; use crate::utils::email_verification; use crate::{AppState, entities::users}; @@ -51,9 +52,10 @@ pub async fn login( Valid(Json(payload)): Valid>, ) -> Result { let LoginRequest { email, password } = payload; + let email = normalize_email(&email); let user = users::Entity::find() - .filter(users::Column::Email.eq(email)) + .filter(users::Column::Email.eq(&email)) .one(&state.db) .await?; @@ -113,6 +115,7 @@ pub async fn register( email, password, } = payload; + let email = normalize_email(&email); let password_hash = create_password_hash(&password)?; let verification_token = generate_email_verification_token(); @@ -237,7 +240,7 @@ pub async fn resend_verification_email( State(state): State, Valid(Json(payload)): Valid>, ) -> Result, AuthError> { - let email = payload.email.trim().to_string(); + let email = normalize_email(&payload.email); if !email_verification::try_acquire_resend_slot(&state.redis_client, &email) .await diff --git a/apps/backend/src/utils/email.rs b/apps/backend/src/utils/email.rs new file mode 100644 index 0000000..7f8973b --- /dev/null +++ b/apps/backend/src/utils/email.rs @@ -0,0 +1,27 @@ +//! メールアドレスの正規化(trim + ASCII 小文字)。 + +/// 前後空白を除去し、ASCII 小文字に揃える。 +/// +/// ドメイン部は RFC 上ケース非依存。ローカル部の Unicode 大文字小文字は変換しない +/// (一般的な Web サービスと同様に全体を小文字化する)。 +pub fn normalize_email(email: &str) -> String { + email.trim().to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trims_and_lowercases() { + assert_eq!( + normalize_email(" User@Example.COM "), + "user@example.com" + ); + } + + #[test] + fn leaves_already_normalized_unchanged() { + assert_eq!(normalize_email("a@b.co"), "a@b.co"); + } +} diff --git a/apps/backend/src/utils/email_verification.rs b/apps/backend/src/utils/email_verification.rs index 6ae6079..ce45452 100644 --- a/apps/backend/src/utils/email_verification.rs +++ b/apps/backend/src/utils/email_verification.rs @@ -7,6 +7,7 @@ use std::sync::LazyLock; use uuid::Uuid; +use super::email::normalize_email; use super::redis::RedisConnection; /// 再送時: 旧 token キー削除 → 新 token/user キー SET を一括実行。 @@ -117,10 +118,7 @@ pub async fn try_acquire_resend_slot(redis: &RedisConnection, email: &str) -> Re .await .map_err(|e| anyhow::anyhow!("redis acquire failed: {e}"))?; - let key = format!( - "{KEY_RESEND}{}", - email.trim().to_ascii_lowercase() - ); + let key = format!("{KEY_RESEND}{}", normalize_email(email)); let set_ok: Option = redis::cmd("SET") .arg(&key) diff --git a/apps/backend/src/utils/mod.rs b/apps/backend/src/utils/mod.rs index 344dfbb..77a5091 100644 --- a/apps/backend/src/utils/mod.rs +++ b/apps/backend/src/utils/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod db; +pub mod email; pub mod email_verification; pub mod redis; pub mod smtp; From a7b7561ded96db39251539e115af175af7393d11 Mon Sep 17 00:00:00 2001 From: yupix Date: Fri, 22 May 2026 07:46:48 +0000 Subject: [PATCH 17/23] refactor(backend): replace hardcoded baseUrl with variable references for improved configurability --- apps/backend/bruno/01-register-verify/01-register.bru | 4 ++-- .../bruno/01-register-verify/04-resend-verification.bru | 2 +- apps/backend/bruno/02-login/02-me.bru | 2 +- apps/backend/bruno/environments/local.bru | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/backend/bruno/01-register-verify/01-register.bru b/apps/backend/bruno/01-register-verify/01-register.bru index 07ad203..2b05dc7 100644 --- a/apps/backend/bruno/01-register-verify/01-register.bru +++ b/apps/backend/bruno/01-register-verify/01-register.bru @@ -23,8 +23,8 @@ body:json { } vars:pre-request { - baseUrl: http://localhost:3400 - username: yupix + baseUrl: "{{baseUrl}}" + username: "{{username}}" email: "{{email}}" password: "{{password}}" } diff --git a/apps/backend/bruno/01-register-verify/04-resend-verification.bru b/apps/backend/bruno/01-register-verify/04-resend-verification.bru index 9b214fe..150c3db 100644 --- a/apps/backend/bruno/01-register-verify/04-resend-verification.bru +++ b/apps/backend/bruno/01-register-verify/04-resend-verification.bru @@ -21,7 +21,7 @@ body:json { } vars:pre-request { - baseUrl: http://localhost:3400 + baseUrl: "{{baseUrl}}" } docs { diff --git a/apps/backend/bruno/02-login/02-me.bru b/apps/backend/bruno/02-login/02-me.bru index 0104d3b..8fa64d6 100644 --- a/apps/backend/bruno/02-login/02-me.bru +++ b/apps/backend/bruno/02-login/02-me.bru @@ -12,7 +12,7 @@ get { vars:pre-request { - baseUrl: http://localhost:3400 + baseUrl: {{baseUrl}} } tests { diff --git a/apps/backend/bruno/environments/local.bru b/apps/backend/bruno/environments/local.bru index 52c0493..c964006 100644 --- a/apps/backend/bruno/environments/local.bru +++ b/apps/backend/bruno/environments/local.bru @@ -1,7 +1,7 @@ vars { baseUrl: http://localhost:3400 - email: test-user@example.com + email: your-unregistered-email@example.com password: password123 username: testuser - verificationToken: + verificationToken: your-verification-token } From 3014bf817fa570d94f7c42b39cf264867e3a965b Mon Sep 17 00:00:00 2001 From: yupix Date: Fri, 22 May 2026 07:51:43 +0000 Subject: [PATCH 18/23] refactor(backend): simplify VerificationEmailJob creation and enhance token management with issued_at timestamp --- apps/backend/src/handlers/auth.rs | 12 ++----- apps/backend/src/jobs/verification_email.rs | 26 +++++++++++++- apps/backend/src/utils/email_verification.rs | 38 ++++++++++++++++---- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/apps/backend/src/handlers/auth.rs b/apps/backend/src/handlers/auth.rs index 8c41f94..f6c8e49 100644 --- a/apps/backend/src/handlers/auth.rs +++ b/apps/backend/src/handlers/auth.rs @@ -151,11 +151,7 @@ pub async fn register( verification_email::enqueue( state.verification_email_storage.as_ref(), - VerificationEmailJob { - user_id, - email: email.clone(), - token: verification_token, - }, + VerificationEmailJob::new(user_id, email.clone(), verification_token), ) .await .map_err(|e| AuthError::Internal(anyhow::anyhow!("enqueue verification email: {e}")))?; @@ -262,11 +258,7 @@ pub async fn resend_verification_email( let token = generate_email_verification_token(); verification_email::enqueue( state.verification_email_storage.as_ref(), - VerificationEmailJob { - user_id: user.id, - email: email.clone(), - token, - }, + VerificationEmailJob::new(user.id, email.clone(), token), ) .await .map_err(|e| AuthError::Internal(anyhow::anyhow!("enqueue verification email: {e}")))?; diff --git a/apps/backend/src/jobs/verification_email.rs b/apps/backend/src/jobs/verification_email.rs index c9c8632..974f41c 100644 --- a/apps/backend/src/jobs/verification_email.rs +++ b/apps/backend/src/jobs/verification_email.rs @@ -21,6 +21,20 @@ pub struct VerificationEmailJob { pub user_id: Uuid, pub email: String, pub token: String, + /// トークン発行世代(Unix ミリ秒)。`store_token` はこれより新しい世代のみ反映する。 + #[serde(default)] + pub issued_at: u64, +} + +impl VerificationEmailJob { + pub fn new(user_id: Uuid, email: String, token: String) -> Self { + Self { + user_id, + email, + token, + issued_at: chrono::Utc::now().timestamp_millis() as u64, + } + } } pub type VerificationEmailStorage = PostgresStorage< @@ -63,7 +77,17 @@ pub async fn enqueue( } pub async fn process(job: VerificationEmailJob, state: Data) -> Result<(), BoxDynError> { - email_verification::store_token(&state.redis_client, job.user_id, &job.token).await?; + let stored = email_verification::store_token( + &state.redis_client, + job.user_id, + &job.token, + job.issued_at, + ) + .await?; + if !stored { + // 再送などでより新しい世代が既に Redis にある。旧ジョブのリトライは送信しない。 + return Ok(()); + } verification_email_delivery::send_verification_email( &state.smtp_client, &job.email, diff --git a/apps/backend/src/utils/email_verification.rs b/apps/backend/src/utils/email_verification.rs index ce45452..701ca68 100644 --- a/apps/backend/src/utils/email_verification.rs +++ b/apps/backend/src/utils/email_verification.rs @@ -2,6 +2,9 @@ //! //! `user->token` と `token->user` は Lua で原子的に更新し、再送時の旧トークン無効化と //! 消費時の逆マッピング削除が同時リクエストでも崩れないようにする。 +//! +//! ジョブごとの `issued_at`(世代)を Redis に保持し、Apalis リトライで古いジョブが +//! 新しいトークンを上書きしないようにする。 use std::sync::LazyLock; @@ -10,16 +13,29 @@ use uuid::Uuid; use super::email::normalize_email; use super::redis::RedisConnection; -/// 再送時: 旧 token キー削除 → 新 token/user キー SET を一括実行。 +/// 世代チェック後、旧 token キー削除 → 新 token/user/gen キー SET を一括実行。 +/// 返却: 1 = 反映した, 0 = より新しい世代が既にあるためスキップ。 static STORE_TOKEN_SCRIPT: LazyLock = LazyLock::new(|| { redis::Script::new( r#" - local old_token = redis.call('GET', KEYS[1]) + local user_key = KEYS[1] + local gen_key = KEYS[2] + local token_key = KEYS[3] + local issued_at = tonumber(ARGV[5]) + local ttl = tonumber(ARGV[4]) + + local current_gen = redis.call('GET', gen_key) + if current_gen and tonumber(current_gen) > issued_at then + return 0 + end + + local old_token = redis.call('GET', user_key) if old_token then redis.call('DEL', ARGV[1] .. old_token) end - redis.call('SET', KEYS[2], ARGV[2], 'EX', tonumber(ARGV[4])) - redis.call('SET', KEYS[1], ARGV[3], 'EX', tonumber(ARGV[4])) + redis.call('SET', token_key, ARGV[2], 'EX', ttl) + redis.call('SET', user_key, ARGV[3], 'EX', ttl) + redis.call('SET', gen_key, tostring(issued_at), 'EX', ttl) return 1 "#, ) @@ -49,13 +65,18 @@ pub const RESEND_COOLDOWN_SECS: u64 = 60; const KEY_TOKEN: &str = "email_verify:t:"; const KEY_USER: &str = "email_verify:u:"; +const KEY_GEN: &str = "email_verify:gen:"; const KEY_RESEND: &str = "email_verify:resend:e:"; +/// トークンを Redis に保存する。`issued_at` が現在世代以上のときのみ反映する。 +/// +/// より新しい世代が既にある場合は `Ok(false)`(上書き・メール送信をスキップすべき)。 pub async fn store_token( redis: &RedisConnection, user_id: Uuid, token: &str, -) -> Result<(), anyhow::Error> { + issued_at: u64, +) -> Result { let mut conn = redis .conn .acquire() @@ -63,21 +84,24 @@ pub async fn store_token( .map_err(|e| anyhow::anyhow!("redis acquire failed: {e}"))?; let user_key = format!("{KEY_USER}{user_id}"); + let gen_key = format!("{KEY_GEN}{user_id}"); let token_key = format!("{KEY_TOKEN}{token}"); let ttl = TOKEN_TTL_SECS.to_string(); - let _: i32 = STORE_TOKEN_SCRIPT + let applied: i32 = STORE_TOKEN_SCRIPT .key(&user_key) + .key(&gen_key) .key(&token_key) .arg(KEY_TOKEN) .arg(user_id.to_string()) .arg(token) .arg(&ttl) + .arg(issued_at.to_string()) .invoke_async(&mut conn) .await .map_err(|e| anyhow::anyhow!("redis store_token script: {e}"))?; - Ok(()) + Ok(applied == 1) } /// GETDEL でトークンを消費し、対応するユーザー ID を返す。無効・期限切れなら `None`。 From 04c0cb81881e0bd7c93b61e65df1a29359d05c18 Mon Sep 17 00:00:00 2001 From: yupix Date: Fri, 22 May 2026 07:54:35 +0000 Subject: [PATCH 19/23] =?UTF-8?q?refactor(backend):=20EnvFilter=E3=82=92?= =?UTF-8?q?=E4=BA=8C=E5=BA=A6=E7=94=9F=E6=88=90=E3=81=9B=E3=81=9A=E3=81=AB?= =?UTF-8?q?=E5=85=B1=E6=9C=89=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/server.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/backend/src/server.rs b/apps/backend/src/server.rs index d58cc0f..bd923b4 100644 --- a/apps/backend/src/server.rs +++ b/apps/backend/src/server.rs @@ -32,17 +32,17 @@ use crate::{ }; pub async fn run(state: AppState) -> Result<(), Box> { - let broadcaster = TracingBroadcaster::create(); - let board_tracing = TracingSubscriber::new(&broadcaster).layer().with_filter( - tracing_subscriber::EnvFilter::new( - std::env::var("RUST_LOG").unwrap_or_else(|_| "info,sqlx=warn".into()), - ), + let log_filter = tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info,sqlx=warn".into()), ); + let broadcaster = TracingBroadcaster::create(); + let board_tracing = TracingSubscriber::new(&broadcaster) + .layer() + .with_filter(log_filter.clone()); + tracing_subscriber::registry() - .with(tracing_subscriber::EnvFilter::new( - std::env::var("RUST_LOG").unwrap_or_else(|_| "info,sqlx=warn".into()), - )) + .with(log_filter) .with(tracing_subscriber::fmt::layer()) .with(board_tracing) .init(); From 3b04e4f9a8b46a85b2f6c9b4adec70009674a739 Mon Sep 17 00:00:00 2001 From: yupix Date: Fri, 22 May 2026 07:58:18 +0000 Subject: [PATCH 20/23] =?UTF-8?q?feat(backend):=20verification=5Femail=5Fw?= =?UTF-8?q?orker=5Fconcurrency=E3=81=AB=E4=B8=8D=E9=81=A9=E5=88=87?= =?UTF-8?q?=E3=81=AA=E6=95=B0=E5=80=A4=E3=82=92=E5=85=A5=E3=82=8C=E3=82=8C?= =?UTF-8?q?=E3=81=AA=E3=81=84=E3=82=88=E3=81=86=E3=81=AB=E6=A4=9C=E8=A8=BC?= =?UTF-8?q?=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/settings.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/backend/src/settings.rs b/apps/backend/src/settings.rs index 737dc20..b984e41 100644 --- a/apps/backend/src/settings.rs +++ b/apps/backend/src/settings.rs @@ -23,6 +23,10 @@ pub struct Settings { ))] pub email_verification_app_url: String, /// 認証メール Apalis ワーカーの並列度 + #[validate(range( + min = 1, + message = "verification_email_worker_concurrency must be >= 1" + ))] #[serde(default = "default_verification_email_worker_concurrency")] pub verification_email_worker_concurrency: usize, } From 99e6af09c71d7c13dcbc1365b839f868e6f9696f Mon Sep 17 00:00:00 2001 From: yupix Date: Fri, 22 May 2026 08:02:15 +0000 Subject: [PATCH 21/23] refactor(backend): enhance documentation for verification email job and token management functions --- apps/backend/src/jobs/verification_email.rs | 30 ++++++++++++++++++- apps/backend/src/utils/email.rs | 10 +++++-- apps/backend/src/utils/email_verification.rs | 31 ++++++++++++++++++-- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/jobs/verification_email.rs b/apps/backend/src/jobs/verification_email.rs index 974f41c..2d76088 100644 --- a/apps/backend/src/jobs/verification_email.rs +++ b/apps/backend/src/jobs/verification_email.rs @@ -16,17 +16,31 @@ use crate::{AppState, settings::Settings}; pub const QUEUE_NAME: &str = "verification_email"; pub const MAX_RETRIES: usize = 8; +/// 認証メール送信ワーカーが処理する Apalis ジョブペイロード。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VerificationEmailJob { + /// 認証対象ユーザーの ID。 pub user_id: Uuid, + /// 送信先メールアドレス(正規化済みを想定)。 pub email: String, + /// メール本文の認証リンクに埋め込むトークン。 pub token: String, - /// トークン発行世代(Unix ミリ秒)。`store_token` はこれより新しい世代のみ反映する。 + /// トークン発行世代(Unix ミリ秒)。[`email_verification::store_token`] は + /// Redis 上の現世代より大きい値のときのみトークンを反映する。 #[serde(default)] pub issued_at: u64, } impl VerificationEmailJob { + /// キュー投入用のジョブを組み立て、`issued_at` に現在時刻(ミリ秒)を付与する。 + /// + /// # Arguments + /// * `user_id` - 認証対象ユーザー ID + /// * `email` - 送信先メールアドレス + /// * `token` - 認証トークン文字列 + /// + /// # Returns + /// * `issued_at` が設定された [`VerificationEmailJob`] pub fn new(user_id: Uuid, email: String, token: String) -> Self { Self { user_id, @@ -76,6 +90,20 @@ pub async fn enqueue( Ok(()) } +/// 認証メールジョブを処理する(Redis にトークン保存 → SMTP 送信)。 +/// +/// 再送などでより新しい `issued_at` が既に Redis にある場合はトークン保存と送信を +/// スキップし、古いジョブの Apalis リトライが最新リンクを無効化しないようにする。 +/// +/// # Arguments +/// * `job` - 送信対象のユーザー・メール・トークン・発行世代 +/// * `state` - DB / Redis / SMTP などを含むアプリ状態 +/// +/// # Returns +/// * `Ok(())` - 送信成功、または世代が古くてスキップした場合(いずれもジョブ成功扱い) +/// +/// # Errors +/// * Redis・SMTP など下位処理の失敗(Apalis がリトライする) pub async fn process(job: VerificationEmailJob, state: Data) -> Result<(), BoxDynError> { let stored = email_verification::store_token( &state.redis_client, diff --git a/apps/backend/src/utils/email.rs b/apps/backend/src/utils/email.rs index 7f8973b..0ada652 100644 --- a/apps/backend/src/utils/email.rs +++ b/apps/backend/src/utils/email.rs @@ -1,9 +1,15 @@ //! メールアドレスの正規化(trim + ASCII 小文字)。 -/// 前後空白を除去し、ASCII 小文字に揃える。 +/// メールアドレスを登録・照合用に正規化する。 /// /// ドメイン部は RFC 上ケース非依存。ローカル部の Unicode 大文字小文字は変換しない -/// (一般的な Web サービスと同様に全体を小文字化する)。 +/// (一般的な Web サービスと同様に全体を `to_ascii_lowercase` する)。 +/// +/// # Arguments +/// * `email` - 正規化前のメールアドレス(前後に空白があってもよい) +/// +/// # Returns +/// * トリム済み・ASCII 小文字化済みの文字列(DB 保存および検索に使う) pub fn normalize_email(email: &str) -> String { email.trim().to_ascii_lowercase() } diff --git a/apps/backend/src/utils/email_verification.rs b/apps/backend/src/utils/email_verification.rs index 701ca68..f46420a 100644 --- a/apps/backend/src/utils/email_verification.rs +++ b/apps/backend/src/utils/email_verification.rs @@ -68,9 +68,23 @@ const KEY_USER: &str = "email_verify:u:"; const KEY_GEN: &str = "email_verify:gen:"; const KEY_RESEND: &str = "email_verify:resend:e:"; -/// トークンを Redis に保存する。`issued_at` が現在世代以上のときのみ反映する。 +/// 認証トークンを Redis に保存する(Lua で原子的に user/token/世代を更新)。 /// -/// より新しい世代が既にある場合は `Ok(false)`(上書き・メール送信をスキップすべき)。 +/// Redis の現世代より `issued_at` が小さい場合は更新せず、Apalis リトライによる +/// 古いジョブの上書きを防ぐ。同じ `issued_at` の再実行は冪等に再反映できる。 +/// +/// # Arguments +/// * `redis` - トークン・世代キーを保持する Redis 接続 +/// * `user_id` - 認証対象ユーザー ID +/// * `token` - 保存する認証トークン文字列 +/// * `issued_at` - ジョブの発行世代(Unix ミリ秒。大きいほど新しい) +/// +/// # Returns +/// * `Ok(true)` - トークンと世代を反映した +/// * `Ok(false)` - より新しい世代が既にあるためスキップした(メール送信も不要) +/// +/// # Errors +/// * Redis 接続・Lua スクリプト実行に失敗した場合 pub async fn store_token( redis: &RedisConnection, user_id: Uuid, @@ -134,7 +148,18 @@ pub async fn consume_token( Ok(Some(uid)) } -/// メールアドレス単位で再送クールダウンを取る。取れたら `true`、取れなければレート制限で `false`。 +/// メールアドレス単位で再送クールダウン枠を取得する(`SET NX`)。 +/// +/// # Arguments +/// * `redis` - クールダウンキーを保持する Redis 接続 +/// * `email` - 対象メールアドレス(内部で [`super::email::normalize_email`] する) +/// +/// # Returns +/// * `Ok(true)` - 枠を取得できた(再送 API が続行してよい) +/// * `Ok(false)` - [`RESEND_COOLDOWN_SECS`] 以内に再送済み(429 相当) +/// +/// # Errors +/// * Redis 接続・コマンド実行に失敗した場合 pub async fn try_acquire_resend_slot(redis: &RedisConnection, email: &str) -> Result { let mut conn = redis .conn From a6f311a30eb4c1ad30911df921ec488c720d89cc Mon Sep 17 00:00:00 2001 From: yupix Date: Fri, 22 May 2026 08:08:41 +0000 Subject: [PATCH 22/23] =?UTF-8?q?feat(backend):=20=E3=82=A2=E3=82=AB?= =?UTF-8?q?=E3=82=A6=E3=83=B3=E3=83=88=E7=99=BB=E9=8C=B2=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E5=BE=8C=E3=81=AB=E3=80=81=E3=83=A1=E3=83=BC=E3=83=AB=E9=80=81?= =?UTF-8?q?=E4=BF=A1=E3=81=AE=E3=82=B8=E3=83=A7=E3=83=96=E4=BD=9C=E6=88=90?= =?UTF-8?q?=E3=81=AB=E5=A4=B1=E6=95=97=E3=81=97=E3=81=9F=E5=A0=B4=E5=90=88?= =?UTF-8?q?=E3=81=AE=E3=82=A8=E3=83=A9=E3=83=BC=E3=83=8F=E3=83=B3=E3=83=89?= =?UTF-8?q?=E3=83=AA=E3=83=B3=E3=82=B0=E3=82=92=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/handlers/auth.rs | 5 +++-- apps/backend/src/openapi/responses.rs | 10 ++++++++++ apps/backend/src/utils/auth.rs | 13 +++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/handlers/auth.rs b/apps/backend/src/handlers/auth.rs index f6c8e49..d3636de 100644 --- a/apps/backend/src/handlers/auth.rs +++ b/apps/backend/src/handlers/auth.rs @@ -154,7 +154,8 @@ pub async fn register( VerificationEmailJob::new(user_id, email.clone(), verification_token), ) .await - .map_err(|e| AuthError::Internal(anyhow::anyhow!("enqueue verification email: {e}")))?; + .map_err(AuthError::VerificationEmailEnqueueFailed)?; + Ok(( StatusCode::CREATED, Json("Register successful".to_string()), @@ -261,7 +262,7 @@ pub async fn resend_verification_email( VerificationEmailJob::new(user.id, email.clone(), token), ) .await - .map_err(|e| AuthError::Internal(anyhow::anyhow!("enqueue verification email: {e}")))?; + .map_err(AuthError::VerificationEmailEnqueueFailed)?; Ok(Json(format!( "確認メールを再送しました(同一メールアドレスへの再送は{}秒に1回までです)。", diff --git a/apps/backend/src/openapi/responses.rs b/apps/backend/src/openapi/responses.rs index 3b8aac6..8e25dd6 100644 --- a/apps/backend/src/openapi/responses.rs +++ b/apps/backend/src/openapi/responses.rs @@ -45,6 +45,11 @@ pub enum RegisterErrors { description = "このメールアドレスはすでに登録されています" )] Conflict(#[to_schema] ServerError), + #[response( + status = 503, + description = "認証メールの送信準備に失敗しました。アカウントは作成済みのため、認証メールの再送をお試しください" + )] + VerificationEmailUnavailable(#[to_schema] ServerError), #[response( status = 500, description = "サーバー側で問題が発生しました。時間をおいて再度お試しください" @@ -98,6 +103,11 @@ pub enum ResendVerificationErrors { Conflict(#[to_schema] ServerError), #[response(status = 429, description = "しばらくしてから再度お試しください")] TooManyRequests(#[to_schema] ServerError), + #[response( + status = 503, + description = "認証メールの送信準備に失敗しました。しばらくしてから再送をお試しください" + )] + VerificationEmailUnavailable(#[to_schema] ServerError), #[response( status = 500, description = "サーバー側で問題が発生しました。時間をおいて再度お試しください" diff --git a/apps/backend/src/utils/auth.rs b/apps/backend/src/utils/auth.rs index 2f56bda..6aa9266 100644 --- a/apps/backend/src/utils/auth.rs +++ b/apps/backend/src/utils/auth.rs @@ -43,6 +43,9 @@ pub enum AuthError { DuplicateEmail, #[error("too many requests")] TooManyRequests, + /// 認証メールジョブのキュー投入に失敗した(未認証ユーザーは残し再送 API で回復する)。 + #[error("verification email enqueue failed")] + VerificationEmailEnqueueFailed(#[source] anyhow::Error), } impl From for AuthError { @@ -121,6 +124,16 @@ impl IntoResponse for AuthError { }), ) .into_response(), + AuthError::VerificationEmailEnqueueFailed(e) => { + debug!("verification email enqueue failed: {:#?}", e); + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ServerError { + message: "verification-email-enqueue-failed".into(), + }), + ) + .into_response() + } } } } From 7113f418abe5e9708d4dba28e3417bf283136192 Mon Sep 17 00:00:00 2001 From: yupix Date: Fri, 22 May 2026 08:20:49 +0000 Subject: [PATCH 23/23] refactor(backend): improve token management logic to ensure idempotency for same issued_at and token --- apps/backend/src/utils/email_verification.rs | 21 +++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/utils/email_verification.rs b/apps/backend/src/utils/email_verification.rs index f46420a..478193b 100644 --- a/apps/backend/src/utils/email_verification.rs +++ b/apps/backend/src/utils/email_verification.rs @@ -14,19 +14,29 @@ use super::email::normalize_email; use super::redis::RedisConnection; /// 世代チェック後、旧 token キー削除 → 新 token/user/gen キー SET を一括実行。 -/// 返却: 1 = 反映した, 0 = より新しい世代が既にあるためスキップ。 +/// 返却: 1 = 反映した, 0 = より新しい世代、または同世代で別トークンのためスキップ。 static STORE_TOKEN_SCRIPT: LazyLock = LazyLock::new(|| { redis::Script::new( r#" local user_key = KEYS[1] local gen_key = KEYS[2] local token_key = KEYS[3] + local new_token = ARGV[3] local issued_at = tonumber(ARGV[5]) local ttl = tonumber(ARGV[4]) local current_gen = redis.call('GET', gen_key) - if current_gen and tonumber(current_gen) > issued_at then - return 0 + if current_gen then + local current_gen_num = tonumber(current_gen) + if current_gen_num > issued_at then + return 0 + end + if current_gen_num == issued_at then + local current_token = redis.call('GET', user_key) + if current_token ~= new_token then + return 0 + end + end end local old_token = redis.call('GET', user_key) @@ -71,7 +81,8 @@ const KEY_RESEND: &str = "email_verify:resend:e:"; /// 認証トークンを Redis に保存する(Lua で原子的に user/token/世代を更新)。 /// /// Redis の現世代より `issued_at` が小さい場合は更新せず、Apalis リトライによる -/// 古いジョブの上書きを防ぐ。同じ `issued_at` の再実行は冪等に再反映できる。 +/// 古いジョブの上書きを防ぐ。同じ `issued_at` かつ同じ `token` のときだけ冪等に再反映する +/// (同一ミリ秒で別トークンが発行された場合はスキップ)。 /// /// # Arguments /// * `redis` - トークン・世代キーを保持する Redis 接続 @@ -81,7 +92,7 @@ const KEY_RESEND: &str = "email_verify:resend:e:"; /// /// # Returns /// * `Ok(true)` - トークンと世代を反映した -/// * `Ok(false)` - より新しい世代が既にあるためスキップした(メール送信も不要) +/// * `Ok(false)` - より新しい世代、または同世代の別トークンが既にあるためスキップした /// /// # Errors /// * Redis 接続・Lua スクリプト実行に失敗した場合