nodemailer, user activation email, user password reset email

This commit is contained in:
Evert Prants 2022-03-10 20:31:05 +02:00
parent 169c76eefc
commit 5967e0da24
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
29 changed files with 654 additions and 95 deletions

33
package-lock.json generated
View File

@ -19,6 +19,7 @@
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express-session": "^1.17.2", "express-session": "^1.17.2",
"mysql2": "^2.3.3", "mysql2": "^2.3.3",
"nodemailer": "^6.7.2",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"pug": "^3.0.2", "pug": "^3.0.2",
"qrcode": "^1.5.0", "qrcode": "^1.5.0",
@ -37,6 +38,7 @@
"@types/express-session": "^1.17.4", "@types/express-session": "^1.17.4",
"@types/jest": "27.4.1", "@types/jest": "27.4.1",
"@types/node": "^16.0.0", "@types/node": "^16.0.0",
"@types/nodemailer": "^6.4.4",
"@types/qrcode": "^1.4.2", "@types/qrcode": "^1.4.2",
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.11",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
@ -2008,6 +2010,15 @@
"integrity": "sha512-GZ7bu5A6+4DtG7q9GsoHXy3ALcgeIHP4NnL0Vv2wu0uUB/yQex26v0tf6/na1mm0+bS9Uw+0DFex7aaKr2qawQ==", "integrity": "sha512-GZ7bu5A6+4DtG7q9GsoHXy3ALcgeIHP4NnL0Vv2wu0uUB/yQex26v0tf6/na1mm0+bS9Uw+0DFex7aaKr2qawQ==",
"dev": true "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": { "node_modules/@types/parse-json": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@ -7151,6 +7162,14 @@
"integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==", "integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==",
"dev": true "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": { "node_modules/nopt": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@ -11630,6 +11649,15 @@
"integrity": "sha512-GZ7bu5A6+4DtG7q9GsoHXy3ALcgeIHP4NnL0Vv2wu0uUB/yQex26v0tf6/na1mm0+bS9Uw+0DFex7aaKr2qawQ==", "integrity": "sha512-GZ7bu5A6+4DtG7q9GsoHXy3ALcgeIHP4NnL0Vv2wu0uUB/yQex26v0tf6/na1mm0+bS9Uw+0DFex7aaKr2qawQ==",
"dev": true "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": { "@types/parse-json": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@ -15593,6 +15621,11 @@
"integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==", "integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==",
"dev": true "dev": true
}, },
"nodemailer": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.2.tgz",
"integrity": "sha512-Dz7zVwlef4k5R71fdmxwR8Q39fiboGbu3xgswkzGwczUfjp873rVxt1O46+Fh0j1ORnAC6L9+heI8uUpO6DT7Q=="
},
"nopt": { "nopt": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",

View File

@ -33,6 +33,7 @@
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express-session": "^1.17.2", "express-session": "^1.17.2",
"mysql2": "^2.3.3", "mysql2": "^2.3.3",
"nodemailer": "^6.7.2",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"pug": "^3.0.2", "pug": "^3.0.2",
"qrcode": "^1.5.0", "qrcode": "^1.5.0",
@ -51,6 +52,7 @@
"@types/express-session": "^1.17.4", "@types/express-session": "^1.17.4",
"@types/jest": "27.4.1", "@types/jest": "27.4.1",
"@types/node": "^16.0.0", "@types/node": "^16.0.0",
"@types/nodemailer": "^6.4.4",
"@types/qrcode": "^1.4.2", "@types/qrcode": "^1.4.2",
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.11",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",

View File

@ -1,5 +1,5 @@
import { Controller, Get, Res, Session } from '@nestjs/common'; import { Controller, Get, Req, Res, Session } from '@nestjs/common';
import { Response } from 'express'; import { Request, Response } from 'express';
import { SessionData } from 'express-session'; import { SessionData } from 'express-session';
import { AppService } from './app.service'; import { AppService } from './app.service';
@ -11,12 +11,13 @@ export class AppController {
getHello( getHello(
@Session() session: SessionData, @Session() session: SessionData,
@Res() res: Response, @Res() res: Response,
@Req() req: Request,
): Record<string, any> { ): Record<string, any> {
if (!session.user) { if (!session.user) {
res.redirect('/login'); res.redirect('/login');
return; return;
} }
res.render('index', { user: session.user }); res.render('index', { user: req.user });
} }
} }

View File

@ -2,11 +2,13 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { CSRFMiddleware } from './middleware/csrf.middleware'; import { CSRFMiddleware } from './middleware/csrf.middleware';
import { UserMiddleware } from './middleware/user.middleware';
import { LoginModule } from './modules/features/login/login.module'; import { LoginModule } from './modules/features/login/login.module';
import { OAuth2Module } from './modules/features/oauth2/oauth2.module'; import { OAuth2Module } from './modules/features/oauth2/oauth2.module';
import { RegisterModule } from './modules/features/register/register.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 { 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 { OAuth2ClientModule } from './modules/objects/oauth2-client/oauth2-client.module';
import { OAuth2TokenModule } from './modules/objects/oauth2-token/oauth2-token.module'; import { OAuth2TokenModule } from './modules/objects/oauth2-token/oauth2-token.module';
import { UploadModule } from './modules/objects/upload/upload.module'; import { UploadModule } from './modules/objects/upload/upload.module';
@ -17,6 +19,7 @@ import { UtilityModule } from './modules/utility/utility.module';
imports: [ imports: [
UtilityModule, UtilityModule,
DatabaseModule, DatabaseModule,
EmailModule,
UserModule, UserModule,
UploadModule, UploadModule,
OAuth2ClientModule, OAuth2ClientModule,
@ -32,5 +35,6 @@ import { UtilityModule } from './modules/utility/utility.module';
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {
consumer.apply(CSRFMiddleware).forRoutes('*'); consumer.apply(CSRFMiddleware).forRoutes('*');
consumer.apply(UserMiddleware).forRoutes('*');
} }
} }

View File

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

View File

@ -11,6 +11,11 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { SessionData } from 'express-session'; 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 { User } from 'src/modules/objects/user/user.entity';
import { UserService } from 'src/modules/objects/user/user.service'; import { UserService } from 'src/modules/objects/user/user.service';
import { FormUtilityService } from 'src/modules/utility/services/form-utility.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 { export class LoginController {
constructor( constructor(
private readonly userService: UserService, private readonly userService: UserService,
private readonly totpService: UserTOTPService,
private readonly formUtil: FormUtilityService, private readonly formUtil: FormUtilityService,
private readonly token: TokenService, private readonly token: TokenService,
) {} ) {}
@ -59,7 +65,7 @@ export class LoginController {
return; return;
} }
if (await this.userService.userHasTOTP(user)) { if (await this.totpService.userHasTOTP(user)) {
const challenge = { type: 'verify', user: user.uuid }; const challenge = { type: 'verify', user: user.uuid };
req.session.challenge = await this.token.encryptChallenge(challenge); req.session.challenge = await this.token.encryptChallenge(challenge);
res.redirect( res.redirect(
@ -69,7 +75,7 @@ export class LoginController {
return; return;
} }
req.session.user = user; req.session.user = user.uuid;
res.redirect(query.redirectTo ? decodeURIComponent(query.redirectTo) : '/'); res.redirect(query.redirectTo ? decodeURIComponent(query.redirectTo) : '/');
} }
@ -135,9 +141,9 @@ export class LoginController {
} }
try { 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!'); throw new Error('Invalid code!');
} }
} catch (e: any) { } catch (e: any) {
@ -150,7 +156,170 @@ export class LoginController {
} }
session.challenge = null; session.challenge = null;
session.user = user; session.user = user.uuid;
res.redirect(query.redirectTo ? decodeURIComponent(query.redirectTo) : '/'); 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);
}
}
} }

View File

@ -36,7 +36,7 @@ export class UserAdapter implements OAuth2UserAdapter {
async fetchFromRequest( async fetchFromRequest(
req: Request<ParamsDictionary, any, any, ParsedQs, Record<string, any>>, req: Request<ParamsDictionary, any, any, ParsedQs, Record<string, any>>,
): Promise<OAuth2User> { ): Promise<OAuth2User> {
return req.session.user; return req.user;
} }
async consented( async consented(

View File

@ -1,7 +1,7 @@
import { Body, Controller, Get, Post, Req, Res, Session } from '@nestjs/common'; import { Body, Controller, Get, Post, Req, Res, Session } from '@nestjs/common';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { SessionData } from 'express-session'; 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 { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
import { QRCodeService } from 'src/modules/utility/services/qr-code.service'; import { QRCodeService } from 'src/modules/utility/services/qr-code.service';
import { TokenService } from 'src/modules/utility/services/token.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') @Controller('/two-factor')
export class TwoFactorController { export class TwoFactorController {
constructor( constructor(
private userService: UserService, private totp: UserTOTPService,
private qr: QRCodeService, private qr: QRCodeService,
private token: TokenService, private token: TokenService,
private form: FormUtilityService, private form: FormUtilityService,
@ -21,7 +21,7 @@ export class TwoFactorController {
@Req() req: Request, @Req() req: Request,
@Res() res: Response, @Res() res: Response,
) { ) {
const twoFA = await this.userService.getUserTOTP(session.user); const twoFA = await this.totp.getUserTOTP(req.user);
let secret: string; let secret: string;
if (!twoFA) { if (!twoFA) {
@ -33,12 +33,12 @@ export class TwoFactorController {
} }
if (!secret) { if (!secret) {
secret = this.userService.createTOTPSecret(); secret = this.totp.createTOTPSecret();
const challenge = { type: 'totp', secret }; const challenge = { type: 'totp', secret };
session.challenge = await this.token.encryptChallenge(challenge); 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); const qrcode = await this.qr.createQRDataURI(url);
res.render('two-factor/activate', { res.render('two-factor/activate', {
@ -72,7 +72,7 @@ export class TwoFactorController {
throw new Error('Invalid request'); throw new Error('Invalid request');
} }
const verify = this.userService.validateTOTP(secret, body.code); const verify = this.totp.validateTOTP(secret, body.code);
if (!verify) { if (!verify) {
throw new Error('Invalid code! Try again.'); throw new Error('Invalid code! Try again.');
} }
@ -85,7 +85,7 @@ export class TwoFactorController {
return; return;
} }
await this.userService.activateTOTP(session.user, secret); await this.totp.activateTOTP(req.user, secret);
session.challenge = null; session.challenge = null;
res.redirect('/'); res.redirect('/');
} }

View File

@ -2,7 +2,7 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AuthMiddleware } from 'src/middleware/auth.middleware'; import { AuthMiddleware } from 'src/middleware/auth.middleware';
import { FlashMiddleware } from 'src/middleware/flash.middleware'; import { FlashMiddleware } from 'src/middleware/flash.middleware';
import { UserModule } from 'src/modules/objects/user/user.module'; import { UserModule } from 'src/modules/objects/user/user.module';
import { TwoFactorController } from './twofactor.controller'; import { TwoFactorController } from './two-factor.controller';
@Module({ @Module({
imports: [UserModule], imports: [UserModule],

View File

@ -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 {}

View File

@ -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,
},
}),
},
];

View File

@ -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<any> {
return this.transport.sendMail({
to,
subject,
text,
html,
from,
});
}
public async sendEmailTemplate(
to: string,
subject: string,
message: EmailTemplate,
): Promise<any> {
return this.sendEmail(to, subject, message.text, message.html);
}
}

View File

@ -0,0 +1,4 @@
export interface EmailTemplate {
text: string;
html: string;
}

View File

@ -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: `
<h1>Icy Network</h1>
<p><strong>Hello, ${username}! You have requested a password reset on Icy Network.</strong></p>
<p>In order to change your password, please click on the following link.</p>
<p>Change your password: <a href="${url}" target="_blank">${url}</a></p>
<p>If you did not request a password change on Icy Network, you can safely ignore this email.</p>
`,
});

View File

@ -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: `
<h1>Icy Network</h1>
<p><strong>Welcome to Icy Network, ${username}!</strong></p>
<p>In order to proceed with logging in, please click on the following link to activate your account.</p>
<p>Activate your account: <a href="${url}" target="_blank">${url}</a></p>
`,
});

View File

@ -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<UserTOTPToken>,
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<boolean> {
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<UserTOTPToken> {
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<UserTOTPToken> {
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;
}
}

View File

@ -1,11 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module'; 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 { userProviders } from './user.providers';
import { UserService } from './user.service'; import { UserService } from './user.service';
@Module({ @Module({
imports: [DatabaseModule], imports: [DatabaseModule, EmailModule],
providers: [...userProviders, UserService], providers: [...userProviders, UserService, UserTOTPService],
exports: [UserService], exports: [UserService, UserTOTPService],
}) })
export class UserModule {} export class UserModule {}

View File

@ -1,15 +1,12 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { UserToken, UserTokenType } from './user-token.entity'; import { UserToken, UserTokenType } from './user-token.entity';
import { UserTOTPToken } from './user-totp-token.entity';
import { User } from './user.entity'; import { User } from './user.entity';
import { TokenService } from 'src/modules/utility/services/token.service'; import { TokenService } from 'src/modules/utility/services/token.service';
import { authenticator as totp } from 'otplib';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { EmailService } from '../email/email.service';
totp.options = { import { RegistrationEmail } from './email/registration.email';
window: 2, import { ForgotPasswordEmail } from './email/forgot-password.email';
};
@Injectable() @Injectable()
export class UserService { export class UserService {
@ -18,9 +15,8 @@ export class UserService {
private userRepository: Repository<User>, private userRepository: Repository<User>,
@Inject('USER_TOKEN_REPOSITORY') @Inject('USER_TOKEN_REPOSITORY')
private userTokenRepository: Repository<UserToken>, private userTokenRepository: Repository<UserToken>,
@Inject('USER_TOTP_TOKEN_REPOSITORY')
private userTOTPTokenRepository: Repository<UserTOTPToken>,
private token: TokenService, private token: TokenService,
private email: EmailService,
) {} ) {}
public async getById(id: number): Promise<User> { public async getById(id: number): Promise<User> {
@ -55,6 +51,11 @@ export class UserService {
return this.getByUsername(input); return this.getByUsername(input);
} }
public async updateUser(user: User): Promise<User> {
await this.userRepository.save(user);
return user;
}
public async comparePasswords( public async comparePasswords(
hash: string, hash: string,
password: string, password: string,
@ -67,56 +68,6 @@ export class UserService {
return bcrypt.hash(password, salt); 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<boolean> {
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<UserTOTPToken> {
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<UserTOTPToken> {
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( public async createUserToken(
user: User, user: User,
type: UserTokenType, type: UserTokenType,
@ -131,12 +82,87 @@ export class UserService {
return token; return token;
} }
public async getUserToken(
token: string,
type: UserTokenType,
): Promise<UserToken> {
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<void> {
await this.userTokenRepository.remove(token);
}
public async sendActivationEmail(user: User): Promise<void> { public async sendActivationEmail(user: User): Promise<void> {
const activationToken = await this.createUserToken( const activationToken = await this.createUserToken(
user, user,
UserTokenType.ACTIVATION, UserTokenType.ACTIVATION,
new Date(Date.now() + 3600 * 1000), 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<void> {
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<void> {
const user = await this.getByEmail(email);
if (!user || !user.activated) {
return;
}
await this.sendPasswordEmail(user);
} }
public async userRegistration(newUserInfo: { public async userRegistration(newUserInfo: {
@ -160,9 +186,17 @@ export class UserService {
user.username = newUserInfo.username; user.username = newUserInfo.username;
user.display_name = newUserInfo.display_name; user.display_name = newUserInfo.display_name;
user.password = hashword; user.password = hashword;
await this.userRepository.insert(user); 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; return user;
} }

View File

@ -10,20 +10,38 @@
max-width: 800px; max-width: 800px;
background-color: #2e6b81; background-color: #2e6b81;
color: #fff; color: #fff;
margin: 2rem auto; margin: 2rem auto 0;
padding: 4rem; padding: 4rem;
position: relative;
box-shadow: 0px 6px 62px -14px rgba(0, 0, 0, 0.45); 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 { h1:first-of-type {
margin-top: 0; margin-top: 0;
text-align: center; 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 { .logo-container {
text-align: center; text-align: center;
margin-top: 2rem; margin-top: 2rem;
} }
.qr-preview {
text-align: center;
}

28
src/scss/_breakpoint.scss Normal file
View File

@ -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";
}
}

View File

@ -6,6 +6,8 @@
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
outline: 0px solid #00c0ff8a; outline: 0px solid #00c0ff8a;
background-color: var(--btn-background);
color: var(--btn-color);
min-width: 120px; min-width: 120px;
transition: background-color 0.35s linear, outline 0.15s linear; transition: background-color 0.35s linear, outline 0.15s linear;
@ -16,8 +18,7 @@
padding: 12px; padding: 12px;
} }
background-color: var(--btn-background); text-shadow: none;
color: var(--btn-color);
&:hover { &:hover {
background-color: var(--btn-background-hover); background-color: var(--btn-background-hover);
@ -31,7 +32,6 @@
--btn-background: #00c4ff; --btn-background: #00c4ff;
--btn-background-hover: #3ed2ff; --btn-background-hover: #3ed2ff;
--btn-color: #002d34; --btn-color: #002d34;
text-transform: uppercase; font-weight: 600;
font-weight: 500;
} }
} }

View File

@ -1,8 +1,10 @@
.form-container { .form-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
row-gap: 0.5rem;
.btn { .btn,
.btn-group {
margin-top: 1rem; margin-top: 1rem;
} }
} }
@ -11,7 +13,6 @@
margin-top: 1rem; margin-top: 1rem;
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
margin-bottom: 0.25rem;
} }
input.form-control { input.form-control {

View File

@ -1,3 +1,4 @@
@import 'breakpoint';
@import 'block'; @import 'block';
@import 'form'; @import 'form';
@import 'button'; @import 'button';
@ -20,4 +21,5 @@ body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #314550; background-color: #314550;
text-shadow: black 1px 1px 2px;
} }

View File

@ -5,6 +5,7 @@ declare global {
namespace Express { namespace Express {
export interface Request { export interface Request {
oauth2: OAuth2; oauth2: OAuth2;
user: User;
flash: (type: string, ...msg: any[]) => Record<string, any>; flash: (type: string, ...msg: any[]) => Record<string, any>;
} }
} }
@ -13,7 +14,7 @@ declare global {
declare module 'express-session' { declare module 'express-session' {
interface SessionData { interface SessionData {
csrf?: string; csrf?: string;
user?: User; user?: string;
challenge?: string; challenge?: string;
flash?: Record<string, any>; flash?: Record<string, any>;
} }

View File

@ -20,8 +20,14 @@ block body
div.form-container div.form-container
input#csrf(type="hidden", name="csrf", value=csrf) input#csrf(type="hidden", name="csrf", value=csrf)
label.form-label(for="username") Username 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 label.form-label(for="password") Password
input.form-control#password(type="password", name="password", placeholder="Password") input.form-control#password(type="password", name="password", placeholder="Password")
button.btn.btn-primary(type="submit") Log in 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.

37
views/login/password.pug Normal file
View File

@ -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

View File

@ -20,5 +20,5 @@ block body
div.form-container div.form-container
input#csrf(type="hidden", name="csrf", value=csrf) input#csrf(type="hidden", name="csrf", value=csrf)
label.form-label(for="totp") Code 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 button.btn.btn-primary(type="submit") Log in

View File

@ -19,15 +19,25 @@ block body
form(method="post") form(method="post")
div.form-container div.form-container
input#csrf(type="hidden", name="csrf", value=csrf) input#csrf(type="hidden", name="csrf", value=csrf)
label.form-label(for="username") Username 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 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) 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 label.form-label(for="email") Email address
input.form-control#email(type="email", name="email", placeholder="Email address", value=form.email) 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 label.form-label(for="password") Password
input.form-control#password(type="password", name="password", placeholder="Password", value=form.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 label.form-label(for="password_repeat") Confirm password
input.form-control#password_repeat(type="password", name="password_repeat", placeholder="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 button.btn.btn-primary(type="submit") Create a new account
a.btn.btn-link.align-self-end(type="button" href="/login") Log in instead a.btn.btn-link.align-self-end(type="button" href="/login") Log in instead

View File

@ -23,5 +23,5 @@ block body
div.form-container div.form-container
input#csrf(type="hidden", name="csrf", value=csrf) input#csrf(type="hidden", name="csrf", value=csrf)
label.form-label(for="code") Code from authenticator app 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 button.btn.btn-primary(type="submit") Activate