icynet-auth-server/src/modules/ssr-front-end/login/login.controller.ts

386 lines
9.8 KiB
TypeScript

import {
Body,
Controller,
Get,
Post,
Query,
Render,
Req,
Res,
Session,
UseGuards,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { Request, Response } from 'express';
import { SessionData } from 'express-session';
import { LoginAntispamGuard } from 'src/guards/login-antispam.guard';
import { AuditAction } from 'src/modules/objects/audit/audit.enum';
import { AuditService } from 'src/modules/objects/audit/audit.service';
import {
UserToken,
UserTokenType,
} from 'src/modules/objects/user-token/user-token.entity';
import { UserTokenService } from 'src/modules/objects/user-token/user-token.service';
import { UserTOTPService } from 'src/modules/objects/user-token/user-totp-token.service';
import { User } from 'src/modules/objects/user/user.entity';
import { UserService } from 'src/modules/objects/user/user.service';
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
import { TokenService } from 'src/modules/utility/services/token.service';
interface VerifyChallenge {
type: 'verify';
id: string;
user: string;
remember: boolean;
}
@Controller('/login')
@UseGuards(LoginAntispamGuard)
export class LoginController {
constructor(
private readonly userService: UserService,
private readonly totpService: UserTOTPService,
private readonly userTokenService: UserTokenService,
private readonly formUtil: FormUtilityService,
private readonly token: TokenService,
private readonly audit: AuditService,
) {}
@Get()
@Render('login/login')
public loginView(
@Req() req: Request,
@Query('redirectTo') redirectTo?: string,
): Record<string, unknown> {
return this.formUtil.populateTemplate(req, {
query: redirectTo
? new URLSearchParams({ redirectTo }).toString()
: undefined,
});
}
@Post()
public async loginRequest(
@Req() req: Request,
@Res() res: Response,
@Body() body: { username: string; password: string; remember: boolean },
@Query('redirectTo') redirectTo?: string,
) {
const { username, password, remember } = this.formUtil.trimmed(body, [
'username',
]);
const user = await this.userService.getByUsername(username);
// User exists and password matches
if (
!user ||
!user.activated ||
!(await this.userService.comparePasswords(user.password, password))
) {
req.flash('form', { username });
req.flash('message', {
error: true,
text: 'Invalid username or password',
});
res.redirect(req.originalUrl);
return;
}
await this.audit.auditRequest(req, AuditAction.LOGIN, req.session.id, user);
if (await this.totpService.userHasTOTP(user)) {
const challenge: VerifyChallenge = {
type: 'verify',
id: req.session.id,
user: user.uuid,
remember,
};
const encrypted = await this.token.encryptChallenge(challenge);
res.redirect(
`login/verify?challenge=${encrypted}${
redirectTo ? '&redirectTo=' + encodeURIComponent(redirectTo) : ''
}`,
);
return;
}
// Extend session cookie to a month
if (remember) {
const month = 30 * 24 * 60 * 60 * 1000;
req.session.cookie.maxAge = month;
req.session.cookie.expires = new Date(Date.now() + month);
}
req.session.user = user.uuid;
res.redirect(redirectTo ? decodeURIComponent(redirectTo) : '/');
}
@Get('verify')
public verifyUserTokenView(
@Req() req: Request,
@Res() res: Response,
@Query() query: { redirectTo: string; challenge: string },
) {
if (!query.challenge) {
req.flash('message', {
error: true,
text: 'An unexpected error occured, please log in again.',
});
res.redirect(
'/login' +
(query.redirectTo
? '?redirectTo=' + encodeURIComponent(query.redirectTo)
: ''),
);
return;
}
res.render('login/totp-verify', this.formUtil.populateTemplate(req));
}
@Post('verify')
public async verifyUserToken(
@Session() session: SessionData,
@Body() body: { totp: string },
@Req() req: Request,
@Res() res: Response,
@Query() query: { redirectTo: string; challenge: string },
) {
let user: User;
let remember = false;
try {
if (!query.challenge) {
throw new Error('No challenge');
}
const decrypted = await this.token.decryptChallenge<VerifyChallenge>(
query.challenge,
);
if (
!decrypted ||
decrypted.type !== 'verify' ||
decrypted.id !== req.session.id ||
!decrypted.user
) {
throw new Error('Bad challenge');
}
user = await this.userService.getByUUID(decrypted.user);
if (!user) {
throw new Error('Bad challenge');
}
remember = decrypted.remember;
} catch (e: unknown) {
req.flash('message', {
error: true,
text: 'An unexpected error occured, please log in again.',
});
res.redirect(
'/login' +
(query.redirectTo
? '?redirectTo=' + encodeURIComponent(query.redirectTo)
: ''),
);
return;
}
try {
const totp = await this.totpService.getUserTOTP(user);
if (!this.totpService.validateTOTP(totp.token, body.totp)) {
throw new Error('Invalid code!');
}
} catch (e: unknown) {
req.flash('message', {
error: true,
text: (e as Error).message,
});
res.redirect(req.originalUrl);
return;
}
// Extend session cookie to a month
if (remember) {
const month = 30 * 24 * 60 * 60 * 1000;
req.session.cookie.maxAge = month;
}
session.user = user.uuid;
res.redirect(query.redirectTo ? decodeURIComponent(query.redirectTo) : '/');
}
@Get('activate')
public async activateUser(
@Req() req: Request,
@Res() res: Response,
@Query() query: { token: string },
@Query('redirectTo') redirectTo?: string,
) {
const loginPath = `/login${
redirectTo ? `?redirectTo=${encodeURIComponent(redirectTo)}` : ''
}`;
let user: User;
let token: UserToken;
try {
if (!query || !query.token) {
throw new Error();
}
token = await this.userTokenService.get(
query.token,
UserTokenType.ACTIVATION,
);
if (!token) {
throw new Error();
}
user = token.user;
} catch (e: unknown) {
req.flash('message', {
error: true,
text: 'Invalid or expired activation link.',
});
res.redirect(loginPath);
return;
}
user.activated = true;
await this.userService.updateUser(user);
await this.userTokenService.delete(token);
req.flash('message', {
error: false,
text: 'Account has been activated successfully. You may now log in.',
});
res.redirect(loginPath);
}
@Get('password')
public async recoverView(
@Req() req: Request,
@Res() res: Response,
@Query() query: { token: string },
) {
if (query.token) {
const token = await this.userTokenService.get(
query.token,
UserTokenType.PASSWORD,
);
if (!token) {
req.flash('message', {
error: true,
text: 'Invalid or expired reset link.',
});
res.redirect('/login');
return;
}
res.render('login/password', {
...this.formUtil.populateTemplate(req),
token: true,
});
return;
}
res.render('login/password', {
...this.formUtil.populateTemplate(req),
token: false,
});
}
@Post('password')
@Throttle(3, 60)
public async setNewPassword(
@Req() req: Request,
@Res() res: Response,
@Body()
body: { email?: string; password?: string; password_repeat?: string },
@Query() query: { token: string },
) {
// Email send fragment
if (!query.token) {
if (!body.email || !body.email.match(this.formUtil.emailRegex)) {
req.flash('message', {
error: true,
text: 'A valid email address is mandatory.',
});
res.redirect(req.originalUrl);
return;
}
await this.userService.userPassword(body.email);
req.flash('message', {
error: false,
text: 'If there is an account registered with this email, instructions to reset the password will be sent shortly!',
});
res.redirect('/login');
return;
}
// Change password fragment
const token = await this.userTokenService.get(
query.token,
UserTokenType.PASSWORD,
);
if (!token) {
req.flash('message', {
error: true,
text: 'Invalid or expired reset link.',
});
res.redirect('/login');
return;
}
const { password, password_repeat } = body;
try {
if (!password || !password_repeat) {
throw new Error('Please fill out all of the fields.');
}
if (!password.match(this.formUtil.passwordRegex)) {
throw new Error(
'Password must be at least 8 characters long, contain a capital and lowercase letter and a number',
);
}
if (password !== password_repeat) {
throw new Error('The passwords do not match!');
}
const hashword = await this.userService.hashPassword(password);
token.user.password = hashword;
await this.userService.updateUser(token.user);
await this.userTokenService.delete(token);
await this.audit.auditRequest(req, AuditAction.PASSWORD_CHANGE, 'token');
req.flash('message', {
error: false,
text: 'Your password has been reset successfully. You may now log in with your new password!',
});
res.redirect('/login');
} catch (e: unknown) {
req.flash('message', { error: true, text: (e as Error).message });
res.redirect(req.originalUrl);
}
}
}