Skip to content

Commit 58f3066

Browse files
authored
feat(authentication): implement username-based authentication (#1327)
* feat(authentication): add register,login functionality via username and checks for email based flows * chore: reduce code complexity so that codefactor checks pass * chore: remove probably unnecessary code so that codefactor checks pass * refactor: introduce new username auth handler instead of modifying existing local auth handler * fix: address code review feedback * fix: address code review feedback
1 parent 0c2c008 commit 58f3066

File tree

12 files changed

+275
-47
lines changed

12 files changed

+275
-47
lines changed

libraries/grpc-sdk/src/modules/authentication/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ export class Authentication extends ConduitModule<typeof AuthenticationDefinitio
3737
return this.client!.userCreate({ email, verify, password, anonymousId });
3838
}
3939

40+
userCreateByUsername(
41+
username: string,
42+
password?: string,
43+
anonymousId?: string,
44+
): Promise<UserCreateResponse> {
45+
return this.client!.userCreateByUsername({ username, password, anonymousId });
46+
}
47+
4048
anonymousUserCreate(clientId: string): Promise<UserLoginResponse> {
4149
return this.client!.anonymousUserCreate({ clientId });
4250
}

modules/authentication/src/Authentication.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
TeamDeleteRequest,
2525
TeamDeleteResponse,
2626
UserChangePass,
27+
UserCreateByUsernameRequest,
2728
UserCreateRequest,
2829
UserCreateResponse,
2930
UserDeleteRequest,
@@ -354,6 +355,61 @@ export default class Authentication extends ManagedModule<Config> {
354355
}
355356
}
356357

358+
async userCreateByUsername(
359+
call: GrpcRequest<UserCreateByUsernameRequest>,
360+
callback: GrpcCallback<UserCreateResponse>,
361+
) {
362+
const username = call.request.username.toLowerCase();
363+
let password = call.request.password;
364+
365+
if (isNil(password) || password.length === 0) {
366+
password = AuthUtils.randomToken(8);
367+
}
368+
try {
369+
let user = await models.User.getInstance().findOne({ username });
370+
if (user) {
371+
return callback({ code: status.ALREADY_EXISTS, message: 'User already exists' });
372+
}
373+
const hashedPassword = await AuthUtils.hashPassword(password);
374+
const anonymousUserId = call.request.anonymousId;
375+
if (!anonymousUserId) {
376+
user = await models.User.getInstance().create({
377+
username,
378+
hashedPassword,
379+
isVerified: true,
380+
});
381+
await TeamsHandler.getInstance()
382+
.addUserToDefault(user)
383+
.catch(err => {
384+
ConduitGrpcSdk.Logger.error(err);
385+
});
386+
} else {
387+
const config = ConfigController.getInstance().config;
388+
if (!config.anonymousUsers.enabled) {
389+
return callback({
390+
code: status.FAILED_PRECONDITION,
391+
message: 'Anonymous users configuration is disabled',
392+
});
393+
}
394+
user = await models.User.getInstance().findByIdAndUpdate(anonymousUserId, {
395+
username,
396+
hashedPassword,
397+
isAnonymous: false,
398+
isVerified: true,
399+
});
400+
if (!user) {
401+
return callback({
402+
code: status.NOT_FOUND,
403+
message: 'Anonymous user not found',
404+
});
405+
}
406+
}
407+
return callback(null, { password });
408+
} catch (e) {
409+
return callback({ code: status.INTERNAL, message: (e as Error).message });
410+
}
411+
}
412+
357413
async anonymousUserCreate(
358414
call: GrpcRequest<AnonymousUserCreateRequest>,
359415
callback: GrpcCallback<UserLoginResponse>,

modules/authentication/src/admin/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,10 @@ export class AdminHandlers {
6262
{
6363
path: '/users',
6464
action: ConduitRouteActions.POST,
65-
description: `Creates a new user using email/password.`,
65+
description: `Creates a new user using email/username and password.`,
6666
bodyParams: {
67-
email: ConduitString.Required,
67+
email: ConduitString.Optional,
68+
username: ConduitString.Optional,
6869
password: ConduitString.Required,
6970
},
7071
},

modules/authentication/src/admin/user.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,22 +55,18 @@ export class UserAdmin {
5555
}
5656

5757
async createUser(call: ParsedRouterRequest): Promise<UnparsedRouterResponse> {
58-
const email = call.request.params.email.toLowerCase();
59-
const password = call.request.params.password;
60-
if (AuthUtils.invalidEmailAddress(email)) {
61-
throw new GrpcError(status.INVALID_ARGUMENT, 'Invalid email address provided');
62-
}
58+
const { email, username, password } = call.request.params;
6359

64-
let user: User | null = await User.getInstance().findOne({
65-
email: email,
66-
});
60+
const query = AuthUtils.validateAndNormalizeIdentifier(email, username);
61+
62+
let user: User | null = await User.getInstance().findOne(query);
6763
if (!isNil(user)) {
6864
throw new GrpcError(status.ALREADY_EXISTS, 'User already exists');
6965
}
7066

7167
const hashedPassword = await AuthUtils.hashPassword(password);
7268
user = await User.getInstance().create({
73-
email: email,
69+
...query,
7470
hashedPassword,
7571
isVerified: true,
7672
});

modules/authentication/src/authentication.proto

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ message UserCreateRequest {
1414
optional string anonymousId = 4;
1515
}
1616

17+
message UserCreateByUsernameRequest {
18+
string username = 1;
19+
optional string password = 2;
20+
optional string anonymousId = 3;
21+
}
22+
1723
message UserChangePass {
1824
string email = 1;
1925
optional string password = 2;
@@ -98,6 +104,7 @@ message UserModifyStatusResponse {
98104
service Authentication {
99105
rpc UserLogin(UserLoginRequest) returns (UserLoginResponse);
100106
rpc UserCreate(UserCreateRequest) returns (UserCreateResponse);
107+
rpc UserCreateByUsername(UserCreateByUsernameRequest) returns (UserCreateResponse);
101108
rpc UserModifyStatus(UserModifyStatusRequest) returns (UserModifyStatusResponse);
102109
rpc AnonymousUserCreate(AnonymousUserCreateRequest) returns (UserLoginResponse);
103110
rpc ChangePass(UserChangePass) returns (UserCreateResponse);

modules/authentication/src/config/local.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,10 @@ export default {
3838
format: 'String',
3939
default: '',
4040
},
41+
username_auth_enabled: {
42+
doc: 'Defines if username authentication strategy is enabled',
43+
format: 'Boolean',
44+
default: false,
45+
},
4146
},
4247
};

modules/authentication/src/handlers/local.ts

Lines changed: 3 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { isNil } from 'lodash-es';
22
import { AuthUtils } from '../utils/index.js';
33
import { TokenType } from '../constants/index.js';
44
import { v4 as uuid } from 'uuid';
5-
import { Config } from '../config/index.js';
65
import {
76
ConduitGrpcSdk,
87
ConduitRouteActions,
@@ -26,6 +25,7 @@ import {
2625
} from '@conduitplatform/module-tools';
2726
import { createHash } from 'crypto';
2827
import { merge } from 'lodash-es';
28+
import { authenticateChecks, changePassword } from './utils.js';
2929

3030
export class LocalHandlers implements IAuthenticationStrategy {
3131
private emailModule: Email;
@@ -156,7 +156,7 @@ export class LocalHandlers implements IAuthenticationStrategy {
156156
middlewares: ['authMiddleware', 'denyAnonymousMiddleware'],
157157
},
158158
new ConduitRouteReturnDefinition('ChangePasswordResponse', 'String'),
159-
this.changePassword.bind(this),
159+
changePassword.bind(this),
160160
);
161161

162162
routingManager.route(
@@ -387,7 +387,7 @@ export class LocalHandlers implements IAuthenticationStrategy {
387387
);
388388
if (isNil(user))
389389
throw new GrpcError(status.UNAUTHENTICATED, 'Invalid login credentials');
390-
await this._authenticateChecks(password, config, user);
390+
await authenticateChecks(password, config, user);
391391
ConduitGrpcSdk.Metrics?.increment('logged_in_users_total');
392392
return TokenProvider.getInstance().provideUserTokens({
393393
user,
@@ -503,20 +503,6 @@ export class LocalHandlers implements IAuthenticationStrategy {
503503
return 'Password reset successful';
504504
}
505505

506-
async changePassword(call: ParsedRouterRequest): Promise<UnparsedRouterResponse> {
507-
if (!call.request.context.jwtPayload.sudo) {
508-
throw new GrpcError(
509-
status.PERMISSION_DENIED,
510-
'Re-login required to enter sudo mode',
511-
);
512-
}
513-
const { user } = call.request.context;
514-
const { newPassword } = call.request.bodyParams;
515-
const hashedPassword = await AuthUtils.hashPassword(newPassword);
516-
await User.getInstance().findByIdAndUpdate(user._id, { hashedPassword });
517-
return 'Password changed successfully';
518-
}
519-
520506
async changeEmail(call: ParsedRouterRequest): Promise<UnparsedRouterResponse> {
521507
if (!call.request.context.jwtPayload.sudo) {
522508
throw new GrpcError(
@@ -723,25 +709,6 @@ export class LocalHandlers implements IAuthenticationStrategy {
723709
return 'OK';
724710
}
725711

726-
private async _authenticateChecks(password: string, config: Config, user: User) {
727-
if (!user.active) throw new GrpcError(status.PERMISSION_DENIED, 'Inactive user');
728-
if (!user.hashedPassword)
729-
throw new GrpcError(
730-
status.PERMISSION_DENIED,
731-
'User does not use password authentication',
732-
);
733-
const passwordsMatch = await AuthUtils.checkPassword(password, user.hashedPassword);
734-
if (!passwordsMatch)
735-
throw new GrpcError(status.UNAUTHENTICATED, 'Invalid login credentials');
736-
737-
if (config.local.verification.required && !user.isVerified) {
738-
throw new GrpcError(
739-
status.PERMISSION_DENIED,
740-
'You must verify your account to login',
741-
);
742-
}
743-
}
744-
745712
private async initDbAndEmail() {
746713
const config = ConfigController.getInstance().config;
747714

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { isNil } from 'lodash-es';
2+
import {
3+
ConduitGrpcSdk,
4+
ConduitRouteActions,
5+
ConduitRouteReturnDefinition,
6+
GrpcError,
7+
ParsedRouterRequest,
8+
UnparsedRouterResponse,
9+
} from '@conduitplatform/grpc-sdk';
10+
import { User } from '../models/index.js';
11+
import { status } from '@grpc/grpc-js';
12+
import { IAuthenticationStrategy } from '../interfaces/index.js';
13+
import { TokenProvider } from './tokenProvider.js';
14+
import {
15+
ConduitString,
16+
ConfigController,
17+
RoutingManager,
18+
} from '@conduitplatform/module-tools';
19+
import { authenticateChecks, changePassword } from './utils.js';
20+
21+
export class UsernameHandlers implements IAuthenticationStrategy {
22+
constructor(private readonly grpcSdk: ConduitGrpcSdk) {}
23+
24+
async declareRoutes(routingManager: RoutingManager): Promise<void> {
25+
const config = ConfigController.getInstance().config;
26+
const captchaConfig = config.captcha;
27+
28+
routingManager.route(
29+
{
30+
path: '/username',
31+
action: ConduitRouteActions.POST,
32+
description: `Login endpoint that can be used to authenticate.
33+
Tokens are returned according to configuration.`,
34+
bodyParams: {
35+
username: ConduitString.Required,
36+
password: ConduitString.Required,
37+
captchaToken: ConduitString.Optional,
38+
},
39+
middlewares:
40+
captchaConfig.enabled && captchaConfig.routes.login
41+
? ['captchaMiddleware']
42+
: undefined,
43+
},
44+
new ConduitRouteReturnDefinition('LoginResponse', {
45+
accessToken: ConduitString.Optional,
46+
refreshToken: ConduitString.Optional,
47+
}),
48+
this.authenticate.bind(this),
49+
);
50+
51+
routingManager.route(
52+
{
53+
path: '/username/change-password',
54+
action: ConduitRouteActions.POST,
55+
description: `Changes the user's password (requires sudo access).`,
56+
bodyParams: {
57+
newPassword: ConduitString.Required,
58+
},
59+
middlewares: ['authMiddleware', 'denyAnonymousMiddleware'],
60+
},
61+
new ConduitRouteReturnDefinition('ChangePasswordResponse', 'String'),
62+
changePassword.bind(this),
63+
);
64+
}
65+
66+
async validate(): Promise<boolean> {
67+
const config = ConfigController.getInstance().config;
68+
if (config.local.username.enabled) {
69+
return Promise.resolve(true);
70+
} else {
71+
ConduitGrpcSdk.Logger.log('Username authentication not available');
72+
return Promise.resolve(false);
73+
}
74+
}
75+
76+
async authenticate(call: ParsedRouterRequest): Promise<UnparsedRouterResponse> {
77+
ConduitGrpcSdk.Metrics?.increment('login_requests_total');
78+
79+
const { username, password } = call.request.params;
80+
const context = call.request.context;
81+
82+
if (isNil(context)) {
83+
throw new GrpcError(status.UNAUTHENTICATED, 'No headers provided');
84+
}
85+
86+
const clientId = context.clientId;
87+
const config = ConfigController.getInstance().config;
88+
89+
const user: User | null = await User.getInstance().findOne(
90+
{ username },
91+
'+hashedPassword',
92+
);
93+
94+
if (isNil(user)) {
95+
throw new GrpcError(status.UNAUTHENTICATED, 'Invalid login credentials');
96+
}
97+
98+
await authenticateChecks(password, config, user);
99+
100+
ConduitGrpcSdk.Metrics?.increment('logged_in_users_total');
101+
102+
return TokenProvider.getInstance().provideUserTokens({
103+
user,
104+
clientId,
105+
config,
106+
});
107+
}
108+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {
2+
GrpcError,
3+
ParsedRouterRequest,
4+
UnparsedRouterResponse,
5+
} from '@conduitplatform/grpc-sdk';
6+
import { status } from '@grpc/grpc-js';
7+
import { User } from '../models/index.js';
8+
import { Config } from '../config/index.js';
9+
import { AuthUtils } from '../utils/index.js';
10+
11+
export async function changePassword(
12+
call: ParsedRouterRequest,
13+
): Promise<UnparsedRouterResponse> {
14+
if (!call.request.context.jwtPayload.sudo) {
15+
throw new GrpcError(status.PERMISSION_DENIED, 'Re-login required to enter sudo mode');
16+
}
17+
const { user } = call.request.context;
18+
const { newPassword } = call.request.bodyParams;
19+
const hashedPassword = await AuthUtils.hashPassword(newPassword);
20+
await User.getInstance().findByIdAndUpdate(user._id, { hashedPassword });
21+
return 'Password changed successfully';
22+
}
23+
24+
export async function authenticateChecks(password: string, config: Config, user: User) {
25+
if (!user.active) throw new GrpcError(status.PERMISSION_DENIED, 'Inactive user');
26+
if (!user.hashedPassword)
27+
throw new GrpcError(
28+
status.PERMISSION_DENIED,
29+
'User does not use password authentication',
30+
);
31+
const passwordsMatch = await AuthUtils.checkPassword(password, user.hashedPassword);
32+
if (!passwordsMatch)
33+
throw new GrpcError(status.UNAUTHENTICATED, 'Invalid login credentials');
34+
35+
if (config.local.verification.required && !user.isVerified) {
36+
throw new GrpcError(
37+
status.PERMISSION_DENIED,
38+
'You must verify your account to login',
39+
);
40+
}
41+
}

0 commit comments

Comments
 (0)