create-forgeon 0.3.25 → 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/dependencies.test.mjs +31 -5
- package/src/modules/executor.mjs +2 -0
- package/src/modules/executor.test.mjs +18 -17
- package/src/modules/registry.mjs +18 -2
- 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-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 +30 -4
- 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
|
@@ -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) {
|
|
@@ -19,10 +19,22 @@ export type RefreshTokenRecord = {
|
|
|
19
19
|
createdAt: Date;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
export type PendingOperationRecord = {
|
|
23
|
+
id: string;
|
|
24
|
+
userId: string;
|
|
25
|
+
type: string;
|
|
26
|
+
tokenHash: string;
|
|
27
|
+
metadata: JsonObject | null;
|
|
28
|
+
expiresAt: Date;
|
|
29
|
+
consumedAt: Date | null;
|
|
30
|
+
createdAt: Date;
|
|
31
|
+
};
|
|
32
|
+
|
|
22
33
|
export interface CreatePasswordAccountInput {
|
|
23
34
|
email: string;
|
|
24
35
|
passwordHash: string;
|
|
25
36
|
status: string;
|
|
37
|
+
emailVerifiedAt: Date | null;
|
|
26
38
|
userData: JsonObject | null;
|
|
27
39
|
profile: {
|
|
28
40
|
name: string | null;
|
|
@@ -45,6 +57,7 @@ export class AuthStore {
|
|
|
45
57
|
const user = await tx.user.create({
|
|
46
58
|
data: {
|
|
47
59
|
status: input.status,
|
|
60
|
+
emailVerifiedAt: input.emailVerifiedAt,
|
|
48
61
|
data: toPrismaJsonInput(input.userData),
|
|
49
62
|
profile: {
|
|
50
63
|
create: {
|
|
@@ -143,6 +156,79 @@ export class AuthStore {
|
|
|
143
156
|
});
|
|
144
157
|
}
|
|
145
158
|
|
|
159
|
+
async markEmailVerified(userId: string, verifiedAt: Date): Promise<void> {
|
|
160
|
+
await this.prisma.user.update({
|
|
161
|
+
where: { id: userId },
|
|
162
|
+
data: {
|
|
163
|
+
status: 'active',
|
|
164
|
+
emailVerifiedAt: verifiedAt,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async updatePrimaryEmail(userId: string, email: string, verifiedAt: Date): Promise<void> {
|
|
170
|
+
await this.prisma.$transaction([
|
|
171
|
+
this.prisma.authIdentity.updateMany({
|
|
172
|
+
where: { userId, provider: 'email' },
|
|
173
|
+
data: { providerId: email },
|
|
174
|
+
}),
|
|
175
|
+
this.prisma.user.update({
|
|
176
|
+
where: { id: userId },
|
|
177
|
+
data: {
|
|
178
|
+
emailVerifiedAt: verifiedAt,
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async createPendingOperation(input: {
|
|
185
|
+
id: string;
|
|
186
|
+
userId: string;
|
|
187
|
+
type: string;
|
|
188
|
+
tokenHash: string;
|
|
189
|
+
metadata: JsonObject | null;
|
|
190
|
+
expiresAt: Date;
|
|
191
|
+
}): Promise<void> {
|
|
192
|
+
await this.prisma.authPendingOperation.create({
|
|
193
|
+
data: {
|
|
194
|
+
id: input.id,
|
|
195
|
+
userId: input.userId,
|
|
196
|
+
type: input.type,
|
|
197
|
+
tokenHash: input.tokenHash,
|
|
198
|
+
metadata: toPrismaJsonInput(input.metadata),
|
|
199
|
+
expiresAt: input.expiresAt,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async findPendingOperationById(id: string): Promise<PendingOperationRecord | null> {
|
|
205
|
+
const operation = await this.prisma.authPendingOperation.findUnique({
|
|
206
|
+
where: { id },
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (!operation) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
id: operation.id,
|
|
215
|
+
userId: operation.userId,
|
|
216
|
+
type: operation.type,
|
|
217
|
+
tokenHash: operation.tokenHash,
|
|
218
|
+
metadata: operation.metadata as JsonObject | null,
|
|
219
|
+
expiresAt: operation.expiresAt,
|
|
220
|
+
consumedAt: operation.consumedAt,
|
|
221
|
+
createdAt: operation.createdAt,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async consumePendingOperation(id: string, consumedAt: Date): Promise<void> {
|
|
226
|
+
await this.prisma.authPendingOperation.updateMany({
|
|
227
|
+
where: { id, consumedAt: null },
|
|
228
|
+
data: { consumedAt },
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
146
232
|
async createRefreshToken(input: {
|
|
147
233
|
id: string;
|
|
148
234
|
userId: string;
|
|
@@ -190,6 +276,7 @@ export class AuthStore {
|
|
|
190
276
|
private mapPasswordAccount(user: {
|
|
191
277
|
id: string;
|
|
192
278
|
status: string;
|
|
279
|
+
emailVerifiedAt: Date | null;
|
|
193
280
|
data: unknown;
|
|
194
281
|
createdAt: Date;
|
|
195
282
|
updatedAt: Date;
|