final commit

This commit is contained in:
Evert Prants 2024-06-14 17:02:46 +03:00
parent ba96b8fbee
commit bc570074b0
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
31 changed files with 275 additions and 161 deletions

View File

@ -1,63 +0,0 @@
import {
Injectable,
CanActivate,
ExecutionContext,
HttpException,
} from '@nestjs/common';
import { Request } from 'express';
import { IPLimitService } from 'src/modules/iplimit/iplimit.service';
import { AuditAction } from 'src/modules/objects/audit/audit.enum';
import { AuditService } from 'src/modules/objects/audit/audit.service';
@Injectable()
export class LoginAntispamGuard implements CanActivate {
constructor(
private iplimit: IPLimitService,
private audit: AuditService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
if (['GET', 'OPTIONS'].includes(request.method)) return true;
const known = await this.iplimit.getAddressLimit(request.ip);
if (known && known.attempts > 10) {
if (known.attempts > 15) {
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 > 15 ? 30 : 10; // Half-Hour
await this.iplimit.limitUntil(
request.ip,
limitMinutes * 60 * 1000,
reported,
);
await new Promise((resolve) =>
setTimeout(resolve, known.attempts * 1000),
);
throw new HttpException(
`Too Many Requests. Try again in ${limitMinutes} minutes.`,
429,
);
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
await this.iplimit.limitUntil(request.ip, 30 * 1000); // 30 seconds
return true;
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { UserTOTPService } from 'src/modules/objects/user-token/user-totp-token.service';
import { User } from 'src/modules/objects/user/user.entity';
import { UserService } from 'src/modules/objects/user/user.service';
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
import { AccountResponseDto } from '../../dtos/account.dto';
@Injectable()
export class AccountApiService {
constructor(
private readonly userService: UserService,
private readonly userTotp: UserTOTPService,
private readonly form: FormUtilityService,
) {}
async getAccountInfo(user: User) {
const fullInfo = await this.userService.getByUUID(user.uuid, [
'picture',
'privileges',
]);
const hasTotp = await this.userTotp.userHasTOTP(fullInfo);
return plainToInstance(AccountResponseDto, {
...fullInfo,
totp_enabled: hasTotp,
});
}
}

View File

@ -5,12 +5,12 @@ import * as mime from 'mime-types';
import { join } from 'path';
import { 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: [

View File

@ -36,7 +36,7 @@ import { FormUtilityService } from 'src/modules/utility/services/form-utility.se
import { PaginationService } from 'src/modules/utility/services/paginate.service';
import { 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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,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()
@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, {
@ -27,11 +34,14 @@ export class Upload {
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
})
@Exclude()
uploader: User;
@CreateDateColumn()
@ApiProperty()
public created_at: Date;
@UpdateDateColumn()
@ApiProperty()
public updated_at: Date;
}

View File

@ -10,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()
@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',
@ -50,6 +64,7 @@ export class User {
})
public picture: Upload;
@ApiProperty({ type: Privilege, isArray: true })
@ManyToMany(() => Privilege)
@JoinTable()
public privileges: Privilege[];

View File

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

View File

@ -9,16 +9,9 @@ import { UserTokenModule } from 'src/modules/objects/user-token/user-token.modul
import { UserModule } from 'src/modules/objects/user/user.module';
import { 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 {

View File

@ -11,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';
@ -20,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,
@ -67,7 +65,7 @@ export class RegisterController {
}
@Post()
@Throttle({ default: { limit: 3, ttl: 10000 } })
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(ThrottlerGuard)
public async registerRequest(
@Req() req: Request,

View File

@ -8,10 +8,9 @@ import { AuditModule } from 'src/modules/objects/audit/audit.module';
import { UserModule } from 'src/modules/objects/user/user.module';
import { 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 {

View File

@ -15,10 +15,25 @@ 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()}
`;
}
@Get('dnt')

View File

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

View File

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

View File

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

View File

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

View File

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