Skip to content

Commit b297d77

Browse files
authored
feat: implement requireEmailVerification, fix ambiguousErrorMessages and enableAutologin relationship (#1261)
1 parent f67254b commit b297d77

File tree

14 files changed

+203
-38
lines changed

14 files changed

+203
-38
lines changed

modules/module-password/__tests__/resolvers/mutation.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ describe('accounts-password resolvers mutation', () => {
1414
options: {},
1515
};
1616
const accountsPasswordMock = {
17+
options: {},
1718
addEmail: jest.fn(),
1819
changePassword: jest.fn(),
1920
createUser: jest.fn(),
@@ -90,6 +91,63 @@ describe('accounts-password resolvers mutation', () => {
9091
expect(accountsPasswordMock.createUser).toHaveBeenCalledWith(user);
9192
});
9293

94+
it('should call createUser and return the userId', async () => {
95+
const createdUserMock = jest.fn(() => user.id);
96+
injector.get = jest.fn((arg) =>
97+
arg === AccountsPassword
98+
? {
99+
options: {},
100+
createUser: createdUserMock,
101+
}
102+
: {
103+
options: {},
104+
}
105+
);
106+
const res = await Mutation.createUser!({}, { user } as any, { injector } as any, {} as any);
107+
expect(injector.get).toHaveBeenCalledWith(AccountsPassword);
108+
expect(injector.get).toHaveBeenCalledWith(AccountsServer);
109+
expect(createdUserMock).toHaveBeenCalledWith(user);
110+
expect(res).toEqual({ userId: user.id });
111+
});
112+
113+
it('should return a null userId if both ambiguousErrorMessages and requireEmailVerification are enabled', async () => {
114+
const createdUserMock = jest.fn(() => user.id);
115+
injector.get = jest.fn((arg) =>
116+
arg === AccountsPassword
117+
? {
118+
options: { requireEmailVerification: true },
119+
createUser: createdUserMock,
120+
}
121+
: {
122+
options: { ambiguousErrorMessages: true },
123+
}
124+
);
125+
const res = await Mutation.createUser!({}, { user } as any, { injector } as any, {} as any);
126+
expect(injector.get).toHaveBeenCalledWith(AccountsPassword);
127+
expect(injector.get).toHaveBeenCalledWith(AccountsServer);
128+
expect(createdUserMock).toHaveBeenCalledWith(user);
129+
expect(res).toEqual({ userId: null });
130+
});
131+
132+
it('should return the userId despite ambiguousErrorMessages being enabled if requireEmailVerification is disabled', async () => {
133+
const createdUserMock = jest.fn(() => user.id);
134+
injector.get = jest.fn((arg) =>
135+
arg === AccountsPassword
136+
? {
137+
options: { requireEmailVerification: false },
138+
createUser: createdUserMock,
139+
}
140+
: {
141+
options: { ambiguousErrorMessages: true },
142+
}
143+
);
144+
const res = await Mutation.createUser!({}, { user } as any, { injector } as any, {} as any);
145+
expect(injector.get).toHaveBeenCalledWith(AccountsPassword);
146+
expect(injector.get).toHaveBeenCalledWith(AccountsServer);
147+
expect(createdUserMock).toHaveBeenCalledWith(user);
148+
expect(res).toEqual({ userId: user.id });
149+
});
150+
93151
it('should call createUser and obfuscate EmailAlreadyExists error if server have ambiguousErrorMessages', async () => {
94152
const createdUserMock = jest.fn(() => {
95153
throw new AccountsJsError('EmailAlreadyExists', 'EmailAlreadyExists');

modules/module-password/src/resolvers/mutation.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,15 @@ export const Mutation: MutationResolvers = {
6969

7070
if (!accountsServer.options.enableAutologin) {
7171
return {
72-
userId: accountsServer.options.ambiguousErrorMessages ? null : userId,
72+
userId:
73+
accountsServer.options.ambiguousErrorMessages &&
74+
accountsPassword.options.requireEmailVerification
75+
? null
76+
: userId,
7377
};
7478
}
7579

76-
// When initializing AccountsServer we check that enableAutologin and ambiguousErrorMessages options
80+
// When initializing AccountsPassword we check that enableAutologin and requireEmailVerification options
7781
// are not enabled at the same time
7882

7983
const createdUser = await accountsServer.findUserById(userId);

modules/module-password/src/schema/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import gql from 'graphql-tag';
22

33
export default gql`
44
type CreateUserResult {
5-
# Will be returned only if ambiguousErrorMessages is set to false.
5+
# Will be returned only if ambiguousErrorMessages is set to false or requireEmailVerification is set to false.
66
userId: ID
7-
# Will be returned only if enableAutologin is set to true.
7+
# Will be returned only if enableAutologin is set to true and requireEmailVerification is set to false.
88
loginResult: LoginResult
99
}
1010

packages/e2e/__tests__/password.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ Object.keys(servers).forEach((key) => {
1919
email: user.email,
2020
password: user.password,
2121
});
22-
// User id will be null for graphql and undefined for rest
23-
expect(userId === null || userId === undefined).toBeTruthy();
22+
expect(typeof userId).toEqual('string');
2423
});
2524
});
2625

packages/password/__tests__/accounts-password.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'reflect-metadata';
22
import set from 'lodash.set';
33
import { AccountsPassword } from '../src';
4+
import { AccountsServer } from '@accounts/server';
45

56
describe('AccountsPassword', () => {
67
const server: any = {
@@ -19,6 +20,20 @@ describe('AccountsPassword', () => {
1920
});
2021

2122
describe('config', () => {
23+
it('should throw if requireEmailVerification and enableAutologin flags enabled at the same time', async () => {
24+
expect(
25+
() =>
26+
new AccountsPassword(
27+
{ requireEmailVerification: true },
28+
undefined,
29+
undefined,
30+
new AccountsServer({ enableAutologin: true, tokenSecret: 'secret' }, {}, {} as any)
31+
)
32+
).toThrow(
33+
"Can't enable autologin when requireEmailVerification is enabled. Please set either of them to false."
34+
);
35+
});
36+
2237
it('should have default options', async () => {
2338
expect((password as any).options.passwordEnrollTokenExpiration).toBe(2592000000);
2439
});
@@ -50,6 +65,34 @@ describe('AccountsPassword', () => {
5065
expect(ret).toEqual(user);
5166
});
5267

68+
it('throws when email has not been verified and requireEmailVerification is true', async () => {
69+
const findUserByEmail = jest.fn(() =>
70+
Promise.resolve({
71+
emails: [
72+
{
73+
address: '[email protected]',
74+
verified: false,
75+
},
76+
],
77+
id: '1',
78+
deactivated: false,
79+
})
80+
);
81+
const findPasswordHash = jest.fn(() => Promise.resolve('hashed'));
82+
const verifyPassword = jest.fn(() => true);
83+
const tmpAccountsPassword = new AccountsPassword({});
84+
tmpAccountsPassword.options.verifyPassword = verifyPassword as any;
85+
tmpAccountsPassword.options.requireEmailVerification = true;
86+
tmpAccountsPassword.setUserStore({ findUserByEmail, findPasswordHash } as any);
87+
tmpAccountsPassword.setSessionsStore();
88+
await expect(
89+
tmpAccountsPassword.authenticate({
90+
91+
password: 'toto',
92+
} as any)
93+
).rejects.toThrow('Email not verified');
94+
});
95+
5396
it('throws when user not found', async () => {
5497
const findUserByEmail = jest.fn(() => Promise.resolve());
5598
password.setUserStore({ findUserByEmail } as any);

packages/password/src/accounts-password.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ export interface AccountsPasswordOptions {
5050
* Two factor options passed down to the @accounts/two-factor service.
5151
*/
5252
twoFactor?: AccountsTwoFactorOptions;
53+
/**
54+
* Whether the email needs to be verified in order to allow authentication.
55+
* From an user enumeration perspective changes what is safe to return when ambiguousErrorMessages are enabled.
56+
* Can be enabled only if enableAutologin is set to false.
57+
* Defaults to false.
58+
*/
59+
requireEmailVerification?: boolean;
5360
/**
5461
* The number of milliseconds from when a link to verify the user email is sent until token expires and user can't verify his email with the link anymore.
5562
* Defaults to 3 days.
@@ -136,6 +143,7 @@ export interface AccountsPasswordOptions {
136143
}
137144

138145
const defaultOptions = {
146+
requireEmailVerification: false,
139147
// 3 days - 3 * 24 * 60 * 60 * 1000
140148
verifyEmailTokenExpiration: 259200000,
141149
// 3 days - 3 * 24 * 60 * 60 * 1000
@@ -185,7 +193,7 @@ export default class AccountsPassword<CustomUser extends User = User>
185193
public serviceName = 'password';
186194
public server!: AccountsServer;
187195
public twoFactor: TwoFactor;
188-
private options: AccountsPasswordOptions & typeof defaultOptions;
196+
options: AccountsPasswordOptions & typeof defaultOptions;
189197
private db!: DatabaseInterfaceUser<CustomUser>;
190198
private dbSessions!: DatabaseInterfaceSessions;
191199

@@ -196,7 +204,21 @@ export default class AccountsPassword<CustomUser extends User = User>
196204
@Inject(DatabaseInterfaceSessionsToken) dbSessions?: DatabaseInterfaceSessions,
197205
server?: AccountsServer
198206
) {
199-
this.options = { ...defaultOptions, ...options };
207+
this.options = { ...defaultOptions, ...options } as typeof options & typeof defaultOptions;
208+
if (this.options.requireEmailVerification) {
209+
if (server?.options.enableAutologin) {
210+
throw new Error(
211+
"Can't enable autologin when requireEmailVerification is enabled. Please set either of them to false."
212+
);
213+
}
214+
// AccountsPassword has been manually instantiated so there is no way to access the AccountsServer options
215+
if (!server) {
216+
console.log(
217+
"Please ensure that 'enableAutologin' has not been set to true in AccountsServer."
218+
);
219+
}
220+
}
221+
200222
this.twoFactor = new TwoFactor(options.twoFactor);
201223
if (db) {
202224
this.db = db;
@@ -716,6 +738,19 @@ export default class AccountsPassword<CustomUser extends User = User>
716738
);
717739
}
718740
}
741+
if (this.options.requireEmailVerification) {
742+
// If the user logs in using the email it must be a verified address, if he provided an username at least one of the associated emails must be verified.
743+
if (
744+
!foundUser.emails?.find(({ address, verified }) =>
745+
email ? address === email && verified : verified
746+
)
747+
) {
748+
throw new AccountsJsError(
749+
this.options.errors.emailNotVerified,
750+
PasswordAuthenticatorErrors.EmailNotVerified
751+
);
752+
}
753+
}
719754

720755
const hash = await this.db.findPasswordHash(foundUser.id);
721756
if (!hash) {

packages/password/src/errors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const errors: ErrorMessages = {
99
matchFailed: 'Match failed',
1010
invalidUsername: 'Invalid username',
1111
invalidEmail: 'Invalid email',
12+
emailNotVerified: 'Email not verified',
1213
invalidPassword: 'Invalid password',
1314
invalidNewPassword: 'Invalid new password',
1415
invalidToken: 'Invalid token',
@@ -95,6 +96,7 @@ export enum ResetPasswordErrors {
9596
}
9697

9798
export enum PasswordAuthenticatorErrors {
99+
EmailNotVerified = 'EmailNotVerified',
98100
InvalidCredentials = 'InvalidCredentials',
99101
UserNotFound = 'UserNotFound',
100102
IncorrectPassword = 'IncorrectPassword',

packages/password/src/types/error-messages.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export interface ErrorMessages {
3131
* Default to 'Invalid email'
3232
*/
3333
invalidEmail: string;
34+
/**
35+
* Default to 'Email not verified'
36+
*/
37+
emailNotVerified: string;
3438
/**
3539
* Default to 'Invalid password'
3640
*/

packages/rest-express/__tests__/endpoints/password/register.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe('registerPassword', () => {
1616
it('calls password.createUser and returns the user json response', async () => {
1717
const userId = '1';
1818
const passwordService = {
19+
options: {},
1920
createUser: jest.fn(() => userId),
2021
};
2122
const accountsServer = {
@@ -47,9 +48,10 @@ describe('registerPassword', () => {
4748
expect(res.status).not.toHaveBeenCalled();
4849
});
4950

50-
it('calls password.createUser and obfuscate user id if server have ambiguousErrorMessages', async () => {
51+
it('calls password.createUser and obfuscate user id if server have both ambiguousErrorMessages and requireEmailVerification', async () => {
5152
const userId = '1';
5253
const passwordService = {
54+
options: { requireEmailVerification: true },
5355
createUser: jest.fn(() => userId),
5456
};
5557
const accountsServer = {
@@ -81,8 +83,44 @@ describe('registerPassword', () => {
8183
expect(res.status).not.toHaveBeenCalled();
8284
});
8385

86+
it('calls password.createUser and not obfuscate user id if server have ambiguousErrorMessages but not requireEmailVerification', async () => {
87+
const userId = '1';
88+
const passwordService = {
89+
options: {},
90+
createUser: jest.fn(() => userId),
91+
};
92+
const accountsServer = {
93+
options: { ambiguousErrorMessages: true },
94+
getServices: () => ({
95+
password: passwordService,
96+
}),
97+
};
98+
const middleware = registerPassword(accountsServer as any);
99+
100+
const req = {
101+
body: {
102+
user: {
103+
username: 'toto',
104+
},
105+
extraFieldThatShouldNotBePassed: 'hey',
106+
},
107+
headers: {},
108+
};
109+
const reqCopy = { ...req };
110+
111+
await middleware(req as any, res);
112+
113+
expect(req).toEqual(reqCopy);
114+
expect(accountsServer.getServices().password.createUser).toHaveBeenCalledWith({
115+
username: 'toto',
116+
});
117+
expect(res.json).toHaveBeenCalledWith({ userId });
118+
expect(res.status).not.toHaveBeenCalled();
119+
});
120+
84121
it('calls password.createUser and obfuscate EmailAlreadyExists error if server have ambiguousErrorMessages', async () => {
85122
const passwordService = {
123+
options: {},
86124
createUser: jest.fn(() => {
87125
throw new AccountsJsError('EmailAlreadyExists', 'EmailAlreadyExists');
88126
}),
@@ -118,6 +156,7 @@ describe('registerPassword', () => {
118156

119157
it('calls password.createUser and obfuscate UsernameAlreadyExists error if server have ambiguousErrorMessages', async () => {
120158
const passwordService = {
159+
options: {},
121160
createUser: jest.fn(() => {
122161
throw new AccountsJsError('UsernameAlreadyExists', 'UsernameAlreadyExists');
123162
}),
@@ -156,6 +195,7 @@ describe('registerPassword', () => {
156195
const userEmail = '[email protected]';
157196

158197
const passwordService = {
198+
options: {},
159199
createUser: jest.fn(() => userId),
160200
};
161201

@@ -212,6 +252,7 @@ describe('registerPassword', () => {
212252
it('Sends error if it was thrown on loginWithService', async () => {
213253
const error = { message: 'Could not login' };
214254
const passwordService = {
255+
options: {},
215256
createUser: jest.fn(() => {
216257
throw error;
217258
}),

packages/rest-express/src/endpoints/password/register.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,16 @@ export const registerPassword =
2929

3030
if (!accountsServer.options.enableAutologin) {
3131
return res.json(
32-
accountsServer.options.ambiguousErrorMessages
32+
accountsServer.options.ambiguousErrorMessages &&
33+
accountsPassword.options.requireEmailVerification
3334
? ({} as CreateUserResult)
3435
: ({
3536
userId,
3637
} as CreateUserResult)
3738
);
3839
}
3940

40-
// When initializing AccountsServer we check that enableAutologin and ambiguousErrorMessages options
41+
// When initializing AccountsPassword we check that enableAutologin and requireEmailVerification options
4142
// are not enabled at the same time
4243

4344
const createdUser = await accountsServer.findUserById(userId);

0 commit comments

Comments
 (0)