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/.env.example b/apps/backend/.env.example index b2afe8a..453c333 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= @@ -6,3 +8,8 @@ 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 +# 認証メール 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 25ff67d..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,12 +751,16 @@ name = "backend" version = "0.1.0" dependencies = [ "anyhow", + "apalis", + "apalis-board", + "apalis-postgres", "argon2", "axum", "axum-valid", "axum_session", "axum_session_redispool", "base64", + "chrono", "config", "dotenvy", "hmac 0.13.0", @@ -655,6 +773,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "sqlx", "strum", "strum_macros", "subtle", @@ -664,6 +783,8 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", + "urlencoding", "utoipa", "utoipa-axum", "utoipa-scalar", @@ -1554,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" @@ -2063,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" @@ -4674,6 +4820,7 @@ dependencies = [ "pin-project-lite", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -4753,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" @@ -4763,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]] @@ -4795,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" @@ -4901,6 +5072,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" @@ -5161,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 5da8cf6..71d969d 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -34,10 +34,13 @@ 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" redis = { version = "1.2.1", features = ["tokio-comp"] } +url = "2.5" +urlencoding = "2.1" hmac = "0.13" sha2 = "0.11" rand = "0.10" @@ -45,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/bruno/01-register-verify/01-register.bru b/apps/backend/bruno/01-register-verify/01-register.bru new file mode 100644 index 0000000..2b05dc7 --- /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: "{{baseUrl}}" + username: "{{username}}" + 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..150c3db --- /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: "{{baseUrl}}" +} + +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..8fa64d6 --- /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: {{baseUrl}} +} + +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..c964006 --- /dev/null +++ b/apps/backend/bruno/environments/local.bru @@ -0,0 +1,7 @@ +vars { + baseUrl: http://localhost:3400 + email: your-unregistered-email@example.com + password: password123 + username: testuser + verificationToken: your-verification-token +} 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/personal_tokens.rs b/apps/backend/src/entities/personal_tokens.rs index a8aaada..3c50c32 100644 --- a/apps/backend/src/entities/personal_tokens.rs +++ b/apps/backend/src/entities/personal_tokens.rs @@ -1,31 +1,34 @@ -use crate::entities::scopes::ScopeList; use sea_orm::entity::prelude::*; +use utoipa::ToSchema; + +use crate::entities::scopes::ScopeList; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[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 +38,4 @@ impl Related for Entity { } } -impl ActiveModelBehavior for ActiveModel {} +impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file 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/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/auth.rs b/apps/backend/src/handlers/auth.rs index b6f6c4d..d3636de 100644 --- a/apps/backend/src/handlers/auth.rs +++ b/apps/backend/src/handlers/auth.rs @@ -1,17 +1,28 @@ -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::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::normalize_email; +use crate::utils::email_verification; use crate::{AppState, entities::users}; #[derive(Validate, Debug, Deserialize, utoipa::ToSchema)] @@ -28,9 +39,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 +50,32 @@ pub async fn login( session: Session, State(state): State, Valid(Json(payload)): Valid>, -) -> Result, AuthError> { +) -> 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? - .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) + .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); + } + + session.set("user_id", user.id); + Ok(StatusCode::NO_CONTENT) } #[derive(Validate, Debug, Deserialize, utoipa::ToSchema)] @@ -71,24 +95,30 @@ 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, password, } = payload; + let email = normalize_email(&email); 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 +126,157 @@ 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) + 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}")) + } + })?; + + Ok(()) + }) + }) + .await?; + + verification_email::enqueue( + state.verification_email_storage.as_ref(), + VerificationEmailJob::new(user_id, email.clone(), verification_token), + ) + .await + .map_err(AuthError::VerificationEmailEnqueueFailed)?; + + 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 = normalize_email(&payload.email); + + if !email_verification::try_acquire_resend_slot(&state.redis_client, &email) .await - .map_err(|e| AuthError::Internal(anyhow::anyhow!("insert user: {e}")))?; + .map_err(|e| AuthError::Internal(anyhow::anyhow!("redis resend cooldown: {e}")))? + { + return Err(AuthError::TooManyRequests); + } - session.set("user_id", user_id); - Ok(Json("Register successful".to_string())) + 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(); + verification_email::enqueue( + state.verification_email_storage.as_ref(), + VerificationEmailJob::new(user.id, email.clone(), token), + ) + .await + .map_err(AuthError::VerificationEmailEnqueueFailed)?; + + 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 +291,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 +301,8 @@ 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) } + diff --git a/apps/backend/src/handlers/labels.rs b/apps/backend/src/handlers/labels.rs index d3da409..f6fa27f 100644 --- a/apps/backend/src/handlers/labels.rs +++ b/apps/backend/src/handlers/labels.rs @@ -1,20 +1,28 @@ use axum::{Json, extract::State}; use sea_orm::EntityTrait; +use crate::error::AppError; +use crate::openapi::InternalOnlyError; 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> { - let labels = entities::labels::Entity::find() - .all(&state.db) - .await - .unwrap_or_default(); - Json(labels) +pub async fn get_labels( + State(state): State, +) -> Result>, AppError> { + // DB 障害時は 500 を返す + let labels = entities::labels::Entity::find().all(&state.db).await?; + 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/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..2d76088 --- /dev/null +++ b/apps/backend/src/jobs/verification_email.rs @@ -0,0 +1,131 @@ +//! 認証メール送信ジョブ(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; + +/// 認証メール送信ワーカーが処理する Apalis ジョブペイロード。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerificationEmailJob { + /// 認証対象ユーザーの ID。 + pub user_id: Uuid, + /// 送信先メールアドレス(正規化済みを想定)。 + pub email: String, + /// メール本文の認証リンクに埋め込むトークン。 + pub token: String, + /// トークン発行世代(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, + email, + token, + issued_at: chrono::Utc::now().timestamp_millis() as u64, + } + } +} + +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(()) +} + +/// 認証メールジョブを処理する(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, + 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, + &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 9daecef..23077b5 100644 --- a/apps/backend/src/lib.rs +++ b/apps/backend/src/lib.rs @@ -5,7 +5,9 @@ use crate::{ 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; @@ -15,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/openapi/mod.rs b/apps/backend/src/openapi/mod.rs index 9904f85..1dd5b9d 100644 --- a/apps/backend/src/openapi/mod.rs +++ b/apps/backend/src/openapi/mod.rs @@ -1,14 +1,17 @@ -//! OpenAPI 用の共通型。 +//! OpenAPI コンポーネント登録。 pub mod responses; use utoipa::openapi::OpenApi; use utoipa::{PartialSchema, ToSchema}; -pub use crate::utils::auth::ServerError; -pub use responses::{CredentialErrors, InternalOnlyError, SessionAuthErrors, UnauthorizedErrors}; +pub use crate::error::ServerError; +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..8e25dd6 100644 --- a/apps/backend/src/openapi/responses.rs +++ b/apps/backend/src/openapi/responses.rs @@ -1,41 +1,116 @@ -//! OpenAPI ドキュメント専用のレスポンス型(実行時には `AuthError` を使用)。 +//! OpenAPI 用の共通レスポンス型(ランタイムの `IntoResponse` とは別定義)。 #![allow(dead_code)] use utoipa::IntoResponses; -use crate::utils::auth::ServerError; +use crate::error::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 = 503, + description = "認証メールの送信準備に失敗しました。アカウントは作成済みのため、認証メールの再送をお試しください" + )] + VerificationEmailUnavailable(#[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 = 503, + description = "認証メールの送信準備に失敗しました。しばらくしてから再送をお試しください" + )] + VerificationEmailUnavailable(#[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/server.rs b/apps/backend/src/server.rs index 5a4ff9e..bd923b4 100644 --- a/apps/backend/src/server.rs +++ b/apps/backend/src/server.rs @@ -1,31 +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}; +use crate::{ + AppState, + jobs::verification_email::{self, MAX_RETRIES, QUEUE_NAME}, + middlewares::logging::logging_middleware, +}; +pub async fn run(state: AppState) -> Result<(), Box> { + 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()); -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()), - )) + tracing_subscriber::registry() + .with(log_filter) .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 { @@ -40,42 +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 app = router + 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(); + 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 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 a1ec837..b984e41 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,6 +14,25 @@ pub struct Settings { pub smtp_username: String, pub smtp_password: String, pub smtp_from: String, + /// 認証メールに載せるリンクのベース 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, + /// 認証メール 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, +} + +fn default_verification_email_worker_concurrency() -> usize { + 1 } fn default_allow_origin() -> String { @@ -25,7 +45,91 @@ pub fn load_settings() -> Result { .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(), + verification_email_worker_concurrency: 1, + } + .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")); + } } diff --git a/apps/backend/src/utils/auth.rs b/apps/backend/src/utils/auth.rs index 78fe0d1..6aa9266 100644 --- a/apps/backend/src/utils/auth.rs +++ b/apps/backend/src/utils/auth.rs @@ -14,17 +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; -#[derive(Serialize, ToSchema)] -pub struct ServerError { - pub message: String, -} +use crate::error::{ServerError, internal_server_error}; #[derive(Debug, Error)] pub enum AuthError { @@ -32,8 +27,25 @@ 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, + /// 認証メールジョブのキュー投入に失敗した(未認証ユーザーは残し再送 API で回復する)。 + #[error("verification email enqueue failed")] + VerificationEmailEnqueueFailed(#[source] anyhow::Error), } impl From for AuthError { @@ -47,13 +59,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, @@ -62,6 +68,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 +82,58 @@ 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(), + AuthError::VerificationEmailEnqueueFailed(e) => { + debug!("verification email enqueue failed: {:#?}", e); + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ServerError { + message: "verification-email-enqueue-failed".into(), + }), + ) + .into_response() + } } } } @@ -113,6 +178,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}")))?; @@ -123,6 +193,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; @@ -166,3 +243,14 @@ pub fn verify_personal_token(token: &str, stored_hash: &str) -> Result + +use std::future::Future; +use std::pin::Pin; + +use sea_orm::{DatabaseConnection, DbErr, TransactionError, TransactionTrait}; + +/// 一意制約違反(重複キーなど)として扱ってよさそうなエラーか。 +pub fn is_postgres_unique_violation(err: &DbErr) -> bool { + matches!( + err.sql_err(), + Some(sea_orm::SqlErr::UniqueConstraintViolation(_)) + ) +} + +/// [`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/email.rs b/apps/backend/src/utils/email.rs new file mode 100644 index 0000000..0ada652 --- /dev/null +++ b/apps/backend/src/utils/email.rs @@ -0,0 +1,33 @@ +//! メールアドレスの正規化(trim + ASCII 小文字)。 + +/// メールアドレスを登録・照合用に正規化する。 +/// +/// ドメイン部は RFC 上ケース非依存。ローカル部の Unicode 大文字小文字は変換しない +/// (一般的な Web サービスと同様に全体を `to_ascii_lowercase` する)。 +/// +/// # Arguments +/// * `email` - 正規化前のメールアドレス(前後に空白があってもよい) +/// +/// # Returns +/// * トリム済み・ASCII 小文字化済みの文字列(DB 保存および検索に使う) +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 new file mode 100644 index 0000000..478193b --- /dev/null +++ b/apps/backend/src/utils/email_verification.rs @@ -0,0 +1,194 @@ +//! メール認証トークンを Redis に保持する(TTL で有効期限)。 +//! +//! `user->token` と `token->user` は Lua で原子的に更新し、再送時の旧トークン無効化と +//! 消費時の逆マッピング削除が同時リクエストでも崩れないようにする。 +//! +//! ジョブごとの `issued_at`(世代)を Redis に保持し、Apalis リトライで古いジョブが +//! 新しいトークンを上書きしないようにする。 + +use std::sync::LazyLock; + +use uuid::Uuid; + +use super::email::normalize_email; +use super::redis::RedisConnection; + +/// 世代チェック後、旧 token キー削除 → 新 token/user/gen キー SET を一括実行。 +/// 返却: 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 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) + if old_token then + redis.call('DEL', ARGV[1] .. old_token) + end + 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 + "#, + ) +}); + +/// 消費時: 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; +/// 認証メール再送のクールダウン(秒)。 +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 に保存する(Lua で原子的に user/token/世代を更新)。 +/// +/// Redis の現世代より `issued_at` が小さい場合は更新せず、Apalis リトライによる +/// 古いジョブの上書きを防ぐ。同じ `issued_at` かつ同じ `token` のときだけ冪等に再反映する +/// (同一ミリ秒で別トークンが発行された場合はスキップ)。 +/// +/// # 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, + token: &str, + issued_at: u64, +) -> Result { + 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 gen_key = format!("{KEY_GEN}{user_id}"); + let token_key = format!("{KEY_TOKEN}{token}"); + let ttl = TOKEN_TTL_SECS.to_string(); + + 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(applied == 1) +} + +/// 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 = CONSUME_TOKEN_SCRIPT + .key(&token_key) + .arg(KEY_USER) + .arg(token) + .invoke_async(&mut conn) + .await + .map_err(|e| anyhow::anyhow!("redis consume_token script: {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}"))?; + + Ok(Some(uid)) +} + +/// メールアドレス単位で再送クールダウン枠を取得する(`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 + .acquire() + .await + .map_err(|e| anyhow::anyhow!("redis acquire failed: {e}"))?; + + let key = format!("{KEY_RESEND}{}", normalize_email(email)); + + 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..77a5091 100644 --- a/apps/backend/src/utils/mod.rs +++ b/apps/backend/src/utils/mod.rs @@ -1,3 +1,7 @@ pub mod auth; +pub mod db; +pub mod email; +pub mod email_verification; pub mod redis; pub mod smtp; +pub mod verification_email_delivery; 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/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';