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",
|
"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",
|
||||||
|
@ -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",
|
||||||
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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('*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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';
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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(
|
||||||
|
@ -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('/');
|
||||||
}
|
}
|
@ -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],
|
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 { 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 {}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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
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;
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
3
src/types/express-session.d.ts
vendored
3
src/types/express-session.d.ts
vendored
@ -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>;
|
||||||
}
|
}
|
||||||
|
@ -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
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
|
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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Reference in New Issue
Block a user