Skip to content
This repository has been archived by the owner on Apr 19, 2023. It is now read-only.

Commit

Permalink
✨ Add support for MFA when logging in
Browse files Browse the repository at this point in the history
  • Loading branch information
AnandChowdhary committed Oct 30, 2020
1 parent 2c892e8 commit 0f219cd
Show file tree
Hide file tree
Showing 6 changed files with 103 additions and 20 deletions.
1 change: 1 addition & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -30,7 +30,7 @@ export class AuthController {
@Body() data: LoginDto,
@Ip() ip: string,
@Headers('User-Agent') userAgent: string,
): Promise<TokenResponse> {
): Promise<TokenResponse | TotpTokenResponse> {
return this.authService.login(
ip,
userAgent,
Expand Down
22 changes: 22 additions & 0 deletions src/modules/auth/auth.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
84 changes: 67 additions & 17 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -55,7 +63,7 @@ export class AuthService {
});
}

async validateUser(email: string, password?: string) {
async validateUser(email: string, password?: string): Promise<ValidatedUser> {
const emailSafe = safeEmail(email);
const user = await this.prisma.users.findFirst({
where: { emails: { some: { emailSafe } } },
Expand All @@ -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);
Expand All @@ -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;
}
Expand All @@ -90,13 +104,12 @@ export class AuthService {
email: string,
password?: string,
code?: string,
) {
): Promise<TokenResponse | TotpTokenResponse> {
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,
Expand Down Expand Up @@ -206,7 +219,7 @@ export class AuthService {
};
}

async logout(token: string) {
async logout(token: string): Promise<void> {
if (!token) throw new UnprocessableEntityException();
const session = await this.prisma.sessions.findFirst({
where: { token },
Expand All @@ -219,15 +232,22 @@ export class AuthService {
});
}

async approveSubnet(ipAddress: string, userAgent: string, token: string) {
async approveSubnet(
ipAddress: string,
userAgent: string,
token: string,
): Promise<TokenResponse> {
if (!token) throw new UnprocessableEntityException();
const id = this.tokensService.verify<number>(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<string> {
const secret = randomStringGenerator();
await this.prisma.users.update({
where: { id: userId },
Expand All @@ -242,7 +262,7 @@ export class AuthService {
}

/** Enable two-factor authentication */
async enableTotp(userId: number, code: string) {
async enableTotp(userId: number, code: string): Promise<Expose<users>> {
const user = await this.prisma.users.findOne({
where: { id: userId },
select: { twoFactorSecret: true, twoFactorEnabled: true },
Expand All @@ -268,9 +288,9 @@ export class AuthService {
userAgent: string,
token: string,
code: string,
) {
const { id } = this.tokensService.verify<{ id: number }>(
TWO_FACTOR_TOKEN,
): Promise<TokenResponse> {
const { id } = this.tokensService.verify<MfaTokenPayload>(
MULTI_FACTOR_TOKEN,
token,
);
return this.loginUserWithTotpCode(ipAddress, userAgent, id, code);
Expand Down Expand Up @@ -311,7 +331,7 @@ export class AuthService {
token: string,
password: string,
ignorePwnedPassword?: boolean,
) {
): Promise<TokenResponse> {
const id = this.tokensService.verify<number>(PASSWORD_RESET_TOKEN, token);
password = await this.hashAndValidatePassword(
password,
Expand All @@ -322,7 +342,7 @@ export class AuthService {
return this.loginResponse(ipAddress, userAgent, id);
}

async verifyEmail(token: string) {
async verifyEmail(token: string): Promise<Expose<emails>> {
const id = this.tokensService.verify<number>(EMAIL_VERIFY_TOKEN, token);
const result = await this.prisma.emails.update({
where: { id },
Expand All @@ -336,7 +356,7 @@ export class AuthService {
userAgent: string,
id: number,
code: string,
) {
): Promise<TokenResponse> {
const user = await this.prisma.users.findOne({
where: { id },
select: {
Expand Down Expand Up @@ -381,6 +401,36 @@ export class AuthService {
};
}

private async mfaResponse(user: ValidatedUser): Promise<TotpTokenResponse> {
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<string>('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<string>('security.mfaTokenExpiry'),
),
link: `${this.configService.get<string>(
'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
Expand Down
3 changes: 2 additions & 1 deletion src/modules/tokens/tokens.constants.ts
Original file line number Diff line number Diff line change
@@ -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';
9 changes: 9 additions & 0 deletions src/templates/auth/mfa-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Approve your login

Hi {{name}},

Someone (hopefully you) logged in to your account, but you'll have to approve it.

<a href="{{ link }}" class="btn btn-primary">Approve this login</a>

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.

0 comments on commit 0f219cd

Please sign in to comment.