config service, separate user token into module

This commit is contained in:
Evert Prants 2022-03-15 19:00:15 +02:00
parent 5967e0da24
commit 5fa307b5b7
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
26 changed files with 269 additions and 144 deletions

1
.gitignore vendored
View File

@ -37,6 +37,7 @@ lerna-debug.log*
# local development environment files
.env
/devdocker
/config*.toml
# front-end items
/public/js

11
package-lock.json generated
View File

@ -26,6 +26,7 @@
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"toml": "^3.0.0",
"typeorm": "^0.2.45",
"uuid": "^8.3.2"
},
@ -9016,6 +9017,11 @@
"resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz",
"integrity": "sha1-zCAOqyYT9BZtJ/+a/HylbUnfbrQ="
},
"node_modules/toml": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz",
"integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="
},
"node_modules/tough-cookie": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz",
@ -17016,6 +17022,11 @@
"resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz",
"integrity": "sha1-zCAOqyYT9BZtJ/+a/HylbUnfbrQ="
},
"toml": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz",
"integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="
},
"tough-cookie": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz",

View File

@ -40,6 +40,7 @@
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"toml": "^3.0.0",
"typeorm": "^0.2.45",
"uuid": "^8.3.2"
},

View File

@ -3,6 +3,7 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { CSRFMiddleware } from './middleware/csrf.middleware';
import { UserMiddleware } from './middleware/user.middleware';
import { ConfigurationModule } from './modules/config/config.module';
import { LoginModule } from './modules/features/login/login.module';
import { OAuth2Module } from './modules/features/oauth2/oauth2.module';
import { RegisterModule } from './modules/features/register/register.module';
@ -17,6 +18,7 @@ import { UtilityModule } from './modules/utility/utility.module';
@Module({
imports: [
ConfigurationModule,
UtilityModule,
DatabaseModule,
EmailModule,

View File

@ -0,0 +1,25 @@
export interface SMTPConfiguration {
host: string;
port: number;
secure: boolean;
auth: {
user: string;
pass: string;
};
}
export interface EmailConfiguration {
from: string;
smtp: SMTPConfiguration;
}
export interface AppConfiguration {
base_url: string;
session_secret: string;
challenge_secret: string;
}
export interface Configuration {
app: AppConfiguration;
email: EmailConfiguration;
}

View File

@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { configProviders } from './config.providers';
import { ConfigurationService } from './config.service';
@Global()
@Module({
providers: [...configProviders, ConfigurationService],
exports: [ConfigurationService],
})
export class ConfigurationModule {}

View File

@ -0,0 +1,55 @@
import * as toml from 'toml';
import { resolve } from 'path';
import { readFile } from 'fs/promises';
import { Configuration } from './config.interfaces';
const CONFIG_ENV = process.env.NODE_ENV === 'production' ? 'prod' : 'dev';
const CONFIG_FILENAME = process.env.CONFIG || `config.${CONFIG_ENV}.toml`;
export const configProviders = [
{
provide: 'CONFIG_PATH',
useValue: resolve(__dirname, '..', '..', '..', CONFIG_FILENAME),
},
{
provide: 'DEFAULT_CONFIG',
useValue: {
app: {
base_url: 'http://localhost:3000',
session_secret: 'change me!',
challenge_secret: 'change me!',
},
email: {
from: 'no-reply@localhost',
smtp: {
host: 'localhost',
port: 587,
secure: false,
auth: {
user: 'root',
pass: 'root',
},
},
},
} as Configuration,
},
{
provide: 'CONFIGURATION',
useFactory: async (
path: string,
def: Configuration,
): Promise<Configuration> => {
try {
const file = await readFile(path, { encoding: 'utf-8' });
return {
...def,
...toml.parse(file),
};
} catch (e: any) {
console.error('Failed to load configuration:', e.message);
return def;
}
},
inject: ['CONFIG_PATH', 'DEFAULT_CONFIG'],
},
];

View File

@ -0,0 +1,18 @@
import { Inject, Injectable } from '@nestjs/common';
import { Configuration } from './config.interfaces';
@Injectable()
export class ConfigurationService {
constructor(
@Inject('CONFIGURATION')
public config: Configuration,
) {}
public get<T>(key: string): T {
return key
.replace(/\[|\]\.?/g, '.')
.split('.')
.filter((s) => s)
.reduce((acc, val) => acc && acc[val], this.config);
}
}

View File

@ -14,8 +14,9 @@ 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';
} from 'src/modules/objects/user-token/user-token.entity';
import { UserTokenService } from 'src/modules/objects/user-token/user-token.service';
import { UserTOTPService } from 'src/modules/objects/user-token/user-totp-token.service';
import { User } from 'src/modules/objects/user/user.entity';
import { UserService } from 'src/modules/objects/user/user.service';
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
@ -26,6 +27,7 @@ export class LoginController {
constructor(
private readonly userService: UserService,
private readonly totpService: UserTOTPService,
private readonly userTokenService: UserTokenService,
private readonly formUtil: FormUtilityService,
private readonly token: TokenService,
) {}
@ -173,7 +175,7 @@ export class LoginController {
throw new Error();
}
token = await this.userService.getUserToken(
token = await this.userTokenService.get(
query.token,
UserTokenType.ACTIVATION,
);
@ -195,7 +197,7 @@ export class LoginController {
user.activated = true;
await this.userService.updateUser(user);
await this.userService.deleteUserToken(token);
await this.userTokenService.delete(token);
req.flash('message', {
error: false,
@ -213,7 +215,7 @@ export class LoginController {
@Query() query: { token: string },
) {
if (query.token) {
const token = await this.userService.getUserToken(
const token = await this.userTokenService.get(
query.token,
UserTokenType.PASSWORD,
);
@ -273,7 +275,7 @@ export class LoginController {
}
// Change password fragment
const token = await this.userService.getUserToken(
const token = await this.userTokenService.get(
query.token,
UserTokenType.PASSWORD,
);
@ -309,7 +311,7 @@ export class LoginController {
token.user.password = hashword;
await this.userService.updateUser(token.user);
await this.userService.deleteUserToken(token);
await this.userTokenService.delete(token);
req.flash('message', {
error: false,

View File

@ -6,11 +6,12 @@ import {
} from '@nestjs/common';
import { FlashMiddleware } from 'src/middleware/flash.middleware';
import { ValidateCSRFMiddleware } from 'src/middleware/validate-csrf.middleware';
import { UserTokenModule } from 'src/modules/objects/user-token/user-token.module';
import { UserModule } from 'src/modules/objects/user/user.module';
import { LoginController } from './login.controller';
@Module({
imports: [UserModule],
imports: [UserModule, UserTokenModule],
controllers: [LoginController],
})
export class LoginModule implements NestModule {

View File

@ -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 { UserTOTPService } from 'src/modules/objects/user/user-totp-token.service';
import { UserTOTPService } from 'src/modules/objects/user-token/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';
@ -85,6 +85,7 @@ export class TwoFactorController {
return;
}
// TODO: show the recovery tokens to the user
await this.totp.activateTOTP(req.user, secret);
session.challenge = null;
res.redirect('/');

View File

@ -1,11 +1,12 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AuthMiddleware } from 'src/middleware/auth.middleware';
import { FlashMiddleware } from 'src/middleware/flash.middleware';
import { UserTokenModule } from 'src/modules/objects/user-token/user-token.module';
import { UserModule } from 'src/modules/objects/user/user.module';
import { TwoFactorController } from './two-factor.controller';
@Module({
imports: [UserModule],
imports: [UserModule, UserTokenModule],
controllers: [TwoFactorController],
})
export class TwoFactorModule implements NestModule {

View File

@ -13,6 +13,7 @@ export const databaseProviders = [
database: 'icyauth',
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
synchronize: true,
logging: ['query', 'error'],
}),
},
];

View File

@ -1,17 +1,12 @@
import * as nodemailer from 'nodemailer';
import { SMTPConfiguration } from 'src/modules/config/config.interfaces';
import { ConfigurationService } from 'src/modules/config/config.service';
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,
},
}),
useFactory: async (config: ConfigurationService) =>
nodemailer.createTransport(config.get<SMTPConfiguration>('email.smtp')),
inject: [ConfigurationService],
},
];

View File

@ -5,7 +5,7 @@ import {
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from './user.entity';
import { User } from '../user/user.entity';
export enum UserTokenType {
GENERIC = 'generic',
@ -14,6 +14,8 @@ export enum UserTokenType {
PASSWORD = 'password',
LOGIN = 'login',
GDPR = 'gdpr',
TOTP = 'totp',
RECOVERY = 'recovery',
}
@Entity()

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { userTokenProviders } from './user-token.providers';
import { UserTokenService } from './user-token.service';
import { UserTOTPService } from './user-totp-token.service';
@Module({
imports: [DatabaseModule],
providers: [...userTokenProviders, UserTokenService, UserTOTPService],
exports: [UserTokenService, UserTOTPService],
})
export class UserTokenModule {}

View File

@ -0,0 +1,10 @@
import { Connection } from 'typeorm';
import { UserToken } from './user-token.entity';
export const userTokenProviders = [
{
provide: 'USER_TOKEN_REPOSITORY',
useFactory: (connection: Connection) => connection.getRepository(UserToken),
inject: ['DATABASE_CONNECTION'],
},
];

View File

@ -0,0 +1,53 @@
import { Inject, Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { UserToken, UserTokenType } from '../user-token/user-token.entity';
import { TokenService } from 'src/modules/utility/services/token.service';
import { User } from '../user/user.entity';
@Injectable()
export class UserTokenService {
constructor(
@Inject('USER_TOKEN_REPOSITORY')
private userTokenRepository: Repository<UserToken>,
private token: TokenService,
) {}
public async create(
user: User,
type: UserTokenType,
expiresAt?: Date,
): Promise<UserToken> {
const token = new UserToken();
token.token = this.token.generateString(64);
token.user = user;
token.type = type;
token.expires_at = expiresAt;
await this.userTokenRepository.save(token);
return token;
}
public async get(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 delete(token: UserToken): Promise<void> {
await this.userTokenRepository.remove(token);
}
}

View File

@ -1,9 +1,9 @@
import { Inject, Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { UserTOTPToken } from './user-totp-token.entity';
import { User } from './user.entity';
import { User } from '../user/user.entity';
import { TokenService } from 'src/modules/utility/services/token.service';
import { authenticator as totp } from 'otplib';
import { UserToken, UserTokenType } from './user-token.entity';
totp.options = {
window: 2,
@ -12,8 +12,8 @@ totp.options = {
@Injectable()
export class UserTOTPService {
constructor(
@Inject('USER_TOTP_TOKEN_REPOSITORY')
private userTOTPTokenRepository: Repository<UserTOTPToken>,
@Inject('USER_TOKEN_REPOSITORY')
private userTokenRepository: Repository<UserToken>,
private token: TokenService,
) {}
@ -31,10 +31,10 @@ export class UserTOTPService {
* @param user User object
* @returns TOTP token
*/
public async getUserTOTP(user: User): Promise<UserTOTPToken> {
return this.userTOTPTokenRepository.findOne({
public async getUserTOTP(user: User): Promise<UserToken> {
return this.userTokenRepository.findOne({
user,
activated: true,
type: UserTokenType.TOTP,
});
}
@ -50,20 +50,23 @@ export class UserTOTPService {
return totp.generateSecret();
}
public async activateTOTP(
user: User,
secret: string,
): Promise<UserTOTPToken> {
const totp = new UserTOTPToken();
totp.activated = true;
public async activateTOTP(user: User, secret: string): Promise<UserToken[]> {
const totp = new UserToken();
const recovery = new UserToken();
totp.user = user;
totp.token = secret;
totp.recovery_token = Array.from({ length: 8 }, () =>
totp.type = UserTokenType.TOTP;
recovery.user = user;
recovery.token = Array.from({ length: 8 }, () =>
this.token.generateString(8),
).join(' ');
recovery.type = UserTokenType.RECOVERY;
await this.userTOTPTokenRepository.save(totp);
await this.userTokenRepository.save(totp);
await this.userTokenRepository.save(recovery);
return totp;
return [totp, recovery];
}
}

View File

@ -1,35 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from './user.entity';
@Entity()
export class UserTOTPToken {
@PrimaryGeneratedColumn()
id: number;
@Column({ nullable: false, type: 'text' })
token: string;
@Column({ nullable: false, type: 'text' })
recovery_token: string;
@Column({ default: false, nullable: false })
activated: boolean;
@Column({ type: 'timestamp', nullable: true })
public expires_at: Date;
@CreateDateColumn({
type: 'timestamp',
default: () => 'CURRENT_TIMESTAMP(6)',
})
public created_at: Date;
@ManyToOne(() => User)
user: User;
}

View File

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

View File

@ -1,6 +1,4 @@
import { Connection } from 'typeorm';
import { UserToken } from './user-token.entity';
import { UserTOTPToken } from './user-totp-token.entity';
import { User } from './user.entity';
export const userProviders = [
@ -9,15 +7,4 @@ export const userProviders = [
useFactory: (connection: Connection) => connection.getRepository(User),
inject: ['DATABASE_CONNECTION'],
},
{
provide: 'USER_TOKEN_REPOSITORY',
useFactory: (connection: Connection) => connection.getRepository(UserToken),
inject: ['DATABASE_CONNECTION'],
},
{
provide: 'USER_TOTP_TOKEN_REPOSITORY',
useFactory: (connection: Connection) =>
connection.getRepository(UserTOTPToken),
inject: ['DATABASE_CONNECTION'],
},
];

View File

@ -1,22 +1,24 @@
import { Inject, Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { UserToken, UserTokenType } from './user-token.entity';
import { UserTokenType } from '../user-token/user-token.entity';
import { User } from './user.entity';
import { TokenService } from 'src/modules/utility/services/token.service';
import * as bcrypt from 'bcrypt';
import { EmailService } from '../email/email.service';
import { RegistrationEmail } from './email/registration.email';
import { ForgotPasswordEmail } from './email/forgot-password.email';
import { UserTokenService } from '../user-token/user-token.service';
import { ConfigurationService } from 'src/modules/config/config.service';
@Injectable()
export class UserService {
constructor(
@Inject('USER_REPOSITORY')
private userRepository: Repository<User>,
@Inject('USER_TOKEN_REPOSITORY')
private userTokenRepository: Repository<UserToken>,
private userToken: UserTokenService,
private token: TokenService,
private email: EmailService,
private config: ConfigurationService,
) {}
public async getById(id: number): Promise<User> {
@ -68,50 +70,8 @@ export class UserService {
return bcrypt.hash(password, salt);
}
public async createUserToken(
user: User,
type: UserTokenType,
expiresAt?: Date,
): Promise<UserToken> {
const token = new UserToken();
token.token = this.token.generateString(64);
token.user = user;
token.type = type;
token.expires_at = expiresAt;
await this.userTokenRepository.save(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> {
const activationToken = await this.createUserToken(
const activationToken = await this.userToken.create(
user,
UserTokenType.ACTIVATION,
new Date(Date.now() + 3600 * 1000),
@ -120,7 +80,9 @@ export class UserService {
try {
const content = RegistrationEmail(
user.username,
`${process.env.BASE_URL}/login/activate?token=${activationToken.token}`,
`${this.config.get<string>('app.base_url')}/login/activate?token=${
activationToken.token
}`,
);
await this.email.sendEmailTemplate(
user.email,
@ -128,13 +90,13 @@ export class UserService {
content,
);
} catch (e) {
await this.userTokenRepository.remove(activationToken);
await this.userToken.delete(activationToken);
throw e;
}
}
public async sendPasswordEmail(user: User): Promise<void> {
const passwordToken = await this.createUserToken(
const passwordToken = await this.userToken.create(
user,
UserTokenType.PASSWORD,
new Date(Date.now() + 3600 * 1000),
@ -143,7 +105,9 @@ export class UserService {
try {
const content = ForgotPasswordEmail(
user.username,
`${process.env.BASE_URL}/login/password?token=${passwordToken.token}`,
`${this.config.get<string>('app.base_url')}/login/password?token=${
passwordToken.token
}`,
);
await this.email.sendEmailTemplate(
user.email,
@ -151,7 +115,7 @@ export class UserService {
content,
);
} catch (e) {
await this.userTokenRepository.remove(passwordToken);
await this.userToken.delete(passwordToken);
// silently fail
}
}

View File

@ -6,7 +6,7 @@ import { SessionData } from 'express-session';
export class FormUtilityService {
public emailRegex =
/^[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;
public passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/;
public passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\w\W]{8,}$/;
public usernameRegex = /^[a-zA-Z0-9_\-.]{3,26}$/;
public mergeObjectArray(flash: Record<string, any>[]): Record<string, any> {

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
import { ConfigurationService } from 'src/modules/config/config.service';
import { v4 } from 'uuid';
const IV_LENGTH = 16;
@ -7,6 +8,8 @@ const ALGORITHM = 'aes-256-cbc';
@Injectable()
export class TokenService {
constructor(private config: ConfigurationService) {}
public generateString(length: number): string {
return crypto.randomBytes(length).toString('hex').slice(0, length);
}
@ -61,13 +64,15 @@ export class TokenService {
): Promise<string> {
return this.encrypt(
JSON.stringify(challenge),
process.env.CHALLENGE_SECRET,
this.config.get<string>('app.challenge_secret'),
);
}
public async decryptChallenge(
challenge: string,
): Promise<Record<string, any>> {
return JSON.parse(this.decrypt(challenge, process.env.CHALLENGE_SECRET));
return JSON.parse(
this.decrypt(challenge, this.config.get<string>('app.challenge_secret')),
);
}
}

View File

@ -19,7 +19,7 @@ block body
div.qr-preview
img(src=qrcode)
form(method="post")
form(method="post",autocomplete="off")
div.form-container
input#csrf(type="hidden", name="csrf", value=csrf)
label.form-label(for="code") Code from authenticator app