@flusys/nestjs-auth 6.4.1 → 7.0.0
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/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/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
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
|
}
|
|
@@ -10,6 +10,7 @@ describe('BranchService', ()=>{
|
|
|
10
10
|
let mockQueryRunner;
|
|
11
11
|
beforeEach(()=>{
|
|
12
12
|
mockRepo = createMockRepository();
|
|
13
|
+
mockRepo.find.mockResolvedValue([]);
|
|
13
14
|
Object.assign(mockRepo.createQueryBuilder(), {
|
|
14
15
|
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
|
15
16
|
addOrderBy: jest.fn().mockReturnThis()
|
|
@@ -78,6 +79,20 @@ describe('BranchService', ()=>{
|
|
|
78
79
|
}, null, mockQueryRunner);
|
|
79
80
|
expect(mockPermissionSyncAdapter.onBranchDeleted).toHaveBeenCalledWith('branch-1', 'company-1');
|
|
80
81
|
});
|
|
82
|
+
it('rejects deleting a readonly branch', async ()=>{
|
|
83
|
+
mockRepo.find.mockResolvedValue([
|
|
84
|
+
{
|
|
85
|
+
id: 'branch-1',
|
|
86
|
+
name: 'Default Branch',
|
|
87
|
+
readOnly: true
|
|
88
|
+
}
|
|
89
|
+
]);
|
|
90
|
+
const permissionRepo = mockQueryRunner.manager.getRepository(UserCompanyPermission);
|
|
91
|
+
await expect(service.beforeDeleteOperation({
|
|
92
|
+
id: 'branch-1'
|
|
93
|
+
}, null, mockQueryRunner)).rejects.toThrow('Cannot delete readonly branch');
|
|
94
|
+
expect(permissionRepo.delete).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
81
96
|
});
|
|
82
97
|
describe('getSelectQuery', ()=>{
|
|
83
98
|
it('applies the default selected fields when none are provided', async ()=>{
|
|
@@ -28,8 +28,9 @@ function _ts_param(paramIndex, decorator) {
|
|
|
28
28
|
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
|
-
import { Inject, Injectable, Optional, Scope } from '@nestjs/common';
|
|
32
|
-
import { In } from 'typeorm';
|
|
31
|
+
import { ForbiddenException, Inject, Injectable, Optional, Scope } from '@nestjs/common';
|
|
32
|
+
import { In, IsNull } from 'typeorm';
|
|
33
|
+
import { COMPANY_MESSAGES } from '../config';
|
|
33
34
|
import { CompanyBranch } from '../entities/company-branch.entity';
|
|
34
35
|
import { Company } from '../entities/company.entity';
|
|
35
36
|
import { UserCompanyPermission } from '../entities/user-company-permission.entity';
|
|
@@ -39,6 +40,24 @@ export class CompanyService extends ApiService {
|
|
|
39
40
|
const companyIds = Array.isArray(dto.id) ? dto.id : [
|
|
40
41
|
dto.id
|
|
41
42
|
];
|
|
43
|
+
await this.ensureDataSourceRepository();
|
|
44
|
+
const readonlyCompanies = await this.repository.find({
|
|
45
|
+
where: {
|
|
46
|
+
id: In(companyIds),
|
|
47
|
+
readOnly: true,
|
|
48
|
+
deletedAt: IsNull()
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
if (readonlyCompanies.length > 0) {
|
|
52
|
+
const names = readonlyCompanies.map((c)=>c.name).join(', ');
|
|
53
|
+
throw new ForbiddenException({
|
|
54
|
+
message: `Cannot delete readonly company(ies): ${names}`,
|
|
55
|
+
messageKey: COMPANY_MESSAGES.READONLY_DELETE_FORBIDDEN,
|
|
56
|
+
messageVariables: {
|
|
57
|
+
names
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
42
61
|
const branchRows = await queryRunner.manager.getRepository(CompanyBranch).find({
|
|
43
62
|
where: {
|
|
44
63
|
companyId: In(companyIds)
|
|
@@ -69,6 +88,7 @@ export class CompanyService extends ApiService {
|
|
|
69
88
|
'email',
|
|
70
89
|
'phone',
|
|
71
90
|
'isActive',
|
|
91
|
+
'readOnly',
|
|
72
92
|
'createdAt'
|
|
73
93
|
];
|
|
74
94
|
}
|
|
@@ -11,6 +11,7 @@ describe('CompanyService', ()=>{
|
|
|
11
11
|
let permissionRepo;
|
|
12
12
|
beforeEach(()=>{
|
|
13
13
|
mockRepo = createMockRepository();
|
|
14
|
+
mockRepo.find.mockResolvedValue([]);
|
|
14
15
|
const mockDataSourceProvider = createMockDataSourceProvider(mockRepo);
|
|
15
16
|
mockPermissionSyncAdapter = {
|
|
16
17
|
onCompanyDeleted: jest.fn()
|
|
@@ -75,6 +76,19 @@ describe('CompanyService', ()=>{
|
|
|
75
76
|
expect(mockPermissionSyncAdapter.onCompanyDeleted).toHaveBeenCalledWith('company-1');
|
|
76
77
|
expect(mockPermissionSyncAdapter.onCompanyDeleted).toHaveBeenCalledWith('company-2');
|
|
77
78
|
});
|
|
79
|
+
it('rejects deleting a readonly company', async ()=>{
|
|
80
|
+
mockRepo.find.mockResolvedValue([
|
|
81
|
+
{
|
|
82
|
+
id: 'company-1',
|
|
83
|
+
name: 'Default Company',
|
|
84
|
+
readOnly: true
|
|
85
|
+
}
|
|
86
|
+
]);
|
|
87
|
+
await expect(service.beforeDeleteOperation({
|
|
88
|
+
id: 'company-1'
|
|
89
|
+
}, null, mockQueryRunner)).rejects.toThrow('Cannot delete readonly company');
|
|
90
|
+
expect(branchRepo.find).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
78
92
|
});
|
|
79
93
|
describe('getSelectQuery', ()=>{
|
|
80
94
|
it('applies the default selected fields when none are provided', async ()=>{
|
|
@@ -26,9 +26,10 @@ 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 { IPermissionSyncAdapter, PERMISSION_SYNC_ADAPTER } from '@flusys/nestjs-shared/interfaces';
|
|
30
31
|
import { ConflictException, Inject, Injectable, NotFoundException, Optional, Scope } from '@nestjs/common';
|
|
31
|
-
import { USER_PERMISSION_MESSAGES } from '../config';
|
|
32
|
+
import { AUTH_EVENT_ACTIONS, AUTH_EVENT_ENTITIES, AUTH_EVENT_MODULE, USER_PERMISSION_MESSAGES } from '../config';
|
|
32
33
|
import { PermissionType, UserCompanyPermission } from '../entities/user-company-permission.entity';
|
|
33
34
|
import { AuthDataSourceProvider } from './auth-datasource.provider';
|
|
34
35
|
export class UserPermissionService {
|
|
@@ -92,6 +93,7 @@ export class UserPermissionService {
|
|
|
92
93
|
}
|
|
93
94
|
throw error;
|
|
94
95
|
}
|
|
96
|
+
await this.publishPermissionEvent(AUTH_EVENT_ACTIONS.PERMISSION_GRANTED, userId, targetId, type);
|
|
95
97
|
}
|
|
96
98
|
async revokePermission(userId, targetId, type) {
|
|
97
99
|
const repository = await this.getRepository();
|
|
@@ -124,6 +126,24 @@ export class UserPermissionService {
|
|
|
124
126
|
} else {
|
|
125
127
|
if (companyId) await this.permissionSyncAdapter?.onUserBranchAccessRevoked?.(userId, targetId, companyId);
|
|
126
128
|
}
|
|
129
|
+
await this.publishPermissionEvent(AUTH_EVENT_ACTIONS.PERMISSION_REVOKED, userId, targetId, type, companyId);
|
|
130
|
+
}
|
|
131
|
+
async publishPermissionEvent(action, userId, targetId, type, companyId) {
|
|
132
|
+
await publishDomainEvent({
|
|
133
|
+
module: AUTH_EVENT_MODULE,
|
|
134
|
+
entity: AUTH_EVENT_ENTITIES.USER_PERMISSION,
|
|
135
|
+
action,
|
|
136
|
+
ids: [
|
|
137
|
+
targetId
|
|
138
|
+
],
|
|
139
|
+
metadata: {
|
|
140
|
+
targetUserId: userId,
|
|
141
|
+
permissionType: type,
|
|
142
|
+
...companyId ? {
|
|
143
|
+
companyId
|
|
144
|
+
} : {}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
127
147
|
}
|
|
128
148
|
async revokeBranchPermissionsForCompany(repository, userId, companyId) {
|
|
129
149
|
const branchPermissions = await repository.createQueryBuilder('permission').innerJoin('permission.branch', 'branch').where('permission.userId = :userId', {
|