From 0f219cdcc36d4b0d15675d847886542cb49612e7 Mon Sep 17 00:00:00 2001 From: Anand Chowdhary Date: Fri, 30 Oct 2020 14:02:41 +0530 Subject: [PATCH] :sparkles: Add support for MFA when logging in --- src/config/configuration.ts | 1 + src/modules/auth/auth.controller.ts | 4 +- src/modules/auth/auth.interface.ts | 22 +++++++ src/modules/auth/auth.service.ts | 84 ++++++++++++++++++++------ src/modules/tokens/tokens.constants.ts | 3 +- src/templates/auth/mfa-code.md | 9 +++ 6 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 src/templates/auth/mfa-code.md diff --git a/src/config/configuration.ts b/src/config/configuration.ts index fc155849b..4e4fb8d70 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -11,6 +11,7 @@ export default () => ({ jwtSecret: process.env.JWT_SECRET ?? 'staart', totpWindowPast: process.env.TOTP_WINDOW_PAST ?? 1, totpWindowFuture: process.env.TOTP_WINDOW_PAST ?? 0, + mfaTokenExpiry: process.env.MFA_TOKEN_EXPIRY ?? '10m', accessTokenExpiry: process.env.ACCESS_TOKEN_EXPIRY ?? '1h', passwordPwnedCheck: !!process.env.PASSWORD_PWNED_CHECK, unusedRefreshTokenExpiryDays: process.env.DELETE_EXPIRED_SESSIONS ?? 30, diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index d67ab4673..97ed59211 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -11,7 +11,7 @@ import { TotpLoginDto, VerifyEmailDto, } from './auth.dto'; -import { TokenResponse } from './auth.interface'; +import { TokenResponse, TotpTokenResponse } from './auth.interface'; import { AuthService } from './auth.service'; import { Public } from './public.decorator'; @@ -30,7 +30,7 @@ export class AuthController { @Body() data: LoginDto, @Ip() ip: string, @Headers('User-Agent') userAgent: string, - ): Promise { + ): Promise { return this.authService.login( ip, userAgent, diff --git a/src/modules/auth/auth.interface.ts b/src/modules/auth/auth.interface.ts index 2b589648f..9a0dd514b 100644 --- a/src/modules/auth/auth.interface.ts +++ b/src/modules/auth/auth.interface.ts @@ -11,12 +11,34 @@ export interface TokenResponse { refreshToken: string; } +export interface TotpTokenResponse { + totpToken: string; + type: MfaTypes; + multiFactorRequired: true; +} + export interface AccessTokenParsed { id: number; scopes: string[]; } +export type MfaTypes = 'TOTP' | 'EMAIL'; + +export interface MfaTokenPayload { + id: number; + type: MfaTypes; +} + type CombinedRequest = ExpressRequest & typeof NestRequest; export interface UserRequest extends CombinedRequest { user: AccessTokenParsed; } + +export interface ValidatedUser { + id: number; + name: string; + twoFactorEnabled: boolean; + twoFactorSecret?: string; + checkLocationOnLogin: boolean; + prefersEmailAddress: string; +} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 0a6c636ed..4078eae71 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -26,12 +26,20 @@ import { PwnedService } from '../pwned/pwned.service'; import { APPROVE_SUBNET_TOKEN, EMAIL_VERIFY_TOKEN, + MULTI_FACTOR_TOKEN, PASSWORD_RESET_TOKEN, - TWO_FACTOR_TOKEN, + EMAIL_MFA_TOKEN, } from '../tokens/tokens.constants'; import { TokensService } from '../tokens/tokens.service'; import { RegisterDto } from './auth.dto'; -import { AccessTokenClaims, TokenResponse } from './auth.interface'; +import { + AccessTokenClaims, + MfaTokenPayload, + MfaTypes, + TokenResponse, + TotpTokenResponse, + ValidatedUser, +} from './auth.interface'; @Injectable() export class AuthService { @@ -55,7 +63,7 @@ export class AuthService { }); } - async validateUser(email: string, password?: string) { + async validateUser(email: string, password?: string): Promise { const emailSafe = safeEmail(email); const user = await this.prisma.users.findFirst({ where: { emails: { some: { emailSafe } } }, @@ -64,7 +72,10 @@ export class AuthService { password: true, emails: true, twoFactorEnabled: true, + twoFactorSecret: true, checkLocationOnLogin: true, + prefersEmail: true, + name: true, }, }); if (!user) throw new HttpException('User not found', HttpStatus.NOT_FOUND); @@ -77,9 +88,12 @@ export class AuthService { ); if (await compare(password, user.password)) return { + name: user.name, id: user.id, twoFactorEnabled: user.twoFactorEnabled, + twoFactorSecret: user.twoFactorSecret, checkLocationOnLogin: user.checkLocationOnLogin, + prefersEmailAddress: user.prefersEmail.emailSafe, }; return null; } @@ -90,13 +104,12 @@ export class AuthService { email: string, password?: string, code?: string, - ) { + ): Promise { const user = await this.validateUser(email, password); if (!user) throw new UnauthorizedException(); if (code) return this.loginUserWithTotpCode(ipAddress, userAgent, user.id, code); - if (user.twoFactorEnabled) { - } + if (user.twoFactorEnabled) return this.mfaResponse(user); await this.checkLoginSubnet( ipAddress, userAgent, @@ -206,7 +219,7 @@ export class AuthService { }; } - async logout(token: string) { + async logout(token: string): Promise { if (!token) throw new UnprocessableEntityException(); const session = await this.prisma.sessions.findFirst({ where: { token }, @@ -219,15 +232,22 @@ export class AuthService { }); } - async approveSubnet(ipAddress: string, userAgent: string, token: string) { + async approveSubnet( + ipAddress: string, + userAgent: string, + token: string, + ): Promise { if (!token) throw new UnprocessableEntityException(); const id = this.tokensService.verify(APPROVE_SUBNET_TOKEN, token); await this.approvedSubnetsService.approveNewSubnet(id, ipAddress); return this.loginResponse(ipAddress, userAgent, id); } - /** Get the two-factor authentication QR code */ - async getTotpQrCode(userId: number) { + /** + * Get the two-factor authentication QR code + * @returns Data URI string with QR code image + */ + async getTotpQrCode(userId: number): Promise { const secret = randomStringGenerator(); await this.prisma.users.update({ where: { id: userId }, @@ -242,7 +262,7 @@ export class AuthService { } /** Enable two-factor authentication */ - async enableTotp(userId: number, code: string) { + async enableTotp(userId: number, code: string): Promise> { const user = await this.prisma.users.findOne({ where: { id: userId }, select: { twoFactorSecret: true, twoFactorEnabled: true }, @@ -268,9 +288,9 @@ export class AuthService { userAgent: string, token: string, code: string, - ) { - const { id } = this.tokensService.verify<{ id: number }>( - TWO_FACTOR_TOKEN, + ): Promise { + const { id } = this.tokensService.verify( + MULTI_FACTOR_TOKEN, token, ); return this.loginUserWithTotpCode(ipAddress, userAgent, id, code); @@ -311,7 +331,7 @@ export class AuthService { token: string, password: string, ignorePwnedPassword?: boolean, - ) { + ): Promise { const id = this.tokensService.verify(PASSWORD_RESET_TOKEN, token); password = await this.hashAndValidatePassword( password, @@ -322,7 +342,7 @@ export class AuthService { return this.loginResponse(ipAddress, userAgent, id); } - async verifyEmail(token: string) { + async verifyEmail(token: string): Promise> { const id = this.tokensService.verify(EMAIL_VERIFY_TOKEN, token); const result = await this.prisma.emails.update({ where: { id }, @@ -336,7 +356,7 @@ export class AuthService { userAgent: string, id: number, code: string, - ) { + ): Promise { const user = await this.prisma.users.findOne({ where: { id }, select: { @@ -381,6 +401,36 @@ export class AuthService { }; } + private async mfaResponse(user: ValidatedUser): Promise { + const type: MfaTypes = user.twoFactorSecret ? 'TOTP' : 'EMAIL'; + const mfaTokenPayload: MfaTokenPayload = { type, id: user.id }; + const totpToken = this.tokensService.signJwt( + MULTI_FACTOR_TOKEN, + mfaTokenPayload, + this.configService.get('security.mfaTokenExpiry'), + ); + if (type === 'EMAIL') { + this.email.send({ + to: `"${user.name}" <${user.prefersEmailAddress}>`, + template: 'auth/mfa-code', + data: { + name: user.name, + minutes: parseInt( + this.configService.get('security.mfaTokenExpiry'), + ), + link: `${this.configService.get( + 'frontendUrl', + )}/auth/token-login?token=${this.tokensService.signJwt( + EMAIL_MFA_TOKEN, + user.id, + '30m', + )}`, + }, + }); + } + return { totpToken, type, multiFactorRequired: true }; + } + private async checkLoginSubnet( ipAddress: string, _: string, // userAgent diff --git a/src/modules/tokens/tokens.constants.ts b/src/modules/tokens/tokens.constants.ts index 47d17ab6b..0d337fd31 100644 --- a/src/modules/tokens/tokens.constants.ts +++ b/src/modules/tokens/tokens.constants.ts @@ -1,4 +1,5 @@ -export const TWO_FACTOR_TOKEN = 'TWO_FACTOR_TOKEN'; +export const MULTI_FACTOR_TOKEN = 'MULTI_FACTOR_TOKEN'; export const PASSWORD_RESET_TOKEN = 'PASSWORD_RESET_TOKEN'; export const EMAIL_VERIFY_TOKEN = 'EMAIL_VERIFY_TOKEN'; export const APPROVE_SUBNET_TOKEN = 'APPROVE_SUBNET_TOKEN'; +export const EMAIL_MFA_TOKEN = 'EMAIL_MFA_TOKEN'; diff --git a/src/templates/auth/mfa-code.md b/src/templates/auth/mfa-code.md new file mode 100644 index 000000000..7f5a52b29 --- /dev/null +++ b/src/templates/auth/mfa-code.md @@ -0,0 +1,9 @@ +# Approve your login + +Hi {{name}}, + +Someone (hopefully you) logged in to your account, but you'll have to approve it. + +Approve this login + +Note that this link is valid for {{ minutes }} minutes only. If you didn't request this email, you can just ignore it; we won't give anyone else access to your account.