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 { 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( 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); } } }