Compare commits

..

1 Commits

Author SHA1 Message Date
Evert Prants c40eb00d4f
adminjs experimentation 2024-03-12 19:12:52 +02:00
34 changed files with 6062 additions and 1113 deletions

1
.gitignore vendored
View File

@ -36,6 +36,7 @@ lerna-debug.log*
# local development environment files
.env
.adminjs
/devdocker
/config*.toml
/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"
},
"dependencies": {
"@adminjs/express": "^6.1.0",
"@adminjs/nestjs": "^6.1.0",
"@adminjs/typeorm": "^5.0.1",
"@icynet/oauth2-provider": "^1.0.8",
"@nestjs/cache-manager": "^2.2.1",
"@nestjs/common": "^10.3.3",
@ -32,6 +35,7 @@
"@nestjs/serve-static": "^4.0.1",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "^5.1.2",
"adminjs": "^7.7.2",
"bcrypt": "^5.1.1",
"cache-manager": "^5.4.0",
"cache-manager-redis-yet": "^4.1.2",
@ -43,6 +47,7 @@
"cropperjs": "^1.6.1",
"csrf": "^3.1.0",
"dotenv": "^16.4.4",
"express-formidable": "^1.2.0",
"express-session": "^1.18.0",
"express-useragent": "^1.0.15",
"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 { WellKnownModule } from './modules/well-known/well-known.module';
import { CommonCacheModule } from './modules/cache/cache.module';
import { AdminjsModule } from './modules/adminjs/adminjs.module';
import { AdminjsService } from './modules/adminjs/adminjs.service';
@Module({
imports: [
@ -37,6 +39,14 @@ import { CommonCacheModule } from './modules/cache/cache.module';
SSRFrontEndModule,
WellKnownModule,
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],
providers: [AppService, CSRFMiddleware],

View File

@ -22,8 +22,8 @@ export class LoginAntispamGuard implements CanActivate {
if (['GET', 'OPTIONS'].includes(request.method)) return true;
const known = await this.iplimit.getAddressLimit(request.ip);
if (known && known.attempts > 10) {
if (known.attempts > 15) {
if (known && known.attempts > 3) {
if (known.attempts > 5) {
let reported = false;
if (!known.reported) {
reported = true;
@ -36,7 +36,7 @@ export class LoginAntispamGuard implements CanActivate {
);
}
const limitMinutes = known.attempts > 15 ? 30 : 10; // Half-Hour
const limitMinutes = known.attempts > 10 ? 30 : 10; // Half-Hour
await this.iplimit.limitUntil(
request.ip,
limitMinutes * 60 * 1000,

View File

@ -1,7 +1,7 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as dotenv from 'dotenv';
import * as cookieParser from 'cookie-parser';
import dotenv from 'dotenv';
import cookieParser from 'cookie-parser';
import { join } from 'path';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
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

@ -67,19 +67,6 @@ export class UserAdminController {
};
}
/**
* Create a registraion invitation and send email.
* @param body
* @returns Success
*/
@Post('invite')
@Scopes('management')
@Privileges('admin', 'admin:user')
async invite(@Body() { email }: Pick<User, 'email'>) {
await this._user.issueRegistrationToken(email);
return { success: true };
}
/**
* Get a single user by ID
* @param id User ID

View File

@ -1,5 +1,5 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import * as cors from 'cors';
import cors from 'cors';
import { ConfigurationModule } from '../config/config.module';
import { JWTModule } from '../jwt/jwt.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 { UserService } from 'src/modules/objects/user/user.service';
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
import { TokenService } from 'src/modules/utility/services/token.service';
@Injectable()
export class UserAdapter implements OAuth2UserAdapter {
@ -13,6 +14,7 @@ export class UserAdapter implements OAuth2UserAdapter {
private readonly userService: UserService,
private readonly clientService: OAuth2ClientService,
private readonly form: FormUtilityService,
private readonly token: TokenService,
) {}
getId(user: OAuth2User): number {
@ -48,7 +50,7 @@ export class UserAdapter implements OAuth2UserAdapter {
}
checkPassword(user: OAuth2User, password: string): Promise<boolean> {
return this.userService.comparePasswords(user.password, password);
return this.token.comparePasswords(user.password, password);
}
async fetchFromRequest(

View File

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

View File

@ -1,6 +1,7 @@
import { Lookup } from 'geoip-lite';
import { Details } from 'express-useragent';
import { AuditAction } from './audit.enum';
import { AuditLog } from './audit.entity';
export interface UserLoginEntry {
login_at: Date;
@ -17,3 +18,8 @@ export interface AuditSearchClause {
content?: string;
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 { AuditLog } from './audit.entity';
import { AuditAction } from './audit.enum';
import { Lookup, lookup } from 'geoip-lite';
import { Details, parse } from 'express-useragent';
import { lookup } from 'geoip-lite';
import { parse } from 'express-useragent';
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_USER_AGENT = ['browser', 'version', 'os', 'platform'];
@ -86,15 +90,7 @@ export class AuditService {
limit = 50,
offset = 0,
search: AuditSearchClause,
): Promise<
[
(AuditLog & {
location?: Partial<Lookup>;
user_agent?: Partial<Details>;
})[],
number,
]
> {
): Promise<[AuditResponse[], number]> {
const [list, num] = await this.audit.findAndCount({
...this.buildAuditSearch(search),
take: limit,
@ -104,22 +100,25 @@ export class AuditService {
});
return [
list.map((entry) => ({
...entry,
location: entry.actor_ip
? this.form.pluckObject(
this.getIPLocation(entry.actor_ip),
PLUCK_LOCATION,
)
: null,
user_agent: entry.actor_ua
? this.form.pluckObject(
this.getUserAgentInfo(entry.actor_ua),
PLUCK_USER_AGENT,
)
: null,
actor: this.form.stripObject(entry.actor, ['password']),
})),
list.map(
(entry) =>
({
...entry,
location: entry.actor_ip
? this.form.pluckObject(
this.getIPLocation(entry.actor_ip),
PLUCK_LOCATION,
)
: null,
user_agent: entry.actor_ua
? this.form.pluckObject(
this.getUserAgentInfo(entry.actor_ua),
PLUCK_USER_AGENT,
)
: null,
actor: this.form.stripObject(entry.actor, ['password']),
}) as AuditResponse,
),
num,
];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,22 +0,0 @@
import { EmailTemplate } from 'src/modules/objects/email/email.template';
export const InvitationEmail = (url: string): EmailTemplate => ({
text: `
Icy Network
Please click on the following link to create an account on Icy Network.
Create your account here: ${url}
This email was sent to you because you have requested an account on Icy Network. If you did not request this, you may safely ignore this email.
`,
html: /* html */ `
<h1>Icy Network</h1>
<p><b>Please click on the following link to create an account on Icy Network.</b></p>
<p>Create your account here: <a href="${url}" target="_blank">${url}</a></p>
<p>This email was sent to you because you have requested an account on Icy Network. If you did not request this, you may safely ignore this email.</p>
`,
});

View File

@ -12,8 +12,6 @@ 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}
This email was sent to you because you have created an account on Icy Network. If you did not create an account, you may contact us or just let the account expire.
`,
html: /* html */ `
<h1>Icy Network</h1>
@ -23,7 +21,5 @@ This email was sent to you because you have created an account on Icy Network. I
<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>
<p>This email was sent to you because you have created an account on Icy Network. If you did not create an account, you may contact us or just let the account expire.</p>
`,
});

View File

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

View File

@ -6,11 +6,9 @@ import { UploadModule } from '../upload/upload.module';
import { UserTokenModule } from '../user-token/user-token.module';
import { userProviders } from './user.providers';
import { UserService } from './user.service';
import { CommonCacheModule } from 'src/modules/cache/cache.module';
@Module({
imports: [
CommonCacheModule,
DatabaseModule,
EmailModule,
UserTokenModule,

View File

@ -1,11 +1,5 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { ILike, Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { UserTokenType } from '../user-token/user-token.entity';
import { User } from './user.entity';
@ -17,14 +11,9 @@ import { UserTokenService } from '../user-token/user-token.service';
import { ConfigurationService } from 'src/modules/config/config.service';
import { Upload } from '../upload/upload.entity';
import { UploadService } from '../upload/upload.service';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { InvitationEmail } from './email/invitation.email';
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
constructor(
@Inject('USER_REPOSITORY')
private userRepository: Repository<User>,
@ -33,7 +22,6 @@ export class UserService {
private email: EmailService,
private config: ConfigurationService,
private upload: UploadService,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
) {}
public async getById(id: number, relations?: string[]): Promise<User> {
@ -151,36 +139,19 @@ export class UserService {
user.picture = upload;
await this.updateUser(user);
this.logger.log(`User ID: ${user.id} avatar has been updated`);
return user;
}
public async deleteAvatar(user: User): Promise<void> {
if (user.picture) {
await this.upload.delete(user.picture);
this.logger.log(`User ID: ${user.id} avatar has been deleted`);
}
}
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(
user: User,
redirectTo?: string,
): Promise<void> {
this.logger.log(
`Sending an activation email to User ID: ${user.id} (${user.email})`,
);
const activationToken = await this.userToken.create(
user,
UserTokenType.ACTIVATION,
@ -204,22 +175,13 @@ export class UserService {
'Activate your account on Icy Network',
content,
);
this.logger.log(
`Sending an activation email to User ID: ${user.id} (${user.email}) was successful.`,
);
} catch (e) {
this.logger.error(
`Sending an activation email to User ID: ${user.id} (${user.email}) failed`,
);
await this.userToken.delete(activationToken);
throw e;
}
}
public async sendPasswordEmail(user: User): Promise<void> {
this.logger.log(
`Sending a password reset email to User ID: ${user.id} (${user.email})`,
);
const passwordToken = await this.userToken.create(
user,
UserTokenType.PASSWORD,
@ -238,13 +200,7 @@ export class UserService {
'Reset your password on Icy Network',
content,
);
this.logger.log(
`Sent a password reset email to User ID: ${user.id} (${user.email}) successfully`,
);
} catch (e) {
this.logger.error(
`Sending a password reset email to User ID: ${user.id} (${user.email}) failed: ${e}`,
);
await this.userToken.delete(passwordToken);
// silently fail
}
@ -267,12 +223,7 @@ export class UserService {
password: string;
},
redirectTo?: string,
activate = false,
): Promise<User> {
this.logger.log(
`Starting registration of new user ${newUserInfo.username} (${newUserInfo.email})`,
);
if (!!(await this.getByEmail(newUserInfo.email))) {
throw new Error('Email is already in use!');
}
@ -281,7 +232,7 @@ export class UserService {
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();
user.email = newUserInfo.email;
user.uuid = this.token.createUUID();
@ -289,71 +240,18 @@ export class UserService {
user.display_name = newUserInfo.display_name;
user.password = hashword;
user.activity_at = new Date();
user.activated = activate;
await this.userRepository.save(user);
await this.userRepository.insert(user);
if (!user.activated) {
try {
await this.sendActivationEmail(user, redirectTo);
} catch (e) {
await this.userRepository.remove(user);
throw new Error(
'Failed to send activation email! Please check your email address and try again!',
);
}
try {
await this.sendActivationEmail(user, redirectTo);
} catch (e) {
await this.userRepository.remove(user);
throw new Error(
'Failed to send activation email! Please check your email address and try again!',
);
}
this.logger.log(
`Registered a new user ${newUserInfo.username} (${newUserInfo.email}) ID: ${user.id}`,
);
return user;
}
public async issueRegistrationToken(email: string) {
this.logger.log(`Issuing a new registration token for ${email}`);
const existingUser = await this.getByEmail(email);
if (existingUser) {
throw new BadRequestException('User by email already exists');
}
const newToken = this.token.generateString(64);
await this.cache.set(
`register-${newToken}`,
email,
7 * 24 * 60 * 60 * 1000, // 7 days
);
try {
const content = InvitationEmail(
`${this.config.get<string>('app.base_url')}/register?token=${newToken}`,
);
await this.email.sendEmailTemplate(
email,
'You have been invited to create an account on Icy Network',
content,
);
this.logger.log(
`Issuing a new registration token for ${email} was successful`,
);
} catch (error) {
this.logger.error(
`Issuing a new registration token for ${email} failed: ${error}`,
);
await this.cache.del(`register-${newToken}`);
throw error;
}
}
public async checkRegistrationToken(token: string) {
return await this.cache.get<string>(`register-${token}`);
}
public async invalidateRegistrationToken(token: string) {
return await this.cache.del(`register-${token}`);
}
}

View File

@ -75,7 +75,7 @@ export class LoginController {
if (
!user ||
!user.activated ||
!(await this.userService.comparePasswords(user.password, password))
!(await this.token.comparePasswords(user.password, password))
) {
req.flash('form', { username });
req.flash('message', {
@ -365,7 +365,7 @@ export class LoginController {
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;
await this.userService.updateUser(token.user);

View File

@ -4,6 +4,7 @@ import {
Get,
Post,
Query,
Render,
Req,
Res,
UnauthorizedException,
@ -30,40 +31,11 @@ export class RegisterController {
) {}
@Get()
public async registerView(
@Req() req: Request,
@Res() res: Response,
@Query('token') registrationToken: string,
@Query('redirectTo') redirectTo: string,
) {
let registrationAuthorized = this.config.get<boolean>('app.registrations');
if (registrationToken) {
const registrationEmail =
await this.userService.checkRegistrationToken(registrationToken);
if (!registrationEmail) {
req.flash('message', {
error: true,
text: `This registration token is invalid or expired.`,
});
return res.redirect(
'/login' + (redirectTo ? '?redirectTo=' + redirectTo : ''),
);
}
req.flash('form', { email: registrationEmail });
// bypass limitations
registrationAuthorized = true;
}
res.render(
'register',
this.formUtil.populateTemplate(req, {
registrationAuthorized,
}),
);
@Render('register')
public registerView(@Req() req: Request): Record<string, unknown> {
return this.formUtil.populateTemplate(req, {
registrationAuthorized: this.config.get<boolean>('app.registrations'),
});
}
@Post()
@ -73,34 +45,12 @@ export class RegisterController {
@Req() req: Request,
@Res() res: Response,
@Body() body: RegisterDto,
@Query('token') registrationToken: string,
@Query('redirectTo') redirectTo?: string,
) {
const { username, display_name, email, password, password_repeat } =
this.formUtil.trimmed(body, ['username', 'display_name', 'email']);
let registrationAuthorized = this.config.get<boolean>('app.registrations');
let tokenEmail: string | undefined;
if (registrationToken) {
tokenEmail =
await this.userService.checkRegistrationToken(registrationToken);
if (!tokenEmail) {
req.flash('message', {
error: true,
text: `This registration token is invalid or expired.`,
});
return res.redirect(
'/login' + (redirectTo ? '?redirectTo=' + redirectTo : ''),
);
}
// bypass limitations
registrationAuthorized = true;
}
if (!registrationAuthorized) {
if (!this.config.get<boolean>('app.registrations')) {
throw new UnauthorizedException(
'Registrations are disabled by administrator.',
);
@ -144,23 +94,12 @@ export class RegisterController {
throw new Error('The passwords do not match!');
}
const sendActivationEmail = tokenEmail ? tokenEmail !== email : true;
const user = await this.userService.userRegistration(
body,
redirectTo,
!sendActivationEmail,
);
const user = await this.userService.userRegistration(body, redirectTo);
await this.audit.auditRequest(req, AuditAction.REGISTRATION, null, user);
if (tokenEmail) {
await this.userService.invalidateRegistrationToken(registrationToken);
}
req.flash('message', {
error: false,
text: sendActivationEmail
? `An activation email has been sent to ${email}!`
: `Welcome, we have been expecting you! You may now log in.`,
text: `An activation email has been sent to ${email}!`,
});
res.redirect('/login' + (redirectTo ? '?redirectTo=' + redirectTo : ''));

View File

@ -1,7 +1,7 @@
import { FactoryProvider } from '@nestjs/common';
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 type { RequestHandler } from 'express';
import type { Redis } from 'src/modules/redis/redis.providers';

View File

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

View File

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

View File

@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { ConfigurationService } from 'src/modules/config/config.service';
import { v4 } from 'uuid';
import * as CSRF from 'csrf';
import CSRF from 'csrf';
import { Request } from 'express';
const IV_LENGTH = 16;
@ -37,6 +38,18 @@ export class TokenService {
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
/**
* Symmetric encryption function

View File

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