From 5967e0da24765334c9d28897feef0f5102be0586 Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Thu, 10 Mar 2022 20:31:05 +0200 Subject: [PATCH] nodemailer, user activation email, user password reset email --- package-lock.json | 33 ++++ package.json | 2 + src/app.controller.ts | 7 +- src/app.module.ts | 6 +- src/middleware/user.middleware.ts | 23 +++ .../features/login/login.controller.ts | 179 +++++++++++++++++- .../features/oauth2/adapter/user.adapter.ts | 2 +- .../two-factor.controller.ts} | 14 +- .../two-factor.module.ts} | 2 +- src/modules/objects/email/email.module.ts | 9 + src/modules/objects/email/email.providers.ts | 17 ++ src/modules/objects/email/email.service.ts | 35 ++++ src/modules/objects/email/email.template.ts | 4 + .../user/email/forgot-password.email.ts | 29 +++ .../objects/user/email/registration.email.ts | 25 +++ .../objects/user/user-totp-token.service.ts | 69 +++++++ src/modules/objects/user/user.module.ts | 8 +- src/modules/objects/user/user.service.ts | 152 +++++++++------ src/scss/_block.scss | 24 ++- src/scss/_breakpoint.scss | 28 +++ src/scss/_button.scss | 8 +- src/scss/_form.scss | 5 +- src/scss/_index.scss | 2 + src/types/express-session.d.ts | 3 +- views/login/login.pug | 10 +- views/login/password.pug | 37 ++++ views/login/totp-verify.pug | 2 +- views/register.pug | 12 +- views/two-factor/activate.pug | 2 +- 29 files changed, 654 insertions(+), 95 deletions(-) create mode 100644 src/middleware/user.middleware.ts rename src/modules/features/{twofactor/twofactor.controller.ts => two-factor/two-factor.controller.ts} (83%) rename src/modules/features/{twofactor/twofactor.module.ts => two-factor/two-factor.module.ts} (89%) create mode 100644 src/modules/objects/email/email.module.ts create mode 100644 src/modules/objects/email/email.providers.ts create mode 100644 src/modules/objects/email/email.service.ts create mode 100644 src/modules/objects/email/email.template.ts create mode 100644 src/modules/objects/user/email/forgot-password.email.ts create mode 100644 src/modules/objects/user/email/registration.email.ts create mode 100644 src/modules/objects/user/user-totp-token.service.ts create mode 100644 src/scss/_breakpoint.scss create mode 100644 views/login/password.pug diff --git a/package-lock.json b/package-lock.json index 5efa4a6..ec4feed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "dotenv": "^16.0.0", "express-session": "^1.17.2", "mysql2": "^2.3.3", + "nodemailer": "^6.7.2", "otplib": "^12.0.1", "pug": "^3.0.2", "qrcode": "^1.5.0", @@ -37,6 +38,7 @@ "@types/express-session": "^1.17.4", "@types/jest": "27.4.1", "@types/node": "^16.0.0", + "@types/nodemailer": "^6.4.4", "@types/qrcode": "^1.4.2", "@types/supertest": "^2.0.11", "@types/uuid": "^8.3.4", @@ -2008,6 +2010,15 @@ "integrity": "sha512-GZ7bu5A6+4DtG7q9GsoHXy3ALcgeIHP4NnL0Vv2wu0uUB/yQex26v0tf6/na1mm0+bS9Uw+0DFex7aaKr2qawQ==", "dev": true }, + "node_modules/@types/nodemailer": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.4.tgz", + "integrity": "sha512-Ksw4t7iliXeYGvIQcSIgWQ5BLuC/mljIEbjf615svhZL10PE9t+ei8O9gDaD3FPCasUJn9KTLwz2JFJyiiyuqw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -7151,6 +7162,14 @@ "integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==", "dev": true }, + "node_modules/nodemailer": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.2.tgz", + "integrity": "sha512-Dz7zVwlef4k5R71fdmxwR8Q39fiboGbu3xgswkzGwczUfjp873rVxt1O46+Fh0j1ORnAC6L9+heI8uUpO6DT7Q==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -11630,6 +11649,15 @@ "integrity": "sha512-GZ7bu5A6+4DtG7q9GsoHXy3ALcgeIHP4NnL0Vv2wu0uUB/yQex26v0tf6/na1mm0+bS9Uw+0DFex7aaKr2qawQ==", "dev": true }, + "@types/nodemailer": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.4.tgz", + "integrity": "sha512-Ksw4t7iliXeYGvIQcSIgWQ5BLuC/mljIEbjf615svhZL10PE9t+ei8O9gDaD3FPCasUJn9KTLwz2JFJyiiyuqw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -15593,6 +15621,11 @@ "integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==", "dev": true }, + "nodemailer": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.2.tgz", + "integrity": "sha512-Dz7zVwlef4k5R71fdmxwR8Q39fiboGbu3xgswkzGwczUfjp873rVxt1O46+Fh0j1ORnAC6L9+heI8uUpO6DT7Q==" + }, "nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", diff --git a/package.json b/package.json index 782cd7f..2e42af2 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dotenv": "^16.0.0", "express-session": "^1.17.2", "mysql2": "^2.3.3", + "nodemailer": "^6.7.2", "otplib": "^12.0.1", "pug": "^3.0.2", "qrcode": "^1.5.0", @@ -51,6 +52,7 @@ "@types/express-session": "^1.17.4", "@types/jest": "27.4.1", "@types/node": "^16.0.0", + "@types/nodemailer": "^6.4.4", "@types/qrcode": "^1.4.2", "@types/supertest": "^2.0.11", "@types/uuid": "^8.3.4", diff --git a/src/app.controller.ts b/src/app.controller.ts index 2b0e570..5da3a59 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Get, Res, Session } from '@nestjs/common'; -import { Response } from 'express'; +import { Controller, Get, Req, Res, Session } from '@nestjs/common'; +import { Request, Response } from 'express'; import { SessionData } from 'express-session'; import { AppService } from './app.service'; @@ -11,12 +11,13 @@ export class AppController { getHello( @Session() session: SessionData, @Res() res: Response, + @Req() req: Request, ): Record { if (!session.user) { res.redirect('/login'); return; } - res.render('index', { user: session.user }); + res.render('index', { user: req.user }); } } diff --git a/src/app.module.ts b/src/app.module.ts index a1675a4..9e93ced 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,11 +2,13 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { CSRFMiddleware } from './middleware/csrf.middleware'; +import { UserMiddleware } from './middleware/user.middleware'; import { LoginModule } from './modules/features/login/login.module'; import { OAuth2Module } from './modules/features/oauth2/oauth2.module'; import { RegisterModule } from './modules/features/register/register.module'; -import { TwoFactorModule } from './modules/features/twofactor/twofactor.module'; +import { TwoFactorModule } from './modules/features/two-factor/two-factor.module'; import { DatabaseModule } from './modules/objects/database/database.module'; +import { EmailModule } from './modules/objects/email/email.module'; import { OAuth2ClientModule } from './modules/objects/oauth2-client/oauth2-client.module'; import { OAuth2TokenModule } from './modules/objects/oauth2-token/oauth2-token.module'; import { UploadModule } from './modules/objects/upload/upload.module'; @@ -17,6 +19,7 @@ import { UtilityModule } from './modules/utility/utility.module'; imports: [ UtilityModule, DatabaseModule, + EmailModule, UserModule, UploadModule, OAuth2ClientModule, @@ -32,5 +35,6 @@ import { UtilityModule } from './modules/utility/utility.module'; export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer.apply(CSRFMiddleware).forRoutes('*'); + consumer.apply(UserMiddleware).forRoutes('*'); } } diff --git a/src/middleware/user.middleware.ts b/src/middleware/user.middleware.ts new file mode 100644 index 0000000..fd19b3a --- /dev/null +++ b/src/middleware/user.middleware.ts @@ -0,0 +1,23 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { NextFunction, Request, Response } from 'express'; +import { UserService } from 'src/modules/objects/user/user.service'; + +@Injectable() +export class UserMiddleware implements NestMiddleware { + constructor(private readonly userService: UserService) {} + + async use(req: Request, res: Response, next: NextFunction) { + if (req.session.user) { + // TODO: Cache user requests + // Might not be a big deal though, there is no expected volume in visitors + // TODO: check for bans + const userObj = await this.userService.getByUUID(req.session.user); + if (userObj && userObj.activated) { + req.user = userObj; + } else { + delete req.session.user; + } + } + next(); + } +} diff --git a/src/modules/features/login/login.controller.ts b/src/modules/features/login/login.controller.ts index 706adc6..39da487 100644 --- a/src/modules/features/login/login.controller.ts +++ b/src/modules/features/login/login.controller.ts @@ -11,6 +11,11 @@ import { } from '@nestjs/common'; import { Request, Response } from 'express'; import { SessionData } from 'express-session'; +import { + UserToken, + UserTokenType, +} from 'src/modules/objects/user/user-token.entity'; +import { UserTOTPService } from 'src/modules/objects/user/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'; @@ -20,6 +25,7 @@ import { TokenService } from 'src/modules/utility/services/token.service'; export class LoginController { constructor( private readonly userService: UserService, + private readonly totpService: UserTOTPService, private readonly formUtil: FormUtilityService, private readonly token: TokenService, ) {} @@ -59,7 +65,7 @@ export class LoginController { return; } - if (await this.userService.userHasTOTP(user)) { + if (await this.totpService.userHasTOTP(user)) { const challenge = { type: 'verify', user: user.uuid }; req.session.challenge = await this.token.encryptChallenge(challenge); res.redirect( @@ -69,7 +75,7 @@ export class LoginController { return; } - req.session.user = user; + req.session.user = user.uuid; res.redirect(query.redirectTo ? decodeURIComponent(query.redirectTo) : '/'); } @@ -135,9 +141,9 @@ export class LoginController { } try { - const totp = await this.userService.getUserTOTP(user); + const totp = await this.totpService.getUserTOTP(user); - if (!this.userService.validateTOTP(totp.token, body.totp)) { + if (!this.totpService.validateTOTP(totp.token, body.totp)) { throw new Error('Invalid code!'); } } catch (e: any) { @@ -150,7 +156,170 @@ export class LoginController { } session.challenge = null; - session.user = user; + 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 }, + ) { + let user: User; + let token: UserToken; + try { + if (!query || !query.token) { + throw new Error(); + } + + token = await this.userService.getUserToken( + 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('/login'); + return; + } + + user.activated = true; + await this.userService.updateUser(user); + await this.userService.deleteUserToken(token); + + req.flash('message', { + error: false, + text: 'Account has been activated successfully. You may now log in.', + }); + + res.redirect('/login'); + } + + @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.userService.getUserToken( + 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, session), + token: true, + }); + return; + } + + res.render('login/password', { + ...this.formUtil.populateTemplate(req, session), + token: false, + }); + } + + @Post('password') + 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.userService.getUserToken( + 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.userService.deleteUserToken(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); + } + } } diff --git a/src/modules/features/oauth2/adapter/user.adapter.ts b/src/modules/features/oauth2/adapter/user.adapter.ts index 6958310..ca5bfce 100644 --- a/src/modules/features/oauth2/adapter/user.adapter.ts +++ b/src/modules/features/oauth2/adapter/user.adapter.ts @@ -36,7 +36,7 @@ export class UserAdapter implements OAuth2UserAdapter { async fetchFromRequest( req: Request>, ): Promise { - return req.session.user; + return req.user; } async consented( diff --git a/src/modules/features/twofactor/twofactor.controller.ts b/src/modules/features/two-factor/two-factor.controller.ts similarity index 83% rename from src/modules/features/twofactor/twofactor.controller.ts rename to src/modules/features/two-factor/two-factor.controller.ts index e98ffd6..adb1796 100644 --- a/src/modules/features/twofactor/twofactor.controller.ts +++ b/src/modules/features/two-factor/two-factor.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Post, Req, Res, Session } from '@nestjs/common'; import { Request, Response } from 'express'; import { SessionData } from 'express-session'; -import { UserService } from 'src/modules/objects/user/user.service'; +import { UserTOTPService } from 'src/modules/objects/user/user-totp-token.service'; import { FormUtilityService } from 'src/modules/utility/services/form-utility.service'; import { QRCodeService } from 'src/modules/utility/services/qr-code.service'; import { TokenService } from 'src/modules/utility/services/token.service'; @@ -9,7 +9,7 @@ import { TokenService } from 'src/modules/utility/services/token.service'; @Controller('/two-factor') export class TwoFactorController { constructor( - private userService: UserService, + private totp: UserTOTPService, private qr: QRCodeService, private token: TokenService, private form: FormUtilityService, @@ -21,7 +21,7 @@ export class TwoFactorController { @Req() req: Request, @Res() res: Response, ) { - const twoFA = await this.userService.getUserTOTP(session.user); + const twoFA = await this.totp.getUserTOTP(req.user); let secret: string; if (!twoFA) { @@ -33,12 +33,12 @@ export class TwoFactorController { } if (!secret) { - secret = this.userService.createTOTPSecret(); + secret = this.totp.createTOTPSecret(); const challenge = { type: 'totp', secret }; session.challenge = await this.token.encryptChallenge(challenge); } - const url = this.userService.getTOTPURL(secret, session.user.username); + const url = this.totp.getTOTPURL(secret, req.user.username); const qrcode = await this.qr.createQRDataURI(url); res.render('two-factor/activate', { @@ -72,7 +72,7 @@ export class TwoFactorController { throw new Error('Invalid request'); } - const verify = this.userService.validateTOTP(secret, body.code); + const verify = this.totp.validateTOTP(secret, body.code); if (!verify) { throw new Error('Invalid code! Try again.'); } @@ -85,7 +85,7 @@ export class TwoFactorController { return; } - await this.userService.activateTOTP(session.user, secret); + await this.totp.activateTOTP(req.user, secret); session.challenge = null; res.redirect('/'); } diff --git a/src/modules/features/twofactor/twofactor.module.ts b/src/modules/features/two-factor/two-factor.module.ts similarity index 89% rename from src/modules/features/twofactor/twofactor.module.ts rename to src/modules/features/two-factor/two-factor.module.ts index b0e6a18..7b8447f 100644 --- a/src/modules/features/twofactor/twofactor.module.ts +++ b/src/modules/features/two-factor/two-factor.module.ts @@ -2,7 +2,7 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { AuthMiddleware } from 'src/middleware/auth.middleware'; import { FlashMiddleware } from 'src/middleware/flash.middleware'; import { UserModule } from 'src/modules/objects/user/user.module'; -import { TwoFactorController } from './twofactor.controller'; +import { TwoFactorController } from './two-factor.controller'; @Module({ imports: [UserModule], diff --git a/src/modules/objects/email/email.module.ts b/src/modules/objects/email/email.module.ts new file mode 100644 index 0000000..573b3b0 --- /dev/null +++ b/src/modules/objects/email/email.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { emailProviders } from './email.providers'; +import { EmailService } from './email.service'; + +@Module({ + providers: [...emailProviders, EmailService], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/src/modules/objects/email/email.providers.ts b/src/modules/objects/email/email.providers.ts new file mode 100644 index 0000000..4d584d9 --- /dev/null +++ b/src/modules/objects/email/email.providers.ts @@ -0,0 +1,17 @@ +import * as nodemailer from 'nodemailer'; + +export const emailProviders = [ + { + provide: 'EMAIL_TRANSPORT', + useFactory: async () => + nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT, 10) || 587, + secure: process.env.SMTP_SECURE === 'true', + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }), + }, +]; diff --git a/src/modules/objects/email/email.service.ts b/src/modules/objects/email/email.service.ts new file mode 100644 index 0000000..98df41e --- /dev/null +++ b/src/modules/objects/email/email.service.ts @@ -0,0 +1,35 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; +import { EmailTemplate } from './email.template'; + +@Injectable() +export class EmailService { + constructor( + @Inject('EMAIL_TRANSPORT') + private transport: nodemailer.Transporter, + ) {} + + public async sendEmail( + to: string, + subject: string, + text: string, + html?: string, + from = 'no-reply@icynet.eu', + ): Promise { + return this.transport.sendMail({ + to, + subject, + text, + html, + from, + }); + } + + public async sendEmailTemplate( + to: string, + subject: string, + message: EmailTemplate, + ): Promise { + return this.sendEmail(to, subject, message.text, message.html); + } +} diff --git a/src/modules/objects/email/email.template.ts b/src/modules/objects/email/email.template.ts new file mode 100644 index 0000000..358f976 --- /dev/null +++ b/src/modules/objects/email/email.template.ts @@ -0,0 +1,4 @@ +export interface EmailTemplate { + text: string; + html: string; +} diff --git a/src/modules/objects/user/email/forgot-password.email.ts b/src/modules/objects/user/email/forgot-password.email.ts new file mode 100644 index 0000000..2f9438d --- /dev/null +++ b/src/modules/objects/user/email/forgot-password.email.ts @@ -0,0 +1,29 @@ +import { EmailTemplate } from 'src/modules/objects/email/email.template'; + +export const ForgotPasswordEmail = ( + username: string, + url: string, +): EmailTemplate => ({ + text: ` +Icy Network + +Hello, ${username}! You have requested a password reset on Icy Network. + +In order to change your password, please click on the following link. + +Change your password: ${url} + +If you did not request a password change on Icy Network, you can safely ignore this email. + `, + html: ` +

Icy Network

+ +

Hello, ${username}! You have requested a password reset on Icy Network.

+ +

In order to change your password, please click on the following link.

+ +

Change your password: ${url}

+ +

If you did not request a password change on Icy Network, you can safely ignore this email.

+ `, +}); diff --git a/src/modules/objects/user/email/registration.email.ts b/src/modules/objects/user/email/registration.email.ts new file mode 100644 index 0000000..2b71e92 --- /dev/null +++ b/src/modules/objects/user/email/registration.email.ts @@ -0,0 +1,25 @@ +import { EmailTemplate } from 'src/modules/objects/email/email.template'; + +export const RegistrationEmail = ( + username: string, + url: string, +): EmailTemplate => ({ + text: ` +Icy Network + +Welcome to Icy Network, ${username}! + +In order to proceed with logging in, please click on the following link to activate your account. + +Activate your account: ${url} + `, + html: ` +

Icy Network

+ +

Welcome to Icy Network, ${username}!

+ +

In order to proceed with logging in, please click on the following link to activate your account.

+ +

Activate your account: ${url}

+ `, +}); diff --git a/src/modules/objects/user/user-totp-token.service.ts b/src/modules/objects/user/user-totp-token.service.ts new file mode 100644 index 0000000..f00bf74 --- /dev/null +++ b/src/modules/objects/user/user-totp-token.service.ts @@ -0,0 +1,69 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { UserTOTPToken } from './user-totp-token.entity'; +import { User } from './user.entity'; +import { TokenService } from 'src/modules/utility/services/token.service'; +import { authenticator as totp } from 'otplib'; + +totp.options = { + window: 2, +}; + +@Injectable() +export class UserTOTPService { + constructor( + @Inject('USER_TOTP_TOKEN_REPOSITORY') + private userTOTPTokenRepository: Repository, + private token: TokenService, + ) {} + + /** + * Check if the user has TOTP enabled + * @param user User object + * @returns true if the user has TOTP enabled + */ + public async userHasTOTP(user: User): Promise { + return !!(await this.getUserTOTP(user)); + } + + /** + * Get the TOTP token of a user + * @param user User object + * @returns TOTP token + */ + public async getUserTOTP(user: User): Promise { + return this.userTOTPTokenRepository.findOne({ + user, + activated: true, + }); + } + + public validateTOTP(secret: string, token: string): boolean { + return totp.verify({ token, secret }); + } + + public getTOTPURL(secret: string, username: string): string { + return totp.keyuri(username, 'Icy Network', secret); + } + + public createTOTPSecret(): string { + return totp.generateSecret(); + } + + public async activateTOTP( + user: User, + secret: string, + ): Promise { + const totp = new UserTOTPToken(); + totp.activated = true; + totp.user = user; + totp.token = secret; + totp.recovery_token = Array.from({ length: 8 }, () => + this.token.generateString(8), + ).join(' '); + + await this.userTOTPTokenRepository.save(totp); + + return totp; + } +} diff --git a/src/modules/objects/user/user.module.ts b/src/modules/objects/user/user.module.ts index d92ddd6..3ab0307 100644 --- a/src/modules/objects/user/user.module.ts +++ b/src/modules/objects/user/user.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; import { DatabaseModule } from '../database/database.module'; +import { EmailModule } from '../email/email.module'; +import { UserTOTPService } from './user-totp-token.service'; import { userProviders } from './user.providers'; import { UserService } from './user.service'; @Module({ - imports: [DatabaseModule], - providers: [...userProviders, UserService], - exports: [UserService], + imports: [DatabaseModule, EmailModule], + providers: [...userProviders, UserService, UserTOTPService], + exports: [UserService, UserTOTPService], }) export class UserModule {} diff --git a/src/modules/objects/user/user.service.ts b/src/modules/objects/user/user.service.ts index a12d06f..eb57dac 100644 --- a/src/modules/objects/user/user.service.ts +++ b/src/modules/objects/user/user.service.ts @@ -1,15 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { Repository } from 'typeorm'; import { UserToken, UserTokenType } from './user-token.entity'; -import { UserTOTPToken } from './user-totp-token.entity'; import { User } from './user.entity'; import { TokenService } from 'src/modules/utility/services/token.service'; -import { authenticator as totp } from 'otplib'; import * as bcrypt from 'bcrypt'; - -totp.options = { - window: 2, -}; +import { EmailService } from '../email/email.service'; +import { RegistrationEmail } from './email/registration.email'; +import { ForgotPasswordEmail } from './email/forgot-password.email'; @Injectable() export class UserService { @@ -18,9 +15,8 @@ export class UserService { private userRepository: Repository, @Inject('USER_TOKEN_REPOSITORY') private userTokenRepository: Repository, - @Inject('USER_TOTP_TOKEN_REPOSITORY') - private userTOTPTokenRepository: Repository, private token: TokenService, + private email: EmailService, ) {} public async getById(id: number): Promise { @@ -55,6 +51,11 @@ export class UserService { return this.getByUsername(input); } + public async updateUser(user: User): Promise { + await this.userRepository.save(user); + return user; + } + public async comparePasswords( hash: string, password: string, @@ -67,56 +68,6 @@ export class UserService { return bcrypt.hash(password, salt); } - /** - * Check if the user has TOTP enabled - * @param user User object - * @returns true if the user has TOTP enabled - */ - public async userHasTOTP(user: User): Promise { - return !!(await this.getUserTOTP(user)); - } - - /** - * Get the TOTP token of a user - * @param user User object - * @returns TOTP token - */ - public async getUserTOTP(user: User): Promise { - return this.userTOTPTokenRepository.findOne({ - user, - activated: true, - }); - } - - public validateTOTP(secret: string, token: string): boolean { - return totp.verify({ token, secret }); - } - - public getTOTPURL(secret: string, username: string): string { - return totp.keyuri(username, 'Icy Network', secret); - } - - public createTOTPSecret(): string { - return totp.generateSecret(); - } - - public async activateTOTP( - user: User, - secret: string, - ): Promise { - const totp = new UserTOTPToken(); - totp.activated = true; - totp.user = user; - totp.token = secret; - totp.recovery_token = Array.from({ length: 8 }, () => - this.token.generateString(8), - ).join(' '); - - await this.userTOTPTokenRepository.save(totp); - - return totp; - } - public async createUserToken( user: User, type: UserTokenType, @@ -131,12 +82,87 @@ export class UserService { return token; } + public async getUserToken( + token: string, + type: UserTokenType, + ): Promise { + const foundOne = await this.userTokenRepository.findOne( + { + token, + type, + }, + { relations: ['user'] }, + ); + + if (!foundOne) { + return null; + } + + if (foundOne.expires_at < new Date()) { + await this.userTokenRepository.remove(foundOne); + return null; + } + + return foundOne; + } + + public async deleteUserToken(token: UserToken): Promise { + await this.userTokenRepository.remove(token); + } + public async sendActivationEmail(user: User): Promise { const activationToken = await this.createUserToken( user, UserTokenType.ACTIVATION, new Date(Date.now() + 3600 * 1000), ); + + try { + const content = RegistrationEmail( + user.username, + `${process.env.BASE_URL}/login/activate?token=${activationToken.token}`, + ); + await this.email.sendEmailTemplate( + user.email, + 'Activate your account on Icy Network', + content, + ); + } catch (e) { + await this.userTokenRepository.remove(activationToken); + throw e; + } + } + + public async sendPasswordEmail(user: User): Promise { + const passwordToken = await this.createUserToken( + user, + UserTokenType.PASSWORD, + new Date(Date.now() + 3600 * 1000), + ); + + try { + const content = ForgotPasswordEmail( + user.username, + `${process.env.BASE_URL}/login/password?token=${passwordToken.token}`, + ); + await this.email.sendEmailTemplate( + user.email, + 'Reset your password on Icy Network', + content, + ); + } catch (e) { + await this.userTokenRepository.remove(passwordToken); + // silently fail + } + } + + public async userPassword(email: string): Promise { + const user = await this.getByEmail(email); + if (!user || !user.activated) { + return; + } + + await this.sendPasswordEmail(user); } public async userRegistration(newUserInfo: { @@ -160,9 +186,17 @@ export class UserService { user.username = newUserInfo.username; user.display_name = newUserInfo.display_name; user.password = hashword; + await this.userRepository.insert(user); - // TODO: activation email + try { + await this.sendActivationEmail(user); + } catch (e) { + await this.userRepository.remove(user); + throw new Error( + 'Failed to send activation email! Please check your email address and try again!', + ); + } return user; } diff --git a/src/scss/_block.scss b/src/scss/_block.scss index 45c1d3d..2d40c9b 100644 --- a/src/scss/_block.scss +++ b/src/scss/_block.scss @@ -10,20 +10,38 @@ max-width: 800px; background-color: #2e6b81; color: #fff; - margin: 2rem auto; + margin: 2rem auto 0; padding: 4rem; + position: relative; box-shadow: 0px 6px 62px -14px rgba(0, 0, 0, 0.45); - -webkit-box-shadow: 0px 6px 62px -14px rgba(0, 0, 0, 0.45); - -moz-box-shadow: 0px 6px 62px -14px rgba(0, 0, 0, 0.45); h1:first-of-type { margin-top: 0; text-align: center; } + + &-addon { + color: #fff; + background-color: #042b3a; + overflow: hidden; + max-width: 600px; + margin: 0 auto; + padding: 16px; + text-align: center; + box-shadow: 0px 6px 62px -14px rgba(0, 0, 0, 0.45); + } + + @include break-on(xs, down) { + padding: 1rem; + } } .logo-container { text-align: center; margin-top: 2rem; } + +.qr-preview { + text-align: center; +} diff --git a/src/scss/_breakpoint.scss b/src/scss/_breakpoint.scss new file mode 100644 index 0000000..2a43723 --- /dev/null +++ b/src/scss/_breakpoint.scss @@ -0,0 +1,28 @@ +$breakpoints: ( + xxs: 480px, + xs: 720px, + sm: 1024px, + md: 1280px, + lg: 1440px, + xl: 1920px, + xxl: 2580px, +); + +@mixin break-on($breakpoint, $type) { + @if map-has-key($breakpoints, $breakpoint) { + $mediaValue: map-get($breakpoints, $breakpoint); + @if $type == up { + @media (min-width: $mediaValue) { + @content; + } + } @else if ($type == down) { + @media (max-width: $mediaValue) { + @content; + } + } @else { + @warn "Unknown `#{$type}` in $media query type"; + } + } @else { + @warn "Unknown `#{$breakpoint}` in $breakpoints"; + } +} diff --git a/src/scss/_button.scss b/src/scss/_button.scss index 536e2b6..b227f66 100644 --- a/src/scss/_button.scss +++ b/src/scss/_button.scss @@ -6,6 +6,8 @@ border-radius: 4px; cursor: pointer; outline: 0px solid #00c0ff8a; + background-color: var(--btn-background); + color: var(--btn-color); min-width: 120px; transition: background-color 0.35s linear, outline 0.15s linear; @@ -16,8 +18,7 @@ padding: 12px; } - background-color: var(--btn-background); - color: var(--btn-color); + text-shadow: none; &:hover { background-color: var(--btn-background-hover); @@ -31,7 +32,6 @@ --btn-background: #00c4ff; --btn-background-hover: #3ed2ff; --btn-color: #002d34; - text-transform: uppercase; - font-weight: 500; + font-weight: 600; } } diff --git a/src/scss/_form.scss b/src/scss/_form.scss index 32b18c1..e1f1c0f 100644 --- a/src/scss/_form.scss +++ b/src/scss/_form.scss @@ -1,8 +1,10 @@ .form-container { display: flex; flex-direction: column; + row-gap: 0.5rem; - .btn { + .btn, + .btn-group { margin-top: 1rem; } } @@ -11,7 +13,6 @@ margin-top: 1rem; text-transform: uppercase; font-weight: 600; - margin-bottom: 0.25rem; } input.form-control { diff --git a/src/scss/_index.scss b/src/scss/_index.scss index 86bdf0c..263674d 100644 --- a/src/scss/_index.scss +++ b/src/scss/_index.scss @@ -1,3 +1,4 @@ +@import 'breakpoint'; @import 'block'; @import 'form'; @import 'button'; @@ -20,4 +21,5 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; background-color: #314550; + text-shadow: black 1px 1px 2px; } diff --git a/src/types/express-session.d.ts b/src/types/express-session.d.ts index 5c729c5..a9b670f 100644 --- a/src/types/express-session.d.ts +++ b/src/types/express-session.d.ts @@ -5,6 +5,7 @@ declare global { namespace Express { export interface Request { oauth2: OAuth2; + user: User; flash: (type: string, ...msg: any[]) => Record; } } @@ -13,7 +14,7 @@ declare global { declare module 'express-session' { interface SessionData { csrf?: string; - user?: User; + user?: string; challenge?: string; flash?: Record; } diff --git a/views/login/login.pug b/views/login/login.pug index 9b1f8c6..c400d4c 100644 --- a/views/login/login.pug +++ b/views/login/login.pug @@ -20,8 +20,14 @@ block body div.form-container input#csrf(type="hidden", name="csrf", value=csrf) label.form-label(for="username") Username - input.form-control#username(type="text", name="username", placeholder="Username", value=form.username) + input.form-control#username(type="text", name="username", placeholder="Username", autofocus, value=form.username) label.form-label(for="password") Password input.form-control#password(type="password", name="password", placeholder="Password") button.btn.btn-primary(type="submit") Log in - a.btn.btn-link.align-self-end(type="button" href="/register") Create a new account + div.btn-group.align-self-end + a.btn.btn-link(type="button" href="/register") Create a new account + |• + a.btn.btn-link(type="button" href="/login/password") Forgot password? + div.center-box-addon + p Icy Network is a Single-Sign-On service used by other applications. + p The website may use temporary cookies for storing your login session. diff --git a/views/login/password.pug b/views/login/password.pug new file mode 100644 index 0000000..f6a1a56 --- /dev/null +++ b/views/login/password.pug @@ -0,0 +1,37 @@ +extends ../partials/layout.pug + +block title + |Icy Network | Set a new password + +block body + include ../partials/logo.pug + div.container + div.center-box + if token + h1 Set a new password + if message.text + if message.error + .alert.alert-danger + span #{message.text} + else + .alert.alert-success + span #{message.text} + + form(method="post") + div.form-container + input#csrf(type="hidden", name="csrf", value=csrf) + label.form-label(for="password") New password + input.form-control#password(type="password", name="password", placeholder="Password") + label.form-label(for="password_repeat") Repeat new password + input.form-control#password_repeat(type="password", name="password_repeat", placeholder="Password") + button.btn.btn-primary(type="submit") Set password + else + h1 Reset password + p If you have forgotten your password, please enter your accounts email address and we will send you a link to recover it. + form(method="post") + div.form-container + input#csrf(type="hidden", name="csrf", value=csrf) + label.form-label(for="email") Email address + input.form-control#email(type="email", name="email", placeholder="Email addres") + button.btn.btn-primary(type="submit") Send recovery email + a.btn.btn-link.align-self-end(type="button" href="/login") Log in instead diff --git a/views/login/totp-verify.pug b/views/login/totp-verify.pug index d461e6e..ef7435e 100644 --- a/views/login/totp-verify.pug +++ b/views/login/totp-verify.pug @@ -20,5 +20,5 @@ block body div.form-container input#csrf(type="hidden", name="csrf", value=csrf) label.form-label(for="totp") Code - input.form-control#totp(type="text", name="totp", placeholder="xxxxxx") + input.form-control#totp(type="text", name="totp", autofocus, placeholder="xxxxxx") button.btn.btn-primary(type="submit") Log in diff --git a/views/register.pug b/views/register.pug index 066fa4a..b3f1a81 100644 --- a/views/register.pug +++ b/views/register.pug @@ -19,15 +19,25 @@ block body form(method="post") div.form-container input#csrf(type="hidden", name="csrf", value=csrf) + label.form-label(for="username") Username - input.form-control#username(type="text", name="username", placeholder="Username", value=form.username) + input.form-control#username(type="text", name="username", placeholder="Username", autofocus, value=form.username) + small.form-hint Between 3 and 26 English alphanumeric characters and .-_ only. + label.form-label(for="display_name") Display name input.form-control#display_name(type="text", name="display_name", placeholder="Display name", value=form.display_name) + small.form-hint Maximum length is 32. + label.form-label(for="email") Email address input.form-control#email(type="email", name="email", placeholder="Email address", value=form.email) + small.form-hint You will need to verify your email address before you can log in. + label.form-label(for="password") Password input.form-control#password(type="password", name="password", placeholder="Password", value=form.password) + small.form-hint Must be at least 8 characters long, contain a capital and lowercase letter and a number. + label.form-label(for="password_repeat") Confirm password input.form-control#password_repeat(type="password", name="password_repeat", placeholder="Confirm password") + button.btn.btn-primary(type="submit") Create a new account a.btn.btn-link.align-self-end(type="button" href="/login") Log in instead diff --git a/views/two-factor/activate.pug b/views/two-factor/activate.pug index 3000ad3..93563ec 100644 --- a/views/two-factor/activate.pug +++ b/views/two-factor/activate.pug @@ -23,5 +23,5 @@ block body div.form-container input#csrf(type="hidden", name="csrf", value=csrf) label.form-label(for="code") Code from authenticator app - input.form-control#code(type="text", name="code", placeholder="xxxxxx") + input.form-control#code(type="text", name="code", autofocus, placeholder="xxxxxx") button.btn.btn-primary(type="submit") Activate