Compare commits
3 Commits
Author | SHA1 | Date | |
---|---|---|---|
bc570074b0 | |||
ba96b8fbee | |||
02689f93d1 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -36,7 +36,6 @@ lerna-debug.log*
|
||||
|
||||
# local development environment files
|
||||
.env
|
||||
.adminjs
|
||||
/devdocker
|
||||
/config*.toml
|
||||
/private
|
||||
|
6603
package-lock.json
generated
6603
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -24,9 +24,6 @@
|
||||
"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",
|
||||
@ -35,7 +32,6 @@
|
||||
"@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",
|
||||
@ -47,7 +43,6 @@
|
||||
"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",
|
||||
|
@ -13,8 +13,6 @@ 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: [
|
||||
@ -39,14 +37,6 @@ import { AdminjsService } from './modules/adminjs/adminjs.service';
|
||||
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],
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import dotenv from 'dotenv';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import { join } from 'path';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
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 { ConfigurationService } from './modules/config/config.service';
|
||||
import { ApiModule } from './modules/api/api.module';
|
||||
import { AccountApiModule } from './modules/api/account/account.module';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@ -44,7 +45,7 @@ async function bootstrap() {
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, docBuilder, {
|
||||
include: [ApiModule, AdminApiModule, OAuth2RouterModule],
|
||||
include: [ApiModule, AdminApiModule, OAuth2RouterModule, AccountApiModule],
|
||||
});
|
||||
|
||||
SwaggerModule.setup('api/openapi', app, document);
|
||||
|
@ -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 {}
|
@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
@ -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',
|
||||
],
|
||||
};
|
||||
}
|
34
src/modules/api/account/account.module.ts
Normal file
34
src/modules/api/account/account.module.ts
Normal 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);
|
||||
}
|
||||
}
|
45
src/modules/api/account/controllers/account.controller.ts
Normal file
45
src/modules/api/account/controllers/account.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
29
src/modules/api/account/services/account.service.ts
Normal file
29
src/modules/api/account/services/account.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
@ -5,12 +5,12 @@ import * as mime from 'mime-types';
|
||||
import { join } from 'path';
|
||||
import { OAuth2Module } from 'src/modules/oauth2/oauth2.module';
|
||||
import { ObjectsModule } from 'src/modules/objects/objects.module';
|
||||
import { OAuth2AdminController } from './oauth2-admin.controller';
|
||||
import { PrivilegeAdminController } from './privilege-admin.controller';
|
||||
import { UserAdminController } from './user-admin.controller';
|
||||
import { OAuth2AdminController } from './controllers/oauth2-admin.controller';
|
||||
import { PrivilegeAdminController } from './controllers/privilege-admin.controller';
|
||||
import { UserAdminController } from './controllers/user-admin.controller';
|
||||
import { ConfigurationModule } from 'src/modules/config/config.module';
|
||||
import { AdminService } from './admin.service';
|
||||
import { AuditAdminController } from './audit-admin.controller';
|
||||
import { AdminService } from './services/admin.service';
|
||||
import { AuditAdminController } from './controllers/audit-admin.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
|
@ -36,7 +36,7 @@ import { FormUtilityService } from 'src/modules/utility/services/form-utility.se
|
||||
import { PaginationService } from 'src/modules/utility/services/paginate.service';
|
||||
import { TokenService } from 'src/modules/utility/services/token.service';
|
||||
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 { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
|
@ -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
|
||||
* @param id User ID
|
@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { OAuth2Client } from 'src/modules/objects/oauth2-client/oauth2-client.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()
|
||||
export class AdminService {
|
@ -1,5 +1,5 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import cors from 'cors';
|
||||
import * as cors from 'cors';
|
||||
import { ConfigurationModule } from '../config/config.module';
|
||||
import { JWTModule } from '../jwt/jwt.module';
|
||||
import { OAuth2Module } from '../oauth2/oauth2.module';
|
||||
@ -7,6 +7,7 @@ import { OAuth2Service } from '../oauth2/oauth2.service';
|
||||
import { ObjectsModule } from '../objects/objects.module';
|
||||
import { AdminApiModule } from './admin/admin.module';
|
||||
import { ApiController } from './api.controller';
|
||||
import { AccountApiModule } from './account/account.module';
|
||||
|
||||
@Module({
|
||||
controllers: [ApiController],
|
||||
@ -14,8 +15,9 @@ import { ApiController } from './api.controller';
|
||||
ConfigurationModule,
|
||||
JWTModule,
|
||||
ObjectsModule,
|
||||
AdminApiModule,
|
||||
OAuth2Module,
|
||||
AdminApiModule,
|
||||
AccountApiModule,
|
||||
],
|
||||
})
|
||||
export class ApiModule implements NestModule {
|
||||
|
7
src/modules/api/dtos/account.dto.ts
Normal file
7
src/modules/api/dtos/account.dto.ts
Normal 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;
|
||||
}
|
@ -17,6 +17,7 @@ export const configProviders = [
|
||||
useValue: {
|
||||
app: {
|
||||
base_url: 'http://localhost:3000',
|
||||
fe_url: 'http://localhost:5173',
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
session_name: '__sid',
|
||||
|
@ -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 {}
|
@ -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;
|
||||
}
|
||||
}
|
@ -6,7 +6,6 @@ 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 {
|
||||
@ -14,7 +13,6 @@ 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 {
|
||||
@ -50,7 +48,7 @@ export class UserAdapter implements OAuth2UserAdapter {
|
||||
}
|
||||
|
||||
checkPassword(user: OAuth2User, password: string): Promise<boolean> {
|
||||
return this.token.comparePasswords(user.password, password);
|
||||
return this.userService.comparePasswords(user.password, password);
|
||||
}
|
||||
|
||||
async fetchFromRequest(
|
||||
|
@ -11,6 +11,7 @@ import { UserAdapter } from './adapter/user.adapter';
|
||||
const SCOPE_DESCRIPTION: Record<string, string> = {
|
||||
email: 'Email address',
|
||||
picture: 'Profile picture',
|
||||
account: 'Password and other account settings',
|
||||
};
|
||||
|
||||
const ALWAYS_AVAILABLE = ['Username and display name'];
|
||||
@ -39,6 +40,10 @@ export class OAuth2Service implements OAuth2AdapterModel {
|
||||
disallowedScopes = null;
|
||||
}
|
||||
|
||||
if (scope.includes('account')) {
|
||||
disallowedScopes = null;
|
||||
}
|
||||
|
||||
res.render('authorize', {
|
||||
csrf: req.csrfToken(),
|
||||
user: req.user,
|
||||
|
@ -1,5 +1,4 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@ -9,7 +8,7 @@ import {
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
@Entity()
|
||||
export class AuditLog extends BaseEntity {
|
||||
export class AuditLog {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
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;
|
||||
@ -18,8 +17,3 @@ export interface AuditSearchClause {
|
||||
content?: string;
|
||||
flagged?: boolean;
|
||||
}
|
||||
|
||||
export interface AuditResponse extends AuditLog {
|
||||
location?: Partial<Lookup>;
|
||||
user_agent?: Partial<Details>;
|
||||
}
|
||||
|
@ -10,14 +10,10 @@ import {
|
||||
import { User } from '../user/user.entity';
|
||||
import { AuditLog } from './audit.entity';
|
||||
import { AuditAction } from './audit.enum';
|
||||
import { lookup } from 'geoip-lite';
|
||||
import { parse } from 'express-useragent';
|
||||
import { Lookup, lookup } from 'geoip-lite';
|
||||
import { Details, parse } from 'express-useragent';
|
||||
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
|
||||
import {
|
||||
AuditResponse,
|
||||
AuditSearchClause,
|
||||
UserLoginEntry,
|
||||
} from './audit.interfaces';
|
||||
import { AuditSearchClause, UserLoginEntry } from './audit.interfaces';
|
||||
|
||||
const PLUCK_LOCATION = ['country', 'city', 'timezone', 'll'];
|
||||
const PLUCK_USER_AGENT = ['browser', 'version', 'os', 'platform'];
|
||||
@ -90,7 +86,15 @@ export class AuditService {
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
search: AuditSearchClause,
|
||||
): Promise<[AuditResponse[], number]> {
|
||||
): Promise<
|
||||
[
|
||||
(AuditLog & {
|
||||
location?: Partial<Lookup>;
|
||||
user_agent?: Partial<Details>;
|
||||
})[],
|
||||
number,
|
||||
]
|
||||
> {
|
||||
const [list, num] = await this.audit.findAndCount({
|
||||
...this.buildAuditSearch(search),
|
||||
take: limit,
|
||||
@ -100,9 +104,7 @@ export class AuditService {
|
||||
});
|
||||
|
||||
return [
|
||||
list.map(
|
||||
(entry) =>
|
||||
({
|
||||
list.map((entry) => ({
|
||||
...entry,
|
||||
location: entry.actor_ip
|
||||
? this.form.pluckObject(
|
||||
@ -117,8 +119,7 @@ export class AuditService {
|
||||
)
|
||||
: null,
|
||||
actor: this.form.stripObject(entry.actor, ['password']),
|
||||
}) as AuditResponse,
|
||||
),
|
||||
})),
|
||||
num,
|
||||
];
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@ -10,7 +9,7 @@ import {
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
@Entity()
|
||||
export class Document extends BaseEntity {
|
||||
export class Document {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
|
@ -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 };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@ -13,7 +12,7 @@ import { User } from '../user/user.entity';
|
||||
import { OAuth2ClientURL } from './oauth2-client-url.entity';
|
||||
|
||||
@Entity()
|
||||
export class OAuth2Client extends BaseEntity {
|
||||
export class OAuth2Client {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
|
@ -26,6 +26,7 @@ export class OAuth2ClientService {
|
||||
'email',
|
||||
'privileges',
|
||||
'management',
|
||||
'account',
|
||||
'openid',
|
||||
];
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@ -17,7 +16,7 @@ export enum OAuth2TokenType {
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class OAuth2Token extends BaseEntity {
|
||||
export class OAuth2Token {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
|
@ -1,11 +1,14 @@
|
||||
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export class Privilege extends BaseEntity {
|
||||
export class Privilege {
|
||||
@PrimaryGeneratedColumn()
|
||||
@ApiProperty()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'text', nullable: false })
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@ -8,19 +7,26 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../user/user.entity';
|
||||
import { Exclude, Expose } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
@Entity()
|
||||
export class Upload extends BaseEntity {
|
||||
@Expose()
|
||||
export class Upload {
|
||||
@PrimaryGeneratedColumn()
|
||||
@ApiProperty()
|
||||
id: number;
|
||||
|
||||
@Column({ nullable: false })
|
||||
@ApiProperty()
|
||||
original_name: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
@ApiProperty()
|
||||
mimetype: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
@ApiProperty()
|
||||
file: string;
|
||||
|
||||
@ManyToOne(() => User, {
|
||||
@ -28,11 +34,14 @@ export class Upload extends BaseEntity {
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
})
|
||||
@Exclude()
|
||||
uploader: User;
|
||||
|
||||
@CreateDateColumn()
|
||||
@ApiProperty()
|
||||
public created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
@ApiProperty()
|
||||
public updated_at: Date;
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@ -21,7 +20,7 @@ export enum UserTokenType {
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class UserToken extends BaseEntity {
|
||||
export class UserToken {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
|
22
src/modules/objects/user/email/invitation.email.ts
Normal file
22
src/modules/objects/user/email/invitation.email.ts
Normal 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>
|
||||
`,
|
||||
});
|
@ -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.
|
||||
|
||||
Activate your account: ${url}
|
||||
|
||||
This email was sent to you because you have created an account on Icy Network. If you did not create an account, you may contact us or just let the account expire.
|
||||
`,
|
||||
html: /* html */ `
|
||||
<h1>Icy Network</h1>
|
||||
@ -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>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>
|
||||
`,
|
||||
});
|
||||
|
@ -1,5 +1,4 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@ -11,39 +10,53 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Privilege } from '../privilege/privilege.entity';
|
||||
import { Upload } from '../upload/upload.entity';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Exclude, Expose } from 'class-transformer';
|
||||
|
||||
@Entity()
|
||||
export class User extends BaseEntity {
|
||||
@Expose()
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn()
|
||||
@ApiProperty()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'uuid', length: 36, nullable: false, unique: true })
|
||||
@ApiProperty()
|
||||
uuid: string;
|
||||
|
||||
@Column({ length: 26, nullable: false, unique: true })
|
||||
@ApiProperty()
|
||||
username: string;
|
||||
|
||||
@Column({ nullable: false, unique: true })
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@Column({ length: 32, nullable: false })
|
||||
@ApiProperty()
|
||||
display_name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
@Exclude()
|
||||
password: string;
|
||||
|
||||
@Column({ default: false })
|
||||
@ApiProperty()
|
||||
activated: boolean;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
@ApiProperty()
|
||||
public activity_at: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
@ApiProperty()
|
||||
public created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
@ApiProperty()
|
||||
public updated_at: Date;
|
||||
|
||||
@ApiProperty({ type: () => Upload })
|
||||
@ManyToOne(() => Upload, {
|
||||
nullable: true,
|
||||
onDelete: 'SET NULL',
|
||||
@ -51,6 +64,7 @@ export class User extends BaseEntity {
|
||||
})
|
||||
public picture: Upload;
|
||||
|
||||
@ApiProperty({ type: Privilege, isArray: true })
|
||||
@ManyToMany(() => Privilege)
|
||||
@JoinTable()
|
||||
public privileges: Privilege[];
|
||||
|
@ -6,9 +6,11 @@ import { UploadModule } from '../upload/upload.module';
|
||||
import { UserTokenModule } from '../user-token/user-token.module';
|
||||
import { userProviders } from './user.providers';
|
||||
import { UserService } from './user.service';
|
||||
import { CommonCacheModule } from 'src/modules/cache/cache.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CommonCacheModule,
|
||||
DatabaseModule,
|
||||
EmailModule,
|
||||
UserTokenModule,
|
||||
|
@ -1,5 +1,11 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
} 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';
|
||||
@ -11,9 +17,14 @@ import { UserTokenService } from '../user-token/user-token.service';
|
||||
import { ConfigurationService } from 'src/modules/config/config.service';
|
||||
import { Upload } from '../upload/upload.entity';
|
||||
import { UploadService } from '../upload/upload.service';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { InvitationEmail } from './email/invitation.email';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private readonly logger = new Logger(UserService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('USER_REPOSITORY')
|
||||
private userRepository: Repository<User>,
|
||||
@ -22,6 +33,7 @@ export class UserService {
|
||||
private email: EmailService,
|
||||
private config: ConfigurationService,
|
||||
private upload: UploadService,
|
||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
||||
) {}
|
||||
|
||||
public async getById(id: number, relations?: string[]): Promise<User> {
|
||||
@ -139,19 +151,36 @@ export class UserService {
|
||||
|
||||
user.picture = upload;
|
||||
await this.updateUser(user);
|
||||
this.logger.log(`User ID: ${user.id} avatar has been updated`);
|
||||
return user;
|
||||
}
|
||||
|
||||
public async deleteAvatar(user: User): Promise<void> {
|
||||
if (user.picture) {
|
||||
await this.upload.delete(user.picture);
|
||||
this.logger.log(`User ID: ${user.id} avatar has been deleted`);
|
||||
}
|
||||
}
|
||||
|
||||
public async comparePasswords(
|
||||
hash: string,
|
||||
password: string,
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
public async hashPassword(password: string): Promise<string> {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
return bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
public async sendActivationEmail(
|
||||
user: User,
|
||||
redirectTo?: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
`Sending an activation email to User ID: ${user.id} (${user.email})`,
|
||||
);
|
||||
const activationToken = await this.userToken.create(
|
||||
user,
|
||||
UserTokenType.ACTIVATION,
|
||||
@ -175,13 +204,22 @@ export class UserService {
|
||||
'Activate your account on Icy Network',
|
||||
content,
|
||||
);
|
||||
this.logger.log(
|
||||
`Sending an activation email to User ID: ${user.id} (${user.email}) was successful.`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Sending an activation email to User ID: ${user.id} (${user.email}) failed`,
|
||||
);
|
||||
await this.userToken.delete(activationToken);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async sendPasswordEmail(user: User): Promise<void> {
|
||||
this.logger.log(
|
||||
`Sending a password reset email to User ID: ${user.id} (${user.email})`,
|
||||
);
|
||||
const passwordToken = await this.userToken.create(
|
||||
user,
|
||||
UserTokenType.PASSWORD,
|
||||
@ -200,7 +238,13 @@ export class UserService {
|
||||
'Reset your password on Icy Network',
|
||||
content,
|
||||
);
|
||||
this.logger.log(
|
||||
`Sent a password reset email to User ID: ${user.id} (${user.email}) successfully`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Sending a password reset email to User ID: ${user.id} (${user.email}) failed: ${e}`,
|
||||
);
|
||||
await this.userToken.delete(passwordToken);
|
||||
// silently fail
|
||||
}
|
||||
@ -223,7 +267,12 @@ export class UserService {
|
||||
password: string;
|
||||
},
|
||||
redirectTo?: string,
|
||||
activate = false,
|
||||
): Promise<User> {
|
||||
this.logger.log(
|
||||
`Starting registration of new user ${newUserInfo.username} (${newUserInfo.email})`,
|
||||
);
|
||||
|
||||
if (!!(await this.getByEmail(newUserInfo.email))) {
|
||||
throw new Error('Email is already in use!');
|
||||
}
|
||||
@ -232,7 +281,7 @@ export class UserService {
|
||||
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();
|
||||
user.email = newUserInfo.email;
|
||||
user.uuid = this.token.createUUID();
|
||||
@ -240,9 +289,11 @@ export class UserService {
|
||||
user.display_name = newUserInfo.display_name;
|
||||
user.password = hashword;
|
||||
user.activity_at = new Date();
|
||||
user.activated = activate;
|
||||
|
||||
await this.userRepository.insert(user);
|
||||
await this.userRepository.save(user);
|
||||
|
||||
if (!user.activated) {
|
||||
try {
|
||||
await this.sendActivationEmail(user, redirectTo);
|
||||
} catch (e) {
|
||||
@ -251,7 +302,58 @@ export class UserService {
|
||||
'Failed to send activation email! Please check your email address and try again!',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Registered a new user ${newUserInfo.username} (${newUserInfo.email}) ID: ${user.id}`,
|
||||
);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public async issueRegistrationToken(email: string) {
|
||||
this.logger.log(`Issuing a new registration token for ${email}`);
|
||||
|
||||
const existingUser = await this.getByEmail(email);
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('User by email already exists');
|
||||
}
|
||||
|
||||
const newToken = this.token.generateString(64);
|
||||
await this.cache.set(
|
||||
`register-${newToken}`,
|
||||
email,
|
||||
7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
);
|
||||
|
||||
try {
|
||||
const content = InvitationEmail(
|
||||
`${this.config.get<string>('app.base_url')}/register?token=${newToken}`,
|
||||
);
|
||||
|
||||
await this.email.sendEmailTemplate(
|
||||
email,
|
||||
'You have been invited to create an account on Icy Network',
|
||||
content,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Issuing a new registration token for ${email} was successful`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Issuing a new registration token for ${email} failed: ${error}`,
|
||||
);
|
||||
await this.cache.del(`register-${newToken}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async checkRegistrationToken(token: string) {
|
||||
return await this.cache.get<string>(`register-${token}`);
|
||||
}
|
||||
|
||||
public async invalidateRegistrationToken(token: string) {
|
||||
return await this.cache.del(`register-${token}`);
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,6 @@ import {
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { Request, Response } from 'express';
|
||||
import { SessionData } from 'express-session';
|
||||
import { LoginAntispamGuard } from 'src/guards/login-antispam.guard';
|
||||
import { AuditAction } from 'src/modules/objects/audit/audit.enum';
|
||||
import { AuditService } from 'src/modules/objects/audit/audit.service';
|
||||
import {
|
||||
@ -35,7 +34,6 @@ interface VerifyChallenge {
|
||||
}
|
||||
|
||||
@Controller('/login')
|
||||
@UseGuards(LoginAntispamGuard)
|
||||
export class LoginController {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
@ -60,6 +58,8 @@ export class LoginController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@UseGuards(ThrottlerGuard)
|
||||
public async loginRequest(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@ -75,7 +75,7 @@ export class LoginController {
|
||||
if (
|
||||
!user ||
|
||||
!user.activated ||
|
||||
!(await this.token.comparePasswords(user.password, password))
|
||||
!(await this.userService.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.token.hashPassword(password);
|
||||
const hashword = await this.userService.hashPassword(password);
|
||||
token.user.password = hashword;
|
||||
|
||||
await this.userService.updateUser(token.user);
|
||||
|
@ -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 { SessionModule } from '../session/session.module';
|
||||
import { LoginController } from './login.controller';
|
||||
import { IPLimitModule } from 'src/modules/iplimit/iplimit.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UserModule,
|
||||
UserTokenModule,
|
||||
AuditModule,
|
||||
SessionModule,
|
||||
IPLimitModule,
|
||||
],
|
||||
imports: [UserModule, UserTokenModule, AuditModule, SessionModule],
|
||||
controllers: [LoginController],
|
||||
})
|
||||
export class LoginModule implements NestModule {
|
||||
|
@ -4,7 +4,6 @@ import {
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
Render,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
@ -12,7 +11,6 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { Request, Response } from 'express';
|
||||
import { LoginAntispamGuard } from 'src/guards/login-antispam.guard';
|
||||
import { ConfigurationService } from 'src/modules/config/config.service';
|
||||
import { AuditAction } from 'src/modules/objects/audit/audit.enum';
|
||||
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';
|
||||
|
||||
@Controller('/register')
|
||||
@UseGuards(LoginAntispamGuard)
|
||||
export class RegisterController {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
@ -31,26 +28,77 @@ export class RegisterController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Render('register')
|
||||
public registerView(@Req() req: Request): Record<string, unknown> {
|
||||
return this.formUtil.populateTemplate(req, {
|
||||
registrationAuthorized: this.config.get<boolean>('app.registrations'),
|
||||
public async registerView(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Query('token') registrationToken: string,
|
||||
@Query('redirectTo') redirectTo: string,
|
||||
) {
|
||||
let registrationAuthorized = this.config.get<boolean>('app.registrations');
|
||||
if (registrationToken) {
|
||||
const registrationEmail =
|
||||
await this.userService.checkRegistrationToken(registrationToken);
|
||||
|
||||
if (!registrationEmail) {
|
||||
req.flash('message', {
|
||||
error: true,
|
||||
text: `This registration token is invalid or expired.`,
|
||||
});
|
||||
|
||||
return res.redirect(
|
||||
'/login' + (redirectTo ? '?redirectTo=' + redirectTo : ''),
|
||||
);
|
||||
}
|
||||
|
||||
req.flash('form', { email: registrationEmail });
|
||||
|
||||
// bypass limitations
|
||||
registrationAuthorized = true;
|
||||
}
|
||||
|
||||
res.render(
|
||||
'register',
|
||||
this.formUtil.populateTemplate(req, {
|
||||
registrationAuthorized,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Throttle({ default: { limit: 3, ttl: 10000 } })
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@UseGuards(ThrottlerGuard)
|
||||
public async registerRequest(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Body() body: RegisterDto,
|
||||
@Query('token') registrationToken: string,
|
||||
@Query('redirectTo') redirectTo?: string,
|
||||
) {
|
||||
const { username, display_name, email, password, password_repeat } =
|
||||
this.formUtil.trimmed(body, ['username', 'display_name', 'email']);
|
||||
|
||||
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(
|
||||
'Registrations are disabled by administrator.',
|
||||
);
|
||||
@ -94,12 +142,23 @@ export class RegisterController {
|
||||
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);
|
||||
|
||||
if (tokenEmail) {
|
||||
await this.userService.invalidateRegistrationToken(registrationToken);
|
||||
}
|
||||
|
||||
req.flash('message', {
|
||||
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 : ''));
|
||||
|
@ -8,10 +8,9 @@ import { AuditModule } from 'src/modules/objects/audit/audit.module';
|
||||
import { UserModule } from 'src/modules/objects/user/user.module';
|
||||
import { SessionModule } from '../session/session.module';
|
||||
import { RegisterController } from './register.controller';
|
||||
import { IPLimitModule } from 'src/modules/iplimit/iplimit.module';
|
||||
|
||||
@Module({
|
||||
imports: [UserModule, AuditModule, SessionModule, IPLimitModule],
|
||||
imports: [UserModule, AuditModule, SessionModule],
|
||||
controllers: [RegisterController],
|
||||
})
|
||||
export class RegisterModule implements NestModule {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { FactoryProvider } from '@nestjs/common';
|
||||
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 type { RequestHandler } from 'express';
|
||||
import type { Redis } from 'src/modules/redis/redis.providers';
|
||||
|
@ -33,15 +33,15 @@ import { SettingsService } from './settings.service';
|
||||
@Controller('/account')
|
||||
export class SettingsController {
|
||||
constructor(
|
||||
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,
|
||||
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,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ -53,7 +53,7 @@ export class SettingsController {
|
||||
@Get('general')
|
||||
@Render('settings/general')
|
||||
public general(@Req() req: Request) {
|
||||
return this.formService.populateTemplate(req, { user: req.user });
|
||||
return this._form.populateTemplate(req, { user: req.user });
|
||||
}
|
||||
|
||||
@Post('general')
|
||||
@ -63,7 +63,7 @@ export class SettingsController {
|
||||
@Body() body: { display_name?: string },
|
||||
) {
|
||||
try {
|
||||
const { display_name } = this.formService.trimmed(body, ['display_name']);
|
||||
const { display_name } = this._form.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.userService.updateUser(req.user);
|
||||
await this._user.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.tokenService.verifyCSRF(req)) {
|
||||
if (!this._token.verifyCSRF(req)) {
|
||||
throw new BadRequestException('Invalid session. Please try again.');
|
||||
}
|
||||
|
||||
@ -107,18 +107,15 @@ export class SettingsController {
|
||||
throw new BadRequestException('Avatar upload failed');
|
||||
}
|
||||
|
||||
const matches = await this.uploadService.checkImageAspect(file);
|
||||
const matches = await this._upload.checkImageAspect(file);
|
||||
if (!matches) {
|
||||
throw new BadRequestException(
|
||||
'Avatar should be with a 1:1 aspect ratio.',
|
||||
);
|
||||
}
|
||||
|
||||
const upload = await this.uploadService.registerUploadedFile(
|
||||
file,
|
||||
req.user,
|
||||
);
|
||||
await this.userService.updateAvatar(req.user, upload);
|
||||
const upload = await this._upload.registerUploadedFile(file, req.user);
|
||||
await this._user.updateAvatar(req.user, upload);
|
||||
|
||||
return {
|
||||
file: upload.file,
|
||||
@ -131,7 +128,7 @@ export class SettingsController {
|
||||
|
||||
@Post('avatar/delete')
|
||||
public async deleteUserAvatar(@Req() req: Request, @Res() res: Response) {
|
||||
this.userService.deleteAvatar(req.user);
|
||||
this._user.deleteAvatar(req.user);
|
||||
req.flash('message', {
|
||||
error: false,
|
||||
text: 'Avatar removed successfully.',
|
||||
@ -142,8 +139,8 @@ export class SettingsController {
|
||||
@Get('oauth2')
|
||||
@Render('settings/oauth2')
|
||||
public async authorizations(@Req() req: Request) {
|
||||
const authorizations = await this.clientService.getAuthorizations(req.user);
|
||||
return this.formService.populateTemplate(req, { authorizations });
|
||||
const authorizations = await this._client.getAuthorizations(req.user);
|
||||
return this._form.populateTemplate(req, { authorizations });
|
||||
}
|
||||
|
||||
@Post('oauth2/revoke/:id')
|
||||
@ -152,7 +149,7 @@ export class SettingsController {
|
||||
@Res() res: Response,
|
||||
@Param('id') id: number,
|
||||
) {
|
||||
const getAuth = await this.clientService.getAuthorization(req.user, id);
|
||||
const getAuth = await this._client.getAuthorization(req.user, id);
|
||||
const jsreq =
|
||||
req.header('content-type').startsWith('application/json') ||
|
||||
req.header('accept').startsWith('application/json');
|
||||
@ -172,8 +169,8 @@ export class SettingsController {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.oaTokenService.wipeClientTokens(getAuth.client, req.user);
|
||||
await this.clientService.revokeAuthorization(getAuth);
|
||||
await this._oaToken.wipeClientTokens(getAuth.client, req.user);
|
||||
await this._client.revokeAuthorization(getAuth);
|
||||
|
||||
if (jsreq) {
|
||||
return res.json({ success: true });
|
||||
@ -189,8 +186,8 @@ export class SettingsController {
|
||||
const emailHint = `${mailSplit[0].substring(0, 1)}${asterisks}@${
|
||||
mailSplit[1]
|
||||
}`;
|
||||
const twofactor = await this.totpService.userHasTOTP(req.user);
|
||||
return this.formService.populateTemplate(req, {
|
||||
const twofactor = await this._totp.userHasTOTP(req.user);
|
||||
return this._form.populateTemplate(req, {
|
||||
user: req.user,
|
||||
emailHint,
|
||||
twofactor,
|
||||
@ -214,13 +211,11 @@ export class SettingsController {
|
||||
throw new Error('Please fill out all of the fields.');
|
||||
}
|
||||
|
||||
if (
|
||||
!(await this.tokenService.comparePasswords(req.user.password, password))
|
||||
) {
|
||||
if (!(await this._user.comparePasswords(req.user.password, password))) {
|
||||
throw new Error('Current password is invalid.');
|
||||
}
|
||||
|
||||
if (!new_password.match(this.formService.passwordRegex)) {
|
||||
if (!new_password.match(this._form.passwordRegex)) {
|
||||
throw new Error(
|
||||
'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;
|
||||
}
|
||||
|
||||
const newPassword = await this.tokenService.hashPassword(new_password);
|
||||
const newPassword = await this._user.hashPassword(new_password);
|
||||
req.user.password = newPassword;
|
||||
await this.userService.updateUser(req.user);
|
||||
await this.auditService.auditRequest(
|
||||
await this._user.updateUser(req.user);
|
||||
await this._audit.auditRequest(
|
||||
req,
|
||||
AuditAction.PASSWORD_CHANGE,
|
||||
'settings',
|
||||
@ -275,13 +270,13 @@ export class SettingsController {
|
||||
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.');
|
||||
}
|
||||
|
||||
if (
|
||||
!current_password ||
|
||||
!(await this.tokenService.comparePasswords(
|
||||
!(await this._user.comparePasswords(
|
||||
req.user.password,
|
||||
current_password,
|
||||
))
|
||||
@ -289,7 +284,7 @@ export class SettingsController {
|
||||
throw new Error('Current password is invalid.');
|
||||
}
|
||||
|
||||
const existing = await this.userService.getByEmail(email);
|
||||
const existing = await this._user.getByEmail(email);
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
'There is already an existing user with this email address.',
|
||||
@ -305,12 +300,8 @@ export class SettingsController {
|
||||
}
|
||||
|
||||
req.user.email = email;
|
||||
await this.userService.updateUser(req.user);
|
||||
await this.auditService.auditRequest(
|
||||
req,
|
||||
AuditAction.EMAIL_CHANGE,
|
||||
'settings',
|
||||
);
|
||||
await this._user.updateUser(req.user);
|
||||
await this._audit.auditRequest(req, AuditAction.EMAIL_CHANGE, 'settings');
|
||||
|
||||
req.flash('message', {
|
||||
error: false,
|
||||
@ -325,7 +316,7 @@ export class SettingsController {
|
||||
@Res() res: Response,
|
||||
@Query('csrf') csrf: string,
|
||||
) {
|
||||
if (!this.tokenService.verifyCSRF(req, csrf)) {
|
||||
if (!this._token.verifyCSRF(req, csrf)) {
|
||||
throw new BadRequestException('Invalid csrf token');
|
||||
}
|
||||
|
||||
@ -335,12 +326,9 @@ export class SettingsController {
|
||||
@Get('logins')
|
||||
@Render('login-list')
|
||||
public async userLogins(@Req() req: Request) {
|
||||
const logins = await this.auditService.getUserLogins(
|
||||
req.user,
|
||||
req.session.id,
|
||||
);
|
||||
const creation = await this.auditService.getUserAccountCreation(req.user);
|
||||
return this.formService.populateTemplate(req, {
|
||||
const logins = await this._audit.getUserLogins(req.user, req.session.id);
|
||||
const creation = await this._audit.getUserAccountCreation(req.user);
|
||||
return this._form.populateTemplate(req, {
|
||||
logins,
|
||||
creation,
|
||||
});
|
||||
|
@ -34,8 +34,9 @@ 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 &&
|
||||
@ -86,8 +87,9 @@ 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 (
|
||||
@ -149,7 +151,7 @@ export class TwoFactorController {
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
@ -1,9 +1,8 @@
|
||||
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 CSRF from 'csrf';
|
||||
import * as CSRF from 'csrf';
|
||||
import { Request } from 'express';
|
||||
|
||||
const IV_LENGTH = 16;
|
||||
@ -38,18 +37,6 @@ 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
|
||||
|
@ -15,9 +15,24 @@ export class WellKnownController {
|
||||
@Get('security.txt')
|
||||
securityTXT(@Res({ passthrough: true }) res: Response) {
|
||||
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
|
||||
# you may report it to:
|
||||
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()}
|
||||
`;
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
|
@ -20,11 +20,27 @@ block body
|
||||
div.form-container
|
||||
input#csrf(type="hidden", name="_csrf", value=csrf)
|
||||
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
|
||||
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
|
||||
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
|
||||
button.btn.btn-primary(type="submit") Log in
|
||||
div.btn-group.align-self-end
|
||||
|
@ -21,18 +21,35 @@ block body
|
||||
div.form-container
|
||||
input#csrf(type="hidden", name="_csrf", value=csrf)
|
||||
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.
|
||||
|
||||
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
|
||||
else
|
||||
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")
|
||||
div.form-container
|
||||
input#csrf(type="hidden", name="_csrf", value=csrf)
|
||||
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
|
||||
a.btn.btn-link.align-self-end(type="button" href="/login") Log in instead
|
||||
|
@ -19,6 +19,19 @@ block body
|
||||
form(method="post")
|
||||
div.form-container
|
||||
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
|
||||
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
|
||||
|
@ -22,23 +22,51 @@ block body
|
||||
input#csrf(type="hidden", name="_csrf", value=csrf)
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
a.btn.btn-link.align-self-end(type="button" href="/login") Log in instead
|
||||
|
@ -18,21 +18,23 @@ block settings
|
||||
form(method="post", action="/account/security/password", autocomplete="off")
|
||||
div.form-container
|
||||
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
|
||||
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
|
||||
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.
|
||||
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
|
||||
.col
|
||||
h2 Change Email Address
|
||||
form(method="post", action="/account/security/email", autocomplete="off")
|
||||
div.form-container
|
||||
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
|
||||
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
|
||||
input.form-control#current_email(type="email", name="current_email")
|
||||
small.form-hint Hint: #{emailHint}
|
||||
|
Reference in New Issue
Block a user