@flusys/nestjs-auth 6.4.0 → 7.0.0-beta.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.
- package/cjs/config/event-actions.js +45 -0
- package/cjs/config/index.js +1 -0
- package/cjs/config/message-keys.js +12 -0
- package/cjs/modules/auth.module.js +2 -1
- package/cjs/services/auth-email.service.js +21 -0
- package/cjs/services/auth-email.service.spec.js +52 -0
- package/cjs/services/authentication.service.js +62 -4
- package/cjs/services/authentication.service.spec.js +58 -0
- package/cjs/services/branch.service.js +20 -0
- package/cjs/services/branch.service.spec.js +15 -0
- package/cjs/services/company.service.js +20 -0
- package/cjs/services/company.service.spec.js +14 -0
- package/cjs/services/user-permission.service.js +20 -0
- package/cjs/services/user-permission.service.spec.js +52 -0
- package/cjs/services/user.service.js +36 -18
- package/cjs/services/user.service.spec.js +15 -1
- package/config/event-actions.d.ts +21 -0
- package/config/index.d.ts +1 -0
- package/config/message-keys.d.ts +6 -0
- package/fesm/config/event-actions.js +24 -0
- package/fesm/config/index.js +1 -0
- package/fesm/config/message-keys.js +6 -0
- package/fesm/modules/auth.module.js +3 -2
- package/fesm/services/auth-email.service.js +22 -1
- package/fesm/services/auth-email.service.spec.js +52 -0
- package/fesm/services/authentication.service.js +64 -6
- package/fesm/services/authentication.service.spec.js +58 -0
- package/fesm/services/branch.service.js +22 -2
- package/fesm/services/branch.service.spec.js +15 -0
- package/fesm/services/company.service.js +22 -2
- package/fesm/services/company.service.spec.js +14 -0
- package/fesm/services/user-permission.service.js +21 -1
- package/fesm/services/user-permission.service.spec.js +52 -0
- package/fesm/services/user.service.js +36 -18
- package/fesm/services/user.service.spec.js +15 -1
- package/interfaces/auth-module-options.interface.d.ts +2 -0
- package/package.json +3 -3
- package/services/auth-email.service.d.ts +1 -0
- package/services/authentication.service.d.ts +3 -0
- package/services/user-permission.service.d.ts +1 -0
|
@@ -130,10 +130,13 @@ let UserService = class UserService extends _classes.ApiService {
|
|
|
130
130
|
await this.repository.update(id, {
|
|
131
131
|
emailVerified: true
|
|
132
132
|
});
|
|
133
|
-
await
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
await Promise.all([
|
|
134
|
+
this.clearCacheForAll(),
|
|
135
|
+
this.clearCacheForId([
|
|
136
|
+
{
|
|
137
|
+
id
|
|
138
|
+
}
|
|
139
|
+
])
|
|
137
140
|
]);
|
|
138
141
|
}
|
|
139
142
|
async verifyPhone(id) {
|
|
@@ -141,10 +144,13 @@ let UserService = class UserService extends _classes.ApiService {
|
|
|
141
144
|
await this.repository.update(id, {
|
|
142
145
|
phoneVerified: true
|
|
143
146
|
});
|
|
144
|
-
await
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
147
|
+
await Promise.all([
|
|
148
|
+
this.clearCacheForAll(),
|
|
149
|
+
this.clearCacheForId([
|
|
150
|
+
{
|
|
151
|
+
id
|
|
152
|
+
}
|
|
153
|
+
])
|
|
148
154
|
]);
|
|
149
155
|
}
|
|
150
156
|
async updateStatus(id, isActive, user) {
|
|
@@ -174,10 +180,13 @@ let UserService = class UserService extends _classes.ApiService {
|
|
|
174
180
|
isActive
|
|
175
181
|
});
|
|
176
182
|
}
|
|
177
|
-
await
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
183
|
+
await Promise.all([
|
|
184
|
+
this.clearCacheForAll(),
|
|
185
|
+
this.clearCacheForId([
|
|
186
|
+
{
|
|
187
|
+
id
|
|
188
|
+
}
|
|
189
|
+
])
|
|
181
190
|
]);
|
|
182
191
|
}
|
|
183
192
|
async convertSingleDtoToEntity(dto, user) {
|
|
@@ -401,8 +410,11 @@ let UserService = class UserService extends _classes.ApiService {
|
|
|
401
410
|
}
|
|
402
411
|
}
|
|
403
412
|
await queryRunner.commitTransaction();
|
|
404
|
-
await
|
|
405
|
-
|
|
413
|
+
await Promise.all([
|
|
414
|
+
this.clearCacheForAll(),
|
|
415
|
+
this.clearCacheForId([
|
|
416
|
+
savedUser
|
|
417
|
+
])
|
|
406
418
|
]);
|
|
407
419
|
return this.convertEntityToResponseDto(savedUser, false);
|
|
408
420
|
} catch (error) {
|
|
@@ -471,8 +483,11 @@ let UserService = class UserService extends _classes.ApiService {
|
|
|
471
483
|
try {
|
|
472
484
|
await this.userEnricher.updateProfileSection(userId, sectionId, data, queryRunner);
|
|
473
485
|
await queryRunner.commitTransaction();
|
|
474
|
-
await
|
|
475
|
-
|
|
486
|
+
await Promise.all([
|
|
487
|
+
this.clearCacheForAll(),
|
|
488
|
+
this.clearCacheForId([
|
|
489
|
+
userEntity
|
|
490
|
+
])
|
|
476
491
|
]);
|
|
477
492
|
} catch (error) {
|
|
478
493
|
await queryRunner.rollbackTransaction();
|
|
@@ -541,8 +556,11 @@ let UserService = class UserService extends _classes.ApiService {
|
|
|
541
556
|
await this.userEnricher.updateProfileExtras(userId, extras, queryRunner);
|
|
542
557
|
}
|
|
543
558
|
await queryRunner.commitTransaction();
|
|
544
|
-
await
|
|
545
|
-
|
|
559
|
+
await Promise.all([
|
|
560
|
+
this.clearCacheForAll(),
|
|
561
|
+
this.clearCacheForId([
|
|
562
|
+
findUser
|
|
563
|
+
])
|
|
546
564
|
]);
|
|
547
565
|
return findUser.id;
|
|
548
566
|
} catch (error) {
|
|
@@ -124,6 +124,13 @@ describe('UserService', ()=>{
|
|
|
124
124
|
};
|
|
125
125
|
service = new _userservice.UserService({}, mockUtilsService, mockDataSourceProvider, mockAuthConfig, mockUserEnricher);
|
|
126
126
|
});
|
|
127
|
+
/**
|
|
128
|
+
* Every write path has to drop both the list cache and the row's own cache, or a
|
|
129
|
+
* stale user keeps being served from `get-all`.
|
|
130
|
+
*/ function expectCacheCleared(id) {
|
|
131
|
+
expect(mockUtilsService.clearCache).toHaveBeenCalledWith('appuser', expect.anything());
|
|
132
|
+
expect(mockUtilsService.clearCache).toHaveBeenCalledWith('appuser', expect.anything(), id);
|
|
133
|
+
}
|
|
127
134
|
describe('ensureDataSourceRepository', ()=>{
|
|
128
135
|
it('does not load the permission repository when the company feature is disabled', async ()=>{
|
|
129
136
|
await service.ensureDataSourceRepository();
|
|
@@ -142,13 +149,14 @@ describe('UserService', ()=>{
|
|
|
142
149
|
expect(userRepo.update).toHaveBeenCalledWith('user-1', {
|
|
143
150
|
emailVerified: true
|
|
144
151
|
});
|
|
145
|
-
|
|
152
|
+
expectCacheCleared('user-1');
|
|
146
153
|
});
|
|
147
154
|
it('marks the phone as verified and clears the cache', async ()=>{
|
|
148
155
|
await service.verifyPhone('user-1');
|
|
149
156
|
expect(userRepo.update).toHaveBeenCalledWith('user-1', {
|
|
150
157
|
phoneVerified: true
|
|
151
158
|
});
|
|
159
|
+
expectCacheCleared('user-1');
|
|
152
160
|
});
|
|
153
161
|
});
|
|
154
162
|
describe('updateStatus', ()=>{
|
|
@@ -157,6 +165,7 @@ describe('UserService', ()=>{
|
|
|
157
165
|
expect(userRepo.update).toHaveBeenCalledWith('user-1', {
|
|
158
166
|
isActive: false
|
|
159
167
|
});
|
|
168
|
+
expectCacheCleared('user-1');
|
|
160
169
|
});
|
|
161
170
|
it('throws NotFoundException when the company permission is missing', async ()=>{
|
|
162
171
|
companyFeatureEnabled = true;
|
|
@@ -179,6 +188,7 @@ describe('UserService', ()=>{
|
|
|
179
188
|
isActive: false
|
|
180
189
|
}));
|
|
181
190
|
expect(userRepo.update).not.toHaveBeenCalled();
|
|
191
|
+
expectCacheCleared('user-1');
|
|
182
192
|
});
|
|
183
193
|
});
|
|
184
194
|
describe('convertSingleDtoToEntity', ()=>{
|
|
@@ -389,6 +399,7 @@ describe('UserService', ()=>{
|
|
|
389
399
|
expect(result).toEqual(expect.objectContaining({
|
|
390
400
|
id: 'user-1'
|
|
391
401
|
}));
|
|
402
|
+
expectCacheCleared(savedUser.id);
|
|
392
403
|
});
|
|
393
404
|
it('rolls back the transaction if permission assignment fails', async ()=>{
|
|
394
405
|
companyFeatureEnabled = true;
|
|
@@ -577,6 +588,7 @@ describe('UserService', ()=>{
|
|
|
577
588
|
foo: 'bar'
|
|
578
589
|
}, queryRunner);
|
|
579
590
|
expect(queryRunner.commitTransaction).toHaveBeenCalled();
|
|
591
|
+
expectCacheCleared('user-1');
|
|
580
592
|
});
|
|
581
593
|
it('rolls back the transaction when the enricher throws', async ()=>{
|
|
582
594
|
userRepo.findOne.mockResolvedValue(buildAppUser());
|
|
@@ -670,6 +682,7 @@ describe('UserService', ()=>{
|
|
|
670
682
|
expect(queryRunner.manager.save).toHaveBeenCalled();
|
|
671
683
|
expect(queryRunner.commitTransaction).toHaveBeenCalled();
|
|
672
684
|
expect(result).toBe('user-1');
|
|
685
|
+
expectCacheCleared('user-1');
|
|
673
686
|
});
|
|
674
687
|
it('does not require the current password when an admin updates another user profile', async ()=>{
|
|
675
688
|
service.dataSource = mockDataSource;
|
|
@@ -686,6 +699,7 @@ describe('UserService', ()=>{
|
|
|
686
699
|
}));
|
|
687
700
|
expect(queryRunner.manager.save).toHaveBeenCalled();
|
|
688
701
|
expect(queryRunner.commitTransaction).toHaveBeenCalled();
|
|
702
|
+
expectCacheCleared('other-user');
|
|
689
703
|
});
|
|
690
704
|
it('rolls back the transaction when save fails', async ()=>{
|
|
691
705
|
service.dataSource = mockDataSource;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const AUTH_EVENT_ACTIONS: {
|
|
2
|
+
readonly LOGGED_IN: "logged-in";
|
|
3
|
+
readonly LOGGED_OUT: "logged-out";
|
|
4
|
+
readonly TOKEN_REFRESHED: "token-refreshed";
|
|
5
|
+
readonly COMPANY_SWITCHED: "company-switched";
|
|
6
|
+
readonly REGISTERED: "registered";
|
|
7
|
+
readonly PASSWORD_CHANGED: "password-changed";
|
|
8
|
+
readonly PASSWORD_RESET_REQUESTED: "password-reset-requested";
|
|
9
|
+
readonly PASSWORD_RESET: "password-reset";
|
|
10
|
+
readonly EMAIL_VERIFIED: "email-verified";
|
|
11
|
+
readonly PERMISSION_GRANTED: "permission-granted";
|
|
12
|
+
readonly PERMISSION_REVOKED: "permission-revoked";
|
|
13
|
+
};
|
|
14
|
+
export declare const AUTH_EVENT_ENTITIES: {
|
|
15
|
+
readonly USER: "appuser";
|
|
16
|
+
readonly COMPANY: "company";
|
|
17
|
+
readonly COMPANY_BRANCH: "company_branch";
|
|
18
|
+
readonly SESSION: "session";
|
|
19
|
+
readonly USER_PERMISSION: "user_company_permission";
|
|
20
|
+
};
|
|
21
|
+
export declare const AUTH_EVENT_MODULE = "auth";
|
package/config/index.d.ts
CHANGED
package/config/message-keys.d.ts
CHANGED
|
@@ -62,6 +62,12 @@ export declare const USER_MESSAGES: {
|
|
|
62
62
|
readonly STATUS_UPDATE_SUCCESS: "user.status.update.success";
|
|
63
63
|
readonly LOOKUP_SUCCESS: "user.lookup.success";
|
|
64
64
|
};
|
|
65
|
+
export declare const COMPANY_MESSAGES: {
|
|
66
|
+
readonly READONLY_DELETE_FORBIDDEN: "company.readonly.delete.forbidden";
|
|
67
|
+
};
|
|
68
|
+
export declare const BRANCH_MESSAGES: {
|
|
69
|
+
readonly READONLY_DELETE_FORBIDDEN: "branch.readonly.delete.forbidden";
|
|
70
|
+
};
|
|
65
71
|
export declare const USER_PERMISSION_MESSAGES: {
|
|
66
72
|
readonly COMPANY_LIST_SUCCESS: "user.permission.company.list.success";
|
|
67
73
|
readonly BRANCH_LIST_SUCCESS: "user.permission.branch.list.success";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain event actions published by the auth module, alongside the CRUD
|
|
3
|
+
* lifecycle actions every ApiService emits.
|
|
4
|
+
*/ export const AUTH_EVENT_ACTIONS = {
|
|
5
|
+
LOGGED_IN: 'logged-in',
|
|
6
|
+
LOGGED_OUT: 'logged-out',
|
|
7
|
+
TOKEN_REFRESHED: 'token-refreshed',
|
|
8
|
+
COMPANY_SWITCHED: 'company-switched',
|
|
9
|
+
REGISTERED: 'registered',
|
|
10
|
+
PASSWORD_CHANGED: 'password-changed',
|
|
11
|
+
PASSWORD_RESET_REQUESTED: 'password-reset-requested',
|
|
12
|
+
PASSWORD_RESET: 'password-reset',
|
|
13
|
+
EMAIL_VERIFIED: 'email-verified',
|
|
14
|
+
PERMISSION_GRANTED: 'permission-granted',
|
|
15
|
+
PERMISSION_REVOKED: 'permission-revoked'
|
|
16
|
+
};
|
|
17
|
+
/** Every entity the auth module publishes under, ApiService backed or not */ export const AUTH_EVENT_ENTITIES = {
|
|
18
|
+
USER: 'appuser',
|
|
19
|
+
COMPANY: 'company',
|
|
20
|
+
COMPANY_BRANCH: 'company_branch',
|
|
21
|
+
SESSION: 'session',
|
|
22
|
+
USER_PERMISSION: 'user_company_permission'
|
|
23
|
+
};
|
|
24
|
+
export const AUTH_EVENT_MODULE = 'auth';
|
package/fesm/config/index.js
CHANGED
|
@@ -63,6 +63,12 @@ export const USER_MESSAGES = {
|
|
|
63
63
|
STATUS_UPDATE_SUCCESS: 'user.status.update.success',
|
|
64
64
|
LOOKUP_SUCCESS: 'user.lookup.success'
|
|
65
65
|
};
|
|
66
|
+
export const COMPANY_MESSAGES = {
|
|
67
|
+
READONLY_DELETE_FORBIDDEN: 'company.readonly.delete.forbidden'
|
|
68
|
+
};
|
|
69
|
+
export const BRANCH_MESSAGES = {
|
|
70
|
+
READONLY_DELETE_FORBIDDEN: 'branch.readonly.delete.forbidden'
|
|
71
|
+
};
|
|
66
72
|
export const USER_PERMISSION_MESSAGES = {
|
|
67
73
|
COMPANY_LIST_SUCCESS: 'user.permission.company.list.success',
|
|
68
74
|
BRANCH_LIST_SUCCESS: 'user.permission.branch.list.success',
|
|
@@ -4,7 +4,7 @@ function _ts_decorate(decorators, target, key, desc) {
|
|
|
4
4
|
else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
}
|
|
7
|
-
import { CacheModule, UtilsModule } from '@flusys/nestjs-shared/modules';
|
|
7
|
+
import { CacheModule, UtilsModule, createModuleEventsProvider } from '@flusys/nestjs-shared/modules';
|
|
8
8
|
import { Module } from '@nestjs/common';
|
|
9
9
|
import { JwtModule } from '@nestjs/jwt';
|
|
10
10
|
import { PassportModule } from '@nestjs/passport';
|
|
@@ -93,7 +93,8 @@ export class AuthModule {
|
|
|
93
93
|
AuthenticationService,
|
|
94
94
|
SetToken,
|
|
95
95
|
ClearToken,
|
|
96
|
-
UserService
|
|
96
|
+
UserService,
|
|
97
|
+
createModuleEventsProvider('auth', AUTH_MODULE_OPTIONS)
|
|
97
98
|
];
|
|
98
99
|
if (flags.enableEmailVerification) {
|
|
99
100
|
providers.push(AuthEmailService);
|
|
@@ -26,11 +26,12 @@ function _ts_param(paramIndex, decorator) {
|
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
import { LogAction } from '@flusys/nestjs-shared';
|
|
29
|
+
import { publishDomainEvent } from '@flusys/nestjs-shared/classes';
|
|
29
30
|
import { SYSTEM_MESSAGES } from '@flusys/nestjs-shared/constants';
|
|
30
31
|
import { BadRequestException, Inject, Injectable, InternalServerErrorException, Optional, Scope } from '@nestjs/common';
|
|
31
32
|
import * as bcrypt from 'bcrypt';
|
|
32
33
|
import * as crypto from 'crypto';
|
|
33
|
-
import { AUTH_MESSAGES, BCRYPT_SALT_ROUNDS, EMAIL_VERIFICATION_MESSAGES, USER_MESSAGES } from '../config';
|
|
34
|
+
import { AUTH_EVENT_ACTIONS, AUTH_EVENT_ENTITIES, AUTH_EVENT_MODULE, AUTH_MESSAGES, BCRYPT_SALT_ROUNDS, EMAIL_VERIFICATION_MESSAGES, USER_MESSAGES } from '../config';
|
|
34
35
|
import { AppUser } from '../entities/app-user.entity';
|
|
35
36
|
import { PendingRegistration } from '../entities/pending-registration.entity';
|
|
36
37
|
import { TokenType, UserToken } from '../entities/user-token.entity';
|
|
@@ -191,6 +192,9 @@ export class AuthEmailService {
|
|
|
191
192
|
}
|
|
192
193
|
});
|
|
193
194
|
}
|
|
195
|
+
await this.publishUserEvent(AUTH_EVENT_ACTIONS.PASSWORD_RESET_REQUESTED, user.id, {
|
|
196
|
+
email: user.email
|
|
197
|
+
});
|
|
194
198
|
return {
|
|
195
199
|
success: true,
|
|
196
200
|
message: 'If the email exists, a password reset link has been sent.',
|
|
@@ -232,6 +236,9 @@ export class AuthEmailService {
|
|
|
232
236
|
// Mark token as used
|
|
233
237
|
userToken.usedAt = new Date();
|
|
234
238
|
await tokenRepository.save(userToken);
|
|
239
|
+
await this.publishUserEvent(AUTH_EVENT_ACTIONS.PASSWORD_RESET, user.id, {
|
|
240
|
+
email: user.email
|
|
241
|
+
});
|
|
235
242
|
return {
|
|
236
243
|
success: true,
|
|
237
244
|
message: 'Password has been reset successfully.',
|
|
@@ -315,12 +322,26 @@ export class AuthEmailService {
|
|
|
315
322
|
// Mark token as used
|
|
316
323
|
userToken.usedAt = new Date();
|
|
317
324
|
await tokenRepository.save(userToken);
|
|
325
|
+
await this.publishUserEvent(AUTH_EVENT_ACTIONS.EMAIL_VERIFIED, user.id, {
|
|
326
|
+
email: user.email
|
|
327
|
+
});
|
|
318
328
|
return {
|
|
319
329
|
type: 'verified',
|
|
320
330
|
message: 'Email has been verified successfully.',
|
|
321
331
|
messageKey: EMAIL_VERIFICATION_MESSAGES.VERIFICATION_SUCCESS
|
|
322
332
|
};
|
|
323
333
|
}
|
|
334
|
+
async publishUserEvent(action, userId, metadata) {
|
|
335
|
+
await publishDomainEvent({
|
|
336
|
+
module: AUTH_EVENT_MODULE,
|
|
337
|
+
entity: AUTH_EVENT_ENTITIES.USER,
|
|
338
|
+
action,
|
|
339
|
+
ids: [
|
|
340
|
+
userId
|
|
341
|
+
],
|
|
342
|
+
metadata
|
|
343
|
+
});
|
|
344
|
+
}
|
|
324
345
|
async resendVerificationEmail(email, verifyUrlBase) {
|
|
325
346
|
this.assertEmailEnabled();
|
|
326
347
|
const pendingRepository = await this.getRepository(PendingRegistration);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
|
|
2
2
|
import * as bcrypt from 'bcrypt';
|
|
3
3
|
import { createMockDataSourceProvider, createMockRepository } from '@test-utils/mocks/repository.mock';
|
|
4
|
+
import { EventBusRegistry } from '@flusys/nestjs-shared/classes';
|
|
4
5
|
import { AuthEmailService } from './auth-email.service';
|
|
5
6
|
import { AppUser } from '../entities/app-user.entity';
|
|
6
7
|
import { PendingRegistration } from '../entities/pending-registration.entity';
|
|
@@ -356,4 +357,55 @@ describe('AuthEmailService', ()=>{
|
|
|
356
357
|
})).rejects.toThrow(InternalServerErrorException);
|
|
357
358
|
});
|
|
358
359
|
});
|
|
360
|
+
describe('domain events', ()=>{
|
|
361
|
+
let publish;
|
|
362
|
+
beforeEach(()=>{
|
|
363
|
+
publish = jest.fn().mockResolvedValue(undefined);
|
|
364
|
+
EventBusRegistry.reset();
|
|
365
|
+
EventBusRegistry.setBus({
|
|
366
|
+
publish,
|
|
367
|
+
subscribe: jest.fn(),
|
|
368
|
+
close: jest.fn()
|
|
369
|
+
});
|
|
370
|
+
EventBusRegistry.configureModule('auth', {
|
|
371
|
+
enabled: true
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
afterEach(()=>{
|
|
375
|
+
EventBusRegistry.reset();
|
|
376
|
+
});
|
|
377
|
+
it('publishes auth.appuser.password-reset-requested once the reset email is sent', async ()=>{
|
|
378
|
+
userRepo.findOne.mockResolvedValue(buildUser());
|
|
379
|
+
tokenRepo.save.mockResolvedValue(buildUserToken());
|
|
380
|
+
await service.forgotPassword('user@example.com', 'https://app.test/reset');
|
|
381
|
+
expect(publish).toHaveBeenCalledWith(expect.objectContaining({
|
|
382
|
+
name: 'auth.appuser.password-reset-requested'
|
|
383
|
+
}));
|
|
384
|
+
});
|
|
385
|
+
it('publishes nothing for an unknown email so enumeration stays impossible', async ()=>{
|
|
386
|
+
userRepo.findOne.mockResolvedValue(null);
|
|
387
|
+
await service.forgotPassword('missing@example.com', 'https://app.test/reset');
|
|
388
|
+
expect(publish).not.toHaveBeenCalled();
|
|
389
|
+
});
|
|
390
|
+
it('publishes auth.appuser.password-reset when the token is consumed', async ()=>{
|
|
391
|
+
const token = buildUserToken();
|
|
392
|
+
tokenRepo.findOne.mockResolvedValue(token);
|
|
393
|
+
userRepo.findOne.mockResolvedValue(buildUser());
|
|
394
|
+
userRepo.save.mockResolvedValue(buildUser());
|
|
395
|
+
tokenRepo.save.mockResolvedValue(token);
|
|
396
|
+
await service.resetPassword('raw-token', 'NewPassword1!');
|
|
397
|
+
expect(publish).toHaveBeenCalledWith(expect.objectContaining({
|
|
398
|
+
name: 'auth.appuser.password-reset'
|
|
399
|
+
}));
|
|
400
|
+
});
|
|
401
|
+
it('does not publish when the auth module has events disabled', async ()=>{
|
|
402
|
+
EventBusRegistry.configureModule('auth', {
|
|
403
|
+
enabled: false
|
|
404
|
+
});
|
|
405
|
+
userRepo.findOne.mockResolvedValue(buildUser());
|
|
406
|
+
tokenRepo.save.mockResolvedValue(buildUserToken());
|
|
407
|
+
await service.forgotPassword('user@example.com', 'https://app.test/reset');
|
|
408
|
+
expect(publish).not.toHaveBeenCalled();
|
|
409
|
+
});
|
|
410
|
+
});
|
|
359
411
|
});
|
|
@@ -26,7 +26,7 @@ function _ts_param(paramIndex, decorator) {
|
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
import { LogAction } from '@flusys/nestjs-shared';
|
|
29
|
-
import { HybridCache } from '@flusys/nestjs-shared/classes';
|
|
29
|
+
import { HybridCache, publishDomainEvent } from '@flusys/nestjs-shared/classes';
|
|
30
30
|
import { SYSTEM_MESSAGES } from '@flusys/nestjs-shared/constants';
|
|
31
31
|
import { NotificationType } from '@flusys/nestjs-shared/enums';
|
|
32
32
|
import { INotificationAdapter, NOTIFICATION_ADAPTER } from '@flusys/nestjs-shared/interfaces';
|
|
@@ -34,7 +34,7 @@ import { generateUniqueSlug } from '@flusys/nestjs-shared/utils';
|
|
|
34
34
|
import { BadRequestException, ForbiddenException, Inject, Injectable, InternalServerErrorException, NotFoundException, Optional, Scope, UnauthorizedException } from '@nestjs/common';
|
|
35
35
|
import { JwtService } from '@nestjs/jwt';
|
|
36
36
|
import * as bcrypt from 'bcrypt';
|
|
37
|
-
import { AUTH_MESSAGES, BCRYPT_SALT_ROUNDS, EMAIL_VERIFICATION_MESSAGES, TOKEN_REVOKED_PREFIX, USER_ENRICHER, USER_MESSAGES } from '../config';
|
|
37
|
+
import { AUTH_EVENT_ACTIONS, AUTH_EVENT_ENTITIES, AUTH_EVENT_MODULE, AUTH_MESSAGES, BCRYPT_SALT_ROUNDS, EMAIL_VERIFICATION_MESSAGES, TOKEN_REVOKED_PREFIX, USER_ENRICHER, USER_MESSAGES } from '../config';
|
|
38
38
|
import { RegistrationDto } from '../dtos/authentication.dto';
|
|
39
39
|
import { AppUser } from '../entities/app-user.entity';
|
|
40
40
|
import { CompanyBranch } from '../entities/company-branch.entity';
|
|
@@ -528,7 +528,12 @@ export class AuthenticationService {
|
|
|
528
528
|
messageKey: AUTH_MESSAGES.BRANCH_NOT_FOUND
|
|
529
529
|
});
|
|
530
530
|
}
|
|
531
|
-
|
|
531
|
+
const response = this.generateLoginResponseWithCompany(user, company, branch);
|
|
532
|
+
this.publishSessionEvent(AUTH_EVENT_ACTIONS.COMPANY_SWITCHED, user, {
|
|
533
|
+
companyId: company.id,
|
|
534
|
+
branchId: branch.id
|
|
535
|
+
});
|
|
536
|
+
return response;
|
|
532
537
|
}
|
|
533
538
|
async refreshToken(refreshToken) {
|
|
534
539
|
try {
|
|
@@ -622,7 +627,7 @@ export class AuthenticationService {
|
|
|
622
627
|
}
|
|
623
628
|
}
|
|
624
629
|
}
|
|
625
|
-
|
|
630
|
+
const tokens = generateTokens(this.jwtService, this.authConfig, {
|
|
626
631
|
id: user.id,
|
|
627
632
|
email: user.email,
|
|
628
633
|
type: 'access',
|
|
@@ -634,6 +639,11 @@ export class AuthenticationService {
|
|
|
634
639
|
branchId: payload.branchId
|
|
635
640
|
}
|
|
636
641
|
});
|
|
642
|
+
this.publishSessionEvent(AUTH_EVENT_ACTIONS.TOKEN_REFRESHED, user, {
|
|
643
|
+
companyId: payload.companyId ?? null,
|
|
644
|
+
branchId: payload.branchId ?? null
|
|
645
|
+
});
|
|
646
|
+
return tokens;
|
|
637
647
|
} catch {
|
|
638
648
|
throw new UnauthorizedException({
|
|
639
649
|
message: 'Invalid or expired refresh token',
|
|
@@ -660,6 +670,7 @@ export class AuthenticationService {
|
|
|
660
670
|
}
|
|
661
671
|
user.password = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
|
|
662
672
|
await userRepository.save(user);
|
|
673
|
+
this.publishUserEvent(AUTH_EVENT_ACTIONS.PASSWORD_CHANGED, user);
|
|
663
674
|
}
|
|
664
675
|
async logout(userId) {
|
|
665
676
|
if (!this.cacheManager) {
|
|
@@ -673,6 +684,17 @@ export class AuthenticationService {
|
|
|
673
684
|
const refreshTokenExpiry = this.authConfig.getRefreshTokenExpiration();
|
|
674
685
|
const ttlMs = parseExpiryToMs(refreshTokenExpiry);
|
|
675
686
|
await this.cacheManager.set(cacheKey, revokedAt, ttlMs);
|
|
687
|
+
await publishDomainEvent({
|
|
688
|
+
module: AUTH_EVENT_MODULE,
|
|
689
|
+
entity: AUTH_EVENT_ENTITIES.SESSION,
|
|
690
|
+
action: AUTH_EVENT_ACTIONS.LOGGED_OUT,
|
|
691
|
+
ids: [
|
|
692
|
+
userId
|
|
693
|
+
],
|
|
694
|
+
user: {
|
|
695
|
+
id: userId
|
|
696
|
+
}
|
|
697
|
+
});
|
|
676
698
|
}
|
|
677
699
|
async isTokenRevoked(userId, tokenIssuedAt) {
|
|
678
700
|
if (!this.cacheManager || !tokenIssuedAt) {
|
|
@@ -846,6 +868,7 @@ export class AuthenticationService {
|
|
|
846
868
|
type: 'access'
|
|
847
869
|
});
|
|
848
870
|
this.sendLoginNotification(user.id);
|
|
871
|
+
this.publishSessionEvent(AUTH_EVENT_ACTIONS.LOGGED_IN, user);
|
|
849
872
|
return {
|
|
850
873
|
accessToken,
|
|
851
874
|
refreshToken,
|
|
@@ -861,6 +884,10 @@ export class AuthenticationService {
|
|
|
861
884
|
branchId: branch?.id
|
|
862
885
|
});
|
|
863
886
|
this.sendLoginNotification(user.id, company.id);
|
|
887
|
+
this.publishSessionEvent(AUTH_EVENT_ACTIONS.LOGGED_IN, user, {
|
|
888
|
+
companyId: company.id,
|
|
889
|
+
branchId: branch?.id ?? null
|
|
890
|
+
});
|
|
864
891
|
return {
|
|
865
892
|
accessToken,
|
|
866
893
|
refreshToken,
|
|
@@ -907,16 +934,47 @@ export class AuthenticationService {
|
|
|
907
934
|
};
|
|
908
935
|
}
|
|
909
936
|
generateRegistrationResponse(user) {
|
|
910
|
-
|
|
937
|
+
const response = {
|
|
911
938
|
...this.generateLoginResponse(user),
|
|
912
939
|
requiresEmailVerification: false
|
|
913
940
|
};
|
|
941
|
+
this.publishUserEvent(AUTH_EVENT_ACTIONS.REGISTERED, user);
|
|
942
|
+
return response;
|
|
914
943
|
}
|
|
915
944
|
generateRegistrationResponseWithCompany(user, company, branch) {
|
|
916
|
-
|
|
945
|
+
const response = {
|
|
917
946
|
...this.generateLoginResponseWithCompany(user, company, branch),
|
|
918
947
|
requiresEmailVerification: false
|
|
919
948
|
};
|
|
949
|
+
this.publishUserEvent(AUTH_EVENT_ACTIONS.REGISTERED, user, {
|
|
950
|
+
companyId: company.id,
|
|
951
|
+
branchId: branch?.id ?? null
|
|
952
|
+
});
|
|
953
|
+
return response;
|
|
954
|
+
}
|
|
955
|
+
publishSessionEvent(action, user, metadata = {}) {
|
|
956
|
+
this.publishAuthEvent(AUTH_EVENT_ENTITIES.SESSION, action, user, metadata);
|
|
957
|
+
}
|
|
958
|
+
publishUserEvent(action, user, metadata = {}) {
|
|
959
|
+
this.publishAuthEvent(AUTH_EVENT_ENTITIES.USER, action, user, metadata);
|
|
960
|
+
}
|
|
961
|
+
/** Never awaited - a login must not wait on, or fail because of, the event bus */ publishAuthEvent(entity, action, user, metadata) {
|
|
962
|
+
void publishDomainEvent({
|
|
963
|
+
module: AUTH_EVENT_MODULE,
|
|
964
|
+
entity,
|
|
965
|
+
action,
|
|
966
|
+
ids: [
|
|
967
|
+
user.id
|
|
968
|
+
],
|
|
969
|
+
metadata: {
|
|
970
|
+
email: user.email,
|
|
971
|
+
...metadata
|
|
972
|
+
},
|
|
973
|
+
user: {
|
|
974
|
+
id: user.id,
|
|
975
|
+
email: user.email
|
|
976
|
+
}
|
|
977
|
+
});
|
|
920
978
|
}
|
|
921
979
|
// NOTE: @Inject() required for bundled code - type metadata may be lost during esbuild
|
|
922
980
|
constructor(jwtService, authConfig, cacheManager, dataSourceProvider, sessionService, authEmailService, userEnricher, notificationAdapter){
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BadRequestException, ForbiddenException, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
|
2
2
|
import * as bcrypt from 'bcrypt';
|
|
3
3
|
import { createMockRepository } from '@test-utils/mocks/repository.mock';
|
|
4
|
+
import { EventBusRegistry } from '@flusys/nestjs-shared/classes';
|
|
4
5
|
import { AuthenticationService } from './authentication.service';
|
|
5
6
|
import { AppUser } from '../entities/app-user.entity';
|
|
6
7
|
import { Company } from '../entities/company.entity';
|
|
@@ -856,4 +857,61 @@ describe('AuthenticationService', ()=>{
|
|
|
856
857
|
}));
|
|
857
858
|
});
|
|
858
859
|
});
|
|
860
|
+
describe('domain events', ()=>{
|
|
861
|
+
let publish;
|
|
862
|
+
beforeEach(()=>{
|
|
863
|
+
publish = jest.fn().mockResolvedValue(undefined);
|
|
864
|
+
EventBusRegistry.reset();
|
|
865
|
+
EventBusRegistry.setBus({
|
|
866
|
+
publish,
|
|
867
|
+
subscribe: jest.fn(),
|
|
868
|
+
close: jest.fn()
|
|
869
|
+
});
|
|
870
|
+
EventBusRegistry.configureModule('auth', {
|
|
871
|
+
enabled: true
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
afterEach(()=>{
|
|
875
|
+
EventBusRegistry.reset();
|
|
876
|
+
});
|
|
877
|
+
it('publishes auth.session.logged-in when a session is issued', async ()=>{
|
|
878
|
+
userRepo.findOne.mockResolvedValue(buildUser());
|
|
879
|
+
bcrypt.compare.mockResolvedValue(true);
|
|
880
|
+
const service = buildService();
|
|
881
|
+
await service.login('user@example.com', 'password');
|
|
882
|
+
expect(publish).toHaveBeenCalledWith(expect.objectContaining({
|
|
883
|
+
name: 'auth.session.logged-in',
|
|
884
|
+
action: 'logged-in',
|
|
885
|
+
payload: expect.objectContaining({
|
|
886
|
+
ids: [
|
|
887
|
+
'user-1'
|
|
888
|
+
]
|
|
889
|
+
})
|
|
890
|
+
}));
|
|
891
|
+
});
|
|
892
|
+
it('publishes auth.session.logged-out on logout', async ()=>{
|
|
893
|
+
const service = buildService();
|
|
894
|
+
await service.logout('user-1');
|
|
895
|
+
expect(publish).toHaveBeenCalledWith(expect.objectContaining({
|
|
896
|
+
name: 'auth.session.logged-out'
|
|
897
|
+
}));
|
|
898
|
+
});
|
|
899
|
+
it('publishes auth.appuser.password-changed after a successful password change', async ()=>{
|
|
900
|
+
userRepo.findOne.mockResolvedValue(buildUser());
|
|
901
|
+
bcrypt.compare.mockResolvedValue(true);
|
|
902
|
+
const service = buildService();
|
|
903
|
+
await service.changePassword('user-1', 'old', 'new');
|
|
904
|
+
expect(publish).toHaveBeenCalledWith(expect.objectContaining({
|
|
905
|
+
name: 'auth.appuser.password-changed'
|
|
906
|
+
}));
|
|
907
|
+
});
|
|
908
|
+
it('does not publish when the auth module has events disabled', async ()=>{
|
|
909
|
+
EventBusRegistry.configureModule('auth', {
|
|
910
|
+
enabled: false
|
|
911
|
+
});
|
|
912
|
+
const service = buildService();
|
|
913
|
+
await service.logout('user-1');
|
|
914
|
+
expect(publish).not.toHaveBeenCalled();
|
|
915
|
+
});
|
|
916
|
+
});
|
|
859
917
|
});
|
|
@@ -29,8 +29,9 @@ import { ApiService, HybridCache } from '@flusys/nestjs-shared/classes';
|
|
|
29
29
|
import { IPermissionSyncAdapter, PERMISSION_SYNC_ADAPTER } from '@flusys/nestjs-shared/interfaces';
|
|
30
30
|
import { UtilsService } from '@flusys/nestjs-shared/modules';
|
|
31
31
|
import { applyCompanyFilter } from '@flusys/nestjs-shared/utils';
|
|
32
|
-
import { Inject, Injectable, Optional, Scope } from '@nestjs/common';
|
|
33
|
-
import { In } from 'typeorm';
|
|
32
|
+
import { ForbiddenException, Inject, Injectable, Optional, Scope } from '@nestjs/common';
|
|
33
|
+
import { In, IsNull } from 'typeorm';
|
|
34
|
+
import { BRANCH_MESSAGES } from '../config';
|
|
34
35
|
import { CompanyBranch } from '../entities/company-branch.entity';
|
|
35
36
|
import { UserCompanyPermission } from '../entities/user-company-permission.entity';
|
|
36
37
|
import { AuthConfigService } from './auth-config.service';
|
|
@@ -40,6 +41,24 @@ export class BranchService extends ApiService {
|
|
|
40
41
|
const branchIds = Array.isArray(dto.id) ? dto.id : [
|
|
41
42
|
dto.id
|
|
42
43
|
];
|
|
44
|
+
await this.ensureDataSourceRepository();
|
|
45
|
+
const readonlyBranches = await this.repository.find({
|
|
46
|
+
where: {
|
|
47
|
+
id: In(branchIds),
|
|
48
|
+
readOnly: true,
|
|
49
|
+
deletedAt: IsNull()
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
if (readonlyBranches.length > 0) {
|
|
53
|
+
const names = readonlyBranches.map((b)=>b.name).join(', ');
|
|
54
|
+
throw new ForbiddenException({
|
|
55
|
+
message: `Cannot delete readonly branch(es): ${names}`,
|
|
56
|
+
messageKey: BRANCH_MESSAGES.READONLY_DELETE_FORBIDDEN,
|
|
57
|
+
messageVariables: {
|
|
58
|
+
names
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
43
62
|
await queryRunner.manager.getRepository(UserCompanyPermission).delete({
|
|
44
63
|
targetId: In(branchIds)
|
|
45
64
|
});
|
|
@@ -67,6 +86,7 @@ export class BranchService extends ApiService {
|
|
|
67
86
|
'email',
|
|
68
87
|
'phone',
|
|
69
88
|
'isActive',
|
|
89
|
+
'readOnly',
|
|
70
90
|
'createdAt'
|
|
71
91
|
];
|
|
72
92
|
}
|