diff --git a/README.md b/README.md index 01c0e80..a2e4347 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Redis Configuration Mailing Configuration - `POSTAL_BASE_URL` - Base URL for the Postal API - `POSTAL_API_KEY` - API Key for the Postal API +- `FROM_EMAIL` - Email address to send emails from ## Credits diff --git a/package.json b/package.json index 6c02e62..9eccb48 100644 --- a/package.json +++ b/package.json @@ -37,12 +37,15 @@ "@nestjs/terminus": "^10.2.3", "@nestjs/typeorm": "^10.0.2", "@opentelemetry/api": "^1.9.0", + "argon2": "^0.40.3", "bullmq": "^5.9.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cookie-parser": "^1.4.6", "dotenv": "^16.4.5", "hbs": "^4.2.0", "ioredis": "^5.4.1", + "keygrip": "^1.1.0", "mysql2": "^3.10.2", "nanoid": "^5.0.7", "nestjs-otel": "^6.1.1", @@ -61,9 +64,12 @@ "@nestjs/cli": "^10.4.2", "@nestjs/schematics": "^10.1.2", "@nestjs/testing": "^10.3.10", + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", + "@types/keygrip": "^1.0.6", "@types/node": "^20.14.10", + "@types/oidc-provider": "^8.5.1", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^7.16.1", "@typescript-eslint/parser": "^7.16.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbcd0a2..d9cf9e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 + argon2: + specifier: ^0.40.3 + version: 0.40.3 bullmq: specifier: ^5.9.0 version: 5.9.0 @@ -50,6 +53,9 @@ importers: class-validator: specifier: ^0.14.1 version: 0.14.1 + cookie-parser: + specifier: ^1.4.6 + version: 1.4.6 dotenv: specifier: ^16.4.5 version: 16.4.5 @@ -59,6 +65,9 @@ importers: ioredis: specifier: ^5.4.1 version: 5.4.1 + keygrip: + specifier: ^1.1.0 + version: 1.1.0 mysql2: specifier: ^3.10.2 version: 3.10.2 @@ -108,15 +117,24 @@ importers: '@nestjs/testing': specifier: ^10.3.10 version: 10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10)) + '@types/cookie-parser': + specifier: ^1.4.7 + version: 1.4.7 '@types/express': specifier: ^4.17.21 version: 4.17.21 '@types/jest': specifier: ^29.5.12 version: 29.5.12 + '@types/keygrip': + specifier: ^1.0.6 + version: 1.0.6 '@types/node': specifier: ^20.14.10 version: 20.14.10 + '@types/oidc-provider': + specifier: ^8.5.1 + version: 8.5.1 '@types/supertest': specifier: ^6.0.2 version: 6.0.2 @@ -824,6 +842,10 @@ packages: resolution: {integrity: sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==} engines: {node: '>=14'} + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -867,6 +889,9 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/accepts@1.3.7': + resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -885,9 +910,18 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/content-disposition@0.5.8': + resolution: {integrity: sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==} + + '@types/cookie-parser@1.4.7': + resolution: {integrity: sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==} + '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/cookies@0.9.0': + resolution: {integrity: sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -906,6 +940,9 @@ packages: '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + '@types/http-assert@1.5.5': + resolution: {integrity: sha512-4+tE/lwdAahgZT1g30Jkdm9PzFRde0xwxBNUyRsCitRvCQB90iuA2uJYdUnhnANRcqGXaWOGY4FEoxeElNAK2g==} + '@types/http-cache-semantics@4.0.4': resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} @@ -927,6 +964,15 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/keygrip@1.0.6': + resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} + + '@types/koa-compose@3.2.8': + resolution: {integrity: sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==} + + '@types/koa@2.15.0': + resolution: {integrity: sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -936,6 +982,9 @@ packages: '@types/node@20.14.10': resolution: {integrity: sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==} + '@types/oidc-provider@8.5.1': + resolution: {integrity: sha512-NS8tBPOj9GG6SxyrUHWBzglOtAYNDX41J4cRE45oeK0iSqI6V6tDW70aPWg25pJFNSC1evccXFm9evfwjxm7HQ==} + '@types/qs@6.9.15': resolution: {integrity: sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==} @@ -1202,6 +1251,10 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argon2@0.40.3: + resolution: {integrity: sha512-FrSmz4VeM91jwFvvjsQv9GYp6o/kARWoYKjbjDB2U5io1H3e5X67PYGclFDeQff6UXIhUd4aHR3mxCdBbMMuQw==} + engines: {node: '>=16.17.0'} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -1551,9 +1604,17 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-parser@1.4.6: + resolution: {integrity: sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==} + engines: {node: '>= 0.8.0'} + cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie@0.4.1: + resolution: {integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==} + engines: {node: '>= 0.6'} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -2918,6 +2979,10 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@8.1.0: + resolution: {integrity: sha512-yBY+qqWSv3dWKGODD6OGE6GnTX7Q2r+4+DfpqxHSHh8x0B4EKP9+wVGLS6U/AM1vxSNNmUEuIV5EGhYwPpfOwQ==} + engines: {node: ^18 || ^20 || >= 21} + node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} @@ -2934,6 +2999,10 @@ packages: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true + node-gyp-build@4.8.1: + resolution: {integrity: sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==} + hasBin: true + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -4894,6 +4963,8 @@ snapshots: '@opentelemetry/semantic-conventions@1.25.1': {} + '@phc/format@1.0.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -4927,6 +4998,10 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@types/accepts@1.3.7': + dependencies: + '@types/node': 20.14.10 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.24.8 @@ -4957,8 +5032,21 @@ snapshots: dependencies: '@types/node': 20.14.10 + '@types/content-disposition@0.5.8': {} + + '@types/cookie-parser@1.4.7': + dependencies: + '@types/express': 4.17.21 + '@types/cookiejar@2.1.5': {} + '@types/cookies@0.9.0': + dependencies: + '@types/connect': 3.4.38 + '@types/express': 4.17.21 + '@types/keygrip': 1.0.6 + '@types/node': 20.14.10 + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 8.56.10 @@ -4989,6 +5077,8 @@ snapshots: dependencies: '@types/node': 20.14.10 + '@types/http-assert@1.5.5': {} + '@types/http-cache-semantics@4.0.4': {} '@types/http-errors@2.0.4': {} @@ -5010,6 +5100,23 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/keygrip@1.0.6': {} + + '@types/koa-compose@3.2.8': + dependencies: + '@types/koa': 2.15.0 + + '@types/koa@2.15.0': + dependencies: + '@types/accepts': 1.3.7 + '@types/content-disposition': 0.5.8 + '@types/cookies': 0.9.0 + '@types/http-assert': 1.5.5 + '@types/http-errors': 2.0.4 + '@types/keygrip': 1.0.6 + '@types/koa-compose': 3.2.8 + '@types/node': 20.14.10 + '@types/methods@1.1.4': {} '@types/mime@1.3.5': {} @@ -5018,6 +5125,11 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/oidc-provider@8.5.1': + dependencies: + '@types/koa': 2.15.0 + '@types/node': 20.14.10 + '@types/qs@6.9.15': {} '@types/range-parser@1.2.7': {} @@ -5324,6 +5436,12 @@ snapshots: arg@5.0.2: {} + argon2@0.40.3: + dependencies: + '@phc/format': 1.0.0 + node-addon-api: 8.1.0 + node-gyp-build: 4.8.1 + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -5737,8 +5855,15 @@ snapshots: convert-source-map@2.0.0: {} + cookie-parser@1.4.6: + dependencies: + cookie: 0.4.1 + cookie-signature: 1.0.6 + cookie-signature@1.0.6: {} + cookie@0.4.1: {} + cookie@0.6.0: {} cookiejar@2.1.4: {} @@ -7368,6 +7493,8 @@ snapshots: node-abort-controller@3.1.1: {} + node-addon-api@8.1.0: {} + node-emoji@1.11.0: dependencies: lodash: 4.17.21 @@ -7381,6 +7508,8 @@ snapshots: detect-libc: 2.0.3 optional: true + node-gyp-build@4.8.1: {} + node-int64@0.4.0: {} node-releases@2.0.14: {} diff --git a/src/app.controller.ts b/src/app.controller.ts index ed5bbbc..76b80d6 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Redirect, Render } from '@nestjs/common'; +import { Controller, Get, Redirect } from '@nestjs/common'; import { AppService } from './app.service'; import { PostalClientService } from 'nestjs-postal-client'; @@ -14,18 +14,4 @@ export class AppController { getHello(): any { return; } - - @Get('email_test') - async getEmailTest(): Promise { - await this.postal.sendMessage({ - to: ['kakious@kakio.us'], - subject: 'Test email', - from: 'kakious@kakio.us', - plain_body: 'This is a test email', - }); - - return { - message: 'Email sent', - }; - } } diff --git a/src/auth/auth.const.ts b/src/auth/auth.const.ts new file mode 100644 index 0000000..f519959 --- /dev/null +++ b/src/auth/auth.const.ts @@ -0,0 +1,20 @@ +// Redis Password Reset Const +export const PASSWORD_RESET_CACHE_KEY = 'password_reset:'; +export const PASSWORD_RESET_EXPIRATION = 60 * 5; // 5 minutes + +export const getResetKey = (code: string): string => `${PASSWORD_RESET_CACHE_KEY}${code}`; + +// Failed Login Attempts Const +export const FAILED_LOGIN_ATTEMPTS_CACHE_KEY = 'failed_login_attempts:'; +export const FAILED_LOGIN_ATTEMPTS_EXPIRATION = 60 * 60 * 1; // 1 hour +export const FAILED_LOGIN_ATTEMPTS_LIMIT = 15; + +export const getFailedLoginAttemptsKey = (ip: string): string => + `${FAILED_LOGIN_ATTEMPTS_CACHE_KEY}${ip}`; + +// Registration Const +export const REGISTRATION_CACHE_KEY = 'registration_count:'; +export const REGISTRATION_EXPIRATION = 60 * 60 * 24; // 24 hours +export const REGISTRATION_LIMIT = 10; + +export const getRegistrationKey = (ip: string): string => `${REGISTRATION_CACHE_KEY}${ip}`; diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 6eac3cb..82cf98d 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -5,11 +5,22 @@ import { OidcService } from './oidc/core.service'; import { UserModule } from '../user/user.module'; import { RedisModule } from '../redis/redis.module'; import { AuthController } from './controllers/auth.controller'; +import { AuthService } from './services/auth.service'; +import { MailModule } from '../mail/mail.module'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { OidcSession } from '../database/models/oidc_session.model'; +import { OidcClient } from '../database/models/oidc_client.model'; +import { OidcClientPermission } from '../database/models/oidc_client_permissions.model'; @Module({ - imports: [UserModule, RedisModule], + imports: [ + UserModule, + RedisModule, + MailModule, + TypeOrmModule.forFeature([OidcSession, OidcClient, OidcClientPermission]), + ], controllers: [OidcController, AuthController], - providers: [ConfigService, OidcService], + providers: [ConfigService, OidcService, AuthService], exports: [], }) export class AuthModule {} diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 94596e4..85fad8f 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -1,34 +1,42 @@ -import { BadRequestException, Body, Controller, Get, Post, Render, Res } from '@nestjs/common'; +import { Body, Controller, Get, Post, Render, Res } from '@nestjs/common'; import { ApiExcludeEndpoint } from '@nestjs/swagger'; + +import { AuthService } from '../services/auth.service'; +import { ForgotPasswordDto } from '../dto/forgotPassword.dto'; +import { CreateUserDto } from '../dto/register.dto'; +import { LoginUserDto } from '../dto/loginUser.dto'; import { Response } from 'express'; @Controller('auth') export class AuthController { - constructor() {} + constructor(private readonly authService: AuthService) {} @Post('login') // TODO: Implement RateLimit - public async postHello(@Body() body: any): Promise { - return body; + public async postLogin( + @Body() body: LoginUserDto, + @Res({ passthrough: true }) res: Response, + ): Promise { + const sessionData = await this.authService.login(body.username, body.password); + + // process the sessionData.cookies and set it in the response + sessionData.cookiesForms.forEach((cookie) => { + res.cookie(cookie.name, cookie.value, cookie.options); + }); + + return sessionData.sessionId; } // TODO: Implement RateLimit @Post('register') - public async postRegister(@Body() body: any): Promise { - return body; + public async postRegister(@Body() body: CreateUserDto): Promise { + return await this.authService.register(body.username, body.email, body.password); } // TODO: Implement RateLimit @Post('reset-password') - public async postForgotPassword(@Body('email') email: string): Promise { - const user = await this.findUserByEmail(email); - - if (!user) { - throw new BadRequestException({ error: true, message: 'User not found' }); - } else { - await this.sendPasswordResetEmail(email); - return { error: false, message: 'Password reset email sent' }; - } + public async postForgotPassword(@Body() body: ForgotPasswordDto): Promise { + return await this.authService.forgotPassword(body.email); } // Render pages @@ -60,15 +68,4 @@ export class AuthController { login: 'login', }; } - - // Helper Functions - - private async findUserByEmail(email: string) { - // Implement this method to find the user by email - return false; - } - - private async sendPasswordResetEmail(email: string) { - // Implement this method to send the password reset email - } } diff --git a/src/auth/mailers/postal.service.ts b/src/auth/decorators/session.decorator.ts similarity index 100% rename from src/auth/mailers/postal.service.ts rename to src/auth/decorators/session.decorator.ts diff --git a/src/auth/dto/forgotPassword.dto.ts b/src/auth/dto/forgotPassword.dto.ts new file mode 100644 index 0000000..bbedf08 --- /dev/null +++ b/src/auth/dto/forgotPassword.dto.ts @@ -0,0 +1,6 @@ +import { IsEmail } from 'class-validator'; + +export class ForgotPasswordDto { + @IsEmail() + email: string; +} diff --git a/src/auth/dto/loginUser.dto.ts b/src/auth/dto/loginUser.dto.ts new file mode 100644 index 0000000..61bcbfe --- /dev/null +++ b/src/auth/dto/loginUser.dto.ts @@ -0,0 +1,11 @@ +import { IsString, IsNotEmpty } from 'class-validator'; + +export class LoginUserDto { + @IsString() + @IsNotEmpty() + username: string; + + @IsString() + @IsNotEmpty() + password: string; +} diff --git a/src/auth/dto/register.dto.ts b/src/auth/dto/register.dto.ts new file mode 100644 index 0000000..20acf88 --- /dev/null +++ b/src/auth/dto/register.dto.ts @@ -0,0 +1,12 @@ +import { IsEmail, IsNotEmpty } from 'class-validator'; + +export class CreateUserDto { + @IsEmail() + email: string; + + @IsNotEmpty() + username: string; + + @IsNotEmpty() + password: string; +} diff --git a/src/auth/oidc/core.service.ts b/src/auth/oidc/core.service.ts index 13760ea..ce85d5b 100644 --- a/src/auth/oidc/core.service.ts +++ b/src/auth/oidc/core.service.ts @@ -25,6 +25,8 @@ import { UserService } from 'src/user/user.service'; import { Span } from 'nestjs-otel'; import generateId from './helper/nanoid.helper'; import { context, trace } from '@opentelemetry/api'; +import * as KeyGrip from 'keygrip'; +import { getEpochTime } from '../../util/time.util'; // This is an async import for the oidc-provider package as it's now only esm and we need to use it in a commonjs environment. async function getProvider(): Promise<{ @@ -445,4 +447,61 @@ export class OidcService implements OnModuleInit { }, }; } + + /** + * Create a new OIDC session + * @param accountId The account ID + * @returns {Promise<{session: any, cookies: string[]}>} - Returns a promise that resolves with the session and cookies + */ + @Span() + async createSession(accountId: string | number) { + const loginTs = getEpochTime(); + const uid = await generateId(); + const sessionId = await generateId(); + const expire = new Date(); + expire.setSeconds(expire.getSeconds() + SESSION_LIFE); + accountId = accountId.toString(); + + await this.provider.Session.adapter.upsert( + sessionId, + { + jti: sessionId, + uid, + accountId, + loginTs, + iat: loginTs, + exp: Math.floor(expire.getTime() / 1000), + }, + SESSION_LIFE, + ); + const keyGrip = KeyGrip(this.cookies); + const sessionCookie = `_session=${sessionId}; path=/; expires=${expire.toUTCString()}; httponly`; + const cookies = [sessionCookie]; + const [pre, ...post] = sessionCookie.split(';'); + cookies.push([`_session.sig=${keyGrip.sign(pre)}`, ...post].join(';')); + // Map the cookies to bbe in this format { name: string, value: string, options: { expires: Date, sameSite: 'strict', httpOnly: true, path: '/' } } + const cookiesForms = [ + { + name: '_session', + value: sessionId, + options: { + expires: expire, + sameSite: 'strict', + httpOnly: true, + }, + }, + { + name: '_session.sig', + value: keyGrip.sign(sessionId), + options: { + expires: expire, + sameSite: 'strict', + httpOnly: true, + }, + }, + ]; + + this.logger.debug(`Created new session`, { sessionId, accountId }); + return { sessionId, cookies, cookiesForms }; + } } diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index e69de29..7786151 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -0,0 +1,131 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { UserService } from '../../user/user.service'; +import { RedisService } from '../../redis/redis.service'; +import { MailService } from '../../mail/mail.service'; +import { getResetKey, PASSWORD_RESET_EXPIRATION } from '../auth.const'; +import { OidcService } from '../oidc/core.service'; + +@Injectable() +export class AuthService { + constructor( + private readonly userService: UserService, + private readonly redisService: RedisService, + private readonly oidcService: OidcService, + private readonly mailService: MailService, + ) {} + + /** + * Mark a password as forgotten. This will send an email to the user with a link to reset their password. + * @param email The email address of the user + * @returns A message indicating that the password reset email has been sent + */ + public async forgotPassword(email: string): Promise<{ error: boolean; message: string }> { + const user = await this.userService.getUserbyEmail(email); + + if (!user) { + throw new BadRequestException('No user found with that email'); + } else { + const resetCode = await this.generateResetCode(); + await this.mailService.sendPasswordResetEmail(email, resetCode); + await this.storePasswordResetCode(resetCode, user.id); + return { error: false, message: 'Password reset email sent' }; + } + } + + /** + * Reset a user's password + * @param code The password reset code + * @param password The new password + */ + public async resetPassword( + code: string, + password: string, + ): Promise<{ error: boolean; message: string }> { + const userId = await this.getPasswordResetCode(code); + + if (!userId) { + throw new BadRequestException('Invalid reset code'); + } + + await this.userService.updatePassword(userId, password); + await this.redisService.del(getResetKey(code)); + return { error: false, message: 'Password reset successfully sent' }; + } + + /** + * Register a new user + * @param username The username of the user + * @param email The email address of the user + * @param password The password of the user + * @returns A message indicating that the user has been registered + */ + public async register( + username: string, + email: string, + password: string, + ): Promise<{ error: boolean; message: string }> { + const validateUnique = await this.userService.validateUniqueUsernameAndEmail(username, email); + + if (validateUnique.length !== 0) { + throw new BadRequestException({ + message: 'Username or email already exists', + fields: validateUnique, + }); + } + + await this.userService.createUser(username, email, password); + await this.mailService.sendWelcomeEmail(email); + return { error: false, message: 'User registered' }; + } + + /** + * Validate a user's login credentials and return the session cookies + * @param username The username or email address of the user + * @param password The password of the user + * @returns The session cookies + */ + public async login(username: string, password: string): Promise { + const user = await this.userService.authenticate(username, password); + + if (!user) { + throw new BadRequestException('Invalid credentials'); + } + + return this.oidcService.createSession(user.id); + } + + /** + * Reset code generation logic + * @returns Promise A reset code + */ + private async generateResetCode(): Promise { + let code = Math.random().toString(36).substring(2, 15); + + // Check if the code already exists + if (await this.redisService.get(code)) { + code = await this.generateResetCode(); + } + + return code; + } + + /** + * Store a password reset code in the Redis store + * @param code The password reset code + * @param userId The user ID associated with the code + * @returns void + */ + private async storePasswordResetCode(code: string, userId: string | number): Promise { + await this.redisService.set(getResetKey(code), userId, PASSWORD_RESET_EXPIRATION); + } + + /** + * Get a password reset code from the Redis store + * @param code The password reset code + * @returns The user ID associated with the code + */ + private async getPasswordResetCode(code: string): Promise { + return this.redisService.get(getResetKey(code)); + } +} diff --git a/src/config/config.ts b/src/config/config.ts index ecf7ac9..9441a7b 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,6 +1,7 @@ export default async () => ({ base: { localUrl: `${process.env.BASE_URL}`, + fromEmail: `${process.env.FROM_EMAIL}`, }, redis: { host: process.env['REDIS_HOST'] ?? 'localhost', diff --git a/src/database/database.const.ts b/src/database/database.const.ts index d832632..072bdcf 100644 --- a/src/database/database.const.ts +++ b/src/database/database.const.ts @@ -1,6 +1,5 @@ export const MAX_STRING_LENGTH = 255; -export const FETCH_AFTER_CREATION_FAILED = - 'Failed to fetch entity after creation'; +export const FETCH_AFTER_CREATION_FAILED = 'Failed to fetch entity after creation'; export enum UserRole { ADMIN = 'admin', diff --git a/src/database/models/oidc_client.model.ts b/src/database/models/oidc_client.model.ts index b35530f..23af5a5 100644 --- a/src/database/models/oidc_client.model.ts +++ b/src/database/models/oidc_client.model.ts @@ -21,19 +21,19 @@ export class OidcClient { @Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: true }) client_name: string; - @Column({ type: 'simple-array', nullable: false, default: [] }) + @Column({ type: 'simple-array', nullable: false }) redirect_uris: string[]; - @Column({ type: 'simple-array', nullable: true, default: [] }) + @Column({ type: 'simple-array', nullable: true }) client_cors: string[]; - @Column({ type: 'simple-array', nullable: true, default: [] }) + @Column({ type: 'simple-array', nullable: true }) allowed_introspection_targets: string[]; - @Column({ type: 'simple-array', nullable: false, default: [] }) + @Column({ type: 'simple-array', nullable: true }) include_permissions_from_client: string[]; - @Column({ type: 'simple-array', nullable: false, default: [] }) + @Column({ type: 'simple-array', nullable: true }) post_logout_redirect_uris: string[]; @Column({ type: 'simple-array', nullable: true }) diff --git a/src/database/models/oidc_session.model.ts b/src/database/models/oidc_session.model.ts index c4f42ed..37b5b59 100644 --- a/src/database/models/oidc_session.model.ts +++ b/src/database/models/oidc_session.model.ts @@ -1,14 +1,7 @@ -import type { - AdapterPayload, - ClientAuthorizationState, - UnknownObject, -} from 'oidc-provider'; +import type { AdapterPayload, ClientAuthorizationState, UnknownObject } from 'oidc-provider'; import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; -import { - convertFromNumberToTime, - convertFromTimeToNumber, -} from '../../util/time.util'; +import { convertFromNumberToTime, convertFromTimeToNumber } from '../../util/time.util'; @Entity('oidc_session') export class OidcSession implements AdapterPayload { diff --git a/src/database/models/user.model.ts b/src/database/models/user.model.ts index 867941b..5361cc4 100644 --- a/src/database/models/user.model.ts +++ b/src/database/models/user.model.ts @@ -1,4 +1,13 @@ -import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { + BeforeInsert, + BeforeUpdate, + Column, + Entity, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { createHash } from 'crypto'; + import { MAX_STRING_LENGTH, UserRole } from '../database.const'; import { ApiKey } from './api_keys.model'; @@ -7,13 +16,13 @@ export class User { @PrimaryGeneratedColumn() id: number; - @Column({ length: MAX_STRING_LENGTH }) + @Column({ length: MAX_STRING_LENGTH, unique: true }) username: string; @Column({ length: MAX_STRING_LENGTH, name: 'display_name', nullable: true }) displayName: string; - @Column({ length: MAX_STRING_LENGTH }) + @Column({ length: MAX_STRING_LENGTH, unique: true }) email: string; @Column({ name: 'email_verified', default: false }) @@ -31,7 +40,7 @@ export class User { @Column({ length: MAX_STRING_LENGTH, nullable: true }) avatar: string; - @Column({ length: 15, type: String }) + @Column({ length: 15, type: String, default: UserRole.USER }) role: UserRole; @Column({ name: 'disabled', default: false }) @@ -41,6 +50,13 @@ export class User { @Column({ name: 'email_hash', length: MAX_STRING_LENGTH, nullable: true }) emailHash: string; + // on update or creation of the email field, update the email hash sha256 + @BeforeInsert() + @BeforeUpdate() + updateEmailHashOnUpdate() { + this.emailHash = createHash('sha256').update(this.email).digest('hex'); + } + // Relationship Mapping @OneToMany(() => ApiKey, (apiKey) => apiKey.user) diff --git a/src/mail/mail.module.ts b/src/mail/mail.module.ts index dc864ec..e96c9f1 100644 --- a/src/mail/mail.module.ts +++ b/src/mail/mail.module.ts @@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { PostalClientModule } from 'nestjs-postal-client'; import postalConfig from './postal.config'; import { PostalConfigService } from './postal_config.service'; +import { MailService } from './mail.service'; @Module({ imports: [ @@ -13,7 +14,7 @@ import { PostalConfigService } from './postal_config.service'; }), ], controllers: [], - providers: [ConfigService], - exports: [PostalClientModule], + providers: [ConfigService, MailService], + exports: [PostalClientModule, MailService], }) export class MailModule {} diff --git a/src/mail/mail.service.ts b/src/mail/mail.service.ts new file mode 100644 index 0000000..8499138 --- /dev/null +++ b/src/mail/mail.service.ts @@ -0,0 +1,46 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PostalClientService } from 'nestjs-postal-client'; + +@Injectable() +export class MailService { + logger = new Logger(MailService.name); + + constructor( + private readonly postalService: PostalClientService, + private readonly configService: ConfigService, + ) {} + + /** + * Send a password reset email to the user + * @param email The email address of the user + */ + public async sendPasswordResetEmail(email: string, resetCode: string): Promise {} + + /** + * Send a welcome email to the user + * @param email The email address of the user + */ + public async sendWelcomeEmail(email: string): Promise { + this.logger.debug(`Sending welcome email to ${email}`); + + this.postalService.sendMessage({ + to: [email], + subject: 'Welcome to the app!', + from: this.configService.getOrThrow('MAIL_FROM'), + plain_body: 'Welcome to the app!', + }); + } + + /** + * Send a verification email to the user + * @param email The email address of the user + */ + public async sendVerificationEmail(email: string): Promise {} + + /** + * Send a password changed email to the user + * @param email The email address of the user + */ + public async sendPasswordChangedEmail(email: string): Promise {} +} diff --git a/src/main.ts b/src/main.ts index 71ad6d6..b47bad0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,11 +2,14 @@ import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { join } from 'path'; import { AppModule } from './app.module'; +import { ValidationPipe } from '@nestjs/common'; +import * as cookieParser from 'cookie-parser'; async function bootstrap() { - const app = await NestFactory.create( - AppModule, - ); + const app = await NestFactory.create(AppModule); + + app.useGlobalPipes(new ValidationPipe()); + app.use(cookieParser()); app.useStaticAssets(join(__dirname, '..', 'public')); app.setBaseViewsDir(join(__dirname, '..', 'views')); @@ -14,4 +17,4 @@ async function bootstrap() { await app.listen(3000); } -bootstrap(); \ No newline at end of file +bootstrap(); diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts index bacaeac..a8db0fc 100644 --- a/src/redis/redis.service.ts +++ b/src/redis/redis.service.ts @@ -68,7 +68,7 @@ export class RedisService implements OnApplicationShutdown { * @param value Value to store * @param ttl Time to live in seconds */ - public async set(key: string, value: string | object, ttl?: number): Promise { + public async set(key: string, value: any, ttl?: number): Promise { this.logger.debug(`Setting key ${key}`); if (typeof value === 'object') { value = JSON.stringify(value); diff --git a/src/user/user.constant.ts b/src/user/user.constant.ts index 8e1f104..4d337c7 100644 --- a/src/user/user.constant.ts +++ b/src/user/user.constant.ts @@ -1,4 +1,7 @@ export const USER_NOT_FOUND_ERROR = 'User not found'; +export const INVALID_CREDENTIALS_ERROR = + 'The email or password you entered is incorrect or the user was not found'; +export const DISABLED_USER_ERROR = 'User is disabled'; // Caching Constants for Redis diff --git a/src/user/user.service.ts b/src/user/user.service.ts index a9205e9..84c0bfd 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -1,11 +1,17 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { hash, verify } from 'argon2'; import { User } from '../database/models/user.model'; import { RedisService } from '../redis/redis.service'; import { Span } from 'nestjs-otel'; -import { USER_NOT_FOUND_ERROR, userCacheKey } from './user.constant'; +import { + DISABLED_USER_ERROR, + INVALID_CREDENTIALS_ERROR, + USER_NOT_FOUND_ERROR, + userCacheKey, +} from './user.constant'; @Injectable() export class UserService { @@ -59,4 +65,105 @@ export class UserService { return user; } + + /** + * Fetch a user by their email address + * @param email The user's email address + * @returns Promise The user + */ + @Span() + async getUserbyEmail(email: string): Promise { + const user = await this.userRepository.findOne({ where: { email } }); + + if (!user) { + throw new NotFoundException(USER_NOT_FOUND_ERROR); + } + + return user; + } + + /** + * Validate that a username and email is unique + * @param username The username to validate + * @param email The email to validate + * @returns Fields[] The fields that are not unique + */ + @Span() + async validateUniqueUsernameAndEmail(username: string, email: string): Promise { + const user = await this.userRepository.findOne({ + select: { username: true, email: true }, + where: [{ username }, { email }], + }); + + const fields: string[] = []; + + if (user) { + if (user.username === username) { + fields.push('username'); + } + if (user.email === email) { + fields.push('email'); + } + } + + return fields; + } + + /** + * Update a user's password + * @param userId The user's ID + * @param password The new password + */ + @Span() + async updatePassword(userId: string | number, password: string): Promise { + const hashedPassword = await hash(password); + await this.userRepository.update(userId, { password: hashedPassword }); + } + + /** + * Register a new user + * @param email The user's email address + * @param username The user's username + * @param password The user's password + * @returns Promise The newly created user + */ + @Span() + async createUser(username: string, email: string, password: string): Promise { + const hashedPassword = await hash(password); + + const user = this.userRepository.create({ email, username, password: hashedPassword }); + + return await this.userRepository.save(user); + } + + /** + * Authenticate a user + * @param username The user's email address or username + * @param password The user's password + * @returns Promise The authenticated user + */ + @Span() + async authenticate(username: string, password: string): Promise { + // lowercase email + username = username.toLowerCase(); + + const user = await this.userRepository.findOne({ where: [{ username }, { email: username }] }); + + if (!user) { + throw new UnauthorizedException(INVALID_CREDENTIALS_ERROR); + } + + const isValid = await verify(user.password, password); + + if (!isValid) { + // TODO: Implement ticking to prevent brute force attacks + throw new UnauthorizedException(INVALID_CREDENTIALS_ERROR); + } + + if (user.disabled) { + throw new UnauthorizedException(DISABLED_USER_ERROR); + } + + return user; + } } diff --git a/src/util/time.util.ts b/src/util/time.util.ts index 984fbce..136855f 100644 --- a/src/util/time.util.ts +++ b/src/util/time.util.ts @@ -5,3 +5,5 @@ export const convertFromNumberToTime = (time: number | null): Date | null => { export const convertFromTimeToNumber = (time: Date | null): number | null => { return time ? time.getTime() / 1000 : null; }; + +export const getEpochTime = (date = Date.now()) => Math.floor(date / 1000); diff --git a/views/auth/login.hbs b/views/auth/login.hbs index 5678bf5..f77ffaa 100644 --- a/views/auth/login.hbs +++ b/views/auth/login.hbs @@ -55,8 +55,8 @@

We're so excited to see you again!

- - + +
diff --git a/views/auth/register.hbs b/views/auth/register.hbs new file mode 100644 index 0000000..a3f4f6d --- /dev/null +++ b/views/auth/register.hbs @@ -0,0 +1,186 @@ + + + + + + Registration Page + + + + + +
+
+

Create an Account

+

Join us by filling out the form below.

+ +
+ + + +
+
+ + + +
+
+ + +
+
+ + + +
+
+ +
+ +
+ Already have an account? Log In +
+
+ + + + + + + + + Powered by Waterwolf +
+
+ + + +