Some progress

This commit is contained in:
Evert Prants 2022-08-17 21:56:47 +03:00
parent 3f399320b6
commit a21a2257db
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
19 changed files with 87 additions and 15 deletions

9
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"editor.formatOnSave": true,
"files.insertFinalNewline": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"eslint.validate": ["typescript", "typescriptreact", "html"],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}

View File

@ -9,6 +9,9 @@ dotenv.config();
const CONFIG_ENV = process.env.NODE_ENV === 'production' ? 'prod' : 'dev'; const CONFIG_ENV = process.env.NODE_ENV === 'production' ? 'prod' : 'dev';
const CONFIG_FILENAME = process.env.CONFIG || `config.${CONFIG_ENV}.toml`; const CONFIG_FILENAME = process.env.CONFIG || `config.${CONFIG_ENV}.toml`;
const CONFIG_PATH = join(process.cwd(), CONFIG_FILENAME); const CONFIG_PATH = join(process.cwd(), CONFIG_FILENAME);
// toml.parse returns an object that doesn't have the correct prototype,
// thus this JSON workaround is used.
const config = JSON.parse( const config = JSON.parse(
JSON.stringify(toml.parse(readFileSync(CONFIG_PATH, { encoding: 'utf-8' }))), JSON.stringify(toml.parse(readFileSync(CONFIG_PATH, { encoding: 'utf-8' }))),
); );

19
package-lock.json generated
View File

@ -49,6 +49,7 @@
"@nestjs/testing": "^8.0.0", "@nestjs/testing": "^8.0.0",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/connect-redis": "^0.0.18", "@types/connect-redis": "^0.0.18",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/express-session": "^1.17.4", "@types/express-session": "^1.17.4",
"@types/jest": "27.4.1", "@types/jest": "27.4.1",
@ -3146,6 +3147,15 @@
"@types/redis": "^2.8.0" "@types/redis": "^2.8.0"
} }
}, },
"node_modules/@types/cookie-parser": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.3.tgz",
"integrity": "sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==",
"dev": true,
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cookiejar": { "node_modules/@types/cookiejar": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
@ -14757,6 +14767,15 @@
"@types/redis": "^2.8.0" "@types/redis": "^2.8.0"
} }
}, },
"@types/cookie-parser": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.3.tgz",
"integrity": "sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==",
"dev": true,
"requires": {
"@types/express": "*"
}
},
"@types/cookiejar": { "@types/cookiejar": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",

View File

@ -63,6 +63,7 @@
"@nestjs/testing": "^8.0.0", "@nestjs/testing": "^8.0.0",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/connect-redis": "^0.0.18", "@types/connect-redis": "^0.0.18",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/express-session": "^1.17.4", "@types/express-session": "^1.17.4",
"@types/jest": "27.4.1", "@types/jest": "27.4.1",

View File

@ -17,6 +17,7 @@ import { OAuth2Module } from './modules/features/oauth2/oauth2.module';
import { RegisterModule } from './modules/features/register/register.module'; import { RegisterModule } from './modules/features/register/register.module';
import { SettingsModule } from './modules/features/settings/settings.module'; import { SettingsModule } from './modules/features/settings/settings.module';
import { TwoFactorModule } from './modules/features/two-factor/two-factor.module'; import { TwoFactorModule } from './modules/features/two-factor/two-factor.module';
import { JWTModule } from './modules/jwt/jwt.module';
import { DatabaseModule } from './modules/objects/database/database.module'; import { DatabaseModule } from './modules/objects/database/database.module';
import { EmailModule } from './modules/objects/email/email.module'; import { EmailModule } from './modules/objects/email/email.module';
import { OAuth2ClientModule } from './modules/objects/oauth2-client/oauth2-client.module'; import { OAuth2ClientModule } from './modules/objects/oauth2-client/oauth2-client.module';
@ -47,6 +48,7 @@ import { UtilityModule } from './modules/utility/utility.module';
LoginModule, LoginModule,
RegisterModule, RegisterModule,
OAuth2Module, OAuth2Module,
JWTModule,
TwoFactorModule, TwoFactorModule,
SettingsModule, SettingsModule,
PrivilegeModule, PrivilegeModule,

View File

@ -4,6 +4,7 @@ import * as dotenv from 'dotenv';
import * as session from 'express-session'; import * as session from 'express-session';
import * as connectRedis from 'connect-redis'; import * as connectRedis from 'connect-redis';
import * as redis from 'redis'; import * as redis from 'redis';
import * as cookieParser from 'cookie-parser';
import { join } from 'path'; import { join } from 'path';
import { NestExpressApplication } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express';
@ -19,7 +20,7 @@ async function bootstrap() {
}); });
// app.use(express.urlencoded()); // app.use(express.urlencoded());
// app.use(cookieParser()); app.use(cookieParser());
// Production servers have to be behind a proxy. // Production servers have to be behind a proxy.
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {

View File

@ -2,17 +2,24 @@ import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import { TokenService } from 'src/modules/utility/services/token.service'; import { TokenService } from 'src/modules/utility/services/token.service';
const DEV = process.env.NODE_ENV !== 'production';
@Injectable() @Injectable()
export class CSRFMiddleware implements NestMiddleware { export class CSRFMiddleware implements NestMiddleware {
constructor(private readonly tokenService: TokenService) {} constructor(private readonly tokenService: TokenService) {}
use(req: Request, res: Response, next: NextFunction) { use(req: Request, res: Response, next: NextFunction) {
// TODO: do not store in session, keep the amount of pointless sessions down let secretToken = req.cookies.XSRF;
if (!req.session.csrf) { if (!secretToken) {
req.session.csrf = this.tokenService.csrf.secretSync(); secretToken = this.tokenService.csrf.secretSync();
res.cookie('XSRF', secretToken, {
maxAge: 60 * 60 * 1000,
secure: !DEV,
sameSite: 'strict',
});
} }
req.csrfToken = () => this.tokenService.csrf.create(req.session.csrf); req.csrfToken = () => this.tokenService.csrf.create(secretToken);
next(); next();
} }

View File

@ -48,7 +48,7 @@ export const configProviders = [
const file = await readFile(path, { encoding: 'utf-8' }); const file = await readFile(path, { encoding: 'utf-8' });
return { return {
...def, ...def,
...toml.parse(file), ...JSON.parse(JSON.stringify(toml.parse(file))),
}; };
} catch (e: any) { } catch (e: any) {
console.error('Failed to load configuration:', e.message); console.error('Failed to load configuration:', e.message);

View File

@ -53,7 +53,7 @@ export class LoginController {
@Body() body: { username: string; password: string }, @Body() body: { username: string; password: string },
@Query('redirectTo') redirectTo?: string, @Query('redirectTo') redirectTo?: string,
) { ) {
const { username, password } = body; const { username, password } = this.formUtil.trimmed(body, ['username']);
const user = await this.userService.getByUsername(username); const user = await this.userService.getByUsername(username);
// User exists and password matches // User exists and password matches

View File

@ -1,5 +1,6 @@
import { OAuth2AdapterModel, OAuth2Provider } from '@icynet/oauth2-provider'; import { OAuth2AdapterModel, OAuth2Provider } from '@icynet/oauth2-provider';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigurationService } from 'src/modules/config/config.service';
import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service'; import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service';
import { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service'; import { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service';
import { UserService } from 'src/modules/objects/user/user.service'; import { UserService } from 'src/modules/objects/user/user.service';
@ -55,6 +56,7 @@ export class OAuth2Service {
constructor( constructor(
public token: TokenService, public token: TokenService,
public config: ConfigurationService,
public userService: UserService, public userService: UserService,
public clientService: OAuth2ClientService, public clientService: OAuth2ClientService,
public tokenService: OAuth2TokenService, public tokenService: OAuth2TokenService,

View File

@ -35,7 +35,8 @@ export class RegisterController {
@Body() body: RegisterDto, @Body() body: RegisterDto,
@Query('redirectTo') redirectTo?: string, @Query('redirectTo') redirectTo?: string,
) { ) {
const { username, display_name, email, password, password_repeat } = body; const { username, display_name, email, password, password_repeat } =
this.formUtil.trimmed(body, ['username', 'display_name', 'email']);
try { try {
if ( if (

View File

@ -56,7 +56,7 @@ export class SettingsController {
@Body() body: { display_name?: string }, @Body() body: { display_name?: string },
) { ) {
try { try {
const { display_name } = body; 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.');
} }

View File

@ -2,6 +2,11 @@ import { Inject, Injectable } from '@nestjs/common';
import { ConfigurationService } from '../config/config.service'; import { ConfigurationService } from '../config/config.service';
import * as jwt from 'jsonwebtoken'; import * as jwt from 'jsonwebtoken';
/**
* Generate JWTs using the following commands:
* Private: ssh-keygen -t rsa -b 4096 -m PEM -f jwt.private.pem
* Public: openssl rsa -in jwt.private.pem -pubout -outform PEM -out jwt.public.pem
*/
@Injectable() @Injectable()
export class JWTService { export class JWTService {
constructor( constructor(

View File

@ -15,7 +15,7 @@ Change your password: ${url}
If you did not request a password change on Icy Network, you can safely ignore this email. If you did not request a password change on Icy Network, you can safely ignore this email.
`, `,
html: ` html: /* html */ `
<h1>Icy Network</h1> <h1>Icy Network</h1>
<p><strong>Hello, ${username}! You have requested a password reset on Icy Network.</strong></p> <p><strong>Hello, ${username}! You have requested a password reset on Icy Network.</strong></p>

View File

@ -13,7 +13,7 @@ In order to proceed with logging in, please click on the following link to activ
Activate your account: ${url} Activate your account: ${url}
`, `,
html: ` html: /* html */ `
<h1>Icy Network</h1> <h1>Icy Network</h1>
<p><strong>Welcome to Icy Network, ${username}!</strong></p> <p><strong>Welcome to Icy Network, ${username}!</strong></p>

View File

@ -25,14 +25,26 @@ export class UserService {
) {} ) {}
public async getById(id: number, relations?: string[]): Promise<User> { public async getById(id: number, relations?: string[]): Promise<User> {
if (!id) {
return null;
}
return this.userRepository.findOne({ id }, { relations }); return this.userRepository.findOne({ id }, { relations });
} }
public async getByUUID(uuid: string, relations?: string[]): Promise<User> { public async getByUUID(uuid: string, relations?: string[]): Promise<User> {
if (!uuid) {
return null;
}
return this.userRepository.findOne({ uuid }, { relations }); return this.userRepository.findOne({ uuid }, { relations });
} }
public async getByEmail(email: string, relations?: string[]): Promise<User> { public async getByEmail(email: string, relations?: string[]): Promise<User> {
if (!email) {
return null;
}
return this.userRepository.findOne({ email }, { relations }); return this.userRepository.findOne({ email }, { relations });
} }
@ -40,6 +52,10 @@ export class UserService {
username: string, username: string,
relations?: string[], relations?: string[],
): Promise<User> { ): Promise<User> {
if (!username) {
return null;
}
return this.userRepository.findOne({ username }, { relations }); return this.userRepository.findOne({ username }, { relations });
} }

View File

@ -15,10 +15,16 @@ export class FormUtilityService {
); );
} }
public trimmed<T>(entry: T, fields: string[]): T {
return fields.reduce<T>(
(object, key) => ({ ...object, [key]: object[key]?.trim() }),
entry,
);
}
/** /**
* Include CSRF token, messages and prefilled form values for a template with a form * Include CSRF token, messages and prefilled form values for a template with a form
* @param req Express request * @param req Express request
* @param session Express session
* @param additional Additional data to pass * @param additional Additional data to pass
* @returns Template locals * @returns Template locals
*/ */

View File

@ -18,7 +18,7 @@ export class TokenService {
constructor(private config: ConfigurationService) {} constructor(private config: ConfigurationService) {}
public verifyCSRF(req: Request, token?: string): boolean { public verifyCSRF(req: Request, token?: string): boolean {
return this.csrf.verify(req.session.csrf, token || req.body._csrf); return this.csrf.verify(req.cookies.XSRF, token || req.body._csrf);
} }
public generateString(length: number): string { public generateString(length: number): string {

View File

@ -17,7 +17,7 @@ block settings
h2 Change Password h2 Change Password
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#csrfa(type="hidden", name="_csrf", value=csrf)
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")
label.form-label(for="new_password") New Password label.form-label(for="new_password") New Password
@ -29,7 +29,7 @@ block settings
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#csrf(type="hidden", name="_csrf", value=csrf) input#csrfb(type="hidden", name="_csrf", value=csrf)
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}