diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e69de29 diff --git a/src/app.controller.ts b/src/app.controller.ts index 6fa353a..5d4331d 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -2,8 +2,10 @@ import { Controller, Get, Redirect, UseGuards } from '@nestjs/common'; import { AppService } from './app.service'; import { LoginGuard } from './auth/guard/login.guard'; import { MailService } from './mail/mail.service'; +import { ApiExcludeController } from '@nestjs/swagger'; @Controller() +@ApiExcludeController() export class AppController { constructor( private readonly appService: AppService, diff --git a/src/app.module.ts b/src/app.module.ts index 4a3ae3e..2ed5c04 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,13 +8,16 @@ import { RedisModule } from './redis/redis.module'; import { OpenTelemetryModule } from 'nestjs-otel'; import databaseConfig from './config/database.config'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { TypeOrmConfigService } from './database/database-config.service'; +import { TypeOrmConfigService } from './database/service/database-config.service'; import { AuthModule } from './auth/auth.module'; import { UserModule } from './user/user.module'; import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; import { AuthMiddleware } from './auth/middleware/auth.middleware'; import { ClsModule } from 'nestjs-cls'; +import { BullModule } from '@nestjs/bullmq'; +import { BullConfigService } from './redis/service/bull-config.service'; +import { RedisService } from './redis/service/redis.service'; @Module({ imports: [ @@ -54,8 +57,13 @@ import { ClsModule } from 'nestjs-cls'; global: true, middleware: { mount: false }, }), - MailModule, RedisModule, + BullModule.forRootAsync({ + imports: [RedisModule], + useClass: BullConfigService, + inject: [RedisService], + }), + MailModule, UserModule, AuthModule, ], diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 8b6c565..8053071 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Get, Post, Render, Res, UseGuards } from '@nestjs/common'; -import { ApiExcludeEndpoint } from '@nestjs/swagger'; +import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; import { AuthService } from '../services/auth.service'; import { ForgotPasswordDto } from '../dto/forgotPassword.dto'; @@ -10,6 +10,7 @@ import { User } from '../decorators/user.decorator'; import { LoginGuard } from '../guard/login.guard'; @Controller('auth') +@ApiTags('Authentication') export class AuthController { constructor(private readonly authService: AuthService) {} diff --git a/src/auth/oidc/adapter.ts b/src/auth/oidc/adapter.ts index 17a9fed..3fd1a54 100644 --- a/src/auth/oidc/adapter.ts +++ b/src/auth/oidc/adapter.ts @@ -9,7 +9,7 @@ import { OidcClient } from '../../database/models/oidc_client.model'; import { OidcGrant } from '../../database/models/oidc_grant.model'; import { OidcRefreshToken } from '../../database/models/oidc_refresh_token.model'; import { OidcSession } from '../../database/models/oidc_session.model'; -import { RedisService } from '../../redis/redis.service'; +import { RedisService } from '../../redis/service/redis.service'; const TCLIENT = 7; const TGRANT = 13; diff --git a/src/auth/oidc/core.service.ts b/src/auth/oidc/core.service.ts index 3402a98..cd32d13 100644 --- a/src/auth/oidc/core.service.ts +++ b/src/auth/oidc/core.service.ts @@ -26,8 +26,8 @@ import { } from './oidc.const'; import { ConfigService } from '@nestjs/config'; import { DataSource } from 'typeorm'; -import { RedisService } from '../../redis/redis.service'; -import { UserService } from 'src/user/user.service'; +import { RedisService } from '../../redis/service/redis.service'; +import { UserService } from '../../user/service/user.service'; import { Span } from 'nestjs-otel'; import generateId from './helper/nanoid.helper'; import { context, trace } from '@opentelemetry/api'; diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index f95c6f4..91ea3e9 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -1,7 +1,7 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; -import { UserService } from '../../user/user.service'; -import { RedisService } from '../../redis/redis.service'; +import { UserService } from '../../user/service/user.service'; +import { RedisService } from '../../redis/service/redis.service'; import { MailService } from '../../mail/mail.service'; import { getResetKey, PASSWORD_RESET_EXPIRATION } from '../auth.const'; import { OidcService } from '../oidc/core.service'; @@ -92,6 +92,10 @@ export class AuthService { throw new BadRequestException('Invalid credentials'); } + if (!user.emailVerified) { + throw new UnauthorizedException('Email not verified'); + } + return this.oidcService.createSession(user.id); } diff --git a/src/config/bull.config.ts b/src/config/bull.config.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/database/database-config.service.ts b/src/database/service/database-config.service.ts similarity index 90% rename from src/database/database-config.service.ts rename to src/database/service/database-config.service.ts index 87de172..8dca0e7 100644 --- a/src/database/database-config.service.ts +++ b/src/database/service/database-config.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; -import { DATABASE_MIGRATION } from './database.migration'; +import { DATABASE_MIGRATION } from '../database.migration'; @Injectable() export class TypeOrmConfigService implements TypeOrmOptionsFactory { diff --git a/src/main.ts b/src/main.ts index 4f07171..f1a7696 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; import * as cookieParser from 'cookie-parser'; import { ClsMiddleware } from 'nestjs-cls'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -22,6 +23,20 @@ async function bootstrap() { }).use, ); + //Swagger Documentation + + const config = new DocumentBuilder() + .setTitle('Waterwolf Identity Provider') + .setDescription('An OpenSource Identity Provider written by Waterwolf') + .setVersion('1.0') + .addTag('Authentication', 'Inital login and registration') + .addTag('Client') + .addTag('Organization') + .addTag('User') + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, document); + // Rendering app.useStaticAssets(join(__dirname, '..', 'public')); app.setBaseViewsDir(join(__dirname, '..', 'views')); diff --git a/src/redis/redis.module.ts b/src/redis/redis.module.ts index 813c14d..3843a78 100644 --- a/src/redis/redis.module.ts +++ b/src/redis/redis.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { RedisService } from './redis.service'; +import { RedisService } from './service/redis.service'; @Module({ providers: [RedisService, ConfigService], diff --git a/src/redis/service/bull-config.service.ts b/src/redis/service/bull-config.service.ts new file mode 100644 index 0000000..e1b5129 --- /dev/null +++ b/src/redis/service/bull-config.service.ts @@ -0,0 +1,17 @@ +import { SharedBullConfigurationFactory } from '@nestjs/bullmq'; +import { Injectable, Logger } from '@nestjs/common'; +import { RedisService } from './redis.service'; +import { QueueOptions } from 'bullmq'; + +@Injectable() +export class BullConfigService implements SharedBullConfigurationFactory { + logger = new Logger('BullConfigService'); + constructor(private readonly redisService: RedisService) {} + + async createSharedConfiguration(): Promise { + this.logger.debug('Creating BullMQ configuration'); + return { + connection: this.redisService.ioredis, + }; + } +} diff --git a/src/redis/redis.service.ts b/src/redis/service/redis.service.ts similarity index 100% rename from src/redis/redis.service.ts rename to src/redis/service/redis.service.ts diff --git a/src/user/controller/user.controller.ts b/src/user/controller/user.controller.ts new file mode 100644 index 0000000..3a03d00 --- /dev/null +++ b/src/user/controller/user.controller.ts @@ -0,0 +1,12 @@ +import { Controller } from '@nestjs/common'; +import { UserService } from '../service/user.service'; +import { ApiTags } from '@nestjs/swagger'; + +@Controller({ + path: 'user', + version: '1', +}) +@ApiTags('User') +export class UserController { + constructor(private readonly userService: UserService) {} +} diff --git a/src/user/jobs/userIndex.serive.ts b/src/user/jobs/userIndex.serive.ts new file mode 100644 index 0000000..cf04c8b --- /dev/null +++ b/src/user/jobs/userIndex.serive.ts @@ -0,0 +1,4 @@ +/** + * This service indexes all the users into redis search index + * TODO: This service should be ran as a listener job + */ diff --git a/src/user/user.service.ts b/src/user/service/user.service.ts similarity index 73% rename from src/user/user.service.ts rename to src/user/service/user.service.ts index a5731a3..f1556ee 100644 --- a/src/user/user.service.ts +++ b/src/user/service/user.service.ts @@ -3,15 +3,16 @@ 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 { User } from '../../database/models/user.model'; +import { RedisService } from '../../redis/service/redis.service'; import { Span } from 'nestjs-otel'; import { DISABLED_USER_ERROR, INVALID_CREDENTIALS_ERROR, + passwordResetKeyGenerate, USER_NOT_FOUND_ERROR, userCacheKey, -} from './user.constant'; +} from '../user.constant'; import { ClsService } from 'nestjs-cls'; @Injectable() @@ -173,4 +174,60 @@ export class UserService { return user; } + + /** + * Generates a validation code and stores it in Redis. + * @param userId The user's ID + * @returns string The generated code + */ + @Span() + async generatePasswordResetCode(userId: string): Promise { + const code = Math.random().toString(36).slice(-8); + await this.redisService.set(passwordResetKeyGenerate(code), userId, 60 * 60 * 24); + return code; + } + + /** + * Validates a password reset code + * @param code The password reset code + * @returns string The user's ID + */ + @Span() + async validatePasswordResetCode(code: string): Promise { + const userId = await this.redisService.get(passwordResetKeyGenerate(code)); + + if (!userId) { + throw new UnauthorizedException('Invalid or expired code'); + } + + return userId; + } + + /** + * Generates a email verification code and stores it in Redis. + * @param userId The user's ID + * @returns string The generated code + */ + @Span() + async generateEmailVerificationCode(userId: string): Promise { + const code = Math.random().toString(36).slice(-8); + await this.redisService.set(passwordResetKeyGenerate(code), userId, 60 * 60 * 24); + return code; + } + + /** + * Validates a email verification code + * @param code The email verification code + * @returns string The user's ID + */ + @Span() + async validateEmailVerificationCode(code: string): Promise { + const userId = await this.redisService.get(passwordResetKeyGenerate(code)); + + if (!userId) { + throw new UnauthorizedException('Invalid or expired code'); + } + + return userId; + } } diff --git a/src/user/user.constant.ts b/src/user/user.constant.ts index 4d337c7..2aa0a27 100644 --- a/src/user/user.constant.ts +++ b/src/user/user.constant.ts @@ -7,3 +7,10 @@ export const DISABLED_USER_ERROR = 'User is disabled'; export const userCacheTTL = 60 * 60 * 24; // 24 hours export const userCacheKey = 'ww-auth:user'; +export const emailVerifyKey = 'ww-auth:email-verify:'; +export const passwordResetKey = 'ww-auth:password-reset:'; + +export const userCacheKeyGenerate = (lookup: string | number) => `${userCacheKey}:${lookup}`; +export const emailVerifyKeyGenerate = (lookup: string | number) => `${emailVerifyKey}:${lookup}`; +export const passwordResetKeyGenerate = (lookup: string | number) => + `${passwordResetKey}:${lookup}`; diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 6a61618..22215aa 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { RedisModule } from '../redis/redis.module'; -import { UserService } from './user.service'; +import { UserService } from './service/user.service'; import { DATABASE_ENTITIES } from 'src/database/database.entities'; @Module({ diff --git a/views/auth/account-disabled.hbs b/views/auth/account-disabled.hbs new file mode 100644 index 0000000..e69de29 diff --git a/views/auth/consent.hbs b/views/auth/consent.hbs new file mode 100644 index 0000000..e69de29 diff --git a/views/auth/logout.hbs b/views/auth/logout.hbs new file mode 100644 index 0000000..e69de29 diff --git a/views/auth/verify-email.hbs b/views/auth/verify-email.hbs new file mode 100644 index 0000000..e69de29