Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
8c4d22e
feat(backend): add email verification functionality with token manage…
yupix May 20, 2026
88ec75f
refactor(backend): SeaORMのv2で推奨されているエラーハンドリングに変更
yupix May 20, 2026
f184165
refactor(backend): トークンの更新をLuaSccriptで原始的に実行するように
yupix May 20, 2026
835e83f
feat(backend): add urlencoding dependency and use it for token encodi…
yupix May 20, 2026
7c5e840
feat(backend): implement verification email outbox for transactional …
yupix May 20, 2026
b691478
chore(front): openapi.jsonからapi呼び出し用の関数を生成
yupix May 20, 2026
46c9b87
feat(backend): add email_verification_app_url to settings with valida…
yupix May 20, 2026
9b59eaf
feat(backend): enhance verification email outbox with processing stat…
yupix May 21, 2026
3c68222
refactor(backend): streamline email worker processing logic and impro…
yupix May 21, 2026
883f35e
feat(backend): introduce centralized error handling with AppError and…
yupix May 21, 2026
1371d4b
feat(backend): タイミング攻撃の対策
yupix May 21, 2026
4632626
feat(backend): implement verification email job processing with Apali…
yupix May 21, 2026
8c3837c
feat(backend): enhance personal_tokens model with ToSchema and remove…
yupix May 21, 2026
2db4f55
feat(backend): implement user registration and email verification flo…
yupix May 21, 2026
43e5187
feat(backend): update personal_tokens model to include ScopeList for …
yupix May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions apps/backend/src/entities/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# entities Module
# entities

このモジュールは、データベースのエンティティを定義します。各エンティティは、SeaORMのマクロを使用して定義されており、データベースのテーブル構造に対応しています。また、OpenAPIドキュメント生成のために、`utoipa::ToSchema`トレイトも実装しています。これにより、APIエンドポイントで使用されるデータ構造が明確になり、クライアントとのインターフェースが一貫性を持つようになります
API や永続化で使うモデルをまとめるモジュールです。レスポンス用の `@ToSchema` 付きモデルもここに置きます
2 changes: 1 addition & 1 deletion apps/backend/src/entities/scopes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ pub enum Scope {
AdminAll,
}

/// JSON カラム用の `Vec<Scope>` ラッパ(SeaORM エンティティ向け)
/// アクセストークン等に付与する権限スコープのリスト
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult, ToSchema)]
#[serde(transparent)]
pub struct ScopeList(pub Vec<Scope>);
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/entities/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ pub struct Model {
#[sea_orm(nullable)]
#[schema(nullable)]
pub avatar_url: Option<String>,
#[sea_orm(unique)]
#[schema(value_type = String, format="email")]
pub email: String,
/// メールアドレスの確認が済んでいるかどうか
pub email_verified: bool,
Comment thread
yupix marked this conversation as resolved.
#[schema(ignore)]
#[serde(skip_serializing)]
pub password_hash: String,
Expand Down
225 changes: 201 additions & 24 deletions apps/backend/src/handlers/auth.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand All @@ -28,30 +36,34 @@ pub struct LoginRequest {
#[utoipa::path(
post,
path = "/login",
summary = "ログイン",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful", body = String),
(status = 204, description = "ログインに成功しました(本文なし)"),
CredentialErrors,
)
)]
pub async fn login(
session: Session<SessionRedisPool>,
State(state): State<AppState>,
Valid(Json(payload)): Valid<Json<LoginRequest>>,
) -> Result<Json<String>, AuthError> {
) -> Result<StatusCode, AuthError> {
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)]
Expand All @@ -71,50 +83,183 @@ 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<SessionRedisPool>,
State(state): State<AppState>,
Valid(Json(payload)): Valid<Json<RegisterRequest>>,
) -> Result<Json<String>, AuthError> {
) -> Result<(StatusCode, Json<String>), AuthError> {
let RegisterRequest {
username,
email,
password,
} = 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 {
id: Set(user_id),
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()),
))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/// メールでの本人確認時に送信する情報。
#[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<AppState>,
Valid(Json(payload)): Valid<Json<VerifyEmailRequest>>,
) -> Result<Json<String>, 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<AppState>,
Valid(Json(payload)): Valid<Json<ResendVerificationRequest>>,
) -> Result<Json<String>, 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,
)
)]
Expand All @@ -129,16 +274,48 @@ pub async fn me(
#[utoipa::path(
post,
path = "/logout",
summary = "ログアウト",
responses(
(status = 200, description = "Logout successful", body = String),
(status = 204, description = "ログアウトしました(本文なし)"),
UnauthorizedErrors,
)
)]
pub async fn logout(
session: Session<SessionRedisPool>,
State(_): State<AppState>,
_auth: AuthUser,
) -> Result<Json<String>, AuthError> {
) -> Result<StatusCode, AuthError> {
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
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

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!(
"<p>以下のリンクからアプリを開き、表示に従ってメールアドレスの確認を完了してください(有効期限は約{mins}分です)。</p><p><a href=\"{verify_url}\">{verify_url}</a></p>",
)),
)
.await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("send verification email: {e}")))?;
Ok(())
}
18 changes: 14 additions & 4 deletions apps/backend/src/handlers/labels.rs
Original file line number Diff line number Diff line change
@@ -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<AppState>) -> Json<Vec<entities::labels::Model>> {
pub async fn get_labels(
State(state): State<AppState>,
) -> Result<Json<Vec<entities::labels::Model>>, AuthError> {
let labels = entities::labels::Entity::find()
.all(&state.db)
.await
.unwrap_or_default();
Json(labels)
.map_err(AuthError::from)?;
Ok(Json(labels))
}
Loading