feat: working login flow and registration system

This commit is contained in:
Kakious 2024-07-26 22:35:48 -04:00
parent b0a3fa82aa
commit 041babae70
27 changed files with 800 additions and 74 deletions

View file

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

View file

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

View file

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

View file

@ -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<any> {
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',
};
}
}

20
src/auth/auth.const.ts Normal file
View file

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

View file

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

View file

@ -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<any> {
return body;
public async postLogin(
@Body() body: LoginUserDto,
@Res({ passthrough: true }) res: Response,
): Promise<any> {
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<any> {
return body;
public async postRegister(@Body() body: CreateUserDto): Promise<any> {
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<any> {
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<any> {
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
}
}

View file

@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';
export class ForgotPasswordDto {
@IsEmail()
email: string;
}

View file

@ -0,0 +1,11 @@
import { IsString, IsNotEmpty } from 'class-validator';
export class LoginUserDto {
@IsString()
@IsNotEmpty()
username: string;
@IsString()
@IsNotEmpty()
password: string;
}

View file

@ -0,0 +1,12 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
export class CreateUserDto {
@IsEmail()
email: string;
@IsNotEmpty()
username: string;
@IsNotEmpty()
password: string;
}

View file

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

View file

@ -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<any> {
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<string> A reset code
*/
private async generateResetCode(): Promise<string> {
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<void> {
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<number | null> {
return this.redisService.get<number>(getResetKey(code));
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

46
src/mail/mail.service.ts Normal file
View file

@ -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<void> {}
/**
* Send a welcome email to the user
* @param email The email address of the user
*/
public async sendWelcomeEmail(email: string): Promise<void> {
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<void> {}
/**
* Send a password changed email to the user
* @param email The email address of the user
*/
public async sendPasswordChangedEmail(email: string): Promise<void> {}
}

View file

@ -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<NestExpressApplication>(
AppModule,
);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useGlobalPipes(new ValidationPipe());
app.use(cookieParser());
app.useStaticAssets(join(__dirname, '..', 'public'));
app.setBaseViewsDir(join(__dirname, '..', 'views'));

View file

@ -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<void> {
public async set(key: string, value: any, ttl?: number): Promise<void> {
this.logger.debug(`Setting key ${key}`);
if (typeof value === 'object') {
value = JSON.stringify(value);

View file

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

View file

@ -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<User> The user
*/
@Span()
async getUserbyEmail(email: string): Promise<User> {
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<string[]> {
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<void> {
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<User> The newly created user
*/
@Span()
async createUser(username: string, email: string, password: string): Promise<User> {
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<User> The authenticated user
*/
@Span()
async authenticate(username: string, password: string): Promise<User> {
// 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;
}
}

View file

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

View file

@ -55,8 +55,8 @@
<p class="text-gray-400 mb-6 text-center">We're so excited to see you again!</p>
<form action="{{ login_url }}" method="POST" class="space-y-6">
<div>
<label for="email" class="block text-sm font-medium text-gray-400">Email or Username</label>
<input type="text" placeholder="yip@yap.yop" name="email" required class="mt-1 block w-full px-3 py-2 bg-gray-700 text-white border border-gray-600 rounded-md shadow-sm placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<label for="username" class="block text-sm font-medium text-gray-400">Email or Username</label>
<input type="text" placeholder="yip@yap.yop" name="username" required class="mt-1 block w-full px-3 py-2 bg-gray-700 text-white border border-gray-600 rounded-md shadow-sm placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-400">Password</label>

186
views/auth/register.hbs Normal file
View file

@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Registration Page</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<style>
.video-bg {
position: fixed;
right: 0;
bottom: 0;
min-width: 100%;
min-height: 100%;
z-index: -1;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
}
@media (max-width: 640px) {
.video-bg, .overlay {
display: none;
}
.registration-prompt {
margin-left: 0;
width: 100%;
max-width: none;
position: static;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
}
}
.shake {
animation: shake 0.3s ease-in-out;
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
50% {
transform: translateX(5px);
}
75% {
transform: translateX(-5px);
}
}
.error-message {
color: #f87171; /* Tailwind class 'text-red-500' */
font-size: 0.875rem; /* Tailwind class 'text-sm' */
margin-top: 0.25rem; /* Tailwind class 'mt-1' */
}
</style>
</head>
<body class="relative flex items-center justify-start min-h-screen">
<video autoplay muted loop class="video-bg">
<source src="/assets/login.webm" type="video/webm">
Your browser does not support the video tag.
</video>
<div class="overlay"></div>
<div class="relative bg-gray-800 p-8 rounded-lg shadow-lg w-full max-w-md ml-16 registration-prompt">
<h2 class="text-2xl font-bold mb-2 text-white text-center">Create an Account</h2>
<p class="text-gray-400 mb-6 text-center">Join us by filling out the form below.</p>
<form id="registrationForm" action="/auth/register" method="POST" class="space-y-6">
<div>
<label for="username" class="block text-sm font-medium text-gray-400">Username</label>
<input type="text" id="username" name="username" required class="mt-1 block w-full px-3 py-2 bg-gray-700 text-white border border-gray-600 rounded-md shadow-sm placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span id="usernameError" class="error-message hidden"></span>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-400">Email</label>
<input type="email" id="email" name="email" required class="mt-1 block w-full px-3 py-2 bg-gray-700 text-white border border-gray-600 rounded-md shadow-sm placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span id="emailError" class="error-message hidden"></span>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-400">Password</label>
<input type="password" id="password" name="password" required class="mt-1 block w-full px-3 py-2 bg-gray-700 text-white border border-gray-600 rounded-md shadow-sm placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
</div>
<div>
<label for="verifyPassword" class="block text-sm font-medium text-gray-400">Verify Password</label>
<input type="password" id="verifyPassword" name="verifyPassword" required class="mt-1 block w-full px-3 py-2 bg-gray-700 text-white border border-gray-600 rounded-md shadow-sm placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span id="passwordError" class="error-message hidden">Passwords do not match</span>
</div>
<div>
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">Register</button>
</div>
</form>
<div class="mt-4 text-sm text-center">
<span class="text-gray-400">Already have an account? </span><a href="/auth/login" class="font-medium text-indigo-500 hover:text-indigo-400">Log In</a>
</div>
<div class="mt-6 flex items-center justify-center text-gray-400 text-xs">
<svg class="w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 204.73 204.47">
<style>
.cls-1 {
fill: #fff;
stroke-width: 0px;
}
</style>
<g id="svg">
<g id="svgg">
<path id="path0" class="cls-1" d="M87.69,1.22C25.89,11.33-13.11,71.52,4.06,130.27c2.91,9.95,2.81,9.92,5.07,1.07,1.11-4.32,2.34-8.7,2.74-9.73.5-1.27.5-3.66,0-7.51C1.85,36.65,88.31-16.64,153.44,26.86c67.68,45.2,44.99,151.23-35.36,165.24-10.87,1.9-10.31,2.09-18.71-6.25-8.16-8.1-8-8.17-4.75,2.22l1.64,5.26-4.77-.51c-7.24-.77-18.02-3.71-19.82-5.4-4.91-4.61-8.76-19.09-9.26-34.81-.18-5.6-.57-10.2-.87-10.23-3.89-.28-13.29,4.17-19.89,9.41-5.97,4.74-6.02,4.7-3.91-3.57,6.93-27.13,27.84-54.82,50.35-66.66l5.01-2.64-2.73-.51c-1.5-.28-5.67-.35-9.26-.14l-6.53.37,6.83-3.43c7.96-4,14.12-6.14,20.87-7.26,2.77-.46,4.82-1.16,4.82-1.64,0-3,19.12-26.83,21.52-26.83.35,0,.43,3.27.19,7.33-.55,9.09-.71,8.86,10.82,15.24,10.14,5.61,11.94,7.09,11.65,9.57-.35,3.02-2.34,2.57-4.44-1-2.17-3.7-4.25-4.99-9.58-5.96-4.45-.81-4.68-.56-3.68,3.9,1.24,5.55,4.55,7.99,13.36,9.85,10.08,2.14,26.65,9.16,26.14,11.07-.19.73.21,2.18.9,3.22,1.2,1.82,1.19,1.98-.18,3.83-4.8,6.48-11.34,7.82-18.59,3.82-15.61-8.62-25.89-12.19-36.77-12.79-10.3-.56-18.56,1.78-10.29,2.91,3.9.54,7.97,2.52,10.33,5.05l1.73,1.86h-3.52c-23.99.1-42.29,20.92-37.02,42.13,1.83,7.37,6.88,16.46,13.12,23.61,2.46,2.82,2.6,2.69,1.05-1.03-4.13-9.89-2.1-27.31,4-34.25,4.43-5.04,4.84-4.83,4.44,2.3-1.22,21.5,13.44,35.47,40.58,38.67,4.98.59,5.26.26,1.59-1.84-14.85-8.47-26.07-27.76-23.68-40.69,2.92-15.8,12.63-21.34,34.29-19.56l10.56.87,3.15-1.84c7.55-4.42,17.35-15.45,15.44-17.36-.58-.58-6.99-3.62-14.23-6.75l-13.18-5.69-.36-4.47c-.44-5.58-.41-5.53-6.84-9.87-4.85-3.27-5.31-3.79-5.31-6,0-6.16-3.48-18.85-4.93-17.96-.37.23-.9,3.95-1.18,8.27-.52,8.03-1.12,9.27-3.25,6.71-.68-.82-.96-3.68-.98-9.93-.03-9.38-.86-11.79-4.09-11.79-5.02,0-19.77,11.77-29.26,23.35-1.53,1.87-3.02,2.57-8.57,4.05-21.98,5.85-51.41,23.66-59.59,36.06-2.69,4.07-2.28,4.17,3.95.94,12.69-6.58,26.23-10.86,19.26-6.1-20.43,13.98-33.21,37.15-37.46,67.92-1.03,7.47,22.04,28.83,40.44,37.46,76.3,35.75,160.95-30.76,143.93-113.09C191.73,29.3,139.44-7.25,87.69,1.22"/>
</g>
</g>
</svg>
Powered by Waterwolf
</div>
</div>
<script>
document.getElementById('registrationForm').addEventListener('submit', async function(event) {
event.preventDefault();
const usernameInput = document.getElementById('username');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const verifyPasswordInput = document.getElementById('verifyPassword');
const usernameError = document.getElementById('usernameError');
const emailError = document.getElementById('emailError');
const passwordError = document.getElementById('passwordError');
// Clear previous error messages
usernameError.classList.add('hidden');
emailError.classList.add('hidden');
passwordError.classList.add('hidden');
usernameInput.classList.remove('border-red-500');
emailInput.classList.remove('border-red-500');
const username = usernameInput.value;
const email = emailInput.value;
const password = passwordInput.value;
const verifyPassword = verifyPasswordInput.value;
if (password !== verifyPassword) {
passwordError.classList.remove('hidden');
return;
}
const response = await fetch('/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, email, password })
});
if (!response.ok) {
const responseData = await response.json();
if (response.status === 400 && responseData.message === 'Username or email already exists') {
if (responseData.fields.includes('username')) {
usernameInput.classList.add('border-red-500', 'shake');
usernameError.textContent = 'Username already exists';
usernameError.classList.remove('hidden');
} else {
usernameInput.classList.remove('border-red-500');
usernameError.classList.add('hidden');
}
if (responseData.fields.includes('email')) {
emailInput.classList.add('border-red-500', 'shake');
emailError.textContent = 'Email already exists';
emailError.classList.remove('hidden');
} else {
emailInput.classList.remove('border-red-500');
emailError.classList.add('hidden');
}
setTimeout(() => {
usernameInput.classList.remove('shake');
emailInput.classList.remove('shake');
}, 300);
}
} else {
window.location.href = '/auth/login?message=Registration+Successful';
}
});
</script>
</body>
</html>