adminjs experimentation

This commit is contained in:
Evert Prants 2024-03-12 19:12:52 +02:00
parent fb4154c9e5
commit c40eb00d4f
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
28 changed files with 6041 additions and 901 deletions

1
.gitignore vendored
View File

@ -36,6 +36,7 @@ lerna-debug.log*
# local development environment files # local development environment files
.env .env
.adminjs
/devdocker /devdocker
/config*.toml /config*.toml
/private /private

6611
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,9 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@adminjs/express": "^6.1.0",
"@adminjs/nestjs": "^6.1.0",
"@adminjs/typeorm": "^5.0.1",
"@icynet/oauth2-provider": "^1.0.8", "@icynet/oauth2-provider": "^1.0.8",
"@nestjs/cache-manager": "^2.2.1", "@nestjs/cache-manager": "^2.2.1",
"@nestjs/common": "^10.3.3", "@nestjs/common": "^10.3.3",
@ -32,6 +35,7 @@
"@nestjs/serve-static": "^4.0.1", "@nestjs/serve-static": "^4.0.1",
"@nestjs/swagger": "^7.3.0", "@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "^5.1.2", "@nestjs/throttler": "^5.1.2",
"adminjs": "^7.7.2",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cache-manager": "^5.4.0", "cache-manager": "^5.4.0",
"cache-manager-redis-yet": "^4.1.2", "cache-manager-redis-yet": "^4.1.2",
@ -43,6 +47,7 @@
"cropperjs": "^1.6.1", "cropperjs": "^1.6.1",
"csrf": "^3.1.0", "csrf": "^3.1.0",
"dotenv": "^16.4.4", "dotenv": "^16.4.4",
"express-formidable": "^1.2.0",
"express-session": "^1.18.0", "express-session": "^1.18.0",
"express-useragent": "^1.0.15", "express-useragent": "^1.0.15",
"geoip-lite": "^1.4.10", "geoip-lite": "^1.4.10",

View File

@ -13,6 +13,8 @@ import { SSRFrontEndModule } from './modules/ssr-front-end/ssr-front-end.module'
import { UtilityModule } from './modules/utility/utility.module'; import { UtilityModule } from './modules/utility/utility.module';
import { WellKnownModule } from './modules/well-known/well-known.module'; import { WellKnownModule } from './modules/well-known/well-known.module';
import { CommonCacheModule } from './modules/cache/cache.module'; import { CommonCacheModule } from './modules/cache/cache.module';
import { AdminjsModule } from './modules/adminjs/adminjs.module';
import { AdminjsService } from './modules/adminjs/adminjs.service';
@Module({ @Module({
imports: [ imports: [
@ -37,6 +39,14 @@ import { CommonCacheModule } from './modules/cache/cache.module';
SSRFrontEndModule, SSRFrontEndModule,
WellKnownModule, WellKnownModule,
ApiModule, ApiModule,
// TODO: https://docs.adminjs.co/installation/plugins/nest
import('@adminjs/nestjs').then(({ AdminModule }) =>
AdminModule.createAdminAsync({
imports: [AdminjsModule],
useFactory: (shims: AdminjsService) => shims.getConfiguration(),
inject: [AdminjsService],
}),
),
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService, CSRFMiddleware], providers: [AppService, CSRFMiddleware],

View File

@ -1,7 +1,7 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import * as dotenv from 'dotenv'; import dotenv from 'dotenv';
import * as cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { join } from 'path'; import { join } from 'path';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express';

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigurationModule } from '../config/config.module';
import { AdminjsService } from './adminjs.service';
import { ObjectsModule } from '../objects/objects.module';
import { UserResource } from './resources/user.resource';
@Module({
imports: [ConfigurationModule, ObjectsModule],
providers: [AdminjsService, UserResource],
exports: [AdminjsService],
})
export class AdminjsModule {}

View File

@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { ConfigurationService } from '../config/config.service';
import { Upload } from '../objects/upload/upload.entity';
import { Privilege } from '../objects/privilege/privilege.entity';
import { OAuth2Client } from '../objects/oauth2-client/oauth2-client.entity';
import { FormUtilityService } from '../utility/services/form-utility.service';
import { TokenService } from '../utility/services/token.service';
import { UserResource } from './resources/user.resource';
/**
* Shim service for AdminJs features
*/
@Injectable({})
export class AdminjsService {
constructor(
private readonly config: ConfigurationService,
private readonly formUtil: FormUtilityService,
private readonly token: TokenService,
private readonly userResource: UserResource,
) {}
async getConfiguration() {
const { AdminJS } = await import('adminjs');
const AdminJSTypeORM = await import('@adminjs/typeorm');
AdminJS.registerAdapter({
Database: AdminJSTypeORM.Database,
Resource: AdminJSTypeORM.Resource,
});
return {
adminJsOptions: {
rootPath: '/admin',
resources: [this.userResource, Upload, Privilege, OAuth2Client],
},
// auth: {
// authenticate: (email, password) => Promise.resolve({ email }),
// cookieName: 'adminjs',
// cookiePassword: 'secret',
// },
sessionOptions: {
resave: true,
saveUninitialized: true,
secret: 'secret',
},
};
}
}

View File

@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { User } from 'src/modules/objects/user/user.entity';
@Injectable()
export class UserResource {
public resource = User;
public options = {
listProperties: [
'id',
'uuid',
'email',
'username',
'display_name',
'activated',
'created_at',
],
};
}

View File

@ -1,5 +1,5 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import * as cors from 'cors'; import cors from 'cors';
import { ConfigurationModule } from '../config/config.module'; import { ConfigurationModule } from '../config/config.module';
import { JWTModule } from '../jwt/jwt.module'; import { JWTModule } from '../jwt/jwt.module';
import { OAuth2Module } from '../oauth2/oauth2.module'; import { OAuth2Module } from '../oauth2/oauth2.module';

View File

@ -6,6 +6,7 @@ import { ParsedQs } from 'qs';
import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service'; import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service';
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';
import { TokenService } from 'src/modules/utility/services/token.service';
@Injectable() @Injectable()
export class UserAdapter implements OAuth2UserAdapter { export class UserAdapter implements OAuth2UserAdapter {
@ -13,6 +14,7 @@ export class UserAdapter implements OAuth2UserAdapter {
private readonly userService: UserService, private readonly userService: UserService,
private readonly clientService: OAuth2ClientService, private readonly clientService: OAuth2ClientService,
private readonly form: FormUtilityService, private readonly form: FormUtilityService,
private readonly token: TokenService,
) {} ) {}
getId(user: OAuth2User): number { getId(user: OAuth2User): number {
@ -48,7 +50,7 @@ export class UserAdapter implements OAuth2UserAdapter {
} }
checkPassword(user: OAuth2User, password: string): Promise<boolean> { checkPassword(user: OAuth2User, password: string): Promise<boolean> {
return this.userService.comparePasswords(user.password, password); return this.token.comparePasswords(user.password, password);
} }
async fetchFromRequest( async fetchFromRequest(

View File

@ -1,4 +1,5 @@
import { import {
BaseEntity,
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
@ -8,7 +9,7 @@ import {
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
@Entity() @Entity()
export class AuditLog { export class AuditLog extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;

View File

@ -1,6 +1,7 @@
import { Lookup } from 'geoip-lite'; import { Lookup } from 'geoip-lite';
import { Details } from 'express-useragent'; import { Details } from 'express-useragent';
import { AuditAction } from './audit.enum'; import { AuditAction } from './audit.enum';
import { AuditLog } from './audit.entity';
export interface UserLoginEntry { export interface UserLoginEntry {
login_at: Date; login_at: Date;
@ -17,3 +18,8 @@ export interface AuditSearchClause {
content?: string; content?: string;
flagged?: boolean; flagged?: boolean;
} }
export interface AuditResponse extends AuditLog {
location?: Partial<Lookup>;
user_agent?: Partial<Details>;
}

View File

@ -10,10 +10,14 @@ import {
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
import { AuditLog } from './audit.entity'; import { AuditLog } from './audit.entity';
import { AuditAction } from './audit.enum'; import { AuditAction } from './audit.enum';
import { Lookup, lookup } from 'geoip-lite'; import { lookup } from 'geoip-lite';
import { Details, parse } from 'express-useragent'; import { parse } from 'express-useragent';
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service'; import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
import { AuditSearchClause, UserLoginEntry } from './audit.interfaces'; import {
AuditResponse,
AuditSearchClause,
UserLoginEntry,
} from './audit.interfaces';
const PLUCK_LOCATION = ['country', 'city', 'timezone', 'll']; const PLUCK_LOCATION = ['country', 'city', 'timezone', 'll'];
const PLUCK_USER_AGENT = ['browser', 'version', 'os', 'platform']; const PLUCK_USER_AGENT = ['browser', 'version', 'os', 'platform'];
@ -86,15 +90,7 @@ export class AuditService {
limit = 50, limit = 50,
offset = 0, offset = 0,
search: AuditSearchClause, search: AuditSearchClause,
): Promise< ): Promise<[AuditResponse[], number]> {
[
(AuditLog & {
location?: Partial<Lookup>;
user_agent?: Partial<Details>;
})[],
number,
]
> {
const [list, num] = await this.audit.findAndCount({ const [list, num] = await this.audit.findAndCount({
...this.buildAuditSearch(search), ...this.buildAuditSearch(search),
take: limit, take: limit,
@ -104,7 +100,9 @@ export class AuditService {
}); });
return [ return [
list.map((entry) => ({ list.map(
(entry) =>
({
...entry, ...entry,
location: entry.actor_ip location: entry.actor_ip
? this.form.pluckObject( ? this.form.pluckObject(
@ -119,7 +117,8 @@ export class AuditService {
) )
: null, : null,
actor: this.form.stripObject(entry.actor, ['password']), actor: this.form.stripObject(entry.actor, ['password']),
})), }) as AuditResponse,
),
num, num,
]; ];
} }

View File

@ -1,4 +1,5 @@
import { import {
BaseEntity,
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
@ -9,7 +10,7 @@ import {
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
@Entity() @Entity()
export class Document { export class Document extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;

View File

@ -38,7 +38,7 @@ export class DocumentService {
return { return {
...doc, ...doc,
html, html,
}; } as Document & { html: string };
} }
public async getDocumentByID( public async getDocumentByID(
@ -49,6 +49,6 @@ export class DocumentService {
return { return {
...doc, ...doc,
html, html,
}; } as Document & { html: string };
} }
} }

View File

@ -1,4 +1,5 @@
import { import {
BaseEntity,
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
@ -12,7 +13,7 @@ import { User } from '../user/user.entity';
import { OAuth2ClientURL } from './oauth2-client-url.entity'; import { OAuth2ClientURL } from './oauth2-client-url.entity';
@Entity() @Entity()
export class OAuth2Client { export class OAuth2Client extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;

View File

@ -1,4 +1,5 @@
import { import {
BaseEntity,
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
@ -16,7 +17,7 @@ export enum OAuth2TokenType {
} }
@Entity() @Entity()
export class OAuth2Token { export class OAuth2Token extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;

View File

@ -1,7 +1,7 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity() @Entity()
export class Privilege { export class Privilege extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;

View File

@ -1,4 +1,5 @@
import { import {
BaseEntity,
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
@ -9,7 +10,7 @@ import {
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
@Entity() @Entity()
export class Upload { export class Upload extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;

View File

@ -1,4 +1,5 @@
import { import {
BaseEntity,
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
@ -20,7 +21,7 @@ export enum UserTokenType {
} }
@Entity() @Entity()
export class UserToken { export class UserToken extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;

View File

@ -1,4 +1,5 @@
import { import {
BaseEntity,
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
@ -12,7 +13,7 @@ import { Privilege } from '../privilege/privilege.entity';
import { Upload } from '../upload/upload.entity'; import { Upload } from '../upload/upload.entity';
@Entity() @Entity()
export class User { export class User extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;

View File

@ -1,6 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ILike, Repository } from 'typeorm'; import { ILike, Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { UserTokenType } from '../user-token/user-token.entity'; import { UserTokenType } from '../user-token/user-token.entity';
import { User } from './user.entity'; import { User } from './user.entity';
@ -149,18 +148,6 @@ export class UserService {
} }
} }
public async comparePasswords(
hash: string,
password: string,
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
public async hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt);
}
public async sendActivationEmail( public async sendActivationEmail(
user: User, user: User,
redirectTo?: string, redirectTo?: string,
@ -245,7 +232,7 @@ export class UserService {
throw new Error('Username is already in use!'); throw new Error('Username is already in use!');
} }
const hashword = await this.hashPassword(newUserInfo.password); const hashword = await this.token.hashPassword(newUserInfo.password);
const user = new User(); const user = new User();
user.email = newUserInfo.email; user.email = newUserInfo.email;
user.uuid = this.token.createUUID(); user.uuid = this.token.createUUID();

View File

@ -75,7 +75,7 @@ export class LoginController {
if ( if (
!user || !user ||
!user.activated || !user.activated ||
!(await this.userService.comparePasswords(user.password, password)) !(await this.token.comparePasswords(user.password, password))
) { ) {
req.flash('form', { username }); req.flash('form', { username });
req.flash('message', { req.flash('message', {
@ -365,7 +365,7 @@ export class LoginController {
throw new Error('The passwords do not match!'); throw new Error('The passwords do not match!');
} }
const hashword = await this.userService.hashPassword(password); const hashword = await this.token.hashPassword(password);
token.user.password = hashword; token.user.password = hashword;
await this.userService.updateUser(token.user); await this.userService.updateUser(token.user);

View File

@ -1,7 +1,7 @@
import { FactoryProvider } from '@nestjs/common'; import { FactoryProvider } from '@nestjs/common';
import { ConfigurationService } from 'src/modules/config/config.service'; import { ConfigurationService } from 'src/modules/config/config.service';
import * as session from 'express-session'; import session from 'express-session';
import RedisStore from 'connect-redis'; import RedisStore from 'connect-redis';
import type { RequestHandler } from 'express'; import type { RequestHandler } from 'express';
import type { Redis } from 'src/modules/redis/redis.providers'; import type { Redis } from 'src/modules/redis/redis.providers';

View File

@ -33,15 +33,15 @@ import { SettingsService } from './settings.service';
@Controller('/account') @Controller('/account')
export class SettingsController { export class SettingsController {
constructor( constructor(
private readonly _service: SettingsService, private readonly settingsService: SettingsService,
private readonly _form: FormUtilityService, private readonly formService: FormUtilityService,
private readonly _upload: UploadService, private readonly uploadService: UploadService,
private readonly _token: TokenService, private readonly tokenService: TokenService,
private readonly _user: UserService, private readonly userService: UserService,
private readonly _totp: UserTOTPService, private readonly totpService: UserTOTPService,
private readonly _client: OAuth2ClientService, private readonly clientService: OAuth2ClientService,
private readonly _oaToken: OAuth2TokenService, private readonly oaTokenService: OAuth2TokenService,
private readonly _audit: AuditService, private readonly auditService: AuditService,
) {} ) {}
@Get() @Get()
@ -53,7 +53,7 @@ export class SettingsController {
@Get('general') @Get('general')
@Render('settings/general') @Render('settings/general')
public general(@Req() req: Request) { public general(@Req() req: Request) {
return this._form.populateTemplate(req, { user: req.user }); return this.formService.populateTemplate(req, { user: req.user });
} }
@Post('general') @Post('general')
@ -63,7 +63,7 @@ export class SettingsController {
@Body() body: { display_name?: string }, @Body() body: { display_name?: string },
) { ) {
try { try {
const { display_name } = this._form.trimmed(body, ['display_name']); const { display_name } = this.formService.trimmed(body, ['display_name']);
if (!display_name) { if (!display_name) {
throw new Error('Display name is required.'); throw new Error('Display name is required.');
} }
@ -76,7 +76,7 @@ export class SettingsController {
req.user.display_name = display_name; req.user.display_name = display_name;
await this._user.updateUser(req.user); await this.userService.updateUser(req.user);
req.flash('message', { req.flash('message', {
error: false, error: false,
text: 'Display name has been changed!', text: 'Display name has been changed!',
@ -99,7 +99,7 @@ export class SettingsController {
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
) { ) {
try { try {
if (!this._token.verifyCSRF(req)) { if (!this.tokenService.verifyCSRF(req)) {
throw new BadRequestException('Invalid session. Please try again.'); throw new BadRequestException('Invalid session. Please try again.');
} }
@ -107,15 +107,18 @@ export class SettingsController {
throw new BadRequestException('Avatar upload failed'); throw new BadRequestException('Avatar upload failed');
} }
const matches = await this._upload.checkImageAspect(file); const matches = await this.uploadService.checkImageAspect(file);
if (!matches) { if (!matches) {
throw new BadRequestException( throw new BadRequestException(
'Avatar should be with a 1:1 aspect ratio.', 'Avatar should be with a 1:1 aspect ratio.',
); );
} }
const upload = await this._upload.registerUploadedFile(file, req.user); const upload = await this.uploadService.registerUploadedFile(
await this._user.updateAvatar(req.user, upload); file,
req.user,
);
await this.userService.updateAvatar(req.user, upload);
return { return {
file: upload.file, file: upload.file,
@ -128,7 +131,7 @@ export class SettingsController {
@Post('avatar/delete') @Post('avatar/delete')
public async deleteUserAvatar(@Req() req: Request, @Res() res: Response) { public async deleteUserAvatar(@Req() req: Request, @Res() res: Response) {
this._user.deleteAvatar(req.user); this.userService.deleteAvatar(req.user);
req.flash('message', { req.flash('message', {
error: false, error: false,
text: 'Avatar removed successfully.', text: 'Avatar removed successfully.',
@ -139,8 +142,8 @@ export class SettingsController {
@Get('oauth2') @Get('oauth2')
@Render('settings/oauth2') @Render('settings/oauth2')
public async authorizations(@Req() req: Request) { public async authorizations(@Req() req: Request) {
const authorizations = await this._client.getAuthorizations(req.user); const authorizations = await this.clientService.getAuthorizations(req.user);
return this._form.populateTemplate(req, { authorizations }); return this.formService.populateTemplate(req, { authorizations });
} }
@Post('oauth2/revoke/:id') @Post('oauth2/revoke/:id')
@ -149,7 +152,7 @@ export class SettingsController {
@Res() res: Response, @Res() res: Response,
@Param('id') id: number, @Param('id') id: number,
) { ) {
const getAuth = await this._client.getAuthorization(req.user, id); const getAuth = await this.clientService.getAuthorization(req.user, id);
const jsreq = const jsreq =
req.header('content-type').startsWith('application/json') || req.header('content-type').startsWith('application/json') ||
req.header('accept').startsWith('application/json'); req.header('accept').startsWith('application/json');
@ -169,8 +172,8 @@ export class SettingsController {
return; return;
} }
await this._oaToken.wipeClientTokens(getAuth.client, req.user); await this.oaTokenService.wipeClientTokens(getAuth.client, req.user);
await this._client.revokeAuthorization(getAuth); await this.clientService.revokeAuthorization(getAuth);
if (jsreq) { if (jsreq) {
return res.json({ success: true }); return res.json({ success: true });
@ -186,8 +189,8 @@ export class SettingsController {
const emailHint = `${mailSplit[0].substring(0, 1)}${asterisks}@${ const emailHint = `${mailSplit[0].substring(0, 1)}${asterisks}@${
mailSplit[1] mailSplit[1]
}`; }`;
const twofactor = await this._totp.userHasTOTP(req.user); const twofactor = await this.totpService.userHasTOTP(req.user);
return this._form.populateTemplate(req, { return this.formService.populateTemplate(req, {
user: req.user, user: req.user,
emailHint, emailHint,
twofactor, twofactor,
@ -211,11 +214,13 @@ export class SettingsController {
throw new Error('Please fill out all of the fields.'); throw new Error('Please fill out all of the fields.');
} }
if (!(await this._user.comparePasswords(req.user.password, password))) { if (
!(await this.tokenService.comparePasswords(req.user.password, password))
) {
throw new Error('Current password is invalid.'); throw new Error('Current password is invalid.');
} }
if (!new_password.match(this._form.passwordRegex)) { if (!new_password.match(this.formService.passwordRegex)) {
throw new Error( throw new Error(
'Password must be at least 8 characters long, contain a capital and lowercase letter and a number', 'Password must be at least 8 characters long, contain a capital and lowercase letter and a number',
); );
@ -233,10 +238,10 @@ export class SettingsController {
return; return;
} }
const newPassword = await this._user.hashPassword(new_password); const newPassword = await this.tokenService.hashPassword(new_password);
req.user.password = newPassword; req.user.password = newPassword;
await this._user.updateUser(req.user); await this.userService.updateUser(req.user);
await this._audit.auditRequest( await this.auditService.auditRequest(
req, req,
AuditAction.PASSWORD_CHANGE, AuditAction.PASSWORD_CHANGE,
'settings', 'settings',
@ -270,13 +275,13 @@ export class SettingsController {
throw new Error('The current email address is invalid.'); throw new Error('The current email address is invalid.');
} }
if (!email.match(this._form.emailRegex)) { if (!email.match(this.formService.emailRegex)) {
throw new Error('The new email address is invalid.'); throw new Error('The new email address is invalid.');
} }
if ( if (
!current_password || !current_password ||
!(await this._user.comparePasswords( !(await this.tokenService.comparePasswords(
req.user.password, req.user.password,
current_password, current_password,
)) ))
@ -284,7 +289,7 @@ export class SettingsController {
throw new Error('Current password is invalid.'); throw new Error('Current password is invalid.');
} }
const existing = await this._user.getByEmail(email); const existing = await this.userService.getByEmail(email);
if (existing) { if (existing) {
throw new Error( throw new Error(
'There is already an existing user with this email address.', 'There is already an existing user with this email address.',
@ -300,8 +305,12 @@ export class SettingsController {
} }
req.user.email = email; req.user.email = email;
await this._user.updateUser(req.user); await this.userService.updateUser(req.user);
await this._audit.auditRequest(req, AuditAction.EMAIL_CHANGE, 'settings'); await this.auditService.auditRequest(
req,
AuditAction.EMAIL_CHANGE,
'settings',
);
req.flash('message', { req.flash('message', {
error: false, error: false,
@ -316,7 +325,7 @@ export class SettingsController {
@Res() res: Response, @Res() res: Response,
@Query('csrf') csrf: string, @Query('csrf') csrf: string,
) { ) {
if (!this._token.verifyCSRF(req, csrf)) { if (!this.tokenService.verifyCSRF(req, csrf)) {
throw new BadRequestException('Invalid csrf token'); throw new BadRequestException('Invalid csrf token');
} }
@ -326,9 +335,12 @@ export class SettingsController {
@Get('logins') @Get('logins')
@Render('login-list') @Render('login-list')
public async userLogins(@Req() req: Request) { public async userLogins(@Req() req: Request) {
const logins = await this._audit.getUserLogins(req.user, req.session.id); const logins = await this.auditService.getUserLogins(
const creation = await this._audit.getUserAccountCreation(req.user); req.user,
return this._form.populateTemplate(req, { req.session.id,
);
const creation = await this.auditService.getUserAccountCreation(req.user);
return this.formService.populateTemplate(req, {
logins, logins,
creation, creation,
}); });

View File

@ -34,9 +34,8 @@ export class TwoFactorController {
if (!twoFA) { if (!twoFA) {
const challengeString = req.query.challenge as string; const challengeString = req.query.challenge as string;
if (challengeString) { if (challengeString) {
const challenge = await this.token.decryptChallenge<ChallengeType>( const challenge =
challengeString, await this.token.decryptChallenge<ChallengeType>(challengeString);
);
if ( if (
challenge.type === 'totp' && challenge.type === 'totp' &&
challenge.user === req.user.uuid && challenge.user === req.user.uuid &&
@ -87,9 +86,8 @@ export class TwoFactorController {
throw new Error('Invalid request'); throw new Error('Invalid request');
} }
const challenge = await this.token.decryptChallenge<ChallengeType>( const challenge =
challengeString, await this.token.decryptChallenge<ChallengeType>(challengeString);
);
secret = challenge.secret; secret = challenge.secret;
if ( if (
@ -151,7 +149,7 @@ export class TwoFactorController {
} }
if ( if (
!(await this.user.comparePasswords(req.user.password, body.password)) !(await this.token.comparePasswords(req.user.password, body.password))
) { ) {
throw new Error('The entered password is invalid.'); throw new Error('The entered password is invalid.');
} }

View File

@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { ConfigurationService } from 'src/modules/config/config.service'; import { ConfigurationService } from 'src/modules/config/config.service';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import * as CSRF from 'csrf'; import CSRF from 'csrf';
import { Request } from 'express'; import { Request } from 'express';
const IV_LENGTH = 16; const IV_LENGTH = 16;
@ -37,6 +38,18 @@ export class TokenService {
return v4(); return v4();
} }
public async comparePasswords(
hash: string,
password: string,
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
public async hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt);
}
// https://stackoverflow.com/q/52212430 // https://stackoverflow.com/q/52212430
/** /**
* Symmetric encryption function * Symmetric encryption function

View File

@ -1,6 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "Node16",
"moduleResolution": "Node16",
"declaration": true, "declaration": true,
"removeComments": true, "removeComments": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,