nodemailer, user activation email, user password reset email
This commit is contained in:
parent
169c76eefc
commit
5967e0da24
33
package-lock.json
generated
33
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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<string, any> {
|
||||
if (!session.user) {
|
||||
res.redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
res.render('index', { user: session.user });
|
||||
res.render('index', { user: req.user });
|
||||
}
|
||||
}
|
||||
|
@ -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('*');
|
||||
}
|
||||
}
|
||||
|
23
src/middleware/user.middleware.ts
Normal file
23
src/middleware/user.middleware.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ export class UserAdapter implements OAuth2UserAdapter {
|
||||
async fetchFromRequest(
|
||||
req: Request<ParamsDictionary, any, any, ParsedQs, Record<string, any>>,
|
||||
): Promise<OAuth2User> {
|
||||
return req.session.user;
|
||||
return req.user;
|
||||
}
|
||||
|
||||
async consented(
|
||||
|
@ -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('/');
|
||||
}
|
@ -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],
|
9
src/modules/objects/email/email.module.ts
Normal file
9
src/modules/objects/email/email.module.ts
Normal 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 {}
|
17
src/modules/objects/email/email.providers.ts
Normal file
17
src/modules/objects/email/email.providers.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
35
src/modules/objects/email/email.service.ts
Normal file
35
src/modules/objects/email/email.service.ts
Normal 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);
|
||||
}
|
||||
}
|
4
src/modules/objects/email/email.template.ts
Normal file
4
src/modules/objects/email/email.template.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface EmailTemplate {
|
||||
text: string;
|
||||
html: string;
|
||||
}
|
29
src/modules/objects/user/email/forgot-password.email.ts
Normal file
29
src/modules/objects/user/email/forgot-password.email.ts
Normal 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>
|
||||
`,
|
||||
});
|
25
src/modules/objects/user/email/registration.email.ts
Normal file
25
src/modules/objects/user/email/registration.email.ts
Normal 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>
|
||||
`,
|
||||
});
|
69
src/modules/objects/user/user-totp-token.service.ts
Normal file
69
src/modules/objects/user/user-totp-token.service.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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 {}
|
||||
|
@ -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<User>,
|
||||
@Inject('USER_TOKEN_REPOSITORY')
|
||||
private userTokenRepository: Repository<UserToken>,
|
||||
@Inject('USER_TOTP_TOKEN_REPOSITORY')
|
||||
private userTOTPTokenRepository: Repository<UserTOTPToken>,
|
||||
private token: TokenService,
|
||||
private email: EmailService,
|
||||
) {}
|
||||
|
||||
public async getById(id: number): Promise<User> {
|
||||
@ -55,6 +51,11 @@ export class UserService {
|
||||
return this.getByUsername(input);
|
||||
}
|
||||
|
||||
public async updateUser(user: User): Promise<User> {
|
||||
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<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(
|
||||
user: User,
|
||||
type: UserTokenType,
|
||||
@ -131,12 +82,87 @@ export class UserService {
|
||||
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> {
|
||||
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<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: {
|
||||
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
28
src/scss/_breakpoint.scss
Normal file
28
src/scss/_breakpoint.scss
Normal 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";
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
|
3
src/types/express-session.d.ts
vendored
3
src/types/express-session.d.ts
vendored
@ -5,6 +5,7 @@ declare global {
|
||||
namespace Express {
|
||||
export interface Request {
|
||||
oauth2: OAuth2;
|
||||
user: User;
|
||||
flash: (type: string, ...msg: any[]) => Record<string, any>;
|
||||
}
|
||||
}
|
||||
@ -13,7 +14,7 @@ declare global {
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
csrf?: string;
|
||||
user?: User;
|
||||
user?: string;
|
||||
challenge?: string;
|
||||
flash?: Record<string, any>;
|
||||
}
|
||||
|
@ -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.
|
||||
|
37
views/login/password.pug
Normal file
37
views/login/password.pug
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user