Skip to content

Commit

Permalink
feat: totp reset and backup codes (#394)
Browse files Browse the repository at this point in the history
  • Loading branch information
Vilsol authored Jan 13, 2025
1 parent 3d60498 commit c67d19e
Show file tree
Hide file tree
Showing 8 changed files with 259 additions and 25 deletions.
4 changes: 3 additions & 1 deletion cfg/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
"invitationSubjectTpl": "http://localhost:5000/storage/internal/identity-srv/templates/invitation_subject.hbs",
"invitationBodyTpl": "http://localhost:5000/storage/internal/identity-srv/templates/invitation_body.hbs",
"layoutTpl": "http://localhost:5000/storage/internal/identity-srv/templates/layout.hbs",
"resourcesTpl": "http://localhost:5000/storage/internal/identity-srv/templates/resources.json"
"resourcesTpl": "http://localhost:5000/storage/internal/identity-srv/templates/resources.json",
"resetTotpSubjectTpl": "http://localhost:5000/storage/internal/identity-srv/templates/reset_totp_email_subject.hbs",
"resetTotpBodyTpl": "http://localhost:5000/storage/internal/identity-srv/templates/reset_totp_email_body.hbs"
},
"activationURL": "https://console.restorecommerce.io/activate-account",
"inactivatedAccountExpiry": "undefined",
Expand Down
4 changes: 3 additions & 1 deletion cfg/config_production.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
"invitationSubjectTpl": "http://facade-srv:5000/storage/internal/identity-srv/templates/invitation_subject.hbs",
"invitationBodyTpl": "http://facade-srv:5000/storage/internal/identity-srv/templates/invitation_body.hbs",
"layoutTpl": "http://facade-srv:5000/storage/internal/identity-srv/templates/layout.hbs",
"resourcesTpl": "http://facade-srv:5000/storage/internal/identity-srv/templates/resources.json"
"resourcesTpl": "http://facade-srv:5000/storage/internal/identity-srv/templates/resources.json",
"resetTotpSubjectTpl": "http://facade-srv:5000/storage/internal/identity-srv/templates/reset_totp_email_subject.hbs",
"resetTotpBodyTpl": "http://facade-srv:5000/storage/internal/identity-srv/templates/reset_totp_email_body.hbs"
}
},
"database": {
Expand Down
11 changes: 11 additions & 0 deletions email_templates/reset_totp_email_body.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{{#extend "layout"}}
{{#content "main"}}
<p>Dear {{firstName}} {{lastName}},</p>
<p>You requested a TOTP code reset. Please use the following code in place of your normal TOTP code:</p>
<pre><code>{{totpCode}}</code></pre>
<p>If you did not request this, please contact the Restorecommerce support team.</p>
<p>The Restorecommerce Team</p>
<br/>
<br/>
{{/content}}
{{/extend}}
1 change: 1 addition & 0 deletions email_templates/reset_totp_email_subject.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Restorecommerce Account TOTP Code Reset
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"@restorecommerce/grpc-client": "^2.2.5",
"@restorecommerce/kafka-client": "1.2.22",
"@restorecommerce/logger": "^1.3.2",
"@restorecommerce/rc-grpc-clients": "5.1.44",
"@restorecommerce/rc-grpc-clients": "5.1.45",
"@restorecommerce/resource-base-interface": "1.6.5",
"@restorecommerce/scs-jobs": "0.1.49",
"@restorecommerce/service-config": "^1.0.16",
Expand Down
161 changes: 144 additions & 17 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import {
import { ResourcesAPIBase, ServiceBase, FilterValueType } from '@restorecommerce/resource-base-interface';
import { Logger } from 'winston';
import {
ACSAuthZ,
AuthZAction,
ACSAuthZ, authZ,
AuthZAction, cfg,
DecisionResponse,
HierarchicalScope,
Operation,
Expand Down Expand Up @@ -63,6 +63,9 @@ import {
ExchangeTOTPRequest,
SetupTOTPRequest,
SetupTOTPResponse,
CreateBackupTOTPCodesRequest,
CreateBackupTOTPCodesResponse,
ResetTOTPRequest,
} from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/user.js';
import {
Role,
Expand Down Expand Up @@ -91,35 +94,37 @@ import {
Subject,
Tokens
} from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/auth.js';
import { zxcvbnOptions, zxcvbnAsync, ZxcvbnResult } from '@zxcvbn-ts/core';
import {zxcvbnOptions, zxcvbnAsync, ZxcvbnResult, Matcher, Match, MatchEstimated, MatchExtended} from '@zxcvbn-ts/core';
import * as zxcvbnCommonPackage from '@zxcvbn-ts/language-common';
import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en';
import * as zxcvbnDePackage from '@zxcvbn-ts/language-de';
import { matcherPwnedFactory } from '@zxcvbn-ts/matcher-pwned';
import {
MatchEstimated,
MatchExtended,
Matcher,
Match,
} from '@zxcvbn-ts/core/dist/types.js';
import fetch from 'node-fetch';

import { authenticator, totp } from 'otplib';
import * as jose from 'jose';
import crypto from 'node:crypto';

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

export class UserService extends ServiceBase<UserListResponse, UserList> implements UserServiceImplementation {
db: Arango;
topics: any;
cfg: any;
registrationSubjectTpl: string;
changePWEmailSubjectTpl: string;

layoutTpl: string;
registrationSubjectTpl: string;
registrationBodyTpl: string;

changePWEmailSubjectTpl: string;
changePWEmailBodyTpl: string;

invitationSubjectTpl: string;
invitationBodyTpl: string;

resetTotpSubjectTpl: string;
resetTotpBodyTpl: string;

emailEnabled: boolean;
emailStyle: string;
roleService: RoleService;
Expand Down Expand Up @@ -762,7 +767,7 @@ export class UserService extends ServiceBase<UserListResponse, UserList> impleme
// the user role associations if not skip validation
let acsResponse: DecisionResponse | PolicySetRQResponse;
try {
const ctx = { subject, resources: [] };
const ctx = { subject, resources: [] as any[] };
acsResponse = await checkAccessRequest(ctx, [{ resource: 'user' }], AuthZAction.MODIFY, Operation.whatIsAllowed);
} catch (err: any) {
this.logger.error('Error making whatIsAllowedACS request for verifying role associations', { code: err.code, message: err.message, stack: err.stack });
Expand Down Expand Up @@ -2659,6 +2664,12 @@ export class UserService extends ServiceBase<UserListResponse, UserList> impleme
response = await fetch(hbsTemplates.layoutTpl, { headers });
this.layoutTpl = await response.text();

response = await fetch(hbsTemplates.resetTotpSubjectTpl, { headers });
this.resetTotpSubjectTpl = await response.text();

response = await fetch(hbsTemplates.resetTotpBodyTpl, { headers });
this.resetTotpBodyTpl = await response.text();

response = await fetch(hbsTemplates.resourcesTpl, { headers });
if (response.status == 200) {
const externalRrc = JSON.parse(await response.text());
Expand Down Expand Up @@ -3225,7 +3236,7 @@ export class UserService extends ServiceBase<UserListResponse, UserList> impleme
this.logger.debug('user does not exist', { identifier: subject.id });
return returnStatus(404, 'user does not exist');
} else if (users.total_count > 1) {
return returnStatus(400, `Invalid identifier provided for totp setup, multiple users found for identifier ${subject.id}`);
return returnStatus(400, `Invalid identifier provided for totp exchange, multiple users found for identifier ${subject.id}`);
}

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

if (!totp.check(request.code, user.totp_secret_processing)) {
return returnStatus(400, 'Invalid TOTP code');
if (totp.check(request.code, user.totp_secret_processing)) {
return { payload: user, status: { code: 200, message: 'success' } };
}

return { payload: user, status: { code: 200, message: 'success' } };
const backupCode = user.totp_recovery_codes.indexOf(request.code);
if (backupCode >= 0) {
user.totp_recovery_codes.splice(backupCode, 1);

const updateStatus = await super.update(UserList.fromPartial({
items: [user]
}), context);

return { payload: updateStatus.items[0].payload, status: { code: 200, message: 'success' } };
}

return returnStatus(400, 'Invalid TOTP code');
}

async getUnauthenticatedSubjectTokenForTenant(request: TenantRequest, context: any): Promise<DeepPartial<TenantResponse>> {
Expand Down Expand Up @@ -3293,6 +3315,111 @@ export class UserService extends ServiceBase<UserListResponse, UserList> impleme
token: token?.token
}));
}

async createBackupTOTPCodes(request: CreateBackupTOTPCodesRequest, context: any): Promise<DeepPartial<CreateBackupTOTPCodesResponse>> {
const subject = request.subject;
const users = await super.read(ReadRequest.fromPartial({
filters: [{
filters: [{
field: 'id',
operation: Filter_Operation.eq,
value: subject?.id
}]
}]
}), context);

if (!users || users.total_count === 0) {
this.logger.debug('user does not exist', { identifier: subject.id });
return returnOperationStatus(404, 'user does not exist');
} else if (users.total_count > 1) {
return returnOperationStatus(400, `Invalid identifier provided for backup totp code setup, multiple users found for identifier ${subject.id}`);
}

const recovery_code_count = 12;
const totp_recovery_codes: string[] = [];
for (let i = 0; i < recovery_code_count; i++) {
totp_recovery_codes[i] = crypto.randomBytes(16).toString('base64url')
}

const user = users.items[0].payload;
let acsResponse: DecisionResponse;
try {
acsResponse = await checkAccessRequest({
...context,
subject,
resources: { id: user.id, totp_recovery_codes, meta: user.meta }
}, [{ resource: 'user', id: user.id, property: ['totp_recovery_codes'] }], AuthZAction.MODIFY, Operation.isAllowed);
} catch (err: any) {
this.logger.error('Error occurred requesting access-control-srv for createBackupTOTPCodes', { code: err.code, message: err.message, stack: err.stack });
return returnOperationStatus(err.code, err.message);
}

if (acsResponse.decision != Response_Decision.PERMIT) {
return { operation_status: acsResponse.operation_status };
}

user.totp_recovery_codes = totp_recovery_codes;

const updateStatus = await super.update(UserList.fromPartial({
items: [user]
}), context);
return {
backup_codes: totp_recovery_codes,
operation_status: updateStatus?.items[0]?.status
};
}

async resetTOTP(request: ResetTOTPRequest, context: any): Promise<DeepPartial<OperationStatusObj>> {
const subject = request.subject;
const users = await super.read(ReadRequest.fromPartial({
filters: [{
filters: [{
field: 'id',
operation: Filter_Operation.eq,
value: subject?.id
}]
}]
}), context);

if (!users || users.total_count === 0) {
this.logger.debug('user does not exist', { identifier: subject.id });
return returnOperationStatus(404, 'user does not exist');
} else if (users.total_count > 1) {
return returnOperationStatus(400, `Invalid identifier provided for backup totp code setup, multiple users found for identifier ${subject.id}`);
}

const totpCode = crypto.randomBytes(16).toString('base64url');

const user = users.items[0].payload;
user.totp_recovery_codes.push(totpCode)

const updateStatus = await super.update(UserList.fromPartial({
items: [user]
}), context);

if (this.emailEnabled) {
await this.fetchHbsTemplates();
const renderRequest = this.makeTOTPResetData(user, totpCode);
await this.topics.rendering.emit('renderRequest', renderRequest);
}

return {
operation_status: updateStatus?.items[0]?.status
};
}

private makeTOTPResetData(user: DeepPartial<User>, totpCode: string): any {
const emailBody = this.resetTotpBodyTpl;
const emailSubject = this.resetTotpSubjectTpl;

const dataBody = {
firstName: user.first_name,
lastName: user.last_name,
totpCode,
};
return this.makeRenderRequestMsg(user, emailSubject, emailBody,
dataBody, {}, user.email);
}
}

export class RoleService extends ServiceBase<RoleListResponse, RoleList> implements RoleServiceImplementation {
Expand All @@ -3316,7 +3443,7 @@ export class RoleService extends ServiceBase<RoleListResponse, RoleList> impleme
}
}
}
super('role', roleTopic, logger, new ResourcesAPIBase(db, 'roles', resourceFieldConfig), isEventsEnabled);
super('role', roleTopic as any, logger, new ResourcesAPIBase(db, 'roles', resourceFieldConfig), isEventsEnabled);
const redisConfig = cfg.get('redis');
redisConfig.database = cfg.get('redis:db-indexes:db-subject');
this.redisClient = createClient(redisConfig);
Expand Down
Loading

0 comments on commit c67d19e

Please sign in to comment.