Compare commits

..

3 Commits

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

1
.gitignore vendored
View File

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

6603
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -24,9 +24,6 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@adminjs/express": "^6.1.0",
"@adminjs/nestjs": "^6.1.0",
"@adminjs/typeorm": "^5.0.1",
"@icynet/oauth2-provider": "^1.0.8",
"@nestjs/cache-manager": "^2.2.1",
"@nestjs/common": "^10.3.3",
@ -35,7 +32,6 @@
"@nestjs/serve-static": "^4.0.1",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "^5.1.2",
"adminjs": "^7.7.2",
"bcrypt": "^5.1.1",
"cache-manager": "^5.4.0",
"cache-manager-redis-yet": "^4.1.2",
@ -47,7 +43,6 @@
"cropperjs": "^1.6.1",
"csrf": "^3.1.0",
"dotenv": "^16.4.4",
"express-formidable": "^1.2.0",
"express-session": "^1.18.0",
"express-useragent": "^1.0.15",
"geoip-lite": "^1.4.10",

View File

@ -13,8 +13,6 @@ import { SSRFrontEndModule } from './modules/ssr-front-end/ssr-front-end.module'
import { UtilityModule } from './modules/utility/utility.module';
import { WellKnownModule } from './modules/well-known/well-known.module';
import { CommonCacheModule } from './modules/cache/cache.module';
import { AdminjsModule } from './modules/adminjs/adminjs.module';
import { AdminjsService } from './modules/adminjs/adminjs.service';
@Module({
imports: [
@ -39,14 +37,6 @@ import { AdminjsService } from './modules/adminjs/adminjs.service';
SSRFrontEndModule,
WellKnownModule,
ApiModule,
// TODO: https://docs.adminjs.co/installation/plugins/nest
import('@adminjs/nestjs').then(({ AdminModule }) =>
AdminModule.createAdminAsync({
imports: [AdminjsModule],
useFactory: (shims: AdminjsService) => shims.getConfiguration(),
inject: [AdminjsService],
}),
),
],
controllers: [AppController],
providers: [AppService, CSRFMiddleware],

View File

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

View File

@ -1,7 +1,7 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import dotenv from 'dotenv';
import cookieParser from 'cookie-parser';
import * as dotenv from 'dotenv';
import * as cookieParser from 'cookie-parser';
import { join } from 'path';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
@ -9,6 +9,7 @@ import { AdminApiModule } from './modules/api/admin/admin.module';
import { OAuth2RouterModule } from './modules/ssr-front-end/oauth2-router/oauth2-router.module';
import { ConfigurationService } from './modules/config/config.service';
import { ApiModule } from './modules/api/api.module';
import { AccountApiModule } from './modules/api/account/account.module';
dotenv.config();
@ -44,7 +45,7 @@ async function bootstrap() {
.build();
const document = SwaggerModule.createDocument(app, docBuilder, {
include: [ApiModule, AdminApiModule, OAuth2RouterModule],
include: [ApiModule, AdminApiModule, OAuth2RouterModule, AccountApiModule],
});
SwaggerModule.setup('api/openapi', app, document);

View File

@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigurationModule } from '../config/config.module';
import { AdminjsService } from './adminjs.service';
import { ObjectsModule } from '../objects/objects.module';
import { UserResource } from './resources/user.resource';
@Module({
imports: [ConfigurationModule, ObjectsModule],
providers: [AdminjsService, UserResource],
exports: [AdminjsService],
})
export class AdminjsModule {}

View File

@ -1,46 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigurationService } from '../config/config.service';
import { Upload } from '../objects/upload/upload.entity';
import { Privilege } from '../objects/privilege/privilege.entity';
import { OAuth2Client } from '../objects/oauth2-client/oauth2-client.entity';
import { FormUtilityService } from '../utility/services/form-utility.service';
import { TokenService } from '../utility/services/token.service';
import { UserResource } from './resources/user.resource';
/**
* Shim service for AdminJs features
*/
@Injectable({})
export class AdminjsService {
constructor(
private readonly config: ConfigurationService,
private readonly formUtil: FormUtilityService,
private readonly token: TokenService,
private readonly userResource: UserResource,
) {}
async getConfiguration() {
const { AdminJS } = await import('adminjs');
const AdminJSTypeORM = await import('@adminjs/typeorm');
AdminJS.registerAdapter({
Database: AdminJSTypeORM.Database,
Resource: AdminJSTypeORM.Resource,
});
return {
adminJsOptions: {
rootPath: '/admin',
resources: [this.userResource, Upload, Privilege, OAuth2Client],
},
// auth: {
// authenticate: (email, password) => Promise.resolve({ email }),
// cookieName: 'adminjs',
// cookiePassword: 'secret',
// },
sessionOptions: {
resave: true,
saveUninitialized: true,
secret: 'secret',
},
};
}
}

View File

@ -1,18 +0,0 @@
import { Injectable } from '@nestjs/common';
import { User } from 'src/modules/objects/user/user.entity';
@Injectable()
export class UserResource {
public resource = User;
public options = {
listProperties: [
'id',
'uuid',
'email',
'username',
'display_name',
'activated',
'created_at',
],
};
}

View File

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

View File

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

View File

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

View File

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

@ -67,6 +67,19 @@ export class UserAdminController {
};
}
/**
* Create a registraion invitation and send email.
* @param body
* @returns Success
*/
@Post('invite')
@Scopes('management')
@Privileges('admin', 'admin:user')
async invite(@Body() { email }: Pick<User, 'email'>) {
await this._user.issueRegistrationToken(email);
return { success: true };
}
/**
* Get a single user by ID
* @param id User ID

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

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

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

@ -6,7 +6,6 @@ import { ParsedQs } from 'qs';
import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service';
import { UserService } from 'src/modules/objects/user/user.service';
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
import { TokenService } from 'src/modules/utility/services/token.service';
@Injectable()
export class UserAdapter implements OAuth2UserAdapter {
@ -14,7 +13,6 @@ export class UserAdapter implements OAuth2UserAdapter {
private readonly userService: UserService,
private readonly clientService: OAuth2ClientService,
private readonly form: FormUtilityService,
private readonly token: TokenService,
) {}
getId(user: OAuth2User): number {
@ -50,7 +48,7 @@ export class UserAdapter implements OAuth2UserAdapter {
}
checkPassword(user: OAuth2User, password: string): Promise<boolean> {
return this.token.comparePasswords(user.password, password);
return this.userService.comparePasswords(user.password, password);
}
async fetchFromRequest(

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

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

View File

@ -1,7 +1,6 @@
import { Lookup } from 'geoip-lite';
import { Details } from 'express-useragent';
import { AuditAction } from './audit.enum';
import { AuditLog } from './audit.entity';
export interface UserLoginEntry {
login_at: Date;
@ -18,8 +17,3 @@ export interface AuditSearchClause {
content?: string;
flagged?: boolean;
}
export interface AuditResponse extends AuditLog {
location?: Partial<Lookup>;
user_agent?: Partial<Details>;
}

View File

@ -10,14 +10,10 @@ import {
import { User } from '../user/user.entity';
import { AuditLog } from './audit.entity';
import { AuditAction } from './audit.enum';
import { lookup } from 'geoip-lite';
import { parse } from 'express-useragent';
import { Lookup, lookup } from 'geoip-lite';
import { Details, parse } from 'express-useragent';
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
import {
AuditResponse,
AuditSearchClause,
UserLoginEntry,
} from './audit.interfaces';
import { AuditSearchClause, UserLoginEntry } from './audit.interfaces';
const PLUCK_LOCATION = ['country', 'city', 'timezone', 'll'];
const PLUCK_USER_AGENT = ['browser', 'version', 'os', 'platform'];
@ -90,7 +86,15 @@ export class AuditService {
limit = 50,
offset = 0,
search: AuditSearchClause,
): Promise<[AuditResponse[], number]> {
): Promise<
[
(AuditLog & {
location?: Partial<Lookup>;
user_agent?: Partial<Details>;
})[],
number,
]
> {
const [list, num] = await this.audit.findAndCount({
...this.buildAuditSearch(search),
take: limit,
@ -100,9 +104,7 @@ export class AuditService {
});
return [
list.map(
(entry) =>
({
list.map((entry) => ({
...entry,
location: entry.actor_ip
? this.form.pluckObject(
@ -117,8 +119,7 @@ export class AuditService {
)
: null,
actor: this.form.stripObject(entry.actor, ['password']),
}) as AuditResponse,
),
})),
num,
];
}

View File

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

View File

@ -38,7 +38,7 @@ export class DocumentService {
return {
...doc,
html,
} as Document & { html: string };
};
}
public async getDocumentByID(
@ -49,6 +49,6 @@ export class DocumentService {
return {
...doc,
html,
} as Document & { html: string };
};
}
}

View File

@ -1,5 +1,4 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
@ -13,7 +12,7 @@ import { User } from '../user/user.entity';
import { OAuth2ClientURL } from './oauth2-client-url.entity';
@Entity()
export class OAuth2Client extends BaseEntity {
export class OAuth2Client {
@PrimaryGeneratedColumn()
id: number;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,22 @@
import { EmailTemplate } from 'src/modules/objects/email/email.template';
export const InvitationEmail = (url: string): EmailTemplate => ({
text: `
Icy Network
Please click on the following link to create an account on Icy Network.
Create your account here: ${url}
This email was sent to you because you have requested an account on Icy Network. If you did not request this, you may safely ignore this email.
`,
html: /* html */ `
<h1>Icy Network</h1>
<p><b>Please click on the following link to create an account on Icy Network.</b></p>
<p>Create your account here: <a href="${url}" target="_blank">${url}</a></p>
<p>This email was sent to you because you have requested an account on Icy Network. If you did not request this, you may safely ignore this email.</p>
`,
});

View File

@ -12,6 +12,8 @@ Welcome to Icy Network, ${username}!
In order to proceed with logging in, please click on the following link to activate your account.
Activate your account: ${url}
This email was sent to you because you have created an account on Icy Network. If you did not create an account, you may contact us or just let the account expire.
`,
html: /* html */ `
<h1>Icy Network</h1>
@ -21,5 +23,7 @@ Activate your account: ${url}
<p>In order to proceed with logging in, please click on the following link to activate your account.</p>
<p>Activate your account: <a href="${url}" target="_blank">${url}</a></p>
<p>This email was sent to you because you have created an account on Icy Network. If you did not create an account, you may contact us or just let the account expire.</p>
`,
});

View File

@ -1,5 +1,4 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
@ -11,39 +10,53 @@ import {
} from 'typeorm';
import { Privilege } from '../privilege/privilege.entity';
import { Upload } from '../upload/upload.entity';
import { ApiProperty } from '@nestjs/swagger';
import { Exclude, Expose } from 'class-transformer';
@Entity()
export class User extends BaseEntity {
@Expose()
export class User {
@PrimaryGeneratedColumn()
@ApiProperty()
id: number;
@Column({ type: 'uuid', length: 36, nullable: false, unique: true })
@ApiProperty()
uuid: string;
@Column({ length: 26, nullable: false, unique: true })
@ApiProperty()
username: string;
@Column({ nullable: false, unique: true })
@ApiProperty()
email: string;
@Column({ length: 32, nullable: false })
@ApiProperty()
display_name: string;
@Column({ type: 'text', nullable: true })
@Exclude()
password: string;
@Column({ default: false })
@ApiProperty()
activated: boolean;
@Column({ type: 'timestamp' })
@ApiProperty()
public activity_at: Date;
@CreateDateColumn()
@ApiProperty()
public created_at: Date;
@UpdateDateColumn()
@ApiProperty()
public updated_at: Date;
@ApiProperty({ type: () => Upload })
@ManyToOne(() => Upload, {
nullable: true,
onDelete: 'SET NULL',
@ -51,6 +64,7 @@ export class User extends BaseEntity {
})
public picture: Upload;
@ApiProperty({ type: Privilege, isArray: true })
@ManyToMany(() => Privilege)
@JoinTable()
public privileges: Privilege[];

View File

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

View File

@ -1,5 +1,11 @@
import { Inject, Injectable } from '@nestjs/common';
import {
BadRequestException,
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import { ILike, Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { UserTokenType } from '../user-token/user-token.entity';
import { User } from './user.entity';
@ -11,9 +17,14 @@ import { UserTokenService } from '../user-token/user-token.service';
import { ConfigurationService } from 'src/modules/config/config.service';
import { Upload } from '../upload/upload.entity';
import { UploadService } from '../upload/upload.service';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { InvitationEmail } from './email/invitation.email';
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
constructor(
@Inject('USER_REPOSITORY')
private userRepository: Repository<User>,
@ -22,6 +33,7 @@ export class UserService {
private email: EmailService,
private config: ConfigurationService,
private upload: UploadService,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
) {}
public async getById(id: number, relations?: string[]): Promise<User> {
@ -139,19 +151,36 @@ export class UserService {
user.picture = upload;
await this.updateUser(user);
this.logger.log(`User ID: ${user.id} avatar has been updated`);
return user;
}
public async deleteAvatar(user: User): Promise<void> {
if (user.picture) {
await this.upload.delete(user.picture);
this.logger.log(`User ID: ${user.id} avatar has been deleted`);
}
}
public async comparePasswords(
hash: string,
password: string,
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
public async hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt);
}
public async sendActivationEmail(
user: User,
redirectTo?: string,
): Promise<void> {
this.logger.log(
`Sending an activation email to User ID: ${user.id} (${user.email})`,
);
const activationToken = await this.userToken.create(
user,
UserTokenType.ACTIVATION,
@ -175,13 +204,22 @@ export class UserService {
'Activate your account on Icy Network',
content,
);
this.logger.log(
`Sending an activation email to User ID: ${user.id} (${user.email}) was successful.`,
);
} catch (e) {
this.logger.error(
`Sending an activation email to User ID: ${user.id} (${user.email}) failed`,
);
await this.userToken.delete(activationToken);
throw e;
}
}
public async sendPasswordEmail(user: User): Promise<void> {
this.logger.log(
`Sending a password reset email to User ID: ${user.id} (${user.email})`,
);
const passwordToken = await this.userToken.create(
user,
UserTokenType.PASSWORD,
@ -200,7 +238,13 @@ export class UserService {
'Reset your password on Icy Network',
content,
);
this.logger.log(
`Sent a password reset email to User ID: ${user.id} (${user.email}) successfully`,
);
} catch (e) {
this.logger.error(
`Sending a password reset email to User ID: ${user.id} (${user.email}) failed: ${e}`,
);
await this.userToken.delete(passwordToken);
// silently fail
}
@ -223,7 +267,12 @@ export class UserService {
password: string;
},
redirectTo?: string,
activate = false,
): Promise<User> {
this.logger.log(
`Starting registration of new user ${newUserInfo.username} (${newUserInfo.email})`,
);
if (!!(await this.getByEmail(newUserInfo.email))) {
throw new Error('Email is already in use!');
}
@ -232,7 +281,7 @@ export class UserService {
throw new Error('Username is already in use!');
}
const hashword = await this.token.hashPassword(newUserInfo.password);
const hashword = await this.hashPassword(newUserInfo.password);
const user = new User();
user.email = newUserInfo.email;
user.uuid = this.token.createUUID();
@ -240,9 +289,11 @@ export class UserService {
user.display_name = newUserInfo.display_name;
user.password = hashword;
user.activity_at = new Date();
user.activated = activate;
await this.userRepository.insert(user);
await this.userRepository.save(user);
if (!user.activated) {
try {
await this.sendActivationEmail(user, redirectTo);
} catch (e) {
@ -251,7 +302,58 @@ export class UserService {
'Failed to send activation email! Please check your email address and try again!',
);
}
}
this.logger.log(
`Registered a new user ${newUserInfo.username} (${newUserInfo.email}) ID: ${user.id}`,
);
return user;
}
public async issueRegistrationToken(email: string) {
this.logger.log(`Issuing a new registration token for ${email}`);
const existingUser = await this.getByEmail(email);
if (existingUser) {
throw new BadRequestException('User by email already exists');
}
const newToken = this.token.generateString(64);
await this.cache.set(
`register-${newToken}`,
email,
7 * 24 * 60 * 60 * 1000, // 7 days
);
try {
const content = InvitationEmail(
`${this.config.get<string>('app.base_url')}/register?token=${newToken}`,
);
await this.email.sendEmailTemplate(
email,
'You have been invited to create an account on Icy Network',
content,
);
this.logger.log(
`Issuing a new registration token for ${email} was successful`,
);
} catch (error) {
this.logger.error(
`Issuing a new registration token for ${email} failed: ${error}`,
);
await this.cache.del(`register-${newToken}`);
throw error;
}
}
public async checkRegistrationToken(token: string) {
return await this.cache.get<string>(`register-${token}`);
}
public async invalidateRegistrationToken(token: string) {
return await this.cache.del(`register-${token}`);
}
}

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,
@ -75,7 +75,7 @@ export class LoginController {
if (
!user ||
!user.activated ||
!(await this.token.comparePasswords(user.password, password))
!(await this.userService.comparePasswords(user.password, password))
) {
req.flash('form', { username });
req.flash('message', {
@ -365,7 +365,7 @@ export class LoginController {
throw new Error('The passwords do not match!');
}
const hashword = await this.token.hashPassword(password);
const hashword = await this.userService.hashPassword(password);
token.user.password = hashword;
await this.userService.updateUser(token.user);

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

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

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

@ -1,7 +1,7 @@
import { FactoryProvider } from '@nestjs/common';
import { ConfigurationService } from 'src/modules/config/config.service';
import session from 'express-session';
import * as session from 'express-session';
import RedisStore from 'connect-redis';
import type { RequestHandler } from 'express';
import type { Redis } from 'src/modules/redis/redis.providers';

View File

@ -33,15 +33,15 @@ import { SettingsService } from './settings.service';
@Controller('/account')
export class SettingsController {
constructor(
private readonly settingsService: SettingsService,
private readonly formService: FormUtilityService,
private readonly uploadService: UploadService,
private readonly tokenService: TokenService,
private readonly userService: UserService,
private readonly totpService: UserTOTPService,
private readonly clientService: OAuth2ClientService,
private readonly oaTokenService: OAuth2TokenService,
private readonly auditService: AuditService,
private readonly _service: SettingsService,
private readonly _form: FormUtilityService,
private readonly _upload: UploadService,
private readonly _token: TokenService,
private readonly _user: UserService,
private readonly _totp: UserTOTPService,
private readonly _client: OAuth2ClientService,
private readonly _oaToken: OAuth2TokenService,
private readonly _audit: AuditService,
) {}
@Get()
@ -53,7 +53,7 @@ export class SettingsController {
@Get('general')
@Render('settings/general')
public general(@Req() req: Request) {
return this.formService.populateTemplate(req, { user: req.user });
return this._form.populateTemplate(req, { user: req.user });
}
@Post('general')
@ -63,7 +63,7 @@ export class SettingsController {
@Body() body: { display_name?: string },
) {
try {
const { display_name } = this.formService.trimmed(body, ['display_name']);
const { display_name } = this._form.trimmed(body, ['display_name']);
if (!display_name) {
throw new Error('Display name is required.');
}
@ -76,7 +76,7 @@ export class SettingsController {
req.user.display_name = display_name;
await this.userService.updateUser(req.user);
await this._user.updateUser(req.user);
req.flash('message', {
error: false,
text: 'Display name has been changed!',
@ -99,7 +99,7 @@ export class SettingsController {
@UploadedFile() file: Express.Multer.File,
) {
try {
if (!this.tokenService.verifyCSRF(req)) {
if (!this._token.verifyCSRF(req)) {
throw new BadRequestException('Invalid session. Please try again.');
}
@ -107,18 +107,15 @@ export class SettingsController {
throw new BadRequestException('Avatar upload failed');
}
const matches = await this.uploadService.checkImageAspect(file);
const matches = await this._upload.checkImageAspect(file);
if (!matches) {
throw new BadRequestException(
'Avatar should be with a 1:1 aspect ratio.',
);
}
const upload = await this.uploadService.registerUploadedFile(
file,
req.user,
);
await this.userService.updateAvatar(req.user, upload);
const upload = await this._upload.registerUploadedFile(file, req.user);
await this._user.updateAvatar(req.user, upload);
return {
file: upload.file,
@ -131,7 +128,7 @@ export class SettingsController {
@Post('avatar/delete')
public async deleteUserAvatar(@Req() req: Request, @Res() res: Response) {
this.userService.deleteAvatar(req.user);
this._user.deleteAvatar(req.user);
req.flash('message', {
error: false,
text: 'Avatar removed successfully.',
@ -142,8 +139,8 @@ export class SettingsController {
@Get('oauth2')
@Render('settings/oauth2')
public async authorizations(@Req() req: Request) {
const authorizations = await this.clientService.getAuthorizations(req.user);
return this.formService.populateTemplate(req, { authorizations });
const authorizations = await this._client.getAuthorizations(req.user);
return this._form.populateTemplate(req, { authorizations });
}
@Post('oauth2/revoke/:id')
@ -152,7 +149,7 @@ export class SettingsController {
@Res() res: Response,
@Param('id') id: number,
) {
const getAuth = await this.clientService.getAuthorization(req.user, id);
const getAuth = await this._client.getAuthorization(req.user, id);
const jsreq =
req.header('content-type').startsWith('application/json') ||
req.header('accept').startsWith('application/json');
@ -172,8 +169,8 @@ export class SettingsController {
return;
}
await this.oaTokenService.wipeClientTokens(getAuth.client, req.user);
await this.clientService.revokeAuthorization(getAuth);
await this._oaToken.wipeClientTokens(getAuth.client, req.user);
await this._client.revokeAuthorization(getAuth);
if (jsreq) {
return res.json({ success: true });
@ -189,8 +186,8 @@ export class SettingsController {
const emailHint = `${mailSplit[0].substring(0, 1)}${asterisks}@${
mailSplit[1]
}`;
const twofactor = await this.totpService.userHasTOTP(req.user);
return this.formService.populateTemplate(req, {
const twofactor = await this._totp.userHasTOTP(req.user);
return this._form.populateTemplate(req, {
user: req.user,
emailHint,
twofactor,
@ -214,13 +211,11 @@ export class SettingsController {
throw new Error('Please fill out all of the fields.');
}
if (
!(await this.tokenService.comparePasswords(req.user.password, password))
) {
if (!(await this._user.comparePasswords(req.user.password, password))) {
throw new Error('Current password is invalid.');
}
if (!new_password.match(this.formService.passwordRegex)) {
if (!new_password.match(this._form.passwordRegex)) {
throw new Error(
'Password must be at least 8 characters long, contain a capital and lowercase letter and a number',
);
@ -238,10 +233,10 @@ export class SettingsController {
return;
}
const newPassword = await this.tokenService.hashPassword(new_password);
const newPassword = await this._user.hashPassword(new_password);
req.user.password = newPassword;
await this.userService.updateUser(req.user);
await this.auditService.auditRequest(
await this._user.updateUser(req.user);
await this._audit.auditRequest(
req,
AuditAction.PASSWORD_CHANGE,
'settings',
@ -275,13 +270,13 @@ export class SettingsController {
throw new Error('The current email address is invalid.');
}
if (!email.match(this.formService.emailRegex)) {
if (!email.match(this._form.emailRegex)) {
throw new Error('The new email address is invalid.');
}
if (
!current_password ||
!(await this.tokenService.comparePasswords(
!(await this._user.comparePasswords(
req.user.password,
current_password,
))
@ -289,7 +284,7 @@ export class SettingsController {
throw new Error('Current password is invalid.');
}
const existing = await this.userService.getByEmail(email);
const existing = await this._user.getByEmail(email);
if (existing) {
throw new Error(
'There is already an existing user with this email address.',
@ -305,12 +300,8 @@ export class SettingsController {
}
req.user.email = email;
await this.userService.updateUser(req.user);
await this.auditService.auditRequest(
req,
AuditAction.EMAIL_CHANGE,
'settings',
);
await this._user.updateUser(req.user);
await this._audit.auditRequest(req, AuditAction.EMAIL_CHANGE, 'settings');
req.flash('message', {
error: false,
@ -325,7 +316,7 @@ export class SettingsController {
@Res() res: Response,
@Query('csrf') csrf: string,
) {
if (!this.tokenService.verifyCSRF(req, csrf)) {
if (!this._token.verifyCSRF(req, csrf)) {
throw new BadRequestException('Invalid csrf token');
}
@ -335,12 +326,9 @@ export class SettingsController {
@Get('logins')
@Render('login-list')
public async userLogins(@Req() req: Request) {
const logins = await this.auditService.getUserLogins(
req.user,
req.session.id,
);
const creation = await this.auditService.getUserAccountCreation(req.user);
return this.formService.populateTemplate(req, {
const logins = await this._audit.getUserLogins(req.user, req.session.id);
const creation = await this._audit.getUserAccountCreation(req.user);
return this._form.populateTemplate(req, {
logins,
creation,
});

View File

@ -34,8 +34,9 @@ export class TwoFactorController {
if (!twoFA) {
const challengeString = req.query.challenge as string;
if (challengeString) {
const challenge =
await this.token.decryptChallenge<ChallengeType>(challengeString);
const challenge = await this.token.decryptChallenge<ChallengeType>(
challengeString,
);
if (
challenge.type === 'totp' &&
challenge.user === req.user.uuid &&
@ -86,8 +87,9 @@ export class TwoFactorController {
throw new Error('Invalid request');
}
const challenge =
await this.token.decryptChallenge<ChallengeType>(challengeString);
const challenge = await this.token.decryptChallenge<ChallengeType>(
challengeString,
);
secret = challenge.secret;
if (
@ -149,7 +151,7 @@ export class TwoFactorController {
}
if (
!(await this.token.comparePasswords(req.user.password, body.password))
!(await this.user.comparePasswords(req.user.password, body.password))
) {
throw new Error('The entered password is invalid.');
}

View File

@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { ConfigurationService } from 'src/modules/config/config.service';
import { v4 } from 'uuid';
import CSRF from 'csrf';
import * as CSRF from 'csrf';
import { Request } from 'express';
const IV_LENGTH = 16;
@ -38,18 +37,6 @@ export class TokenService {
return v4();
}
public async comparePasswords(
hash: string,
password: string,
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
public async hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt);
}
// https://stackoverflow.com/q/52212430
/**
* Symmetric encryption function

View File

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

View File

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

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}