From a21a2257dbc8dbd039199207ccecc95fe76cdbe0 Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Wed, 17 Aug 2022 21:56:47 +0300 Subject: [PATCH] Some progress --- .vscode/settings.json | 9 +++++++++ ormconfig.js | 3 +++ package-lock.json | 19 +++++++++++++++++++ package.json | 1 + src/app.module.ts | 2 ++ src/main.ts | 3 ++- src/middleware/csrf.middleware.ts | 15 +++++++++++---- src/modules/config/config.providers.ts | 2 +- .../features/login/login.controller.ts | 2 +- src/modules/features/oauth2/oauth2.service.ts | 2 ++ .../features/register/register.controller.ts | 3 ++- .../features/settings/settings.controller.ts | 2 +- src/modules/jwt/jwt.service.ts | 5 +++++ .../user/email/forgot-password.email.ts | 2 +- .../objects/user/email/registration.email.ts | 2 +- src/modules/objects/user/user.service.ts | 16 ++++++++++++++++ .../utility/services/form-utility.service.ts | 8 +++++++- src/modules/utility/services/token.service.ts | 2 +- views/settings/security.pug | 4 ++-- 19 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8599e60 --- /dev/null +++ b/.vscode/settings.json @@ -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 + } +} diff --git a/ormconfig.js b/ormconfig.js index d0813d9..1bc00ba 100644 --- a/ormconfig.js +++ b/ormconfig.js @@ -9,6 +9,9 @@ dotenv.config(); const CONFIG_ENV = process.env.NODE_ENV === 'production' ? 'prod' : 'dev'; const CONFIG_FILENAME = process.env.CONFIG || `config.${CONFIG_ENV}.toml`; 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( JSON.stringify(toml.parse(readFileSync(CONFIG_PATH, { encoding: 'utf-8' }))), ); diff --git a/package-lock.json b/package-lock.json index 4125dfa..987be0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "@nestjs/testing": "^8.0.0", "@types/bcrypt": "^5.0.0", "@types/connect-redis": "^0.0.18", + "@types/cookie-parser": "^1.4.3", "@types/express": "^4.17.13", "@types/express-session": "^1.17.4", "@types/jest": "27.4.1", @@ -3146,6 +3147,15 @@ "@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": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", @@ -14757,6 +14767,15 @@ "@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": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", diff --git a/package.json b/package.json index eabe541..25eee8a 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@nestjs/testing": "^8.0.0", "@types/bcrypt": "^5.0.0", "@types/connect-redis": "^0.0.18", + "@types/cookie-parser": "^1.4.3", "@types/express": "^4.17.13", "@types/express-session": "^1.17.4", "@types/jest": "27.4.1", diff --git a/src/app.module.ts b/src/app.module.ts index 4128aba..711ce56 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { OAuth2Module } from './modules/features/oauth2/oauth2.module'; import { RegisterModule } from './modules/features/register/register.module'; import { SettingsModule } from './modules/features/settings/settings.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 { EmailModule } from './modules/objects/email/email.module'; import { OAuth2ClientModule } from './modules/objects/oauth2-client/oauth2-client.module'; @@ -47,6 +48,7 @@ import { UtilityModule } from './modules/utility/utility.module'; LoginModule, RegisterModule, OAuth2Module, + JWTModule, TwoFactorModule, SettingsModule, PrivilegeModule, diff --git a/src/main.ts b/src/main.ts index dba9d5c..41f7564 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import * as dotenv from 'dotenv'; import * as session from 'express-session'; import * as connectRedis from 'connect-redis'; import * as redis from 'redis'; +import * as cookieParser from 'cookie-parser'; import { join } from 'path'; import { NestExpressApplication } from '@nestjs/platform-express'; @@ -19,7 +20,7 @@ async function bootstrap() { }); // app.use(express.urlencoded()); - // app.use(cookieParser()); + app.use(cookieParser()); // Production servers have to be behind a proxy. if (process.env.NODE_ENV === 'production') { diff --git a/src/middleware/csrf.middleware.ts b/src/middleware/csrf.middleware.ts index af3b3f0..0192791 100644 --- a/src/middleware/csrf.middleware.ts +++ b/src/middleware/csrf.middleware.ts @@ -2,17 +2,24 @@ import { Injectable, NestMiddleware } from '@nestjs/common'; import { NextFunction, Request, Response } from 'express'; import { TokenService } from 'src/modules/utility/services/token.service'; +const DEV = process.env.NODE_ENV !== 'production'; + @Injectable() export class CSRFMiddleware implements NestMiddleware { constructor(private readonly tokenService: TokenService) {} use(req: Request, res: Response, next: NextFunction) { - // TODO: do not store in session, keep the amount of pointless sessions down - if (!req.session.csrf) { - req.session.csrf = this.tokenService.csrf.secretSync(); + let secretToken = req.cookies.XSRF; + if (!secretToken) { + 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(); } diff --git a/src/modules/config/config.providers.ts b/src/modules/config/config.providers.ts index d5a9301..0e7a8d3 100644 --- a/src/modules/config/config.providers.ts +++ b/src/modules/config/config.providers.ts @@ -48,7 +48,7 @@ export const configProviders = [ const file = await readFile(path, { encoding: 'utf-8' }); return { ...def, - ...toml.parse(file), + ...JSON.parse(JSON.stringify(toml.parse(file))), }; } catch (e: any) { console.error('Failed to load configuration:', e.message); diff --git a/src/modules/features/login/login.controller.ts b/src/modules/features/login/login.controller.ts index 6939d4f..67a51e6 100644 --- a/src/modules/features/login/login.controller.ts +++ b/src/modules/features/login/login.controller.ts @@ -53,7 +53,7 @@ export class LoginController { @Body() body: { username: string; password: string }, @Query('redirectTo') redirectTo?: string, ) { - const { username, password } = body; + const { username, password } = this.formUtil.trimmed(body, ['username']); const user = await this.userService.getByUsername(username); // User exists and password matches diff --git a/src/modules/features/oauth2/oauth2.service.ts b/src/modules/features/oauth2/oauth2.service.ts index 1de7e93..32bf81d 100644 --- a/src/modules/features/oauth2/oauth2.service.ts +++ b/src/modules/features/oauth2/oauth2.service.ts @@ -1,5 +1,6 @@ import { OAuth2AdapterModel, OAuth2Provider } from '@icynet/oauth2-provider'; 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 { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service'; import { UserService } from 'src/modules/objects/user/user.service'; @@ -55,6 +56,7 @@ export class OAuth2Service { constructor( public token: TokenService, + public config: ConfigurationService, public userService: UserService, public clientService: OAuth2ClientService, public tokenService: OAuth2TokenService, diff --git a/src/modules/features/register/register.controller.ts b/src/modules/features/register/register.controller.ts index 9d0417a..1b49d36 100644 --- a/src/modules/features/register/register.controller.ts +++ b/src/modules/features/register/register.controller.ts @@ -35,7 +35,8 @@ export class RegisterController { @Body() body: RegisterDto, @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 { if ( diff --git a/src/modules/features/settings/settings.controller.ts b/src/modules/features/settings/settings.controller.ts index 99cf18b..ad383cb 100644 --- a/src/modules/features/settings/settings.controller.ts +++ b/src/modules/features/settings/settings.controller.ts @@ -56,7 +56,7 @@ export class SettingsController { @Body() body: { display_name?: string }, ) { try { - const { display_name } = body; + const { display_name } = this._form.trimmed(body, ['display_name']); if (!display_name) { throw new Error('Display name is required.'); } diff --git a/src/modules/jwt/jwt.service.ts b/src/modules/jwt/jwt.service.ts index 621c81a..8c922d7 100644 --- a/src/modules/jwt/jwt.service.ts +++ b/src/modules/jwt/jwt.service.ts @@ -2,6 +2,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { ConfigurationService } from '../config/config.service'; 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() export class JWTService { constructor( diff --git a/src/modules/objects/user/email/forgot-password.email.ts b/src/modules/objects/user/email/forgot-password.email.ts index 2f9438d..095456b 100644 --- a/src/modules/objects/user/email/forgot-password.email.ts +++ b/src/modules/objects/user/email/forgot-password.email.ts @@ -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. `, - html: ` + html: /* html */ `

Icy Network

Hello, ${username}! You have requested a password reset on Icy Network.

diff --git a/src/modules/objects/user/email/registration.email.ts b/src/modules/objects/user/email/registration.email.ts index 2b71e92..3e0a280 100644 --- a/src/modules/objects/user/email/registration.email.ts +++ b/src/modules/objects/user/email/registration.email.ts @@ -13,7 +13,7 @@ In order to proceed with logging in, please click on the following link to activ Activate your account: ${url} `, - html: ` + html: /* html */ `

Icy Network

Welcome to Icy Network, ${username}!

diff --git a/src/modules/objects/user/user.service.ts b/src/modules/objects/user/user.service.ts index 4a51e8c..1aa5b73 100644 --- a/src/modules/objects/user/user.service.ts +++ b/src/modules/objects/user/user.service.ts @@ -25,14 +25,26 @@ export class UserService { ) {} public async getById(id: number, relations?: string[]): Promise { + if (!id) { + return null; + } + return this.userRepository.findOne({ id }, { relations }); } public async getByUUID(uuid: string, relations?: string[]): Promise { + if (!uuid) { + return null; + } + return this.userRepository.findOne({ uuid }, { relations }); } public async getByEmail(email: string, relations?: string[]): Promise { + if (!email) { + return null; + } + return this.userRepository.findOne({ email }, { relations }); } @@ -40,6 +52,10 @@ export class UserService { username: string, relations?: string[], ): Promise { + if (!username) { + return null; + } + return this.userRepository.findOne({ username }, { relations }); } diff --git a/src/modules/utility/services/form-utility.service.ts b/src/modules/utility/services/form-utility.service.ts index 9a239cd..9adc18a 100644 --- a/src/modules/utility/services/form-utility.service.ts +++ b/src/modules/utility/services/form-utility.service.ts @@ -15,10 +15,16 @@ export class FormUtilityService { ); } + public trimmed(entry: T, fields: string[]): T { + return fields.reduce( + (object, key) => ({ ...object, [key]: object[key]?.trim() }), + entry, + ); + } + /** * Include CSRF token, messages and prefilled form values for a template with a form * @param req Express request - * @param session Express session * @param additional Additional data to pass * @returns Template locals */ diff --git a/src/modules/utility/services/token.service.ts b/src/modules/utility/services/token.service.ts index 7f59f2c..2b28f62 100644 --- a/src/modules/utility/services/token.service.ts +++ b/src/modules/utility/services/token.service.ts @@ -18,7 +18,7 @@ export class TokenService { constructor(private config: ConfigurationService) {} 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 { diff --git a/views/settings/security.pug b/views/settings/security.pug index f0506c9..d9c0d71 100644 --- a/views/settings/security.pug +++ b/views/settings/security.pug @@ -17,7 +17,7 @@ block settings h2 Change Password form(method="post", action="/account/security/password", autocomplete="off") 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 input.form-control#password(type="password", name="password") label.form-label(for="new_password") New Password @@ -29,7 +29,7 @@ block settings h2 Change Email Address form(method="post", action="/account/security/email", autocomplete="off") 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 input.form-control#current_email(type="email", name="current_email") small.form-hint Hint: #{emailHint}