Compare commits

..

3 Commits

Author SHA1 Message Date
bc570074b0
final commit 2024-06-14 17:02:46 +03:00
ba96b8fbee
remove this straggler 2024-03-13 22:19:28 +02:00
02689f93d1
add invitations 2024-03-13 22:18:57 +02:00
56 changed files with 1381 additions and 6216 deletions

1
.gitignore vendored
View File

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

6603
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -24,9 +24,6 @@
"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",
@ -35,7 +32,6 @@
"@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",
@ -47,7 +43,6 @@
"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,8 +13,6 @@ 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: [
@ -39,14 +37,6 @@ import { AdminjsService } from './modules/adminjs/adminjs.service';
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,63 +0,0 @@
import {
Injectable,
CanActivate,
ExecutionContext,
HttpException,
} from '@nestjs/common';
import { Request } from 'express';
import { IPLimitService } from 'src/modules/iplimit/iplimit.service';
import { AuditAction } from 'src/modules/objects/audit/audit.enum';
import { AuditService } from 'src/modules/objects/audit/audit.service';
@Injectable()
export class LoginAntispamGuard implements CanActivate {
constructor(
private iplimit: IPLimitService,
private audit: AuditService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
if (['GET', 'OPTIONS'].includes(request.method)) return true;
const known = await this.iplimit.getAddressLimit(request.ip);
if (known && known.attempts > 3) {
if (known.attempts > 5) {
let reported = false;
if (!known.reported) {
reported = true;
await this.audit.insertAudit(
AuditAction.THROTTLE,
`antispam-guard ${known.attempts} attempts`,
undefined,
request.ip,
request.header('user-agent'),
);
}
const limitMinutes = known.attempts > 10 ? 30 : 10; // Half-Hour
await this.iplimit.limitUntil(
request.ip,
limitMinutes * 60 * 1000,
reported,
);
await new Promise((resolve) =>
setTimeout(resolve, known.attempts * 1000),
);
throw new HttpException(
`Too Many Requests. Try again in ${limitMinutes} minutes.`,
429,
);
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
await this.iplimit.limitUntil(request.ip, 30 * 1000); // 30 seconds
return true;
}
}

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 dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import cookieParser from 'cookie-parser'; import * as 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';
@ -9,6 +9,7 @@ import { AdminApiModule } from './modules/api/admin/admin.module';
import { OAuth2RouterModule } from './modules/ssr-front-end/oauth2-router/oauth2-router.module'; import { OAuth2RouterModule } from './modules/ssr-front-end/oauth2-router/oauth2-router.module';
import { ConfigurationService } from './modules/config/config.service'; import { ConfigurationService } from './modules/config/config.service';
import { ApiModule } from './modules/api/api.module'; import { ApiModule } from './modules/api/api.module';
import { AccountApiModule } from './modules/api/account/account.module';
dotenv.config(); dotenv.config();
@ -44,7 +45,7 @@ async function bootstrap() {
.build(); .build();
const document = SwaggerModule.createDocument(app, docBuilder, { const document = SwaggerModule.createDocument(app, docBuilder, {
include: [ApiModule, AdminApiModule, OAuth2RouterModule], include: [ApiModule, AdminApiModule, OAuth2RouterModule, AccountApiModule],
}); });
SwaggerModule.setup('api/openapi', app, document); SwaggerModule.setup('api/openapi', app, document);

View File

@ -1,12 +0,0 @@
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

@ -1,46 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -0,0 +1,34 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import * as cors from 'cors';
import { ConfigurationModule } from 'src/modules/config/config.module';
import { ConfigurationService } from 'src/modules/config/config.service';
import { JWTModule } from 'src/modules/jwt/jwt.module';
import { OAuth2Module } from 'src/modules/oauth2/oauth2.module';
import { ObjectsModule } from 'src/modules/objects/objects.module';
import { AccountApiController } from './controllers/account.controller';
import { AccountApiService } from './services/account.service';
import { UserTokenModule } from 'src/modules/objects/user-token/user-token.module';
@Module({
controllers: [AccountApiController],
imports: [
ConfigurationModule,
JWTModule,
ObjectsModule,
UserTokenModule,
OAuth2Module,
AccountApiModule,
],
providers: [AccountApiService],
})
export class AccountApiModule implements NestModule {
constructor(private readonly config: ConfigurationService) {}
configure(consumer: MiddlewareConsumer) {
const corsOpts = cors({
origin: [this.config.get('app.base_url'), this.config.get('app.fe_url')],
credentials: true,
});
consumer.apply(corsOpts).forRoutes(AccountApiController);
}
}

View File

@ -0,0 +1,45 @@
import {
ClassSerializerInterceptor,
Controller,
Get,
UseGuards,
UseInterceptors,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiTags,
ApiOAuth2,
ApiOperation,
ApiOkResponse,
} from '@nestjs/swagger';
import { Scopes } from 'src/decorators/scopes.decorator';
import { OAuth2Guard } from 'src/guards/oauth2.guard';
import { PrivilegesGuard } from 'src/guards/privileges.guard';
import { ScopesGuard } from 'src/guards/scopes.guard';
import { AccountApiService } from '../services/account.service';
import { CurrentUser } from 'src/decorators/user.decorator';
import { User } from 'src/modules/objects/user/user.entity';
import { AccountResponseDto } from '../../dtos/account.dto';
@Controller({
path: '/api/account',
})
@ApiBearerAuth()
@ApiTags('account')
@ApiOAuth2(['account'])
@Scopes('account')
@UseInterceptors(ClassSerializerInterceptor)
@UsePipes(new ValidationPipe({ whitelist: true }))
@UseGuards(OAuth2Guard, PrivilegesGuard, ScopesGuard)
export class AccountApiController {
constructor(private readonly service: AccountApiService) {}
@Get()
@ApiOperation({ summary: 'Get account details' })
@ApiOkResponse({ type: AccountResponseDto })
async getAccount(@CurrentUser() user: User) {
return this.service.getAccountInfo(user);
}
}

View File

@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
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';
import { AccountResponseDto } from '../../dtos/account.dto';
@Injectable()
export class AccountApiService {
constructor(
private readonly userService: UserService,
private readonly userTotp: UserTOTPService,
private readonly form: FormUtilityService,
) {}
async getAccountInfo(user: User) {
const fullInfo = await this.userService.getByUUID(user.uuid, [
'picture',
'privileges',
]);
const hasTotp = await this.userTotp.userHasTOTP(fullInfo);
return plainToInstance(AccountResponseDto, {
...fullInfo,
totp_enabled: hasTotp,
});
}
}

View File

@ -5,12 +5,12 @@ import * as mime from 'mime-types';
import { join } from 'path'; import { join } from 'path';
import { OAuth2Module } from 'src/modules/oauth2/oauth2.module'; import { OAuth2Module } from 'src/modules/oauth2/oauth2.module';
import { ObjectsModule } from 'src/modules/objects/objects.module'; import { ObjectsModule } from 'src/modules/objects/objects.module';
import { OAuth2AdminController } from './oauth2-admin.controller'; import { OAuth2AdminController } from './controllers/oauth2-admin.controller';
import { PrivilegeAdminController } from './privilege-admin.controller'; import { PrivilegeAdminController } from './controllers/privilege-admin.controller';
import { UserAdminController } from './user-admin.controller'; import { UserAdminController } from './controllers/user-admin.controller';
import { ConfigurationModule } from 'src/modules/config/config.module'; import { ConfigurationModule } from 'src/modules/config/config.module';
import { AdminService } from './admin.service'; import { AdminService } from './services/admin.service';
import { AuditAdminController } from './audit-admin.controller'; import { AuditAdminController } from './controllers/audit-admin.controller';
@Module({ @Module({
controllers: [ controllers: [

View File

@ -36,7 +36,7 @@ import { FormUtilityService } from 'src/modules/utility/services/form-utility.se
import { PaginationService } from 'src/modules/utility/services/paginate.service'; import { PaginationService } from 'src/modules/utility/services/paginate.service';
import { TokenService } from 'src/modules/utility/services/token.service'; import { TokenService } from 'src/modules/utility/services/token.service';
import { PageOptions } from 'src/types/pagination.interfaces'; import { PageOptions } from 'src/types/pagination.interfaces';
import { AdminService } from './admin.service'; import { AdminService } from '../services/admin.service';
import { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service'; import { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { Throttle, ThrottlerGuard } from '@nestjs/throttler';

View File

@ -67,6 +67,19 @@ 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 * Get a single user by ID
* @param id User ID * @param id User ID

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { OAuth2Client } from 'src/modules/objects/oauth2-client/oauth2-client.entity'; import { OAuth2Client } from 'src/modules/objects/oauth2-client/oauth2-client.entity';
import { User } from 'src/modules/objects/user/user.entity'; import { User } from 'src/modules/objects/user/user.entity';
const UNPRIVILEGED_STRIP = ['id_token', 'management', 'implicit']; const UNPRIVILEGED_STRIP = ['id_token', 'management', 'implicit', 'account'];
@Injectable() @Injectable()
export class AdminService { export class AdminService {

View File

@ -1,5 +1,5 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import cors from 'cors'; import * as 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';
@ -7,6 +7,7 @@ import { OAuth2Service } from '../oauth2/oauth2.service';
import { ObjectsModule } from '../objects/objects.module'; import { ObjectsModule } from '../objects/objects.module';
import { AdminApiModule } from './admin/admin.module'; import { AdminApiModule } from './admin/admin.module';
import { ApiController } from './api.controller'; import { ApiController } from './api.controller';
import { AccountApiModule } from './account/account.module';
@Module({ @Module({
controllers: [ApiController], controllers: [ApiController],
@ -14,8 +15,9 @@ import { ApiController } from './api.controller';
ConfigurationModule, ConfigurationModule,
JWTModule, JWTModule,
ObjectsModule, ObjectsModule,
AdminApiModule,
OAuth2Module, OAuth2Module,
AdminApiModule,
AccountApiModule,
], ],
}) })
export class ApiModule implements NestModule { export class ApiModule implements NestModule {

View File

@ -0,0 +1,7 @@
import { ApiProperty, OmitType } from '@nestjs/swagger';
import { User } from 'src/modules/objects/user/user.entity';
export class AccountResponseDto extends OmitType(User, ['password']) {
@ApiProperty()
totp_enabled: boolean;
}

View File

@ -17,6 +17,7 @@ export const configProviders = [
useValue: { useValue: {
app: { app: {
base_url: 'http://localhost:3000', base_url: 'http://localhost:3000',
fe_url: 'http://localhost:5173',
host: '0.0.0.0', host: '0.0.0.0',
port: 3000, port: 3000,
session_name: '__sid', session_name: '__sid',

View File

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { IPLimitService } from './iplimit.service';
import { CommonCacheModule } from '../cache/cache.module';
@Module({
imports: [CommonCacheModule],
providers: [IPLimitService],
exports: [IPLimitService],
})
export class IPLimitModule {}

View File

@ -1,47 +0,0 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { TokenService } from '../utility/services/token.service';
export interface IPLimit {
ip: string;
attempts: number;
reported: boolean;
}
@Injectable()
export class IPLimitService {
constructor(
@Inject(CACHE_MANAGER)
private readonly cache: Cache,
private readonly token: TokenService,
) {}
public async getAddressLimit(ip: string) {
const ipHash = this.token.insecureHash(ip);
const entry = await this.cache.get<IPLimit>(`iplimit-${ipHash}`);
if (!entry) return null;
return entry;
}
public async limitUntil(ip: string, expires: number, reported = false) {
const ipHash = this.token.insecureHash(ip);
const existing = await this.cache.get<IPLimit>(`iplimit-${ipHash}`);
if (existing) {
existing.attempts++;
if (reported) existing.reported = true;
await this.cache.set(`iplimit-${ipHash}`, existing, expires + Date.now());
return existing;
}
const newObj = {
ip,
attempts: 0,
reported,
};
await this.cache.set(`iplimit-${ipHash}`, newObj, expires + Date.now());
return newObj;
}
}

View File

@ -6,7 +6,6 @@ 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 {
@ -14,7 +13,6 @@ 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 {
@ -50,7 +48,7 @@ export class UserAdapter implements OAuth2UserAdapter {
} }
checkPassword(user: OAuth2User, password: string): Promise<boolean> { checkPassword(user: OAuth2User, password: string): Promise<boolean> {
return this.token.comparePasswords(user.password, password); return this.userService.comparePasswords(user.password, password);
} }
async fetchFromRequest( async fetchFromRequest(

View File

@ -11,6 +11,7 @@ import { UserAdapter } from './adapter/user.adapter';
const SCOPE_DESCRIPTION: Record<string, string> = { const SCOPE_DESCRIPTION: Record<string, string> = {
email: 'Email address', email: 'Email address',
picture: 'Profile picture', picture: 'Profile picture',
account: 'Password and other account settings',
}; };
const ALWAYS_AVAILABLE = ['Username and display name']; const ALWAYS_AVAILABLE = ['Username and display name'];
@ -39,6 +40,10 @@ export class OAuth2Service implements OAuth2AdapterModel {
disallowedScopes = null; disallowedScopes = null;
} }
if (scope.includes('account')) {
disallowedScopes = null;
}
res.render('authorize', { res.render('authorize', {
csrf: req.csrfToken(), csrf: req.csrfToken(),
user: req.user, user: req.user,

View File

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

View File

@ -1,7 +1,6 @@
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;
@ -18,8 +17,3 @@ 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,14 +10,10 @@ 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 } from 'geoip-lite'; import { Lookup, lookup } from 'geoip-lite';
import { parse } from 'express-useragent'; import { Details, 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 { import { AuditSearchClause, UserLoginEntry } from './audit.interfaces';
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'];
@ -90,7 +86,15 @@ export class AuditService {
limit = 50, limit = 50,
offset = 0, offset = 0,
search: AuditSearchClause, search: AuditSearchClause,
): Promise<[AuditResponse[], number]> { ): Promise<
[
(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,
@ -100,9 +104,7 @@ export class AuditService {
}); });
return [ return [
list.map( list.map((entry) => ({
(entry) =>
({
...entry, ...entry,
location: entry.actor_ip location: entry.actor_ip
? this.form.pluckObject( ? this.form.pluckObject(
@ -117,8 +119,7 @@ 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,5 +1,4 @@
import { import {
BaseEntity,
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
@ -10,7 +9,7 @@ import {
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
@Entity() @Entity()
export class Document extends BaseEntity { export class Document {
@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,5 +1,4 @@
import { import {
BaseEntity,
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
@ -13,7 +12,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 extends BaseEntity { export class OAuth2Client {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;

View File

@ -26,6 +26,7 @@ export class OAuth2ClientService {
'email', 'email',
'privileges', 'privileges',
'management', 'management',
'account',
'openid', 'openid',
]; ];

View File

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

View File

@ -1,11 +1,14 @@
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity() @Entity()
export class Privilege extends BaseEntity { export class Privilege {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@ApiProperty()
id: number; id: number;
@Column({ type: 'text', nullable: false }) @Column({ type: 'text', nullable: false })
@ApiProperty()
name: string; name: string;
} }

View File

@ -1,5 +1,4 @@
import { import {
BaseEntity,
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
@ -8,19 +7,26 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
import { Exclude, Expose } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
@Entity() @Entity()
export class Upload extends BaseEntity { @Expose()
export class Upload {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@ApiProperty()
id: number; id: number;
@Column({ nullable: false }) @Column({ nullable: false })
@ApiProperty()
original_name: string; original_name: string;
@Column({ nullable: false }) @Column({ nullable: false })
@ApiProperty()
mimetype: string; mimetype: string;
@Column({ nullable: false }) @Column({ nullable: false })
@ApiProperty()
file: string; file: string;
@ManyToOne(() => User, { @ManyToOne(() => User, {
@ -28,11 +34,14 @@ export class Upload extends BaseEntity {
onDelete: 'SET NULL', onDelete: 'SET NULL',
onUpdate: 'CASCADE', onUpdate: 'CASCADE',
}) })
@Exclude()
uploader: User; uploader: User;
@CreateDateColumn() @CreateDateColumn()
@ApiProperty()
public created_at: Date; public created_at: Date;
@UpdateDateColumn() @UpdateDateColumn()
@ApiProperty()
public updated_at: Date; public updated_at: Date;
} }

View File

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

View File

@ -0,0 +1,22 @@
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,6 +12,8 @@ Welcome to Icy Network, ${username}!
In order to proceed with logging in, please click on the following link to activate your account. In order to proceed with logging in, please click on the following link to activate your account.
Activate your account: ${url} 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 */ ` html: /* html */ `
<h1>Icy Network</h1> <h1>Icy Network</h1>
@ -21,5 +23,7 @@ Activate your account: ${url}
<p>In order to proceed with logging in, please click on the following link to activate your account.</p> <p>In order to proceed with logging in, please click on the following link to activate your account.</p>
<p>Activate your account: <a href="${url}" target="_blank">${url}</a></p> <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,5 +1,4 @@
import { import {
BaseEntity,
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
@ -11,39 +10,53 @@ import {
} from 'typeorm'; } from 'typeorm';
import { Privilege } from '../privilege/privilege.entity'; import { Privilege } from '../privilege/privilege.entity';
import { Upload } from '../upload/upload.entity'; import { Upload } from '../upload/upload.entity';
import { ApiProperty } from '@nestjs/swagger';
import { Exclude, Expose } from 'class-transformer';
@Entity() @Entity()
export class User extends BaseEntity { @Expose()
export class User {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@ApiProperty()
id: number; id: number;
@Column({ type: 'uuid', length: 36, nullable: false, unique: true }) @Column({ type: 'uuid', length: 36, nullable: false, unique: true })
@ApiProperty()
uuid: string; uuid: string;
@Column({ length: 26, nullable: false, unique: true }) @Column({ length: 26, nullable: false, unique: true })
@ApiProperty()
username: string; username: string;
@Column({ nullable: false, unique: true }) @Column({ nullable: false, unique: true })
@ApiProperty()
email: string; email: string;
@Column({ length: 32, nullable: false }) @Column({ length: 32, nullable: false })
@ApiProperty()
display_name: string; display_name: string;
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
@Exclude()
password: string; password: string;
@Column({ default: false }) @Column({ default: false })
@ApiProperty()
activated: boolean; activated: boolean;
@Column({ type: 'timestamp' }) @Column({ type: 'timestamp' })
@ApiProperty()
public activity_at: Date; public activity_at: Date;
@CreateDateColumn() @CreateDateColumn()
@ApiProperty()
public created_at: Date; public created_at: Date;
@UpdateDateColumn() @UpdateDateColumn()
@ApiProperty()
public updated_at: Date; public updated_at: Date;
@ApiProperty({ type: () => Upload })
@ManyToOne(() => Upload, { @ManyToOne(() => Upload, {
nullable: true, nullable: true,
onDelete: 'SET NULL', onDelete: 'SET NULL',
@ -51,6 +64,7 @@ export class User extends BaseEntity {
}) })
public picture: Upload; public picture: Upload;
@ApiProperty({ type: Privilege, isArray: true })
@ManyToMany(() => Privilege) @ManyToMany(() => Privilege)
@JoinTable() @JoinTable()
public privileges: Privilege[]; public privileges: Privilege[];

View File

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

View File

@ -1,5 +1,11 @@
import { Inject, Injectable } from '@nestjs/common'; import {
BadRequestException,
Inject,
Injectable,
Logger,
} 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';
@ -11,9 +17,14 @@ import { UserTokenService } from '../user-token/user-token.service';
import { ConfigurationService } from 'src/modules/config/config.service'; import { ConfigurationService } from 'src/modules/config/config.service';
import { Upload } from '../upload/upload.entity'; import { Upload } from '../upload/upload.entity';
import { UploadService } from '../upload/upload.service'; 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() @Injectable()
export class UserService { export class UserService {
private readonly logger = new Logger(UserService.name);
constructor( constructor(
@Inject('USER_REPOSITORY') @Inject('USER_REPOSITORY')
private userRepository: Repository<User>, private userRepository: Repository<User>,
@ -22,6 +33,7 @@ export class UserService {
private email: EmailService, private email: EmailService,
private config: ConfigurationService, private config: ConfigurationService,
private upload: UploadService, private upload: UploadService,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
) {} ) {}
public async getById(id: number, relations?: string[]): Promise<User> { public async getById(id: number, relations?: string[]): Promise<User> {
@ -139,19 +151,36 @@ export class UserService {
user.picture = upload; user.picture = upload;
await this.updateUser(user); await this.updateUser(user);
this.logger.log(`User ID: ${user.id} avatar has been updated`);
return user; return user;
} }
public async deleteAvatar(user: User): Promise<void> { public async deleteAvatar(user: User): Promise<void> {
if (user.picture) { if (user.picture) {
await this.upload.delete(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( public async sendActivationEmail(
user: User, user: User,
redirectTo?: string, redirectTo?: string,
): Promise<void> { ): Promise<void> {
this.logger.log(
`Sending an activation email to User ID: ${user.id} (${user.email})`,
);
const activationToken = await this.userToken.create( const activationToken = await this.userToken.create(
user, user,
UserTokenType.ACTIVATION, UserTokenType.ACTIVATION,
@ -175,13 +204,22 @@ export class UserService {
'Activate your account on Icy Network', 'Activate your account on Icy Network',
content, content,
); );
this.logger.log(
`Sending an activation email to User ID: ${user.id} (${user.email}) was successful.`,
);
} catch (e) { } catch (e) {
this.logger.error(
`Sending an activation email to User ID: ${user.id} (${user.email}) failed`,
);
await this.userToken.delete(activationToken); await this.userToken.delete(activationToken);
throw e; throw e;
} }
} }
public async sendPasswordEmail(user: User): Promise<void> { 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( const passwordToken = await this.userToken.create(
user, user,
UserTokenType.PASSWORD, UserTokenType.PASSWORD,
@ -200,7 +238,13 @@ export class UserService {
'Reset your password on Icy Network', 'Reset your password on Icy Network',
content, content,
); );
this.logger.log(
`Sent a password reset email to User ID: ${user.id} (${user.email}) successfully`,
);
} catch (e) { } catch (e) {
this.logger.error(
`Sending a password reset email to User ID: ${user.id} (${user.email}) failed: ${e}`,
);
await this.userToken.delete(passwordToken); await this.userToken.delete(passwordToken);
// silently fail // silently fail
} }
@ -223,7 +267,12 @@ export class UserService {
password: string; password: string;
}, },
redirectTo?: string, redirectTo?: string,
activate = false,
): Promise<User> { ): Promise<User> {
this.logger.log(
`Starting registration of new user ${newUserInfo.username} (${newUserInfo.email})`,
);
if (!!(await this.getByEmail(newUserInfo.email))) { if (!!(await this.getByEmail(newUserInfo.email))) {
throw new Error('Email is already in use!'); throw new Error('Email is already in use!');
} }
@ -232,7 +281,7 @@ export class UserService {
throw new Error('Username is already in use!'); throw new Error('Username is already in use!');
} }
const hashword = await this.token.hashPassword(newUserInfo.password); const hashword = await this.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();
@ -240,9 +289,11 @@ export class UserService {
user.display_name = newUserInfo.display_name; user.display_name = newUserInfo.display_name;
user.password = hashword; user.password = hashword;
user.activity_at = new Date(); user.activity_at = new Date();
user.activated = activate;
await this.userRepository.insert(user); await this.userRepository.save(user);
if (!user.activated) {
try { try {
await this.sendActivationEmail(user, redirectTo); await this.sendActivationEmail(user, redirectTo);
} catch (e) { } catch (e) {
@ -251,7 +302,58 @@ export class UserService {
'Failed to send activation email! Please check your email address and try again!', '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; 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

@ -13,7 +13,6 @@ import {
import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { SessionData } from 'express-session'; import { SessionData } from 'express-session';
import { LoginAntispamGuard } from 'src/guards/login-antispam.guard';
import { AuditAction } from 'src/modules/objects/audit/audit.enum'; import { AuditAction } from 'src/modules/objects/audit/audit.enum';
import { AuditService } from 'src/modules/objects/audit/audit.service'; import { AuditService } from 'src/modules/objects/audit/audit.service';
import { import {
@ -35,7 +34,6 @@ interface VerifyChallenge {
} }
@Controller('/login') @Controller('/login')
@UseGuards(LoginAntispamGuard)
export class LoginController { export class LoginController {
constructor( constructor(
private readonly userService: UserService, private readonly userService: UserService,
@ -60,6 +58,8 @@ export class LoginController {
} }
@Post() @Post()
@Throttle({ default: { limit: 5, ttl: 60000 } })
@UseGuards(ThrottlerGuard)
public async loginRequest( public async loginRequest(
@Req() req: Request, @Req() req: Request,
@Res() res: Response, @Res() res: Response,
@ -75,7 +75,7 @@ export class LoginController {
if ( if (
!user || !user ||
!user.activated || !user.activated ||
!(await this.token.comparePasswords(user.password, password)) !(await this.userService.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.token.hashPassword(password); const hashword = await this.userService.hashPassword(password);
token.user.password = hashword; token.user.password = hashword;
await this.userService.updateUser(token.user); await this.userService.updateUser(token.user);

View File

@ -9,16 +9,9 @@ import { UserTokenModule } from 'src/modules/objects/user-token/user-token.modul
import { UserModule } from 'src/modules/objects/user/user.module'; import { UserModule } from 'src/modules/objects/user/user.module';
import { SessionModule } from '../session/session.module'; import { SessionModule } from '../session/session.module';
import { LoginController } from './login.controller'; import { LoginController } from './login.controller';
import { IPLimitModule } from 'src/modules/iplimit/iplimit.module';
@Module({ @Module({
imports: [ imports: [UserModule, UserTokenModule, AuditModule, SessionModule],
UserModule,
UserTokenModule,
AuditModule,
SessionModule,
IPLimitModule,
],
controllers: [LoginController], controllers: [LoginController],
}) })
export class LoginModule implements NestModule { export class LoginModule implements NestModule {

View File

@ -4,7 +4,6 @@ import {
Get, Get,
Post, Post,
Query, Query,
Render,
Req, Req,
Res, Res,
UnauthorizedException, UnauthorizedException,
@ -12,7 +11,6 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { LoginAntispamGuard } from 'src/guards/login-antispam.guard';
import { ConfigurationService } from 'src/modules/config/config.service'; import { ConfigurationService } from 'src/modules/config/config.service';
import { AuditAction } from 'src/modules/objects/audit/audit.enum'; import { AuditAction } from 'src/modules/objects/audit/audit.enum';
import { AuditService } from 'src/modules/objects/audit/audit.service'; import { AuditService } from 'src/modules/objects/audit/audit.service';
@ -21,7 +19,6 @@ import { FormUtilityService } from 'src/modules/utility/services/form-utility.se
import { RegisterDto } from './register.interfaces'; import { RegisterDto } from './register.interfaces';
@Controller('/register') @Controller('/register')
@UseGuards(LoginAntispamGuard)
export class RegisterController { export class RegisterController {
constructor( constructor(
private readonly userService: UserService, private readonly userService: UserService,
@ -31,26 +28,77 @@ export class RegisterController {
) {} ) {}
@Get() @Get()
@Render('register') public async registerView(
public registerView(@Req() req: Request): Record<string, unknown> { @Req() req: Request,
return this.formUtil.populateTemplate(req, { @Res() res: Response,
registrationAuthorized: this.config.get<boolean>('app.registrations'), @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,
}),
);
} }
@Post() @Post()
@Throttle({ default: { limit: 3, ttl: 10000 } }) @Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(ThrottlerGuard) @UseGuards(ThrottlerGuard)
public async registerRequest( public async registerRequest(
@Req() req: Request, @Req() req: Request,
@Res() res: Response, @Res() res: Response,
@Body() body: RegisterDto, @Body() body: RegisterDto,
@Query('token') registrationToken: string,
@Query('redirectTo') redirectTo?: string, @Query('redirectTo') redirectTo?: string,
) { ) {
const { username, display_name, email, password, password_repeat } = const { username, display_name, email, password, password_repeat } =
this.formUtil.trimmed(body, ['username', 'display_name', 'email']); this.formUtil.trimmed(body, ['username', 'display_name', 'email']);
if (!this.config.get<boolean>('app.registrations')) { 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) {
throw new UnauthorizedException( throw new UnauthorizedException(
'Registrations are disabled by administrator.', 'Registrations are disabled by administrator.',
); );
@ -94,12 +142,23 @@ export class RegisterController {
throw new Error('The passwords do not match!'); throw new Error('The passwords do not match!');
} }
const user = await this.userService.userRegistration(body, redirectTo); const sendActivationEmail = tokenEmail ? tokenEmail !== email : true;
const user = await this.userService.userRegistration(
body,
redirectTo,
!sendActivationEmail,
);
await this.audit.auditRequest(req, AuditAction.REGISTRATION, null, user); await this.audit.auditRequest(req, AuditAction.REGISTRATION, null, user);
if (tokenEmail) {
await this.userService.invalidateRegistrationToken(registrationToken);
}
req.flash('message', { req.flash('message', {
error: false, error: false,
text: `An activation email has been sent to ${email}!`, text: sendActivationEmail
? `An activation email has been sent to ${email}!`
: `Welcome, we have been expecting you! You may now log in.`,
}); });
res.redirect('/login' + (redirectTo ? '?redirectTo=' + redirectTo : '')); res.redirect('/login' + (redirectTo ? '?redirectTo=' + redirectTo : ''));

View File

@ -8,10 +8,9 @@ import { AuditModule } from 'src/modules/objects/audit/audit.module';
import { UserModule } from 'src/modules/objects/user/user.module'; import { UserModule } from 'src/modules/objects/user/user.module';
import { SessionModule } from '../session/session.module'; import { SessionModule } from '../session/session.module';
import { RegisterController } from './register.controller'; import { RegisterController } from './register.controller';
import { IPLimitModule } from 'src/modules/iplimit/iplimit.module';
@Module({ @Module({
imports: [UserModule, AuditModule, SessionModule, IPLimitModule], imports: [UserModule, AuditModule, SessionModule],
controllers: [RegisterController], controllers: [RegisterController],
}) })
export class RegisterModule implements NestModule { export class RegisterModule implements NestModule {

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 session from 'express-session'; import * as 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 settingsService: SettingsService, private readonly _service: SettingsService,
private readonly formService: FormUtilityService, private readonly _form: FormUtilityService,
private readonly uploadService: UploadService, private readonly _upload: UploadService,
private readonly tokenService: TokenService, private readonly _token: TokenService,
private readonly userService: UserService, private readonly _user: UserService,
private readonly totpService: UserTOTPService, private readonly _totp: UserTOTPService,
private readonly clientService: OAuth2ClientService, private readonly _client: OAuth2ClientService,
private readonly oaTokenService: OAuth2TokenService, private readonly _oaToken: OAuth2TokenService,
private readonly auditService: AuditService, private readonly _audit: 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.formService.populateTemplate(req, { user: req.user }); return this._form.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.formService.trimmed(body, ['display_name']); const { display_name } = this._form.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.userService.updateUser(req.user); await this._user.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.tokenService.verifyCSRF(req)) { if (!this._token.verifyCSRF(req)) {
throw new BadRequestException('Invalid session. Please try again.'); throw new BadRequestException('Invalid session. Please try again.');
} }
@ -107,18 +107,15 @@ export class SettingsController {
throw new BadRequestException('Avatar upload failed'); throw new BadRequestException('Avatar upload failed');
} }
const matches = await this.uploadService.checkImageAspect(file); const matches = await this._upload.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.uploadService.registerUploadedFile( const upload = await this._upload.registerUploadedFile(file, req.user);
file, await this._user.updateAvatar(req.user, upload);
req.user,
);
await this.userService.updateAvatar(req.user, upload);
return { return {
file: upload.file, file: upload.file,
@ -131,7 +128,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.userService.deleteAvatar(req.user); this._user.deleteAvatar(req.user);
req.flash('message', { req.flash('message', {
error: false, error: false,
text: 'Avatar removed successfully.', text: 'Avatar removed successfully.',
@ -142,8 +139,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.clientService.getAuthorizations(req.user); const authorizations = await this._client.getAuthorizations(req.user);
return this.formService.populateTemplate(req, { authorizations }); return this._form.populateTemplate(req, { authorizations });
} }
@Post('oauth2/revoke/:id') @Post('oauth2/revoke/:id')
@ -152,7 +149,7 @@ export class SettingsController {
@Res() res: Response, @Res() res: Response,
@Param('id') id: number, @Param('id') id: number,
) { ) {
const getAuth = await this.clientService.getAuthorization(req.user, id); const getAuth = await this._client.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');
@ -172,8 +169,8 @@ export class SettingsController {
return; return;
} }
await this.oaTokenService.wipeClientTokens(getAuth.client, req.user); await this._oaToken.wipeClientTokens(getAuth.client, req.user);
await this.clientService.revokeAuthorization(getAuth); await this._client.revokeAuthorization(getAuth);
if (jsreq) { if (jsreq) {
return res.json({ success: true }); return res.json({ success: true });
@ -189,8 +186,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.totpService.userHasTOTP(req.user); const twofactor = await this._totp.userHasTOTP(req.user);
return this.formService.populateTemplate(req, { return this._form.populateTemplate(req, {
user: req.user, user: req.user,
emailHint, emailHint,
twofactor, twofactor,
@ -214,13 +211,11 @@ export class SettingsController {
throw new Error('Please fill out all of the fields.'); throw new Error('Please fill out all of the fields.');
} }
if ( if (!(await this._user.comparePasswords(req.user.password, password))) {
!(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.formService.passwordRegex)) { if (!new_password.match(this._form.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',
); );
@ -238,10 +233,10 @@ export class SettingsController {
return; return;
} }
const newPassword = await this.tokenService.hashPassword(new_password); const newPassword = await this._user.hashPassword(new_password);
req.user.password = newPassword; req.user.password = newPassword;
await this.userService.updateUser(req.user); await this._user.updateUser(req.user);
await this.auditService.auditRequest( await this._audit.auditRequest(
req, req,
AuditAction.PASSWORD_CHANGE, AuditAction.PASSWORD_CHANGE,
'settings', 'settings',
@ -275,13 +270,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.formService.emailRegex)) { if (!email.match(this._form.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.tokenService.comparePasswords( !(await this._user.comparePasswords(
req.user.password, req.user.password,
current_password, current_password,
)) ))
@ -289,7 +284,7 @@ export class SettingsController {
throw new Error('Current password is invalid.'); throw new Error('Current password is invalid.');
} }
const existing = await this.userService.getByEmail(email); const existing = await this._user.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.',
@ -305,12 +300,8 @@ export class SettingsController {
} }
req.user.email = email; req.user.email = email;
await this.userService.updateUser(req.user); await this._user.updateUser(req.user);
await this.auditService.auditRequest( await this._audit.auditRequest(req, AuditAction.EMAIL_CHANGE, 'settings');
req,
AuditAction.EMAIL_CHANGE,
'settings',
);
req.flash('message', { req.flash('message', {
error: false, error: false,
@ -325,7 +316,7 @@ export class SettingsController {
@Res() res: Response, @Res() res: Response,
@Query('csrf') csrf: string, @Query('csrf') csrf: string,
) { ) {
if (!this.tokenService.verifyCSRF(req, csrf)) { if (!this._token.verifyCSRF(req, csrf)) {
throw new BadRequestException('Invalid csrf token'); throw new BadRequestException('Invalid csrf token');
} }
@ -335,12 +326,9 @@ 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.auditService.getUserLogins( const logins = await this._audit.getUserLogins(req.user, req.session.id);
req.user, const creation = await this._audit.getUserAccountCreation(req.user);
req.session.id, return this._form.populateTemplate(req, {
);
const creation = await this.auditService.getUserAccountCreation(req.user);
return this.formService.populateTemplate(req, {
logins, logins,
creation, creation,
}); });

View File

@ -34,8 +34,9 @@ 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 = const challenge = await this.token.decryptChallenge<ChallengeType>(
await this.token.decryptChallenge<ChallengeType>(challengeString); challengeString,
);
if ( if (
challenge.type === 'totp' && challenge.type === 'totp' &&
challenge.user === req.user.uuid && challenge.user === req.user.uuid &&
@ -86,8 +87,9 @@ export class TwoFactorController {
throw new Error('Invalid request'); throw new Error('Invalid request');
} }
const challenge = const challenge = await this.token.decryptChallenge<ChallengeType>(
await this.token.decryptChallenge<ChallengeType>(challengeString); challengeString,
);
secret = challenge.secret; secret = challenge.secret;
if ( if (
@ -149,7 +151,7 @@ export class TwoFactorController {
} }
if ( if (
!(await this.token.comparePasswords(req.user.password, body.password)) !(await this.user.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,9 +1,8 @@
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 CSRF from 'csrf'; import * as CSRF from 'csrf';
import { Request } from 'express'; import { Request } from 'express';
const IV_LENGTH = 16; const IV_LENGTH = 16;
@ -38,18 +37,6 @@ 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

@ -15,9 +15,24 @@ export class WellKnownController {
@Get('security.txt') @Get('security.txt')
securityTXT(@Res({ passthrough: true }) res: Response) { securityTXT(@Res({ passthrough: true }) res: Response) {
res.set('content-type', 'text/plain'); res.set('content-type', 'text/plain');
const date = new Date();
date.setMonth(date.getMonth() + 6);
date.setDate(1);
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
return `# If you would like to report a security issue return `# If you would like to report a security issue
# you may report it to: # you may report it to:
Contact: mailto:evert@lunasqu.ee Contact: mailto:evert@lunasqu.ee
# GnuPG public key
Encryption: https://lunasqu.ee/public/keys/pgp/Evert%20Prants.pub
# English and Estonian
Preferred-Languages: en, et
Expires: ${date.toISOString()}
`; `;
} }

View File

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

View File

@ -20,11 +20,27 @@ block body
div.form-container div.form-container
input#csrf(type="hidden", name="_csrf", value=csrf) input#csrf(type="hidden", name="_csrf", value=csrf)
label.form-label(for="username") Username label.form-label(for="username") Username
input.form-control#username(type="text", name="username", placeholder="Username", autofocus, value=form.username) input.form-control#username(
type="text",
name="username",
autocomplete="username",
placeholder="Username",
autofocus,
value=form.username
)
label.form-label(for="password") Password label.form-label(for="password") Password
input.form-control#password(type="password", name="password", placeholder="Password") input.form-control#password(
type="password",
name="password",
autocomplete="current-password",
placeholder="Password"
)
div.form-checkbox div.form-checkbox
input.form-control#remember(type="checkbox", name="remember", checked=form.remember) input.form-control#remember(
type="checkbox",
name="remember",
checked=form.remember
)
label(for="remember") Remember me label(for="remember") Remember me
button.btn.btn-primary(type="submit") Log in button.btn.btn-primary(type="submit") Log in
div.btn-group.align-self-end div.btn-group.align-self-end

View File

@ -21,18 +21,35 @@ block body
div.form-container div.form-container
input#csrf(type="hidden", name="_csrf", value=csrf) input#csrf(type="hidden", name="_csrf", value=csrf)
label.form-label(for="password") New password label.form-label(for="password") New password
input.form-control#password(type="password", name="password", autofocus, placeholder="Password") input.form-control#password(
type="password",
name="password",
autocomplete="new-password",
autofocus,
placeholder="Password"
)
small.form-hint Must be at least 8 characters long, contain a capital and lowercase letter and a number. small.form-hint Must be at least 8 characters long, contain a capital and lowercase letter and a number.
label.form-label(for="password_repeat") Repeat new password label.form-label(for="password_repeat") Repeat new password
input.form-control#password_repeat(type="password", name="password_repeat", placeholder="Password") input.form-control#password_repeat(
type="password",
name="password_repeat",
autocomplete="new-password",
placeholder="Password"
)
button.btn.btn-primary(type="submit") Set password button.btn.btn-primary(type="submit") Set password
else else
h1 Reset password h1 Reset password
p If you have forgotten your password, please enter your accounts email address and we will send you a link to recover it. p If you have forgotten your password, please enter your account email address and we will send you a link to recover it.
form(method="post") form(method="post")
div.form-container div.form-container
input#csrf(type="hidden", name="_csrf", value=csrf) input#csrf(type="hidden", name="_csrf", value=csrf)
label.form-label(for="email") Email address label.form-label(for="email") Email address
input.form-control#email(type="email", name="email", autofocus, placeholder="Email addres") input.form-control#email(
type="email",
name="email",
autofocus,
placeholder="Email address"
)
button.btn.btn-primary(type="submit") Send recovery email button.btn.btn-primary(type="submit") Send recovery email
a.btn.btn-link.align-self-end(type="button" href="/login") Log in instead a.btn.btn-link.align-self-end(type="button" href="/login") Log in instead

View File

@ -19,6 +19,19 @@ block body
form(method="post") form(method="post")
div.form-container div.form-container
input#csrf(type="hidden", name="_csrf", value=csrf) input#csrf(type="hidden", name="_csrf", value=csrf)
input#username(
type="text",
name="username",
value=user.username,
autocomplete="username",
style="display: none"
)
label.form-label(for="password") Password label.form-label(for="password") Password
input.form-control#password(type="password", name="password", autofocus) input.form-control#password(
type="password",
name="password",
autofocus,
autocomplete="current-password"
)
button.btn.btn-primary(type="submit") Submit button.btn.btn-primary(type="submit") Submit

View File

@ -22,23 +22,51 @@ block body
input#csrf(type="hidden", name="_csrf", value=csrf) input#csrf(type="hidden", name="_csrf", value=csrf)
label.form-label(for="username") Username label.form-label(for="username") Username
input.form-control#username(type="text", name="username", placeholder="Username", autofocus, value=form.username) input.form-control#username(
type="text",
name="username",
placeholder="Username",
autocomplete="username",
autofocus,
value=form.username
)
small.form-hint Between 3 and 26 English alphanumeric characters and .-_ only. small.form-hint Between 3 and 26 English alphanumeric characters and .-_ only.
label.form-label(for="display_name") Display name label.form-label(for="display_name") Display name
input.form-control#display_name(type="text", name="display_name", placeholder="Display name", value=form.display_name) input.form-control#display_name(
type="text",
name="display_name",
placeholder="Display name",
value=form.display_name
)
small.form-hint Maximum length is 32. small.form-hint Maximum length is 32.
label.form-label(for="email") Email address label.form-label(for="email") Email address
input.form-control#email(type="email", name="email", placeholder="Email address", value=form.email) input.form-control#email(
type="email",
name="email",
placeholder="Email address",
value=form.email
)
small.form-hint You will need to verify your email address before you can log in. small.form-hint You will need to verify your email address before you can log in.
label.form-label(for="password") Password label.form-label(for="password") Password
input.form-control#password(type="password", name="password", placeholder="Password", value=form.password) input.form-control#password(
type="password",
name="password",
autocomplete="new-password",
placeholder="Password",
value=form.password
)
small.form-hint Must be at least 8 characters long, contain a capital and lowercase letter and a number. small.form-hint Must be at least 8 characters long, contain a capital and lowercase letter and a number.
label.form-label(for="password_repeat") Confirm password label.form-label(for="password_repeat") Confirm password
input.form-control#password_repeat(type="password", name="password_repeat", placeholder="Confirm password") input.form-control#password_repeat(
type="password",
name="password_repeat",
autocomplete="new-password",
placeholder="Confirm password"
)
button.btn.btn-primary(type="submit") Create a new account button.btn.btn-primary(type="submit") Create a new account
a.btn.btn-link.align-self-end(type="button" href="/login") Log in instead a.btn.btn-link.align-self-end(type="button" href="/login") Log in instead

View File

@ -18,21 +18,23 @@ block settings
form(method="post", action="/account/security/password", autocomplete="off") form(method="post", action="/account/security/password", autocomplete="off")
div.form-container div.form-container
input#csrf(type="hidden", name="_csrf", value=csrf) input#csrf(type="hidden", name="_csrf", value=csrf)
input#username(type="text", name="username", value=user.username, autocomplete="username", style="display: none")
label.form-label(for="password") Current Password label.form-label(for="password") Current Password
input.form-control#password(type="password", name="password") input.form-control#password(type="password", name="password", autocomplete="current-password")
label.form-label(for="new_password") New Password label.form-label(for="new_password") New Password
input.form-control#new_password(type="password", name="new_password", autocomplete="new-password") input.form-control#new_password(type="password", name="new_password", autocomplete="new-password")
small.form-hint At least 8 characters, a capital letter and a number required. small.form-hint At least 8 characters, a capital letter and a number required.
label.form-label(for="password_repeat") Repeat new password label.form-label(for="password_repeat") Repeat new password
input.form-control#password_repeat(type="password", name="password_repeat") input.form-control#password_repeat(type="password", name="password_repeat", autocomplete="new-password")
button.btn.btn-primary(type="submit") Change button.btn.btn-primary(type="submit") Change
.col .col
h2 Change Email Address h2 Change Email Address
form(method="post", action="/account/security/email", autocomplete="off") form(method="post", action="/account/security/email", autocomplete="off")
div.form-container div.form-container
input(type="hidden", name="_csrf", value=csrf) input(type="hidden", name="_csrf", value=csrf)
input#email_username(type="text", name="username", value=user.username, autocomplete="username", style="display: none")
label.form-label(for="current_password") Current Password label.form-label(for="current_password") Current Password
input.form-control#current_password(type="password", name="current_password") input.form-control#current_password(type="password", name="current_password", autocomplete="current-password")
label.form-label(for="current_email") Current Email Address label.form-label(for="current_email") Current Email Address
input.form-control#current_email(type="email", name="current_email") input.form-control#current_email(type="email", name="current_email")
small.form-hint Hint: #{emailHint} small.form-hint Hint: #{emailHint}