create-forgeon 0.3.24 → 0.3.26
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.
- package/package.json +1 -1
- package/src/modules/accounts-communications.mjs +146 -0
- package/src/modules/accounts.mjs +17 -5
- package/src/modules/communications.mjs +4 -3
- package/src/modules/dependencies.test.mjs +37 -14
- package/src/modules/executor.mjs +2 -0
- package/src/modules/executor.test.mjs +105 -16
- package/src/modules/registry.mjs +24 -8
- package/templates/module-fragments/accounts/20_scope.md +4 -5
- package/templates/module-fragments/accounts/90_status_implemented.md +2 -2
- package/templates/module-fragments/accounts-communications/00_title.md +1 -0
- package/templates/module-fragments/accounts-communications/10_overview.md +3 -0
- package/templates/module-fragments/accounts-communications/20_scope.md +24 -0
- package/templates/module-fragments/accounts-communications/90_status_implemented.md +3 -0
- package/templates/module-fragments/communications/20_scope.md +5 -2
- package/templates/module-presets/accounts/apps/api/prisma/migrations/0002_accounts_core/migration.sql +22 -1
- package/templates/module-presets/accounts/packages/accounts-api/package.json +0 -1
- package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +122 -117
- package/templates/module-presets/accounts/packages/accounts-api/src/auth-pending-operations.ts +9 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/auth.controller.ts +2 -21
- package/templates/module-presets/accounts/packages/accounts-api/src/auth.handlers.ts +45 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/auth.service.ts +19 -18
- package/templates/module-presets/accounts/packages/accounts-api/src/auth.store.ts +87 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +29 -5
- package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +2 -0
- package/templates/module-presets/accounts/packages/accounts-contracts/src/index.ts +37 -1
- package/templates/module-presets/accounts-communications/packages/accounts-communications/package.json +22 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/auth-communications.controller.ts +69 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/auth-communications.service.ts +221 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/confirmed-change-password.handler.ts +16 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-change-email.dto.ts +8 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-change-password.dto.ts +8 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-password-reset.dto.ts +12 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/index.ts +6 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/request-change-email.dto.ts +7 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/request-password-reset.dto.ts +7 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/verify-email.dto.ts +8 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/index.ts +5 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/pending-verification-register.handler.ts +13 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/tsconfig.json +9 -0
- package/templates/module-presets/communications/packages/communications/src/communications-config.loader.ts +2 -1
- package/templates/module-presets/communications/packages/communications/src/communications-env.schema.ts +21 -5
- package/templates/module-presets/communications/packages/communications/src/email/providers/gmail-smtp-email.provider.ts +52 -12
- package/templates/module-presets/communications/packages/communications/src/forgeon-communications.module.ts +2 -1
|
@@ -5,11 +5,10 @@ Implemented scope:
|
|
|
5
5
|
1. Public installer surface:
|
|
6
6
|
- single umbrella add-module: `accounts`
|
|
7
7
|
- requires `db-adapter`
|
|
8
|
-
- requires `communications-runtime`
|
|
9
8
|
2. Internal runtime split:
|
|
10
9
|
- `@forgeon/accounts-contracts`
|
|
11
10
|
- `@forgeon/accounts-api`
|
|
12
|
-
- users core, auth core, auth-jwt, auth-password
|
|
11
|
+
- users core, auth core, handlers, auth-jwt, auth-password
|
|
13
12
|
3. API runtime:
|
|
14
13
|
- `POST /api/auth/register`
|
|
15
14
|
- `POST /api/auth/login`
|
|
@@ -17,14 +16,14 @@ Implemented scope:
|
|
|
17
16
|
- `POST /api/auth/logout`
|
|
18
17
|
- `GET /api/auth/me`
|
|
19
18
|
- `POST /api/auth/change-password`
|
|
20
|
-
- stub endpoints for verify-email and password reset confirmation
|
|
21
19
|
4. Users surface:
|
|
22
20
|
- owner-scoped routes under `/api/users/:id`, `/api/users/:id/profile`, `/api/users/:id/settings`
|
|
23
21
|
- `/users/me` is resolved through the same owner-scoped route surface
|
|
24
22
|
5. Persistence and security:
|
|
25
|
-
- DB-backed `User`, `UserProfile`, `UserSettings`, `AuthIdentity`, `AuthCredential`, `AuthRefreshToken`
|
|
23
|
+
- DB-backed `User`, `UserProfile`, `UserSettings`, `AuthIdentity`, `AuthCredential`, `AuthRefreshToken`, `AuthPendingOperation`
|
|
26
24
|
- argon2 for password and refresh-token hashing
|
|
27
25
|
- refresh token rotation + revoke with per-token storage rows
|
|
26
|
+
- pending operation records for delayed confirmation and recovery flows
|
|
28
27
|
6. Module checks:
|
|
29
28
|
- API probe endpoint: `GET /api/health/auth`
|
|
30
|
-
- default web probe button + result block
|
|
29
|
+
- default web probe button + result block
|
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
Status: implemented.
|
|
4
4
|
|
|
5
5
|
Notes:
|
|
6
|
-
- `accounts` is a hard consumer of the `db-adapter`
|
|
6
|
+
- `accounts` is a hard consumer of the `db-adapter` capability only.
|
|
7
7
|
- The base accounts schema does not store RBAC roles or permissions.
|
|
8
|
-
-
|
|
8
|
+
- Delivery-assisted auth/account flows belong to the optional `accounts-communications` extension.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# {{MODULE_LABEL}}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
## Scope
|
|
2
|
+
|
|
3
|
+
Implemented scope:
|
|
4
|
+
|
|
5
|
+
1. Public installer surface:
|
|
6
|
+
- single add-module: `accounts-communications`
|
|
7
|
+
- requires `accounts`
|
|
8
|
+
- requires `communications`
|
|
9
|
+
2. Runtime package:
|
|
10
|
+
- `@forgeon/accounts-communications`
|
|
11
|
+
3. Handler rebinding:
|
|
12
|
+
- pending-verification `register`
|
|
13
|
+
- confirmable `change-password`
|
|
14
|
+
4. Extension routes:
|
|
15
|
+
- `POST /api/auth/verify-email`
|
|
16
|
+
- `POST /api/auth/password-reset/request`
|
|
17
|
+
- `POST /api/auth/password-reset/confirm`
|
|
18
|
+
- `POST /api/auth/change-password/confirm`
|
|
19
|
+
- `POST /api/auth/change-email/request`
|
|
20
|
+
- `POST /api/auth/change-email/confirm`
|
|
21
|
+
5. Runtime boundaries:
|
|
22
|
+
- one `AuthCommunicationsController`
|
|
23
|
+
- one `AuthCommunicationsService`
|
|
24
|
+
- base account/auth state remains owned by `accounts`
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
## Scope
|
|
1
|
+
## Scope
|
|
2
2
|
|
|
3
3
|
Implemented scope:
|
|
4
4
|
|
|
@@ -14,7 +14,10 @@ Implemented scope:
|
|
|
14
14
|
- email channel with Gmail SMTP transport configuration
|
|
15
15
|
- sms stub channel
|
|
16
16
|
- push stub channel
|
|
17
|
-
5.
|
|
17
|
+
5. SMTP defaults:
|
|
18
|
+
- `COMMUNICATIONS_EMAIL_SMTP_SECURE=false` uses STARTTLS mode correctly on port `587`
|
|
19
|
+
- `COMMUNICATIONS_EMAIL_FROM` falls back to the authenticated SMTP user when left empty
|
|
20
|
+
6. Module checks:
|
|
18
21
|
- `GET /api/health/communications`
|
|
19
22
|
- `POST /api/health/communications`
|
|
20
23
|
- default web probe with email input + test send
|
|
@@ -6,6 +6,7 @@ ALTER TABLE "User"
|
|
|
6
6
|
DROP COLUMN IF EXISTS "email",
|
|
7
7
|
ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'active',
|
|
8
8
|
ADD COLUMN IF NOT EXISTS "data" JSONB,
|
|
9
|
+
ADD COLUMN IF NOT EXISTS "emailVerifiedAt" TIMESTAMP(3),
|
|
9
10
|
ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3),
|
|
10
11
|
ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
11
12
|
|
|
@@ -57,10 +58,24 @@ CREATE TABLE IF NOT EXISTS "AuthRefreshToken" (
|
|
|
57
58
|
CONSTRAINT "AuthRefreshToken_pkey" PRIMARY KEY ("id")
|
|
58
59
|
);
|
|
59
60
|
|
|
61
|
+
-- CreateTable
|
|
62
|
+
CREATE TABLE IF NOT EXISTS "AuthPendingOperation" (
|
|
63
|
+
"id" TEXT NOT NULL,
|
|
64
|
+
"userId" TEXT NOT NULL,
|
|
65
|
+
"type" TEXT NOT NULL,
|
|
66
|
+
"tokenHash" TEXT NOT NULL,
|
|
67
|
+
"metadata" JSONB,
|
|
68
|
+
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
69
|
+
"consumedAt" TIMESTAMP(3),
|
|
70
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
71
|
+
CONSTRAINT "AuthPendingOperation_pkey" PRIMARY KEY ("id")
|
|
72
|
+
);
|
|
73
|
+
|
|
60
74
|
-- Indexes
|
|
61
75
|
CREATE UNIQUE INDEX IF NOT EXISTS "AuthIdentity_provider_providerId_key" ON "AuthIdentity"("provider", "providerId");
|
|
62
76
|
CREATE UNIQUE INDEX IF NOT EXISTS "AuthCredential_userId_key" ON "AuthCredential"("userId");
|
|
63
77
|
CREATE INDEX IF NOT EXISTS "AuthRefreshToken_userId_createdAt_idx" ON "AuthRefreshToken"("userId", "createdAt");
|
|
78
|
+
CREATE INDEX IF NOT EXISTS "AuthPendingOperation_userId_type_createdAt_idx" ON "AuthPendingOperation"("userId", "type", "createdAt");
|
|
64
79
|
|
|
65
80
|
-- Foreign keys
|
|
66
81
|
DO $$
|
|
@@ -94,4 +109,10 @@ BEGIN
|
|
|
94
109
|
ADD CONSTRAINT "AuthRefreshToken_userId_fkey"
|
|
95
110
|
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
96
111
|
END IF;
|
|
97
|
-
|
|
112
|
+
|
|
113
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AuthPendingOperation_userId_fkey') THEN
|
|
114
|
+
ALTER TABLE "AuthPendingOperation"
|
|
115
|
+
ADD CONSTRAINT "AuthPendingOperation_userId_fkey"
|
|
116
|
+
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
117
|
+
END IF;
|
|
118
|
+
END $$;
|
|
@@ -3,18 +3,22 @@ import {
|
|
|
3
3
|
BadRequestException,
|
|
4
4
|
ConflictException,
|
|
5
5
|
Injectable,
|
|
6
|
-
|
|
6
|
+
NotFoundException,
|
|
7
7
|
UnauthorizedException,
|
|
8
8
|
} from '@nestjs/common';
|
|
9
|
-
import { CommunicationsService } from '@forgeon/communications';
|
|
10
9
|
import type {
|
|
11
10
|
AuthSessionResponse,
|
|
11
|
+
JsonObject,
|
|
12
12
|
RegisterRequest,
|
|
13
13
|
UserRecordDto,
|
|
14
14
|
} from '@forgeon/accounts-contracts';
|
|
15
15
|
import { AuthJwtService } from './auth-jwt.service';
|
|
16
16
|
import { AuthPasswordService } from './auth-password.service';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
AuthStore,
|
|
19
|
+
type PasswordAccountRecord,
|
|
20
|
+
type PendingOperationRecord,
|
|
21
|
+
} from './auth.store';
|
|
18
22
|
import type { AuthRefreshTokenPayload } from './auth.types';
|
|
19
23
|
import { UsersService } from './users.service';
|
|
20
24
|
import { toUserRecordDto } from './users.types';
|
|
@@ -25,21 +29,27 @@ const AUTH_ERROR_CODES = {
|
|
|
25
29
|
tokenExpired: 'AUTH_TOKEN_EXPIRED',
|
|
26
30
|
emailTaken: 'AUTH_EMAIL_TAKEN',
|
|
27
31
|
accountDisabled: 'AUTH_ACCOUNT_DISABLED',
|
|
32
|
+
pendingOperationInvalid: 'AUTH_PENDING_OPERATION_INVALID',
|
|
28
33
|
} as const;
|
|
29
34
|
|
|
35
|
+
const DEFAULT_PENDING_OPERATION_TTL_MS = 1000 * 60 * 30;
|
|
36
|
+
|
|
30
37
|
@Injectable()
|
|
31
38
|
export class AuthCoreService {
|
|
32
|
-
private readonly logger = new Logger(AuthCoreService.name);
|
|
33
|
-
|
|
34
39
|
constructor(
|
|
35
40
|
private readonly authStore: AuthStore,
|
|
36
|
-
private readonly communicationsService: CommunicationsService,
|
|
37
41
|
private readonly authJwtService: AuthJwtService,
|
|
38
42
|
private readonly authPasswordService: AuthPasswordService,
|
|
39
43
|
private readonly usersService: UsersService,
|
|
40
44
|
) {}
|
|
41
45
|
|
|
42
|
-
async
|
|
46
|
+
async createPasswordAccount(
|
|
47
|
+
input: RegisterRequest,
|
|
48
|
+
options: {
|
|
49
|
+
status: string;
|
|
50
|
+
emailVerifiedAt: Date | null;
|
|
51
|
+
},
|
|
52
|
+
): Promise<PasswordAccountRecord> {
|
|
43
53
|
const email = input.email.trim().toLowerCase();
|
|
44
54
|
const existing = await this.authStore.findPasswordAccountByEmail(email);
|
|
45
55
|
if (existing) {
|
|
@@ -50,10 +60,11 @@ export class AuthCoreService {
|
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
const passwordHash = await this.authPasswordService.hash(input.password);
|
|
53
|
-
|
|
63
|
+
return this.authStore.createPasswordAccount({
|
|
54
64
|
email,
|
|
55
65
|
passwordHash,
|
|
56
|
-
status:
|
|
66
|
+
status: options.status,
|
|
67
|
+
emailVerifiedAt: options.emailVerifiedAt,
|
|
57
68
|
userData: this.usersService.resolveUserData(input.user),
|
|
58
69
|
profile: {
|
|
59
70
|
name: this.readNullableString(input.profile, 'name'),
|
|
@@ -66,40 +77,6 @@ export class AuthCoreService {
|
|
|
66
77
|
data: this.usersService.resolveSettingsData(this.readNestedObject(input.settings, 'data')),
|
|
67
78
|
},
|
|
68
79
|
});
|
|
69
|
-
|
|
70
|
-
const accountDto = toUserRecordDto(account);
|
|
71
|
-
const verificationToken = this.createStubToken('verify', account.id);
|
|
72
|
-
await Promise.all([
|
|
73
|
-
this.sendCommunicationSafely({
|
|
74
|
-
kind: 'email_verification_code',
|
|
75
|
-
channels: ['email'],
|
|
76
|
-
recipient: { email },
|
|
77
|
-
payload: {
|
|
78
|
-
NAME: accountDto.profile?.name ?? 'there',
|
|
79
|
-
CODE: verificationToken,
|
|
80
|
-
},
|
|
81
|
-
locale: accountDto.settings?.locale ?? undefined,
|
|
82
|
-
metadata: {
|
|
83
|
-
USER_ID: account.id,
|
|
84
|
-
SOURCE: 'accounts.register',
|
|
85
|
-
},
|
|
86
|
-
}),
|
|
87
|
-
this.sendCommunicationSafely({
|
|
88
|
-
kind: 'welcome_email',
|
|
89
|
-
channels: ['email'],
|
|
90
|
-
recipient: { email },
|
|
91
|
-
payload: {
|
|
92
|
-
NAME: accountDto.profile?.name ?? 'there',
|
|
93
|
-
},
|
|
94
|
-
locale: accountDto.settings?.locale ?? undefined,
|
|
95
|
-
metadata: {
|
|
96
|
-
USER_ID: account.id,
|
|
97
|
-
SOURCE: 'accounts.register',
|
|
98
|
-
},
|
|
99
|
-
}),
|
|
100
|
-
]);
|
|
101
|
-
|
|
102
|
-
return this.issueSession(accountDto);
|
|
103
80
|
}
|
|
104
81
|
|
|
105
82
|
async loginWithPassword(emailInput: string, password: string): Promise<AuthSessionResponse> {
|
|
@@ -116,7 +93,7 @@ export class AuthCoreService {
|
|
|
116
93
|
throw this.invalidCredentialsError();
|
|
117
94
|
}
|
|
118
95
|
|
|
119
|
-
return this.
|
|
96
|
+
return this.issueSessionForAccount(account);
|
|
120
97
|
}
|
|
121
98
|
|
|
122
99
|
async refreshTokens(refreshToken: string): Promise<AuthSessionResponse> {
|
|
@@ -152,17 +129,10 @@ export class AuthCoreService {
|
|
|
152
129
|
});
|
|
153
130
|
}
|
|
154
131
|
|
|
155
|
-
const account = await this.
|
|
156
|
-
if (!account) {
|
|
157
|
-
throw new UnauthorizedException({
|
|
158
|
-
message: 'Refresh token is invalid or expired',
|
|
159
|
-
details: { code: AUTH_ERROR_CODES.refreshInvalid },
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
|
|
132
|
+
const account = await this.findAccountByUserIdOrThrow(payload.sub);
|
|
163
133
|
this.assertAccountActive(account);
|
|
164
134
|
await this.authStore.revokeRefreshToken(record.id, new Date());
|
|
165
|
-
return this.
|
|
135
|
+
return this.issueSessionForAccount(account);
|
|
166
136
|
}
|
|
167
137
|
|
|
168
138
|
async logout(userId: string, refreshToken: string): Promise<void> {
|
|
@@ -177,62 +147,104 @@ export class AuthCoreService {
|
|
|
177
147
|
await this.authStore.revokeRefreshToken(payload.jti, new Date());
|
|
178
148
|
}
|
|
179
149
|
|
|
180
|
-
async
|
|
150
|
+
async changePasswordNow(userId: string, newPassword: string): Promise<void> {
|
|
181
151
|
const passwordHash = await this.authPasswordService.hash(newPassword);
|
|
152
|
+
await this.applyPasswordHash(userId, passwordHash);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async applyPasswordHash(userId: string, passwordHash: string): Promise<void> {
|
|
182
156
|
await this.authStore.updatePassword(userId, passwordHash);
|
|
183
157
|
await this.authStore.revokeRefreshTokensForUser(userId, new Date());
|
|
184
158
|
}
|
|
185
159
|
|
|
186
|
-
async
|
|
160
|
+
async markEmailVerified(userId: string): Promise<PasswordAccountRecord> {
|
|
161
|
+
await this.authStore.markEmailVerified(userId, new Date());
|
|
162
|
+
return this.findAccountByUserIdOrThrow(userId);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async updatePrimaryEmail(userId: string, emailInput: string): Promise<PasswordAccountRecord> {
|
|
187
166
|
const email = emailInput.trim().toLowerCase();
|
|
188
|
-
const
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
channels: ['email'],
|
|
194
|
-
recipient: { email },
|
|
195
|
-
payload: {
|
|
196
|
-
NAME: user.profile?.name ?? 'there',
|
|
197
|
-
TOKEN: this.createStubToken('reset', account.id),
|
|
198
|
-
},
|
|
199
|
-
locale: user.settings?.locale ?? undefined,
|
|
200
|
-
metadata: {
|
|
201
|
-
USER_ID: account.id,
|
|
202
|
-
SOURCE: 'accounts.password-reset',
|
|
203
|
-
},
|
|
167
|
+
const existing = await this.authStore.findPasswordAccountByEmail(email);
|
|
168
|
+
if (existing && existing.id !== userId) {
|
|
169
|
+
throw new ConflictException({
|
|
170
|
+
message: 'Email is already registered',
|
|
171
|
+
details: { code: AUTH_ERROR_CODES.emailTaken },
|
|
204
172
|
});
|
|
205
173
|
}
|
|
206
174
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
delivery: 'communications',
|
|
210
|
-
};
|
|
175
|
+
await this.authStore.updatePrimaryEmail(userId, email, new Date());
|
|
176
|
+
return this.findAccountByUserIdOrThrow(userId);
|
|
211
177
|
}
|
|
212
178
|
|
|
213
|
-
async
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
179
|
+
async issuePendingOperation(input: {
|
|
180
|
+
userId: string;
|
|
181
|
+
type: string;
|
|
182
|
+
metadata?: JsonObject | null;
|
|
183
|
+
ttlMs?: number;
|
|
184
|
+
}): Promise<{ token: string; id: string; expiresAt: Date }> {
|
|
185
|
+
const id = crypto.randomUUID();
|
|
186
|
+
const secret = crypto.randomBytes(24).toString('hex');
|
|
187
|
+
const tokenHash = await this.authPasswordService.hash(secret);
|
|
188
|
+
const expiresAt = new Date(Date.now() + (input.ttlMs ?? DEFAULT_PENDING_OPERATION_TTL_MS));
|
|
189
|
+
|
|
190
|
+
await this.authStore.createPendingOperation({
|
|
191
|
+
id,
|
|
192
|
+
userId: input.userId,
|
|
193
|
+
type: input.type,
|
|
194
|
+
tokenHash,
|
|
195
|
+
metadata: input.metadata ?? null,
|
|
196
|
+
expiresAt,
|
|
197
|
+
});
|
|
217
198
|
|
|
218
199
|
return {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
passwordLength: newPassword.length,
|
|
200
|
+
id,
|
|
201
|
+
token: `${id}.${secret}`,
|
|
202
|
+
expiresAt,
|
|
223
203
|
};
|
|
224
204
|
}
|
|
225
205
|
|
|
226
|
-
async
|
|
227
|
-
|
|
228
|
-
|
|
206
|
+
async readPendingOperation(token: string, expectedType: string): Promise<PendingOperationRecord> {
|
|
207
|
+
const [id, secret] = token.trim().split('.');
|
|
208
|
+
if (!id || !secret) {
|
|
209
|
+
throw new BadRequestException({
|
|
210
|
+
message: 'Pending operation token is invalid',
|
|
211
|
+
details: { code: AUTH_ERROR_CODES.pendingOperationInvalid },
|
|
212
|
+
});
|
|
229
213
|
}
|
|
230
214
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
215
|
+
const operation = await this.authStore.findPendingOperationById(id);
|
|
216
|
+
if (!operation || operation.type !== expectedType || operation.consumedAt || operation.expiresAt <= new Date()) {
|
|
217
|
+
throw new BadRequestException({
|
|
218
|
+
message: 'Pending operation token is invalid',
|
|
219
|
+
details: { code: AUTH_ERROR_CODES.pendingOperationInvalid },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const matched = await this.authPasswordService.verify(secret, operation.tokenHash);
|
|
224
|
+
if (!matched) {
|
|
225
|
+
throw new BadRequestException({
|
|
226
|
+
message: 'Pending operation token is invalid',
|
|
227
|
+
details: { code: AUTH_ERROR_CODES.pendingOperationInvalid },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return operation;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async consumePendingOperation(operationId: string): Promise<void> {
|
|
235
|
+
await this.authStore.consumePendingOperation(operationId, new Date());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async findPasswordAccountByEmail(emailInput: string): Promise<PasswordAccountRecord | null> {
|
|
239
|
+
return this.authStore.findPasswordAccountByEmail(emailInput.trim().toLowerCase());
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async findAccountByUserIdOrThrow(userId: string): Promise<PasswordAccountRecord> {
|
|
243
|
+
const account = await this.authStore.findAccountByUserId(userId);
|
|
244
|
+
if (!account) {
|
|
245
|
+
throw new NotFoundException('Account was not found');
|
|
246
|
+
}
|
|
247
|
+
return account;
|
|
236
248
|
}
|
|
237
249
|
|
|
238
250
|
async me(userId: string): Promise<{ user: UserRecordDto }> {
|
|
@@ -240,12 +252,16 @@ export class AuthCoreService {
|
|
|
240
252
|
return { user };
|
|
241
253
|
}
|
|
242
254
|
|
|
255
|
+
async issueSessionForAccount(account: PasswordAccountRecord): Promise<AuthSessionResponse> {
|
|
256
|
+
return this.issueSession(toUserRecordDto(account));
|
|
257
|
+
}
|
|
258
|
+
|
|
243
259
|
getProbeStatus() {
|
|
244
260
|
return {
|
|
245
261
|
status: 'ok',
|
|
246
262
|
feature: 'accounts',
|
|
247
263
|
storage: 'db-prisma',
|
|
248
|
-
|
|
264
|
+
messagingExtension: 'accounts-communications (optional)',
|
|
249
265
|
selfServiceRoutes: [
|
|
250
266
|
'/api/users/:id',
|
|
251
267
|
'/api/users/:id/profile',
|
|
@@ -288,16 +304,6 @@ export class AuthCoreService {
|
|
|
288
304
|
};
|
|
289
305
|
}
|
|
290
306
|
|
|
291
|
-
private async sendCommunicationSafely(input: Parameters<CommunicationsService['send']>[0]): Promise<void> {
|
|
292
|
-
try {
|
|
293
|
-
await this.communicationsService.send(input);
|
|
294
|
-
} catch (error) {
|
|
295
|
-
this.logger.warn(
|
|
296
|
-
`accounts.communication_failed kind=${input.kind} channel=${input.channels.join(',')} reason=${error instanceof Error ? error.message : 'unknown'}`,
|
|
297
|
-
);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
307
|
private assertAccountActive(user: { status: string; deletedAt: Date | null }) {
|
|
302
308
|
if (user.deletedAt || user.status !== 'active') {
|
|
303
309
|
throw new UnauthorizedException({
|
|
@@ -314,10 +320,6 @@ export class AuthCoreService {
|
|
|
314
320
|
});
|
|
315
321
|
}
|
|
316
322
|
|
|
317
|
-
private createStubToken(kind: string, userId: string): string {
|
|
318
|
-
return `stub-${kind}-${userId}-${Date.now()}`;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
323
|
private readNullableString(input: unknown, key: string): string | null {
|
|
322
324
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
323
325
|
return null;
|
|
@@ -338,17 +340,20 @@ export class AuthCoreService {
|
|
|
338
340
|
}
|
|
339
341
|
|
|
340
342
|
private toExpiresAt(ttl: string): Date {
|
|
341
|
-
const
|
|
342
|
-
const
|
|
343
|
-
if (!
|
|
344
|
-
|
|
345
|
-
return new Date(now + (Number.isFinite(seconds) ? seconds : 7 * 24 * 60 * 60) * 1000);
|
|
343
|
+
const value = ttl.trim();
|
|
344
|
+
const matched = value.match(/^(\d+)([smhd])$/);
|
|
345
|
+
if (!matched) {
|
|
346
|
+
return new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
|
|
346
347
|
}
|
|
347
348
|
|
|
348
|
-
const amount = Number
|
|
349
|
-
const unit =
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
349
|
+
const amount = Number(matched[1]);
|
|
350
|
+
const unit = matched[2];
|
|
351
|
+
const multipliers = {
|
|
352
|
+
s: 1000,
|
|
353
|
+
m: 1000 * 60,
|
|
354
|
+
h: 1000 * 60 * 60,
|
|
355
|
+
d: 1000 * 60 * 60 * 24,
|
|
356
|
+
} as const;
|
|
357
|
+
return new Date(Date.now() + amount * multipliers[unit]);
|
|
353
358
|
}
|
|
354
|
-
}
|
|
359
|
+
}
|
package/templates/module-presets/accounts/packages/accounts-api/src/auth-pending-operations.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const AUTH_PENDING_OPERATION_TYPES = {
|
|
2
|
+
emailVerification: 'email_verification',
|
|
3
|
+
passwordReset: 'password_reset',
|
|
4
|
+
passwordChange: 'password_change',
|
|
5
|
+
emailChange: 'email_change',
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export type AuthPendingOperationType =
|
|
9
|
+
(typeof AUTH_PENDING_OPERATION_TYPES)[keyof typeof AUTH_PENDING_OPERATION_TYPES];
|
|
@@ -9,12 +9,9 @@ import {
|
|
|
9
9
|
import { AuthService } from './auth.service';
|
|
10
10
|
import {
|
|
11
11
|
ChangePasswordDto,
|
|
12
|
-
ConfirmPasswordResetDto,
|
|
13
12
|
LoginDto,
|
|
14
13
|
RefreshDto,
|
|
15
14
|
RegisterDto,
|
|
16
|
-
RequestPasswordResetDto,
|
|
17
|
-
VerifyEmailDto,
|
|
18
15
|
} from './dto';
|
|
19
16
|
import { JwtAuthGuard } from './access-token.guard';
|
|
20
17
|
import type { AuthAccessTokenPayload } from './auth.types';
|
|
@@ -57,25 +54,9 @@ export class AuthController {
|
|
|
57
54
|
|
|
58
55
|
@UseGuards(JwtAuthGuard)
|
|
59
56
|
@Post('change-password')
|
|
60
|
-
|
|
57
|
+
changePassword(@Body() body: ChangePasswordDto, @Req() request: RequestWithUser) {
|
|
61
58
|
const user = this.getRequestUser(request);
|
|
62
|
-
|
|
63
|
-
return { status: 'ok' };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
@Post('verify-email')
|
|
67
|
-
verifyEmail(@Body() body: VerifyEmailDto) {
|
|
68
|
-
return this.authService.verifyEmail(body.token);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
@Post('password-reset/request')
|
|
72
|
-
requestPasswordReset(@Body() body: RequestPasswordResetDto) {
|
|
73
|
-
return this.authService.requestPasswordReset(body.email);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
@Post('password-reset/confirm')
|
|
77
|
-
confirmPasswordReset(@Body() body: ConfirmPasswordResetDto) {
|
|
78
|
-
return this.authService.resetPassword(body.token, body.newPassword);
|
|
59
|
+
return this.authService.changePassword(user.sub, body);
|
|
79
60
|
}
|
|
80
61
|
|
|
81
62
|
private getRequestUser(request: RequestWithUser): AuthAccessTokenPayload {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import type {
|
|
3
|
+
ChangePasswordRequest,
|
|
4
|
+
ChangePasswordResult,
|
|
5
|
+
RegisterRequest,
|
|
6
|
+
RegisterResult,
|
|
7
|
+
} from '@forgeon/accounts-contracts';
|
|
8
|
+
import { AuthCoreService } from './auth-core.service';
|
|
9
|
+
|
|
10
|
+
export const REGISTER_HANDLER = Symbol('REGISTER_HANDLER');
|
|
11
|
+
export const CHANGE_PASSWORD_HANDLER = Symbol('CHANGE_PASSWORD_HANDLER');
|
|
12
|
+
|
|
13
|
+
export interface RegisterHandler {
|
|
14
|
+
execute(input: RegisterRequest): Promise<RegisterResult>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ChangePasswordHandler {
|
|
18
|
+
execute(userId: string, input: ChangePasswordRequest): Promise<ChangePasswordResult>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Injectable()
|
|
22
|
+
export class DefaultRegisterHandler implements RegisterHandler {
|
|
23
|
+
constructor(private readonly authCoreService: AuthCoreService) {}
|
|
24
|
+
|
|
25
|
+
async execute(input: RegisterRequest): Promise<RegisterResult> {
|
|
26
|
+
const account = await this.authCoreService.createPasswordAccount(input, {
|
|
27
|
+
status: 'active',
|
|
28
|
+
emailVerifiedAt: new Date(),
|
|
29
|
+
});
|
|
30
|
+
return this.authCoreService.issueSessionForAccount(account);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Injectable()
|
|
35
|
+
export class DefaultChangePasswordHandler implements ChangePasswordHandler {
|
|
36
|
+
constructor(private readonly authCoreService: AuthCoreService) {}
|
|
37
|
+
|
|
38
|
+
async execute(userId: string, input: ChangePasswordRequest): Promise<ChangePasswordResult> {
|
|
39
|
+
await this.authCoreService.changePasswordNow(userId, input.newPassword);
|
|
40
|
+
return {
|
|
41
|
+
status: 'completed',
|
|
42
|
+
message: 'Password changed successfully',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -1,13 +1,26 @@
|
|
|
1
|
-
import { Injectable } from '@nestjs/common';
|
|
2
|
-
import type {
|
|
1
|
+
import { Inject, Injectable } from '@nestjs/common';
|
|
2
|
+
import type {
|
|
3
|
+
ChangePasswordRequest,
|
|
4
|
+
RegisterRequest,
|
|
5
|
+
} from '@forgeon/accounts-contracts';
|
|
3
6
|
import { AuthCoreService } from './auth-core.service';
|
|
7
|
+
import {
|
|
8
|
+
CHANGE_PASSWORD_HANDLER,
|
|
9
|
+
REGISTER_HANDLER,
|
|
10
|
+
type ChangePasswordHandler,
|
|
11
|
+
type RegisterHandler,
|
|
12
|
+
} from './auth.handlers';
|
|
4
13
|
|
|
5
14
|
@Injectable()
|
|
6
15
|
export class AuthService {
|
|
7
|
-
constructor(
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly authCoreService: AuthCoreService,
|
|
18
|
+
@Inject(REGISTER_HANDLER) private readonly registerHandler: RegisterHandler,
|
|
19
|
+
@Inject(CHANGE_PASSWORD_HANDLER) private readonly changePasswordHandler: ChangePasswordHandler,
|
|
20
|
+
) {}
|
|
8
21
|
|
|
9
22
|
register(input: RegisterRequest) {
|
|
10
|
-
return this.
|
|
23
|
+
return this.registerHandler.execute(input);
|
|
11
24
|
}
|
|
12
25
|
|
|
13
26
|
login(input: { email: string; password: string }) {
|
|
@@ -22,20 +35,8 @@ export class AuthService {
|
|
|
22
35
|
return this.authCoreService.logout(userId, refreshToken);
|
|
23
36
|
}
|
|
24
37
|
|
|
25
|
-
changePassword(userId: string,
|
|
26
|
-
return this.
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
requestPasswordReset(email: string) {
|
|
30
|
-
return this.authCoreService.requestPasswordReset(email);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
resetPassword(token: string, newPassword: string) {
|
|
34
|
-
return this.authCoreService.resetPassword(token, newPassword);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
verifyEmail(token: string) {
|
|
38
|
-
return this.authCoreService.verifyEmail(token);
|
|
38
|
+
changePassword(userId: string, input: ChangePasswordRequest) {
|
|
39
|
+
return this.changePasswordHandler.execute(userId, input);
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
me(userId: string) {
|