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
.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

@ -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

@ -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,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

@ -1,6 +1,5 @@
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';
@ -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(
user: User,
redirectTo?: string,
@ -245,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();

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

@ -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,