Skip to content

Commit c67d19e

Browse files
authored
feat: totp reset and backup codes (#394)
1 parent 3d60498 commit c67d19e

8 files changed

+259
-25
lines changed

cfg/config.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
"invitationSubjectTpl": "http://localhost:5000/storage/internal/identity-srv/templates/invitation_subject.hbs",
1616
"invitationBodyTpl": "http://localhost:5000/storage/internal/identity-srv/templates/invitation_body.hbs",
1717
"layoutTpl": "http://localhost:5000/storage/internal/identity-srv/templates/layout.hbs",
18-
"resourcesTpl": "http://localhost:5000/storage/internal/identity-srv/templates/resources.json"
18+
"resourcesTpl": "http://localhost:5000/storage/internal/identity-srv/templates/resources.json",
19+
"resetTotpSubjectTpl": "http://localhost:5000/storage/internal/identity-srv/templates/reset_totp_email_subject.hbs",
20+
"resetTotpBodyTpl": "http://localhost:5000/storage/internal/identity-srv/templates/reset_totp_email_body.hbs"
1921
},
2022
"activationURL": "https://console.restorecommerce.io/activate-account",
2123
"inactivatedAccountExpiry": "undefined",

cfg/config_production.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@
3535
"invitationSubjectTpl": "http://facade-srv:5000/storage/internal/identity-srv/templates/invitation_subject.hbs",
3636
"invitationBodyTpl": "http://facade-srv:5000/storage/internal/identity-srv/templates/invitation_body.hbs",
3737
"layoutTpl": "http://facade-srv:5000/storage/internal/identity-srv/templates/layout.hbs",
38-
"resourcesTpl": "http://facade-srv:5000/storage/internal/identity-srv/templates/resources.json"
38+
"resourcesTpl": "http://facade-srv:5000/storage/internal/identity-srv/templates/resources.json",
39+
"resetTotpSubjectTpl": "http://facade-srv:5000/storage/internal/identity-srv/templates/reset_totp_email_subject.hbs",
40+
"resetTotpBodyTpl": "http://facade-srv:5000/storage/internal/identity-srv/templates/reset_totp_email_body.hbs"
3941
}
4042
},
4143
"database": {
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{{#extend "layout"}}
2+
{{#content "main"}}
3+
<p>Dear {{firstName}} {{lastName}},</p>
4+
<p>You requested a TOTP code reset. Please use the following code in place of your normal TOTP code:</p>
5+
<pre><code>{{totpCode}}</code></pre>
6+
<p>If you did not request this, please contact the Restorecommerce support team.</p>
7+
<p>The Restorecommerce Team</p>
8+
<br/>
9+
<br/>
10+
{{/content}}
11+
{{/extend}}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Restorecommerce Account TOTP Code Reset

package-lock.json

+4-4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"@restorecommerce/grpc-client": "^2.2.5",
2323
"@restorecommerce/kafka-client": "1.2.22",
2424
"@restorecommerce/logger": "^1.3.2",
25-
"@restorecommerce/rc-grpc-clients": "5.1.44",
25+
"@restorecommerce/rc-grpc-clients": "5.1.45",
2626
"@restorecommerce/resource-base-interface": "1.6.5",
2727
"@restorecommerce/scs-jobs": "0.1.49",
2828
"@restorecommerce/service-config": "^1.0.16",

src/service.ts

+144-17
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import {
1818
import { ResourcesAPIBase, ServiceBase, FilterValueType } from '@restorecommerce/resource-base-interface';
1919
import { Logger } from 'winston';
2020
import {
21-
ACSAuthZ,
22-
AuthZAction,
21+
ACSAuthZ, authZ,
22+
AuthZAction, cfg,
2323
DecisionResponse,
2424
HierarchicalScope,
2525
Operation,
@@ -63,6 +63,9 @@ import {
6363
ExchangeTOTPRequest,
6464
SetupTOTPRequest,
6565
SetupTOTPResponse,
66+
CreateBackupTOTPCodesRequest,
67+
CreateBackupTOTPCodesResponse,
68+
ResetTOTPRequest,
6669
} from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/user.js';
6770
import {
6871
Role,
@@ -91,35 +94,37 @@ import {
9194
Subject,
9295
Tokens
9396
} from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/auth.js';
94-
import { zxcvbnOptions, zxcvbnAsync, ZxcvbnResult } from '@zxcvbn-ts/core';
97+
import {zxcvbnOptions, zxcvbnAsync, ZxcvbnResult, Matcher, Match, MatchEstimated, MatchExtended} from '@zxcvbn-ts/core';
9598
import * as zxcvbnCommonPackage from '@zxcvbn-ts/language-common';
9699
import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en';
97100
import * as zxcvbnDePackage from '@zxcvbn-ts/language-de';
98101
import { matcherPwnedFactory } from '@zxcvbn-ts/matcher-pwned';
99-
import {
100-
MatchEstimated,
101-
MatchExtended,
102-
Matcher,
103-
Match,
104-
} from '@zxcvbn-ts/core/dist/types.js';
105102
import fetch from 'node-fetch';
106103

107104
import { authenticator, totp } from 'otplib';
108105
import * as jose from 'jose';
106+
import crypto from 'node:crypto';
109107

110108
export const DELETE_USERS_WITH_EXPIRED_ACTIVATION = 'delete-users-with-expired-activation-job';
111109

112110
export class UserService extends ServiceBase<UserListResponse, UserList> implements UserServiceImplementation {
113111
db: Arango;
114112
topics: any;
115113
cfg: any;
116-
registrationSubjectTpl: string;
117-
changePWEmailSubjectTpl: string;
114+
118115
layoutTpl: string;
116+
registrationSubjectTpl: string;
119117
registrationBodyTpl: string;
118+
119+
changePWEmailSubjectTpl: string;
120120
changePWEmailBodyTpl: string;
121+
121122
invitationSubjectTpl: string;
122123
invitationBodyTpl: string;
124+
125+
resetTotpSubjectTpl: string;
126+
resetTotpBodyTpl: string;
127+
123128
emailEnabled: boolean;
124129
emailStyle: string;
125130
roleService: RoleService;
@@ -762,7 +767,7 @@ export class UserService extends ServiceBase<UserListResponse, UserList> impleme
762767
// the user role associations if not skip validation
763768
let acsResponse: DecisionResponse | PolicySetRQResponse;
764769
try {
765-
const ctx = { subject, resources: [] };
770+
const ctx = { subject, resources: [] as any[] };
766771
acsResponse = await checkAccessRequest(ctx, [{ resource: 'user' }], AuthZAction.MODIFY, Operation.whatIsAllowed);
767772
} catch (err: any) {
768773
this.logger.error('Error making whatIsAllowedACS request for verifying role associations', { code: err.code, message: err.message, stack: err.stack });
@@ -2659,6 +2664,12 @@ export class UserService extends ServiceBase<UserListResponse, UserList> impleme
26592664
response = await fetch(hbsTemplates.layoutTpl, { headers });
26602665
this.layoutTpl = await response.text();
26612666

2667+
response = await fetch(hbsTemplates.resetTotpSubjectTpl, { headers });
2668+
this.resetTotpSubjectTpl = await response.text();
2669+
2670+
response = await fetch(hbsTemplates.resetTotpBodyTpl, { headers });
2671+
this.resetTotpBodyTpl = await response.text();
2672+
26622673
response = await fetch(hbsTemplates.resourcesTpl, { headers });
26632674
if (response.status == 200) {
26642675
const externalRrc = JSON.parse(await response.text());
@@ -3225,7 +3236,7 @@ export class UserService extends ServiceBase<UserListResponse, UserList> impleme
32253236
this.logger.debug('user does not exist', { identifier: subject.id });
32263237
return returnStatus(404, 'user does not exist');
32273238
} else if (users.total_count > 1) {
3228-
return returnStatus(400, `Invalid identifier provided for totp setup, multiple users found for identifier ${subject.id}`);
3239+
return returnStatus(400, `Invalid identifier provided for totp exchange, multiple users found for identifier ${subject.id}`);
32293240
}
32303241

32313242
const user = users.items[0].payload;
@@ -3241,11 +3252,22 @@ export class UserService extends ServiceBase<UserListResponse, UserList> impleme
32413252
return returnStatus(400, 'Invalid TOTP session token');
32423253
}
32433254

3244-
if (!totp.check(request.code, user.totp_secret_processing)) {
3245-
return returnStatus(400, 'Invalid TOTP code');
3255+
if (totp.check(request.code, user.totp_secret_processing)) {
3256+
return { payload: user, status: { code: 200, message: 'success' } };
32463257
}
32473258

3248-
return { payload: user, status: { code: 200, message: 'success' } };
3259+
const backupCode = user.totp_recovery_codes.indexOf(request.code);
3260+
if (backupCode >= 0) {
3261+
user.totp_recovery_codes.splice(backupCode, 1);
3262+
3263+
const updateStatus = await super.update(UserList.fromPartial({
3264+
items: [user]
3265+
}), context);
3266+
3267+
return { payload: updateStatus.items[0].payload, status: { code: 200, message: 'success' } };
3268+
}
3269+
3270+
return returnStatus(400, 'Invalid TOTP code');
32493271
}
32503272

32513273
async getUnauthenticatedSubjectTokenForTenant(request: TenantRequest, context: any): Promise<DeepPartial<TenantResponse>> {
@@ -3293,6 +3315,111 @@ export class UserService extends ServiceBase<UserListResponse, UserList> impleme
32933315
token: token?.token
32943316
}));
32953317
}
3318+
3319+
async createBackupTOTPCodes(request: CreateBackupTOTPCodesRequest, context: any): Promise<DeepPartial<CreateBackupTOTPCodesResponse>> {
3320+
const subject = request.subject;
3321+
const users = await super.read(ReadRequest.fromPartial({
3322+
filters: [{
3323+
filters: [{
3324+
field: 'id',
3325+
operation: Filter_Operation.eq,
3326+
value: subject?.id
3327+
}]
3328+
}]
3329+
}), context);
3330+
3331+
if (!users || users.total_count === 0) {
3332+
this.logger.debug('user does not exist', { identifier: subject.id });
3333+
return returnOperationStatus(404, 'user does not exist');
3334+
} else if (users.total_count > 1) {
3335+
return returnOperationStatus(400, `Invalid identifier provided for backup totp code setup, multiple users found for identifier ${subject.id}`);
3336+
}
3337+
3338+
const recovery_code_count = 12;
3339+
const totp_recovery_codes: string[] = [];
3340+
for (let i = 0; i < recovery_code_count; i++) {
3341+
totp_recovery_codes[i] = crypto.randomBytes(16).toString('base64url')
3342+
}
3343+
3344+
const user = users.items[0].payload;
3345+
let acsResponse: DecisionResponse;
3346+
try {
3347+
acsResponse = await checkAccessRequest({
3348+
...context,
3349+
subject,
3350+
resources: { id: user.id, totp_recovery_codes, meta: user.meta }
3351+
}, [{ resource: 'user', id: user.id, property: ['totp_recovery_codes'] }], AuthZAction.MODIFY, Operation.isAllowed);
3352+
} catch (err: any) {
3353+
this.logger.error('Error occurred requesting access-control-srv for createBackupTOTPCodes', { code: err.code, message: err.message, stack: err.stack });
3354+
return returnOperationStatus(err.code, err.message);
3355+
}
3356+
3357+
if (acsResponse.decision != Response_Decision.PERMIT) {
3358+
return { operation_status: acsResponse.operation_status };
3359+
}
3360+
3361+
user.totp_recovery_codes = totp_recovery_codes;
3362+
3363+
const updateStatus = await super.update(UserList.fromPartial({
3364+
items: [user]
3365+
}), context);
3366+
return {
3367+
backup_codes: totp_recovery_codes,
3368+
operation_status: updateStatus?.items[0]?.status
3369+
};
3370+
}
3371+
3372+
async resetTOTP(request: ResetTOTPRequest, context: any): Promise<DeepPartial<OperationStatusObj>> {
3373+
const subject = request.subject;
3374+
const users = await super.read(ReadRequest.fromPartial({
3375+
filters: [{
3376+
filters: [{
3377+
field: 'id',
3378+
operation: Filter_Operation.eq,
3379+
value: subject?.id
3380+
}]
3381+
}]
3382+
}), context);
3383+
3384+
if (!users || users.total_count === 0) {
3385+
this.logger.debug('user does not exist', { identifier: subject.id });
3386+
return returnOperationStatus(404, 'user does not exist');
3387+
} else if (users.total_count > 1) {
3388+
return returnOperationStatus(400, `Invalid identifier provided for backup totp code setup, multiple users found for identifier ${subject.id}`);
3389+
}
3390+
3391+
const totpCode = crypto.randomBytes(16).toString('base64url');
3392+
3393+
const user = users.items[0].payload;
3394+
user.totp_recovery_codes.push(totpCode)
3395+
3396+
const updateStatus = await super.update(UserList.fromPartial({
3397+
items: [user]
3398+
}), context);
3399+
3400+
if (this.emailEnabled) {
3401+
await this.fetchHbsTemplates();
3402+
const renderRequest = this.makeTOTPResetData(user, totpCode);
3403+
await this.topics.rendering.emit('renderRequest', renderRequest);
3404+
}
3405+
3406+
return {
3407+
operation_status: updateStatus?.items[0]?.status
3408+
};
3409+
}
3410+
3411+
private makeTOTPResetData(user: DeepPartial<User>, totpCode: string): any {
3412+
const emailBody = this.resetTotpBodyTpl;
3413+
const emailSubject = this.resetTotpSubjectTpl;
3414+
3415+
const dataBody = {
3416+
firstName: user.first_name,
3417+
lastName: user.last_name,
3418+
totpCode,
3419+
};
3420+
return this.makeRenderRequestMsg(user, emailSubject, emailBody,
3421+
dataBody, {}, user.email);
3422+
}
32963423
}
32973424

32983425
export class RoleService extends ServiceBase<RoleListResponse, RoleList> implements RoleServiceImplementation {
@@ -3316,7 +3443,7 @@ export class RoleService extends ServiceBase<RoleListResponse, RoleList> impleme
33163443
}
33173444
}
33183445
}
3319-
super('role', roleTopic, logger, new ResourcesAPIBase(db, 'roles', resourceFieldConfig), isEventsEnabled);
3446+
super('role', roleTopic as any, logger, new ResourcesAPIBase(db, 'roles', resourceFieldConfig), isEventsEnabled);
33203447
const redisConfig = cfg.get('redis');
33213448
redisConfig.database = cfg.get('redis:db-indexes:db-subject');
33223449
this.redisClient = createClient(redisConfig);

0 commit comments

Comments
 (0)