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
|
# local development environment files
|
||||||
.env
|
.env
|
||||||
.adminjs
|
|
||||||
/devdocker
|
/devdocker
|
||||||
/config*.toml
|
/config*.toml
|
||||||
/private
|
/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"
|
"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",
|
||||||
|
@ -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],
|
||||||
|
@ -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 { 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);
|
||||||
|
@ -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 { 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: [
|
||||||
|
@ -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';
|
||||||
|
|
@ -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
|
@ -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 {
|
@ -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 {
|
||||||
|
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: {
|
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',
|
||||||
|
@ -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 { 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(
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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>;
|
|
||||||
}
|
|
||||||
|
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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 };
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ export class OAuth2ClientService {
|
|||||||
'email',
|
'email',
|
||||||
'privileges',
|
'privileges',
|
||||||
'management',
|
'management',
|
||||||
|
'account',
|
||||||
'openid',
|
'openid',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
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.
|
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>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
@ -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[];
|
||||||
|
@ -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,
|
||||||
|
@ -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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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 {
|
||||||
|
@ -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 : ''));
|
||||||
|
@ -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 {
|
||||||
|
@ -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';
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
|
@ -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.');
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -15,10 +15,25 @@ 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()}
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('dnt')
|
@Get('dnt')
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "Node16",
|
"module": "commonjs",
|
||||||
"moduleResolution": "Node16",
|
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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}
|
||||||
|
Reference in New Issue
Block a user