@gravito/satellite-membership 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.dockerignore +8 -0
  2. package/.env.example +19 -0
  3. package/ARCHITECTURE.md +14 -0
  4. package/CHANGELOG.md +14 -0
  5. package/Dockerfile +25 -0
  6. package/README.md +112 -0
  7. package/WHITEPAPER.md +20 -0
  8. package/dist/index.d.ts +26 -0
  9. package/dist/index.js +1121 -0
  10. package/docs/EXTENDING.md +99 -0
  11. package/docs/PASSKEYS.md +78 -0
  12. package/locales/en.json +30 -0
  13. package/locales/zh-TW.json +30 -0
  14. package/package.json +35 -0
  15. package/src/Application/DTOs/MemberDTO.ts +34 -0
  16. package/src/Application/Mail/ForgotPasswordMail.ts +42 -0
  17. package/src/Application/Mail/MemberLevelChangedMail.ts +41 -0
  18. package/src/Application/Mail/WelcomeMail.ts +45 -0
  19. package/src/Application/Services/PasskeysService.ts +198 -0
  20. package/src/Application/UseCases/ForgotPassword.ts +34 -0
  21. package/src/Application/UseCases/LoginMember.ts +64 -0
  22. package/src/Application/UseCases/RegisterMember.ts +65 -0
  23. package/src/Application/UseCases/ResetPassword.ts +30 -0
  24. package/src/Application/UseCases/UpdateMemberLevel.ts +47 -0
  25. package/src/Application/UseCases/UpdateSettings.ts +81 -0
  26. package/src/Application/UseCases/VerifyEmail.ts +23 -0
  27. package/src/Domain/Contracts/IMemberPasskeyRepository.ts +8 -0
  28. package/src/Domain/Contracts/IMemberRepository.ts +11 -0
  29. package/src/Domain/Entities/Member.ts +219 -0
  30. package/src/Domain/Entities/MemberPasskey.ts +97 -0
  31. package/src/Infrastructure/Auth/SentinelMemberProvider.ts +52 -0
  32. package/src/Infrastructure/Persistence/AtlasMemberPasskeyRepository.ts +63 -0
  33. package/src/Infrastructure/Persistence/AtlasMemberRepository.ts +91 -0
  34. package/src/Infrastructure/Persistence/Migrations/20250101_create_members_table.ts +30 -0
  35. package/src/Infrastructure/Persistence/Migrations/20250102_create_member_passkeys_table.ts +25 -0
  36. package/src/Interface/Http/Controllers/PasskeyController.ts +98 -0
  37. package/src/Interface/Http/Middleware/VerifySingleDevice.ts +51 -0
  38. package/src/index.ts +234 -0
  39. package/src/manifest.json +15 -0
  40. package/tests/PasskeysService.test.ts +113 -0
  41. package/tests/email-integration.test.ts +161 -0
  42. package/tests/grand-review.ts +176 -0
  43. package/tests/integration.test.ts +75 -0
  44. package/tests/member.test.ts +47 -0
  45. package/tests/unit.test.ts +7 -0
  46. package/tests/update-settings.test.ts +101 -0
  47. package/tsconfig.json +26 -0
  48. package/views/emails/level_changed.html +35 -0
  49. package/views/emails/reset_password.html +34 -0
  50. package/views/emails/welcome.html +31 -0
@@ -0,0 +1,99 @@
1
+ # 🛠️ Membership Satellite 擴充與自定義指南
2
+
3
+ 歡迎使用 Gravito 會員系統!本模組設計之初就考慮到了極高的靈活性。您可以透過以下幾種方式,在不修改原始碼的情況下,將此模組轉化為您專屬的服務。
4
+
5
+ ## 1. 視覺與品牌 (Branding)
6
+
7
+ 這是最簡單的自定義方式。
8
+
9
+ ### 更改顏色與名稱
10
+ 在您的 `PlanetCore` 配置中:
11
+ ```typescript
12
+ core.configure({
13
+ membership: {
14
+ branding: {
15
+ name: '您的專案名稱',
16
+ primary_color: '#FF5733' // 您的品牌主色調
17
+ }
18
+ }
19
+ })
20
+ ```
21
+
22
+ ### 覆寫郵件模板
23
+ 本模組使用 Prism 模板引擎。如果您想更換郵件設計,**不需要**更動本模組。
24
+ 只需在您的專案根目錄下建立對應檔案:
25
+ - `views/emails/welcome.html`
26
+ - `views/emails/reset_password.html`
27
+
28
+ 系統會自動優先讀取您的檔案。您可以在模板中使用 `{{ branding.name }}` 等變數來保持同步。
29
+
30
+ ---
31
+
32
+ ## 2. 業務邏輯擴充 (Hooks)
33
+
34
+ 如果您想在特定動作發生時「順便」做些什麼,請使用 Hook。
35
+
36
+ ### 範例:註冊成功後發送 Slack 通知
37
+ 在您的 `ServiceProvider` 中:
38
+ ```typescript
39
+ core.hooks.addAction('membership:registered', async ({ member }) => {
40
+ // 調用您的 Slack API
41
+ await mySlackService.notify(`新用戶註冊: ${member.email}`);
42
+ });
43
+ ```
44
+
45
+ ---
46
+
47
+ ## 3. 深度行為替換 (Container Override)
48
+
49
+ 如果您覺得預設的登入邏輯不符合需求(例如:您想增加圖形驗證碼檢查),您可以直接替換 UseCase。
50
+
51
+ ### 步驟 A:繼承並擴充
52
+ ```typescript
53
+ import { LoginMember } from '@gravito/satellite-membership'
54
+
55
+ export class MyCustomLogin extends LoginMember {
56
+ async execute(input) {
57
+ // 1. 執行您的自定義驗證
58
+ if (!await checkCaptcha(input.captcha)) {
59
+ throw new Error('驗證碼錯誤');
60
+ }
61
+ // 2. 調用父類別完成標準登入
62
+ return super.execute(input);
63
+ }
64
+ }
65
+ ```
66
+
67
+ ### 步驟 B:重新註冊
68
+ 在您的 `bootstrap` 過程中:
69
+ ```typescript
70
+ core.container.singleton('membership.login', () => new MyCustomLogin(core));
71
+ ```
72
+
73
+ ---
74
+
75
+ ## 4. 數據擴充 (Metadata)
76
+
77
+ 您不需要為會員資料表增加欄位(如:電話、地址)。
78
+
79
+ ### 存入自定義資料
80
+ ```typescript
81
+ const update = container.make('membership.update-settings')
82
+ await update.execute({
83
+ memberId: '...',
84
+ metadata: {
85
+ phone: '0912345678',
86
+ address: '台北市...',
87
+ preferences: { theme: 'dark' }
88
+ }
89
+ })
90
+ ```
91
+ 這些資料會以 JSON 格式存儲於 `metadata` 欄位中,並隨時可以透過 `member.metadata.phone` 讀取。
92
+
93
+ ---
94
+
95
+ ## 🎯 DX 小貼士
96
+ - **本地預覽**: 啟動 `devMode: true`,所有發出的郵件都會在 Console 印出,並可在 `/__mail` 介面預覽。
97
+ - **類型安全**: 建議始終使用 `MemberDTO` 來進行前端數據交換,確保敏感資料(如 Password Hash)不會洩漏。
98
+
99
+ 希望這份指南能幫助您快速打造出完美的會員系統!🚀
@@ -0,0 +1,78 @@
1
+ # Passkeys (WebAuthn) Integration
2
+
3
+ This document shows how to integrate **Passkeys** (WebAuthn) with the [Membership Satellite](../../README.md) using the APIs that were added under `/api/membership/passkeys`.
4
+
5
+ ## Dependencies
6
+ - Install `@simplewebauthn/browser` on the client.
7
+ - Make sure your site is served over HTTPS (or `localhost` during development).
8
+
9
+ ```bash
10
+ bun add @simplewebauthn/browser
11
+ ```
12
+
13
+ ## Registration flow
14
+
15
+ ```ts
16
+ import { startRegistration } from '@simplewebauthn/browser'
17
+
18
+ const baseUrl = '/api/membership/passkeys'
19
+
20
+ async function beginPasskeyRegistration() {
21
+ const opts = await fetch(`${baseUrl}/register/options`, {
22
+ method: 'POST',
23
+ credentials: 'include',
24
+ }).then((res) => res.json())
25
+
26
+ const credential = await startRegistration(opts)
27
+
28
+ await fetch(`${baseUrl}/register/verify`, {
29
+ method: 'POST',
30
+ credentials: 'include',
31
+ headers: { 'Content-Type': 'application/json' },
32
+ body: JSON.stringify({
33
+ credential,
34
+ displayName: 'My iPhone',
35
+ }),
36
+ })
37
+ }
38
+ ```
39
+
40
+ 1. POST to `/register/options` after the user is authenticated (call `auth()` route guard from the backend) to receive the registration challenge.
41
+ 2. Call `startRegistration` from `@simplewebauthn/browser` and send the resulting credential to `/register/verify`.
42
+ 3. The server will verify the attestation, store the credential, and keep the session logged in.
43
+
44
+ ## Authentication flow
45
+
46
+ ```ts
47
+ import { startAuthentication } from '@simplewebauthn/browser'
48
+
49
+ async function beginPasskeyLogin(email: string) {
50
+ const opts = await fetch(`${baseUrl}/login/options`, {
51
+ method: 'POST',
52
+ credentials: 'include',
53
+ headers: { 'Content-Type': 'application/json' },
54
+ body: JSON.stringify({ email }),
55
+ }).then((res) => res.json())
56
+
57
+ const assertion = await startAuthentication(opts)
58
+
59
+ await fetch(`${baseUrl}/login/verify`, {
60
+ method: 'POST',
61
+ credentials: 'include',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({
64
+ email,
65
+ assertion,
66
+ }),
67
+ })
68
+ }
69
+ ```
70
+
71
+ 1. Send the member's email to `/login/options` to receive challenge data; this endpoint does not require authentication but expects a valid email.
72
+ 2. Run `startAuthentication` and POST the assertion to `/login/verify`.
73
+ 3. On success the backend logs the member in via the existing `AuthManager`.
74
+
75
+ ## Notes
76
+ - These endpoints rely on session storage (`core.adapter` session provider). Ensure your client includes `credentials: 'include'`.
77
+ - You can show UI feedback when registration/authentication fails by catching the thrown errors.
78
+ - Store `credential.id` locally if you want to list registered devices; the backend already saves `displayName` and `transports`.
@@ -0,0 +1,30 @@
1
+ {
2
+ "errors": {
3
+ "member_exists": "Member with this email already exists",
4
+ "invalid_credentials": "Invalid credentials",
5
+ "account_suspended": "Account is suspended",
6
+ "member_not_found": "Member not found",
7
+ "current_password_required": "Current password is required to set a new password",
8
+ "invalid_current_password": "Invalid current password",
9
+ "invalid_token": "Invalid or expired token",
10
+ "session_expired_other_device": "Your account has been logged in on another device. This session is now invalid."
11
+ },
12
+ "notifications": {
13
+ "operational": "Satellite Membership is operational"
14
+ },
15
+ "emails": {
16
+ "welcome_subject": "Welcome to Gravito!",
17
+ "welcome_title": "Welcome Aboard",
18
+ "welcome_body": "Please click the button below to verify your email address:",
19
+ "verify_button": "Verify Email",
20
+ "reset_password_subject": "Reset Your Password",
21
+ "reset_password_title": "Reset Password Request",
22
+ "reset_password_body": "We received a request to reset your password. If you didn't make this request, just ignore this email.",
23
+ "reset_password_button": "Reset Password",
24
+ "reset_password_warning": "If you did not request a password reset, no further action is required.",
25
+ "level_changed_subject": "Your membership level has been updated!",
26
+ "level_changed_badge": "Level Up",
27
+ "level_changed_title": "Congratulations!",
28
+ "level_changed_body": "Your membership level has been successfully upgraded. You can now enjoy more exclusive benefits."
29
+ }
30
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "errors": {
3
+ "member_exists": "使用此電子郵件的會員已存在",
4
+ "invalid_credentials": "認證資料無效",
5
+ "account_suspended": "帳號已被停權",
6
+ "member_not_found": "找不到會員",
7
+ "current_password_required": "設定新密碼需要輸入目前密碼",
8
+ "invalid_current_password": "目前密碼錯誤",
9
+ "invalid_token": "無效或已過期的權杖",
10
+ "session_expired_other_device": "您的帳號已在其他裝置登入,目前的連線已失效。"
11
+ },
12
+ "notifications": {
13
+ "operational": "會員衛星模組已啟動"
14
+ },
15
+ "emails": {
16
+ "welcome_subject": "歡迎加入 Gravito!",
17
+ "welcome_title": "歡迎您的加入",
18
+ "welcome_body": "請點擊下方按鈕驗證您的電子郵件地址:",
19
+ "verify_button": "驗證電子郵件",
20
+ "reset_password_subject": "重設您的密碼",
21
+ "reset_password_title": "重設密碼請求",
22
+ "reset_password_body": "我們收到了重設您密碼的請求。如果您沒有提出此請求,請忽略此郵件。",
23
+ "reset_password_button": "重設密碼",
24
+ "reset_password_warning": "如果您沒有要求重設密碼,請忽略此封郵件。",
25
+ "level_changed_subject": "您的會員等級已更新!",
26
+ "level_changed_badge": "晉級通知",
27
+ "level_changed_title": "恭喜晉級!",
28
+ "level_changed_body": "您的會員帳號已成功升級。現在您可以享受更多專屬權益與服務。"
29
+ }
30
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@gravito/satellite-membership",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsup src/index.ts --format esm --dts --clean --external @gravito/atlas --external @gravito/cosmos --external @gravito/enterprise --external @gravito/sentinel --external @gravito/signal --external @gravito/stasis --external @simplewebauthn/server --external @gravito/core",
10
+ "test": "bun test",
11
+ "typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck"
12
+ },
13
+ "dependencies": {
14
+ "@gravito/atlas": "workspace:*",
15
+ "@gravito/cosmos": "workspace:*",
16
+ "@gravito/enterprise": "workspace:*",
17
+ "@gravito/sentinel": "workspace:*",
18
+ "@gravito/signal": "workspace:*",
19
+ "@gravito/stasis": "workspace:*",
20
+ "@simplewebauthn/server": "^13.2.2",
21
+ "@gravito/core": "workspace:*"
22
+ },
23
+ "devDependencies": {
24
+ "tsup": "^8.0.0",
25
+ "typescript": "^5.9.3"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/gravito-framework/gravito.git",
33
+ "directory": "satellites/membership"
34
+ }
35
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Data Transfer Object for Member information
3
+ */
4
+ export interface MemberDTO {
5
+ id: string
6
+ name: string
7
+ email: string
8
+ status: string
9
+ level: string
10
+ roles: string[]
11
+ createdAt: string
12
+ metadata?: Record<string, any>
13
+ }
14
+
15
+ /**
16
+ * Mapper to convert Domain Entities to DTOs
17
+ */
18
+ export class MemberMapper {
19
+ /**
20
+ * Convert a member entity to a serializable DTO
21
+ */
22
+ static toDTO(member: any): MemberDTO {
23
+ return {
24
+ id: member.id,
25
+ name: member.name,
26
+ email: member.email,
27
+ status: member.status,
28
+ level: member.level || 'standard',
29
+ roles: member.roles || [],
30
+ createdAt: member.createdAt.toISOString(),
31
+ metadata: member.metadata,
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,42 @@
1
+ import { app } from '@gravito/core'
2
+ import { Mailable } from '@gravito/signal'
3
+
4
+ export class ForgotPasswordMail extends Mailable {
5
+ constructor(
6
+ private email: string,
7
+ private token: string
8
+ ) {
9
+ super()
10
+ }
11
+
12
+ build(): this {
13
+ const branding = {
14
+ name: 'Gravito App',
15
+ color: '#f43f5e',
16
+ }
17
+ let baseUrl = 'http://localhost:3000'
18
+
19
+ try {
20
+ const core = app()
21
+ if (core) {
22
+ branding.name = core.config.get('membership.branding.name', branding.name)
23
+ branding.color = core.config.get('membership.branding.primary_color', branding.color)
24
+ baseUrl = core.config.get('app.url', baseUrl)
25
+ }
26
+ } catch (_e) {}
27
+
28
+ return this.to(this.email)
29
+ .subject(this.t('membership.emails.reset_password_subject'))
30
+ .view('emails/reset_password', {
31
+ resetUrl: `${baseUrl}/reset-password?token=${this.token}`,
32
+ branding,
33
+ currentYear: new Date().getFullYear(),
34
+ lang: {
35
+ reset_password_title: this.t('membership.emails.reset_password_title'),
36
+ reset_password_body: this.t('membership.emails.reset_password_body'),
37
+ reset_password_button: this.t('membership.emails.reset_password_button'),
38
+ reset_password_warning: this.t('membership.emails.reset_password_warning'),
39
+ },
40
+ })
41
+ }
42
+ }
@@ -0,0 +1,41 @@
1
+ import { app } from '@gravito/core'
2
+ import { Mailable } from '@gravito/signal'
3
+
4
+ export class MemberLevelChangedMail extends Mailable {
5
+ constructor(
6
+ private email: string,
7
+ private oldLevel: string,
8
+ private newLevel: string
9
+ ) {
10
+ super()
11
+ }
12
+
13
+ build(): this {
14
+ const branding = {
15
+ name: 'Gravito App',
16
+ color: '#f59e0b',
17
+ }
18
+
19
+ try {
20
+ const core = app()
21
+ if (core) {
22
+ branding.name = core.config.get('membership.branding.name', branding.name)
23
+ branding.color = core.config.get('membership.branding.primary_color', branding.color)
24
+ }
25
+ } catch (_e) {}
26
+
27
+ return this.to(this.email)
28
+ .subject(this.t('membership.emails.level_changed_subject'))
29
+ .view('emails/level_changed', {
30
+ oldLevel: this.oldLevel,
31
+ newLevel: this.newLevel,
32
+ branding,
33
+ currentYear: new Date().getFullYear(),
34
+ lang: {
35
+ badge_text: this.t('membership.emails.level_changed_badge'),
36
+ title: this.t('membership.emails.level_changed_title'),
37
+ body: this.t('membership.emails.level_changed_body'),
38
+ },
39
+ })
40
+ }
41
+ }
@@ -0,0 +1,45 @@
1
+ import { app } from '@gravito/core'
2
+ import { Mailable } from '@gravito/signal'
3
+
4
+ export class WelcomeMail extends Mailable {
5
+ constructor(
6
+ private email: string,
7
+ private token: string
8
+ ) {
9
+ super()
10
+ }
11
+
12
+ build(): this {
13
+ // 品牌配置抽象化 (提供安全回退值)
14
+ const branding = {
15
+ name: 'Gravito App',
16
+ color: '#6366f1',
17
+ }
18
+ let baseUrl = 'http://localhost:3000'
19
+
20
+ try {
21
+ // 嘗試從全域獲取 (僅在 PlanetCore 運行時有效)
22
+ const core = app()
23
+ if (core) {
24
+ branding.name = core.config.get('membership.branding.name', branding.name)
25
+ branding.color = core.config.get('membership.branding.primary_color', branding.color)
26
+ baseUrl = core.config.get('app.url', baseUrl)
27
+ }
28
+ } catch (_e) {
29
+ // 忽略錯誤,使用預設值
30
+ }
31
+
32
+ return this.to(this.email)
33
+ .subject(this.t('membership.emails.welcome_subject'))
34
+ .view('emails/welcome', {
35
+ verificationUrl: `${baseUrl}/verify?token=${this.token}`,
36
+ branding,
37
+ currentYear: new Date().getFullYear(),
38
+ lang: {
39
+ welcome_title: this.t('membership.emails.welcome_title'),
40
+ welcome_body: this.t('membership.emails.welcome_body'),
41
+ verify_button: this.t('membership.emails.verify_button'),
42
+ },
43
+ })
44
+ }
45
+ }
@@ -0,0 +1,198 @@
1
+ import { AuthenticationException } from '@gravito/core'
2
+ import type {
3
+ AuthenticationResponseJSON,
4
+ AuthenticatorTransportFuture,
5
+ RegistrationResponseJSON,
6
+ WebAuthnCredential,
7
+ } from '@simplewebauthn/server'
8
+ import {
9
+ generateAuthenticationOptions,
10
+ generateRegistrationOptions,
11
+ verifyAuthenticationResponse,
12
+ verifyRegistrationResponse,
13
+ } from '@simplewebauthn/server'
14
+ import type { IMemberPasskeyRepository } from '../../Domain/Contracts/IMemberPasskeyRepository'
15
+ import type { Member } from '../../Domain/Entities/Member'
16
+ import { MemberPasskey } from '../../Domain/Entities/MemberPasskey'
17
+
18
+ interface SessionLike {
19
+ get(key: string): string | null | undefined
20
+ put(key: string, value: unknown): void
21
+ forget?(key: string): void
22
+ }
23
+
24
+ const SESSION_KEYS = {
25
+ registration: 'membership.passkeys.registration.challenge',
26
+ authentication: 'membership.passkeys.authentication.challenge',
27
+ }
28
+
29
+ export interface PasskeysConfig {
30
+ rpName: string
31
+ rpID: string
32
+ origin: string
33
+ timeout?: number
34
+ userVerification?: 'required' | 'preferred' | 'discouraged'
35
+ attestationType?: 'direct' | 'enterprise' | 'none'
36
+ }
37
+
38
+ export class PasskeysService {
39
+ constructor(
40
+ private passkeyRepo: IMemberPasskeyRepository,
41
+ private config: PasskeysConfig
42
+ ) {}
43
+
44
+ public async generateRegistrationOptions(member: Member, session: SessionLike) {
45
+ const credentials = await this.passkeyRepo.findByMemberId(member.id)
46
+ const options = await generateRegistrationOptions({
47
+ rpName: this.config.rpName,
48
+ rpID: this.config.rpID,
49
+ userName: member.email,
50
+ userID: new TextEncoder().encode(member.id),
51
+ attestationType: this.config.attestationType ?? 'none',
52
+ timeout: this.config.timeout || 60000,
53
+ userDisplayName: member.name,
54
+ authenticatorSelection: {
55
+ userVerification: this.config.userVerification ?? 'preferred',
56
+ },
57
+ excludeCredentials: credentials.map((credential) => ({
58
+ id: credential.credentialId,
59
+ type: 'public-key',
60
+ transports: normalizeTransports(credential.transports),
61
+ })),
62
+ })
63
+
64
+ this.storeChallenge(session, SESSION_KEYS.registration, options.challenge)
65
+ return options
66
+ }
67
+
68
+ public async verifyRegistrationResponse(
69
+ member: Member,
70
+ response: RegistrationResponseJSON,
71
+ session: SessionLike,
72
+ displayName?: string
73
+ ) {
74
+ const challenge = this.requireChallenge(session, SESSION_KEYS.registration)
75
+
76
+ const verification = await verifyRegistrationResponse({
77
+ response,
78
+ expectedChallenge: challenge,
79
+ expectedOrigin: this.config.origin,
80
+ expectedRPID: this.config.rpID,
81
+ requireUserVerification: this.config.userVerification !== 'discouraged',
82
+ })
83
+
84
+ this.clearChallenge(session, SESSION_KEYS.registration)
85
+
86
+ if (!verification.verified || !verification.registrationInfo) {
87
+ throw new AuthenticationException('Passkey registration could not be verified.')
88
+ }
89
+
90
+ const { credential } = verification.registrationInfo
91
+
92
+ const passkey = MemberPasskey.create({
93
+ memberId: member.id,
94
+ credentialId: credential.id,
95
+ publicKey: Buffer.from(credential.publicKey).toString('base64'),
96
+ counter: credential.counter,
97
+ transports: credential.transports,
98
+ displayName,
99
+ })
100
+
101
+ await this.passkeyRepo.save(passkey)
102
+ return passkey
103
+ }
104
+
105
+ public async generateAuthenticationOptions(member: Member, session: SessionLike) {
106
+ const credentials = await this.passkeyRepo.findByMemberId(member.id)
107
+
108
+ const options = await generateAuthenticationOptions({
109
+ rpID: this.config.rpID,
110
+ timeout: this.config.timeout || 60000,
111
+ userVerification: this.config.userVerification ?? 'preferred',
112
+ allowCredentials: credentials.map((credential) => ({
113
+ id: credential.credentialId,
114
+ transports: normalizeTransports(credential.transports),
115
+ })),
116
+ })
117
+
118
+ this.storeChallenge(session, SESSION_KEYS.authentication, options.challenge)
119
+ return options
120
+ }
121
+
122
+ public async verifyAuthenticationResponse(
123
+ member: Member,
124
+ response: AuthenticationResponseJSON,
125
+ session: SessionLike
126
+ ) {
127
+ const challenge = this.requireChallenge(session, SESSION_KEYS.authentication)
128
+ const credential = await this.passkeyRepo.findByCredentialId(response.id)
129
+
130
+ if (!credential) {
131
+ throw new AuthenticationException('Passkey not registered.')
132
+ }
133
+
134
+ if (credential.memberId !== member.id) {
135
+ throw new AuthenticationException('Passkey does not belong to the member.')
136
+ }
137
+
138
+ const verification = await verifyAuthenticationResponse({
139
+ response,
140
+ expectedChallenge: challenge,
141
+ expectedOrigin: this.config.origin,
142
+ expectedRPID: this.config.rpID,
143
+ credential: buildWebAuthnCredential(credential),
144
+ requireUserVerification: this.config.userVerification !== 'discouraged',
145
+ })
146
+
147
+ this.clearChallenge(session, SESSION_KEYS.authentication)
148
+
149
+ if (!verification.verified) {
150
+ throw new AuthenticationException('Passkey authentication failed.')
151
+ }
152
+
153
+ credential.updateCounter(verification.authenticationInfo.newCounter)
154
+ await this.passkeyRepo.save(credential)
155
+ return credential
156
+ }
157
+
158
+ private storeChallenge(session: SessionLike, key: string, challenge: string) {
159
+ this.sessionGuard(session)
160
+ session.put(key, challenge)
161
+ }
162
+
163
+ private requireChallenge(session: SessionLike, key: string): string {
164
+ this.sessionGuard(session)
165
+ const challenge = session.get(key)
166
+ if (!challenge) {
167
+ throw new AuthenticationException('Passkey challenge is missing.')
168
+ }
169
+ return challenge
170
+ }
171
+
172
+ private clearChallenge(session: SessionLike, key: string) {
173
+ this.sessionGuard(session)
174
+ if (typeof session.forget === 'function') {
175
+ session.forget(key)
176
+ }
177
+ }
178
+
179
+ private sessionGuard(session: SessionLike) {
180
+ if (!session || typeof session.put !== 'function' || typeof session.get !== 'function') {
181
+ throw new AuthenticationException('Session store is required for Passkeys.')
182
+ }
183
+ }
184
+ }
185
+
186
+ function normalizeTransports(transports?: string[]): AuthenticatorTransportFuture[] | undefined {
187
+ return transports ? (transports as AuthenticatorTransportFuture[]) : undefined
188
+ }
189
+
190
+ function buildWebAuthnCredential(passkey: MemberPasskey): WebAuthnCredential {
191
+ const publicKey = Buffer.from(passkey.publicKey, 'base64')
192
+ return {
193
+ id: passkey.credentialId,
194
+ publicKey,
195
+ counter: passkey.counter,
196
+ transports: normalizeTransports(passkey.transports),
197
+ }
198
+ }
@@ -0,0 +1,34 @@
1
+ import type { PlanetCore } from '@gravito/core'
2
+ import { UseCase } from '@gravito/enterprise'
3
+ import type { IMemberRepository } from '../../Domain/Contracts/IMemberRepository'
4
+
5
+ export interface ForgotPasswordInput {
6
+ email: string
7
+ }
8
+
9
+ export class ForgotPassword extends UseCase<ForgotPasswordInput, void> {
10
+ constructor(
11
+ private repository: IMemberRepository,
12
+ private core: PlanetCore
13
+ ) {
14
+ super()
15
+ }
16
+
17
+ async execute(input: ForgotPasswordInput): Promise<void> {
18
+ const member = await this.repository.findByEmail(input.email)
19
+
20
+ if (!member) {
21
+ // Security: Don't reveal if user exists
22
+ return
23
+ }
24
+
25
+ member.generatePasswordResetToken()
26
+ await this.repository.save(member)
27
+
28
+ // Trigger hook to send reset email
29
+ await this.core.hooks.doAction('membership:send-reset-password', {
30
+ email: member.email,
31
+ token: member.passwordResetToken,
32
+ })
33
+ }
34
+ }