import { Body, Controller, Get, Post, Query, Render, Req, Res, Session, } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { Request, Response } from 'express'; import { SessionData } from 'express-session'; 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'; @Controller('/login') export class LoginController { constructor( private readonly userService: UserService, private readonly totpService: UserTOTPService, private readonly userTokenService: UserTokenService, private readonly formUtil: FormUtilityService, private readonly token: TokenService, ) {} @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; } if (await this.totpService.userHasTOTP(user)) { const challenge = { type: 'verify', user: user.uuid, remember }; req.session.challenge = await this.token.encryptChallenge(challenge); res.redirect( '/login/verify' + (redirectTo ? '?redirectTo=' + 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( @Session() session: SessionData, @Req() req: Request, @Res() res: Response, @Query('redirectTo') redirectTo?: string, ) { if (!session.challenge) { req.flash('message', { error: true, text: 'An unexpected error occured, please log in again.', }); res.redirect('/login' + (redirectTo ? '?redirectTo=' + 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('redirectTo') redirectTo?: string, ) { let user: User; let remember = false; try { if (!session.challenge) { throw new Error('No challenge'); } const challenge = await this.token.decryptChallenge(session.challenge); if (!challenge || challenge.type !== 'verify' || !challenge.user) { throw new Error('Bad challenge'); } user = await this.userService.getByUUID(challenge.user); if (!user) { throw new Error('Bad challenge'); } remember = challenge.remember; } catch (e: any) { req.flash('message', { error: true, text: 'An unexpected error occured, please log in again.', }); res.redirect('/login' + (redirectTo ? '?redirectTo=' + 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: any) { req.flash('message', { error: true, text: e.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.challenge = null; session.user = user.uuid; res.redirect(redirectTo ? decodeURIComponent(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: any) { 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, @Session() session: SessionData, @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); 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: any) { req.flash('message', { error: true, text: e.message }); res.redirect(req.originalUrl); } } }