diff --git a/.prettierrc b/.prettierrc index 0d73619..2536ecf 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,5 +3,6 @@ "trailingComma": "all", "tabWidth": 2, "semi": true, - "printWidth": 100 + "printWidth": 100, + "endOfLine":"auto" } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 28deceb..1595227 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,22 +7,14 @@ services: environment: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: waterwolf-auth + ports: + - "3306:3306" volumes: - mysql-data:/var/lib/mysql redis: image: redis/redis-stack-server:6.2.2-v5 restart: unless-stopped - waterwolf-auth: - depends_on: - - mysql - - redis - build: - context: . - target: all-source-stage - restart: unless-stopped - expose: - - '3000' - stdin_open: true - tty: true + ports: + - "6379:6379" volumes: mysql-data: \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 2ed5c04..ef10bde 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,6 +18,7 @@ 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'; +import { OrganizationModule } from './organization/organization.module'; @Module({ imports: [ @@ -66,6 +67,7 @@ import { RedisService } from './redis/service/redis.service'; MailModule, UserModule, AuthModule, + OrganizationModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/auth/auth.const.ts b/src/auth/auth.const.ts index fb42d18..84ce056 100644 --- a/src/auth/auth.const.ts +++ b/src/auth/auth.const.ts @@ -12,7 +12,7 @@ export const emailVerifyTTL = 60 * 60 * 24 * 5; // 5 days export const USER_TO_VERIFY_CACHE_KEY = 'ww-auth:user-to-verify:'; export const getEmailVerifyKey = (code: string): string => `${EMAIL_VERIFY_CACHE_KEY}${code}`; -export const getUserToVerifyKey = (userId: number): string => +export const getUserToVerifyKey = (userId: string): string => `${USER_TO_VERIFY_CACHE_KEY}${userId}`; // Failed Login Attempts Const diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 9225706..5c8c870 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { OidcController } from './controllers/oidc.controller'; -import { OidcService } from './oidc/core.service'; +import { OidcService } from './oidc/service/core.service'; import { UserModule } from '../user/user.module'; import { RedisModule } from '../redis/redis.module'; import { AuthController } from './controllers/auth.controller'; @@ -11,6 +11,8 @@ 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'; +import { InteractionService } from './oidc/service/interaction.service'; +import { InteractionController } from './controllers/interaction.controller'; @Module({ imports: [ @@ -19,8 +21,8 @@ import { OidcClientPermission } from '../database/models/oidc_client_permissions MailModule, TypeOrmModule.forFeature([OidcSession, OidcClient, OidcClientPermission]), ], - controllers: [OidcController, AuthController], - providers: [ConfigService, OidcService, AuthService], - exports: [OidcService], + controllers: [OidcController, AuthController, InteractionController], + providers: [ConfigService, OidcService, AuthService, InteractionService], + exports: [OidcService, InteractionService], }) export class AuthModule {} diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index b81750e..eebf57a 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -1,13 +1,13 @@ -import { Body, Controller, Get, Post, Query, Render, Res, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Post, Query, Render, Req, Res, UseGuards } from '@nestjs/common'; import { ApiExcludeEndpoint, ApiTags } 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'; import { User } from '../decorators/user.decorator'; import { LoginGuard } from '../guard/login.guard'; +import { Response, Request } from 'express'; // TODO: Implement RateLimit @Controller('auth') @@ -19,6 +19,7 @@ export class AuthController { public async postLogin( @Body() body: LoginUserDto, @Res({ passthrough: true }) res: Response, + @Req() request: Request, ): Promise { const sessionData = await this.authService.login(body.username, body.password); @@ -26,7 +27,23 @@ export class AuthController { res.cookie(cookie.name, cookie.value, cookie.options); }); - return sessionData.sessionId; + // if loginRedirect cookie is set, redirect to that page. + + console.log(request.cookies); + if (request.cookies['interactionId']) { + console.log('interactionRedirect'); + return { + status: 'interactionRedirect', + interactionId: request.cookies['interactionId'], + }; + } + + console.log('Logged in successfully'); + return { + status: 'success', + message: 'Logged in successfully', + sessionId: sessionData.sessionId, + }; } @Post('register') @@ -54,6 +71,7 @@ export class AuthController { return { forgot_password: 'forgot-password', register: 'register', + login_url: '/auth/login', //background_image: 'https://waterwolf.club/static/img/portal/portal7.jpg', }; } @@ -99,7 +117,7 @@ export class AuthController { error_message: 'The verification code provided is invalid. Please try sending your verification email again.', button_name: 'Go Back to Login', - button_link: '/auth/login', + button_link: '/api/v1/auth/login', }); } @@ -111,18 +129,16 @@ export class AuthController { error_message: 'The verification code provided is invalid. Please try sending your verification email again.', button_name: 'Go Back to Login', - button_link: '/auth/login', + button_link: 'api/v1/auth/login', }); } response.redirect('/auth/login'); } - //TODO: Work on interaction view. - @Get('interaction/:id') + @Get('auth-test') @ApiExcludeEndpoint() - public async getInteraction(@User() user: any): Promise { - // TODO: If user is not logged in. Set a cookie to redirect to this page after login. + public async getAuthTest(@User() user: any): Promise { return user; } } diff --git a/src/auth/controllers/interaction.controller.ts b/src/auth/controllers/interaction.controller.ts new file mode 100644 index 0000000..762b0cf --- /dev/null +++ b/src/auth/controllers/interaction.controller.ts @@ -0,0 +1,136 @@ +import { + BadRequestException, + Body, + Controller, + Get, + InternalServerErrorException, + Logger, + Post, + Req, + Res, +} from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; + +import { AuthService } from '../services/auth.service'; + +import { User } from '../decorators/user.decorator'; +import { Request, Response } from 'express'; +import { InteractionService } from '../oidc/service/interaction.service'; +import { OidcService } from '../oidc/service/core.service'; +import { InteractionParams, InteractionSession } from '../oidc/types/interaction.type'; +import { User as UserObject } from 'src/database/models/user.model'; +import { LoginUserDto } from '../dto/loginUser.dto'; + +@Controller('interaction') +@ApiExcludeController() +export class InteractionController { + constructor( + private readonly authService: AuthService, + private readonly oidcService: OidcService, + private readonly interactionService: InteractionService, + ) {} + + logger = new Logger(InteractionController.name); + + @Get(':id') + async getUserInteraction(@Res() res: Response, @Req() req: Request, @User() user: UserObject) { + const details: { + session?: InteractionSession; + uid: string; + prompt: any; + params: InteractionParams; + } = await this.interactionService.get(req, res).catch((err: unknown) => { + console.log(err); + //TODO: Handle error in a nice way. + throw new BadRequestException("Couldn't get interaction details", { cause: err }); + }); + + const { uid, prompt, params } = details; + switch (prompt.name) { + case 'login': { + //Set a login redirect cookie to redirect the user back to the interaction page after login. + // Take the interaction cookies and write them to /auth/login + // _interaction, _interaction.sig, interactionId + + if (!uid) { + throw new InternalServerErrorException('No uid found'); + } + + const cookies = req.cookies; + + res.cookie('interactionId', uid, { httpOnly: true, path: '/auth/login', secure: true }); + res.cookie('_interaction', cookies['_interaction'], { + httpOnly: true, + path: '/auth/login', + secure: true, + expires: new Date(Date.now() + 900000), + }); + + res.cookie('_interaction.sig', cookies['_interaction.sig'], { + httpOnly: true, + path: '/auth/login', + secure: true, + expires: new Date(Date.now() + 900000), + }); + + return res.redirect('/auth/login'); + } + + case 'consent': { + if (!details.session) { + throw new BadRequestException('No active session found'); + } + + if (!params.scope || !params.client_id) { + throw new InternalServerErrorException('Missing required parameters'); + } + + const scopesArray = params.scope.split(' '); + + if (!user) { + return null; + } + + return res.render('interaction/consent', { + client: { + clientName: details.params.client_id, + clientLogo: 'https://via.placeholder.com/150', + }, + uid, + scopes: scopesArray, + session: details.session, + user: { + displayName: user.displayName, + avatar: user.avatar, + }, + }); + } + } + } + + @Post(':id/consent') + async consentInteraction(@Req() req: Request, @Res() res: Response) { + return this.interactionService.consent(req, res); + } + + @Post(':id/deny') + async denyInteraction(@Req() req: Request, @Res() res: Response) { + return this.interactionService.abort(req, res); + } + + @Post(':id/login') + async loginInteraction(@Body() login: LoginUserDto, @Req() req: Request, @Res() res: Response) { + const userId = await this.authService.validateLogin(login.username, login.password); + + if (!userId) { + throw new BadRequestException('Invalid login'); + } + + const redirectUrl = await this.interactionService.login(req, res, userId, true); + + res.json({ + redirectUrl, + status: 'interactionRedirect', + }); + } +} diff --git a/src/auth/controllers/oidc.controller.ts b/src/auth/controllers/oidc.controller.ts index 240bacd..6a99545 100644 --- a/src/auth/controllers/oidc.controller.ts +++ b/src/auth/controllers/oidc.controller.ts @@ -1,7 +1,7 @@ import { All, Controller, Req, Res, UseInterceptors } from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; -import { OidcService } from '../oidc/core.service'; +import { OidcService } from '../oidc/service/core.service'; import { ExpressResErrorInterceptor } from '../../interceptor/express_res_error.interceptor'; @UseInterceptors(new ExpressResErrorInterceptor()) diff --git a/src/auth/controllers/view.controller.ts b/src/auth/controllers/view.controller.ts new file mode 100644 index 0000000..2986222 --- /dev/null +++ b/src/auth/controllers/view.controller.ts @@ -0,0 +1,3 @@ +export class AuthViewController { + constructor() {} +} diff --git a/src/auth/decorators/user.decorator.ts b/src/auth/decorators/user.decorator.ts index 3452eec..5846b73 100644 --- a/src/auth/decorators/user.decorator.ts +++ b/src/auth/decorators/user.decorator.ts @@ -1,7 +1,8 @@ import { createParamDecorator } from '@nestjs/common'; import { ClsServiceManager } from 'nestjs-cls'; +import { User as UserObject } from 'src/database/models/user.model'; -export const User = createParamDecorator(() => { +export const User = createParamDecorator((): UserObject | null => { const cls = ClsServiceManager.getClsService(); const authType = cls.get('authType'); diff --git a/src/auth/jobs/oidc-CleanUp.job.ts b/src/auth/jobs/oidc-CleanUp.job.ts new file mode 100644 index 0000000..4e252d8 --- /dev/null +++ b/src/auth/jobs/oidc-CleanUp.job.ts @@ -0,0 +1,5 @@ +/** + * This is the cleanup job for the OIDC provider. It will remove any expired data from the database. + */ + +//TODO: Actually write this!! diff --git a/src/auth/middleware/auth.middleware.ts b/src/auth/middleware/auth.middleware.ts index 197c3d3..11b1c33 100644 --- a/src/auth/middleware/auth.middleware.ts +++ b/src/auth/middleware/auth.middleware.ts @@ -1,6 +1,6 @@ import { Injectable, NestMiddleware } from '@nestjs/common'; import { Request, Response, NextFunction } from 'express'; -import { OidcService } from '../oidc/core.service'; +import { OidcService } from '../oidc/service/core.service'; import { ClsService } from 'nestjs-cls'; @Injectable() diff --git a/src/auth/oidc/adapter.ts b/src/auth/oidc/adapter.ts index 3fd1a54..0f58fce 100644 --- a/src/auth/oidc/adapter.ts +++ b/src/auth/oidc/adapter.ts @@ -438,6 +438,10 @@ export const createOidcAdapter: (db: DataSource, redis: RedisService, baseUrl: s client.post_logout_redirect_uris = []; } + if (client.logo_uri?.length === 0 || !client.logo_uri) { + delete client.logo_uri; + } + await redis.set(this.key(id), client, globalCacheTTL); return client; diff --git a/src/auth/oidc/core.service.ts b/src/auth/oidc/service/core.service.ts similarity index 94% rename from src/auth/oidc/core.service.ts rename to src/auth/oidc/service/core.service.ts index cd32d13..9db5003 100644 --- a/src/auth/oidc/core.service.ts +++ b/src/auth/oidc/service/core.service.ts @@ -10,7 +10,7 @@ import { promises as fs } from 'fs'; import type Provider from 'oidc-provider'; import psl from 'psl'; import type { Configuration, errors, KoaContextWithOIDC } from 'oidc-provider'; -import { createOidcAdapter } from './adapter'; +import { createOidcAdapter } from '../adapter'; import wildcard from 'wildcard'; import { ACCESS_TOKEN_LIFE, @@ -23,17 +23,17 @@ import { PUSHED_AUTH_REQ_LIFE, REFRESH_TOKEN_LIFE, SESSION_LIFE, -} from './oidc.const'; +} from '../oidc.const'; import { ConfigService } from '@nestjs/config'; import { DataSource } from 'typeorm'; -import { RedisService } from '../../redis/service/redis.service'; -import { UserService } from '../../user/service/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 generateId from '../helper/nanoid.helper'; import { context, trace } from '@opentelemetry/api'; import * as KeyGrip from 'keygrip'; -import { getEpochTime } from '../../util/time.util'; -import { VerifiedSessionFromRequest } from './types/session.type'; +import { getEpochTime } from '../../../util/time.util'; +import { VerifiedSessionFromRequest } from '../types/session.type'; import { Request, Response } from 'express'; // 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. @@ -291,6 +291,9 @@ export class OidcService implements OnModuleInit { }, conformIdTokenClaims: false, renderError(ctx, out, _error) { + console.log(out); + console.log(_error); + let statusCode = 500; let errorMessage = 'Internal Server Error'; // Look at the first error in the out object @@ -413,9 +416,9 @@ export class OidcService implements OnModuleInit { private getKeysFolder(): string { if (process.env.NODE_ENV === 'production') { - return __dirname + '/../../../../'; + return __dirname + '/../../../../../'; } - return __dirname + '/../../../'; + return __dirname + '/../../../../'; } /** @@ -489,7 +492,6 @@ export class OidcService implements OnModuleInit { 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', @@ -505,7 +507,7 @@ export class OidcService implements OnModuleInit { value: sessionId, options: { expires: expire, - sameSite: 'None', + sameSite: 'strict', httpOnly: true, }, }, @@ -531,7 +533,10 @@ export class OidcService implements OnModuleInit { * @returns any */ @Span() - async verifyByRequest(req: Request, res: Response): Promise { + async verifyByRequest( + req: Request, + res: Response, + ): Promise { try { const ctx = this.provider.app.createContext(req, res); const session = await this.provider.Session.get(ctx); @@ -557,6 +562,18 @@ export class OidcService implements OnModuleInit { user, }; } catch (err) { + // If the error is a NotFoundException, we can safely clear the session cookies and redirect to the login page + if (err instanceof NotFoundException) { + this.logger.error( + err, + 'There was an error while trying to verify session, purging session cookies', + ); + + this.clearSessionCookies(res); + res.redirect('/auth/login'); + return; + } + this.logger.error( err, 'There was an error while trying to verify session, purging session cookies', diff --git a/src/auth/oidc/service/interaction.service.ts b/src/auth/oidc/service/interaction.service.ts new file mode 100644 index 0000000..7c98ede --- /dev/null +++ b/src/auth/oidc/service/interaction.service.ts @@ -0,0 +1,218 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { Span } from 'nestjs-otel'; + +import { Interaction } from '../types/interaction.type'; +import { OidcService } from './core.service'; + +@Injectable() +export class InteractionService { + private readonly logger = new Logger(InteractionService.name); + + constructor(private readonly oidcService: OidcService) {} + + /** + * Get an interaction from the provider + * @param req The request + * @param res The response + * @param throwOnFailure Throw an error if the interaction can't be found + * @returns The interaction + */ + @Span() + public async get(req: Request, res: Response): Promise { + try { + return await this.oidcService.provider.interactionDetails(req, res); + } catch (error) { + throw new BadRequestException("Couldn't get interaction details", { cause: error }); + } + } + + /** + * Get a user from the interaction + * @param req The request + * @param res The response + * @param error The error + * @param error_desc The error description + */ + @Span() + public async abort( + req: Request, + res: Response, + error = 'access_denied', + error_desc = 'The user denied the request', + ): Promise { + try { + await this.oidcService.provider.interactionFinished(req, res, { + error, + error_description: error_desc, + }); + } catch (thrownError) { + throw new BadRequestException("Couldn't abort interaction", { cause: thrownError }); + } + } + + //TODO: Only approve scopes defined in DB to prevent abuse if autoConsent is true + + /** + * Mark a interaction as having the user consented fully to the request + * @param req The request + * @param res The response + * @returns Promise + */ + @Span() + public async consent(req: Request, res: Response, autoApprove = false): Promise { + const interaction = await this.get(req, res); + const { + prompt: { details }, + params, + session, + uid, + } = interaction; + + let { grantId } = interaction; + + if (!session) { + this.logger.error('No session found in interaction details', { + interactionID: uid, + }); + + throw new NotFoundException('No session found in interaction details'); + } + + let grant; + let grantUpdated = false; + + if (grantId) { + this.logger.debug('Grant ID found, getting grant', { + interactionID: uid, + }); + + grant = await this.oidcService.provider.Grant.find(grantId); + + if (!grant) { + this.logger.debug('Grant not found in database, creating new grant', { + interactionID: uid, + }); + + grantUpdated = true; + grant = new this.oidcService.provider.Grant({ + accountId: session.accountId, + clientId: params.client_id, + }); + } else { + this.logger.debug('Grant found in database', { + interactionID: uid, + }); + grantId = grant.jti; + } + } else { + this.logger.debug('No grant ID found, creating new grant', { + interactionID: uid, + }); + + grantUpdated = true; + grant = new this.oidcService.provider.Grant({ + accountId: session.accountId, + clientId: params.client_id, + }); + } + + if (details.missingOIDCScope) { + grantUpdated = true; + grant.addOIDCScope(details.missingOIDCScope.join(' ')); + } + + if (details.missingOIDCClaims) { + grantUpdated = true; + grant.addOIDCClaims(details.missingOIDCClaims); + } + + if (details.missingResourceScopes) { + grantUpdated = true; + } + + // This is new, If grants break try removing this else block and having everything inside run like normal. + if (grantUpdated) { + this.logger.debug('Saving grant to database', { + interactionID: uid, + }); + grantId = await grant.save(); + } + + const consent = { grantId }; + + if (!interaction.grantId) { + consent.grantId = grantId; + } + + await this.oidcService.provider.interactionFinished( + req, + res, + { consent }, + { mergeWithLastSubmission: autoApprove }, + ); + } + + /** + * Login an account with the interaction + * @param req The request + * @param res The response + * @param accountId The account ID + */ + @Span() + public async login( + req: Request, + res: Response, + accountId: string, + noRedirect = false, + ): Promise { + const interaction = await this.get(req, res); + const { uid } = interaction; + + try { + if (noRedirect) { + return await this.oidcService.provider.interactionResult( + req, + res, + { + login: { + accountId, + }, + }, + { + mergeWithLastSubmission: true, + }, + ); + } + + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + return await this.oidcService.provider.interactionFinished( + req, + res, + { + login: { + accountId, + }, + }, + { + mergeWithLastSubmission: noRedirect, + }, + ); + } catch (error) { + this.logger.error('Error while trying to finish interaction', { + interactionID: uid, + error, + }); + + throw new InternalServerErrorException('Error while trying to finish interaction', { + cause: error, + }); + } + } +} diff --git a/src/auth/resources/views/login.pug b/src/auth/resources/views/login.pug deleted file mode 100644 index e69de29..0000000 diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 7a7a787..8da6cb5 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -10,7 +10,7 @@ import { PASSWORD_RESET_CACHE_KEY, PASSWORD_RESET_EXPIRATION, } from '../auth.const'; -import { OidcService } from '../oidc/core.service'; +import { OidcService } from '../oidc/service/core.service'; @Injectable() export class AuthService { @@ -70,6 +70,22 @@ export class AuthService { return this.oidcService.createSession(user.id); } + /** + * Validate a user's login credential and return the user id + * @param username The username or email address of the user + * @param password The password of the user + * @returns The user id + */ + public async validateLogin(username: string, password: string): Promise { + const user = await this.userService.authenticate(username, password); + + if (!user) { + throw new BadRequestException('Invalid credentials'); + } + + return user.id; + } + // == Password Reset Logic == // /** @@ -179,7 +195,7 @@ export class AuthService { * @param userId The user ID associated with the code * @returns void */ - private async storeEmailVerifyCode(code: string, userId: number): Promise { + private async storeEmailVerifyCode(code: string, userId: string): Promise { await this.cleanupOldEmailVerificationCode(userId); await Promise.all([ @@ -193,8 +209,8 @@ export class AuthService { * @param code The email verify code * @returns The user ID associated with the code */ - private async getEmailVerifyCode(code: string): Promise { - return this.redisService.get(getEmailVerifyKey(code)); + private async getEmailVerifyCode(code: string): Promise { + return this.redisService.get(getEmailVerifyKey(code)); } /** @@ -202,7 +218,7 @@ export class AuthService { * @param userId The user ID * @returns void */ - public async cleanupOldEmailVerificationCode(userId: number): Promise { + public async cleanupOldEmailVerificationCode(userId: string): Promise { const code = await this.redisService.get(getUserToVerifyKey(userId)); if (code) { diff --git a/src/client/client.module.ts b/src/client/client.module.ts new file mode 100644 index 0000000..eacc6ba --- /dev/null +++ b/src/client/client.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { RedisModule } from '../redis/redis.module'; +import { DATABASE_ENTITIES } from 'src/database/database.entities'; + +@Module({ + imports: [TypeOrmModule.forFeature(DATABASE_ENTITIES), RedisModule], + controllers: [], + providers: [], + exports: [], +}) +export class UserModule {} diff --git a/src/account/account.module.ts b/src/client/service/client.service.ts similarity index 100% rename from src/account/account.module.ts rename to src/client/service/client.service.ts diff --git a/src/config/database.config.ts b/src/config/database.config.ts index 9395c16..45afc48 100644 --- a/src/config/database.config.ts +++ b/src/config/database.config.ts @@ -17,7 +17,6 @@ export default registerAs('database', () => { entities: [__dirname + '/../database/*.model{.ts,.js}'], }; - console.log(__dirname + '/../database/*.model{.ts,.js}'); // Error throwing if (!conf.host) { throw new Error('DATABASE_HOST is not set'); diff --git a/src/database/database.entities.ts b/src/database/database.entities.ts index 3c68879..4f41223 100644 --- a/src/database/database.entities.ts +++ b/src/database/database.entities.ts @@ -1,5 +1,16 @@ import { ApiKey } from './models/api_keys.model'; +import { OidcGrant } from './models/oidc_grant.model'; +import { OidcRefreshToken } from './models/oidc_refresh_token.model'; +import { Organization } from './models/organization.model'; +import { OrganizationToUser } from './models/organization_to_user.model'; import { User } from './models/user.model'; // Database Entities Array -export const DATABASE_ENTITIES = [User, ApiKey]; +export const DATABASE_ENTITIES = [ + User, + ApiKey, + Organization, + OidcGrant, + OidcRefreshToken, + OrganizationToUser, +]; diff --git a/src/database/models/oidc_client.model.ts b/src/database/models/oidc_client.model.ts index 23af5a5..51a2ffc 100644 --- a/src/database/models/oidc_client.model.ts +++ b/src/database/models/oidc_client.model.ts @@ -51,8 +51,8 @@ export class OidcClient { @Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: false }) application_type: 'web' | 'native'; - @Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: false }) - logo_uri: string; + @Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: true }) + logo_uri?: string; @Column({ type: 'boolean', nullable: false, default: false }) restricted: boolean; diff --git a/src/database/models/oidc_grant.model.ts b/src/database/models/oidc_grant.model.ts index 07f3a3e..00e8fff 100644 --- a/src/database/models/oidc_grant.model.ts +++ b/src/database/models/oidc_grant.model.ts @@ -1,10 +1,7 @@ import type { AdapterPayload } 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() export class OidcGrant implements AdapterPayload { diff --git a/src/database/models/organization.model.ts b/src/database/models/organization.model.ts index 37526c9..1426559 100644 --- a/src/database/models/organization.model.ts +++ b/src/database/models/organization.model.ts @@ -1,10 +1,25 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { BeforeInsert, Column, Entity, OneToMany, PrimaryColumn } from 'typeorm'; import { MAX_STRING_LENGTH } from '../database.const'; +import { v4 as uuidv4 } from 'uuid'; +import { OrganizationToUser } from './organization_to_user.model'; @Entity('organization') export class Organization { - @PrimaryGeneratedColumn() - id: number; + @PrimaryColumn({ + length: 40, + }) + id: string; + + /** + * @autoMapIgnore + */ + @BeforeInsert() + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + setId() { + if (!this.id) { + this.id = `org_${uuidv4()}`; + } + } @Column({ length: MAX_STRING_LENGTH }) name: string; @@ -25,7 +40,10 @@ export class Organization { description?: string; @Column({ name: 'owner_id' }) - ownerId: number; + ownerId: string; + + @Column({ name: 'set_flags', type: 'simple-array', default: () => "('')" }) + flags: string[]; @Column({ name: 'created_at', @@ -41,4 +59,7 @@ export class Organization { onUpdate: 'CURRENT_TIMESTAMP', }) updatedAt: Date; + + @OneToMany(() => OrganizationToUser, (organizationToUser) => organizationToUser.organization) + public organizationToUser: OrganizationToUser[]; } diff --git a/src/database/models/organization_to_user.model.ts b/src/database/models/organization_to_user.model.ts new file mode 100644 index 0000000..9a62863 --- /dev/null +++ b/src/database/models/organization_to_user.model.ts @@ -0,0 +1,57 @@ +import { Entity, Column, ManyToOne, PrimaryColumn, BeforeInsert } from 'typeorm'; +import { MAX_STRING_LENGTH } from '../database.const'; +import { v4 as uuidv4 } from 'uuid'; +import { Organization } from './organization.model'; +import { User } from './user.model'; + +export enum OrganizationRole { + OWNER = 'owner', + ADMIN = 'admin', + MEMBER = 'member', +} + +@Entity('organization_to_user') +export class OrganizationToUser { + @PrimaryColumn({ + length: 41, + }) + id: string; + + @Column({ length: MAX_STRING_LENGTH }) + organizationId: string; + + @Column({ length: MAX_STRING_LENGTH }) + userId: string; + + @Column({ + name: 'role', + length: MAX_STRING_LENGTH, + default: OrganizationRole.MEMBER, + }) + role: OrganizationRole; + + @Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + createdAt: Date; + + @Column({ + name: 'updated_at', + type: 'timestamp', + default: () => 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }) + updatedAt: Date; + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + @BeforeInsert() + setId() { + if (!this.id) { + this.id = `ouid_${uuidv4()}`; + } + } + + @ManyToOne(() => Organization, (organization) => organization.id) + public organization: Organization; + + @ManyToOne(() => User, (user) => user.id, {}) + public user: User; +} diff --git a/src/database/models/user.model.ts b/src/database/models/user.model.ts index 7a36a6d..bb90343 100644 --- a/src/database/models/user.model.ts +++ b/src/database/models/user.model.ts @@ -1,20 +1,28 @@ -import { - BeforeInsert, - BeforeUpdate, - Column, - Entity, - OneToMany, - PrimaryGeneratedColumn, -} from 'typeorm'; +import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryColumn } from 'typeorm'; import { createHash } from 'crypto'; +import { v4 as uuidv4 } from 'uuid'; import { MAX_STRING_LENGTH, UserRole } from '../database.const'; import { ApiKey } from './api_keys.model'; +import { OrganizationToUser } from './organization_to_user.model'; @Entity('user') export class User { - @PrimaryGeneratedColumn() - id: number; + @PrimaryColumn({ + length: 40, + }) + id: string; + + /** + * @autoMapIgnore + */ + @BeforeInsert() + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + setId() { + if (!this.id) { + this.id = `uid_${uuidv4()}`; + } + } @Column({ length: MAX_STRING_LENGTH, unique: true }) username: string; @@ -49,6 +57,16 @@ export class User { @Column({ name: 'disabled', default: false }) disabled: boolean; + // This column is the date an account is scheduled to be deleted + @Column({ name: 'scheduled_for_deletion', type: 'timestamp', nullable: true }) + scheduledForDeletion: Date | null; + + @Column({ name: 'last_login', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + lastLogin: Date; + + @Column({ name: 'last_password_update', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + lastPasswordUpdate: Date | null; + // This is for Gravatar Support @Column({ name: 'email_hash', length: MAX_STRING_LENGTH, nullable: true }) emailHash: string; @@ -57,7 +75,7 @@ export class User { @BeforeInsert() @BeforeUpdate() updateEmailHashOnUpdate() { - this.emailHash = createHash('sha256').update(this.email).digest('hex'); + this.emailHash = createHash('sha256').update(this.email.trim().toLowerCase()).digest('hex'); } // Relationship Mapping @@ -80,4 +98,7 @@ export class User { onUpdate: 'CURRENT_TIMESTAMP', }) updatedAt: Date; + + @OneToMany(() => OrganizationToUser, (organizationToUser) => organizationToUser.user) + organizations: OrganizationToUser[]; } diff --git a/src/database/seeds/initial.seed.ts b/src/database/seeds/initial.seed.ts index 4167432..6ead1af 100644 --- a/src/database/seeds/initial.seed.ts +++ b/src/database/seeds/initial.seed.ts @@ -1 +1,11 @@ -// This contains the inital setup data for the IdP. This should be standalone. +// This contains the inital setup data for the IdP. This contains a setup user and a initial org that is marked as the master org. + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class InitalSeed implements MigrationInterface { + name = 'InitalSeedDB'; + + public async up(queryRunner: QueryRunner): Promise {} + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/src/mail/mail.service.ts b/src/mail/mail.service.ts index d6e6694..3880e4c 100644 --- a/src/mail/mail.service.ts +++ b/src/mail/mail.service.ts @@ -65,7 +65,7 @@ export class MailService { public async sendVerificationEmail(email: string, code: string): Promise { this.logger.debug(`Sending verification email to ${email}`); - const verificationUrl = `${this.configService.getOrThrow('base.localUrl')}/auth/verify?code=${code}`; + const verificationUrl = `${this.configService.getOrThrow('base.localUrl')}/auth/verify-email?code=${code}`; //handlebar render email template const { html: templateHtml, text: templateText } = await this.fetchTemplate('verify-email'); diff --git a/src/mail/postal_config.service.ts b/src/mail/postal_config.service.ts index 1bfd24a..b86be55 100644 --- a/src/mail/postal_config.service.ts +++ b/src/mail/postal_config.service.ts @@ -1,7 +1,4 @@ -import type { - PostalModuleOptions, - PostalModuleOptionsFactory, -} from 'nestjs-postal-client'; +import type { PostalModuleOptions, PostalModuleOptionsFactory } from 'nestjs-postal-client'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -14,8 +11,7 @@ export class PostalConfigService implements PostalModuleOptionsFactory { constructor(private readonly configService: ConfigService) {} createPostalOptions(): Promise | PostalModuleOptions { - const postalConfig = - this.configService.getOrThrow(POSTAL_CONFIG_KEY); + const postalConfig = this.configService.getOrThrow(POSTAL_CONFIG_KEY); return { http: { diff --git a/src/main.ts b/src/main.ts index f1a7696..cfde4aa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,7 +3,7 @@ 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 { RequestMethod, ValidationPipe, VersioningType } from '@nestjs/common'; import * as cookieParser from 'cookie-parser'; import { ClsMiddleware } from 'nestjs-cls'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; @@ -11,6 +11,21 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.setGlobalPrefix('api', { + exclude: [ + { path: 'auth/login', method: RequestMethod.GET }, + { path: '', method: RequestMethod.GET }, + { path: 'auth/login/totp', method: RequestMethod.GET }, + { path: 'auth/forgot-password', method: RequestMethod.GET }, + { path: ':oidc*', method: RequestMethod.ALL }, + { path: ':interaction*', method: RequestMethod.ALL }, + ], + }); + + app.enableVersioning({ + type: VersioningType.URI, + }); + app.useGlobalPipes(new ValidationPipe()); app.use(cookieParser()); @@ -24,16 +39,16 @@ async function bootstrap() { ); //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('Authentication', 'Initial login and registration') .addTag('Client') .addTag('Organization') .addTag('User') .build(); + const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api', app, document); diff --git a/src/organization/controller/organization.controller.ts b/src/organization/controller/organization.controller.ts new file mode 100644 index 0000000..3a4d924 --- /dev/null +++ b/src/organization/controller/organization.controller.ts @@ -0,0 +1,72 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { OrganizationService } from '../service/organization.service'; +import { OrgSetFlagDto } from '../dto/orgSetFlag.dto'; +import { CreateOrgDto } from '../dto/orgCreate.dto'; +import { User as UserObject } from '../../database/models/user.model'; +import { User } from '../../auth/decorators/user.decorator'; + +@Controller('organization') +@ApiTags('Organization') +export class OrganizationController { + constructor(private readonly organizationService: OrganizationService) {} + + @Get() + // Admin: Paginated list of organizations. + public async getOrganizations(): Promise { + return await this.organizationService.getOrganizations(); + } + + @Get(':id') + public async getOrganization(@Param('id') id: string): Promise { + return await this.organizationService.getOrganizationById(id); + } + + @Get(':id/members') + public async getOrganizationMembers(@Param('id') id: string): Promise { + return await this.organizationService.getOrgMembers(id); + } + + @Delete(':id') + public async deleteOrganization(@Param('id') id: string): Promise { + return await this.organizationService.deleteOrganization(id); + } + + @Patch(':id') + public async partialUpdateOrganization(@Param('id') id: string): Promise {} + + @Put(':id') + public async updateOrganization(@Param('id') id: string): Promise {} + + @Post() + public async createOrganization( + @Body() body: CreateOrgDto, + @User() user: UserObject, + ): Promise { + return await this.organizationService.createOrganization(user.id, body); + } + + /** + * This sets a flag on the organization. + * @param id THe organization id + */ + @ApiOperation({ + summary: 'Set flag on organization', + description: + 'Set flag on organization, this is normally handled by Waterwolf to enable certain privileges. Example: Poster Network, Relay Network.', + }) + @Put(':id/flag') + public async setFlag(@Query('id') id: string, @Body() param: OrgSetFlagDto): Promise { + return await this.organizationService.setFlag(id, param.flag); + } + + @ApiOperation({ + summary: 'Remove flag on organization', + description: + 'Remove flag on organization. This is normally handled by Waterwolf to disable certain privileges.', + }) + @Delete(':id/flag') + public async removeFlag(@Query('id') id: string, @Body() param: OrgSetFlagDto): Promise { + return await this.organizationService.removeFlag(id, param.flag); + } +} diff --git a/src/organization/dto/orgCreate.dto.ts b/src/organization/dto/orgCreate.dto.ts new file mode 100644 index 0000000..37cae2c --- /dev/null +++ b/src/organization/dto/orgCreate.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateOrgDto { + @ApiProperty({ + description: 'Name of the organization.', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ + description: 'Unique identifier for the organization. This will be used in the URL.', + }) + @IsString() + @IsNotEmpty() + slug: string; +} diff --git a/src/organization/dto/orgSetFlag.dto.ts b/src/organization/dto/orgSetFlag.dto.ts new file mode 100644 index 0000000..3b73dc7 --- /dev/null +++ b/src/organization/dto/orgSetFlag.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class OrgSetFlagDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + flag: string; +} diff --git a/src/organization/jobs/orgIndex.service.ts b/src/organization/jobs/orgIndex.service.ts new file mode 100644 index 0000000..456dff9 --- /dev/null +++ b/src/organization/jobs/orgIndex.service.ts @@ -0,0 +1,5 @@ +/** + * This service indexes all the orgs into redis search index + * TODO: This service should be ran as a listener job. + * This is only enabled is REDIS_INDEXING is set to true + */ diff --git a/src/organization/organization.const.ts b/src/organization/organization.const.ts new file mode 100644 index 0000000..01d928b --- /dev/null +++ b/src/organization/organization.const.ts @@ -0,0 +1,18 @@ +export const ORG_NOT_FOUND_ERROR = 'Organization not found'; +export const ORG_EXISTS_ERROR = 'Organization already exists'; +export const ORG_NOT_UNIQUE_ERROR = 'Organization name is not unique'; +export const ORG_NOT_UNIQUE_SLUG_ERROR = 'Organization slug is not unique'; + +// Caching Constants for Redis + +export const orgCacheTTL = 60 * 60 * 24 * 7; // 1 week +export const orgCacheKey = 'ww-auth:organization'; + +export const orgCacheKeyGenerate = (lookup: string | number) => `${orgCacheKey}:${lookup}`; + +// Org Member Lookup Constants for Redis + +export const orgMemberCacheTTL = 60 * 60 * 24; // 24 hours + +export const orgMemberCacheKey = (lookup: string | number) => + `ww-auth:organization:${lookup}:member`; diff --git a/src/organization/organization.module.ts b/src/organization/organization.module.ts new file mode 100644 index 0000000..e801237 --- /dev/null +++ b/src/organization/organization.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { RedisModule } from '../redis/redis.module'; +import { DATABASE_ENTITIES } from '../database/database.entities'; +import { OrganizationService } from './service/organization.service'; +import { OrganizationController } from './controller/organization.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature(DATABASE_ENTITIES), RedisModule], + controllers: [OrganizationController], + providers: [OrganizationService], + exports: [OrganizationService], +}) +export class OrganizationModule {} diff --git a/src/organization/service/organization.service.ts b/src/organization/service/organization.service.ts new file mode 100644 index 0000000..84e1e0e --- /dev/null +++ b/src/organization/service/organization.service.ts @@ -0,0 +1,158 @@ +import { Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Span } from 'nestjs-otel'; + +import { Organization } from '../../database/models/organization.model'; +import { orgCacheKeyGenerate, orgCacheTTL, orgMemberCacheKey } from '../organization.const'; +import { RedisService } from '../../redis/service/redis.service'; +import { CreateOrgDto } from '../dto/orgCreate.dto'; +import { + OrganizationRole, + OrganizationToUser, +} from '../../database/models/organization_to_user.model'; + +@Injectable() +export class OrganizationService { + constructor( + @InjectRepository(Organization) + private readonly organizationRepository: Repository, + @InjectRepository(OrganizationToUser) + private readonly orgToUser: Repository, + private readonly redisService: RedisService, + ) {} + + @Span() + async getOrganizationById(id: string): Promise { + const cachedOrganization = await this.redisService.get(orgCacheKeyGenerate(id)); + + if (cachedOrganization) { + return cachedOrganization; + } + + const organization = await this.organizationRepository.findOneBy({ + id, + }); + + if (!organization) { + throw new NotFoundException('Organization not found'); + } + + await this.redisService.set(orgCacheKeyGenerate(id), organization, orgCacheTTL); + + return organization; + } + + @Span() + async createOrganization(userId: string, organization: CreateOrgDto): Promise { + let newOrganization = await this.organizationRepository.create({ + name: organization.name, + slug: organization.slug, + ownerId: userId, + }); + + try { + newOrganization = await this.organizationRepository.save(newOrganization); + } catch (e) { + // If the error is a duplicate key error, we can assume that the slug is already taken. + if (e.code === 'ER_DUP_ENTRY') { + throw new UnprocessableEntityException('Slug is not unique'); + } + + throw e; + } + + let newOrgToUser = await this.orgToUser.create({ + userId, + organizationId: newOrganization.id, + role: OrganizationRole.OWNER, + }); + + newOrgToUser = await this.orgToUser.save(newOrgToUser); + + newOrganization.organizationToUser = [newOrgToUser]; + + await this.redisService.set( + orgCacheKeyGenerate(newOrganization.id), + newOrganization, + orgCacheTTL, + ); + + return newOrganization; + } + + @Span() + async deleteOrganization(id: string): Promise { + await this.organizationRepository.delete(id); + + await this.redisService.del(orgCacheKeyGenerate(id)); + await this.redisService.del(orgMemberCacheKey(id)); + } + + @Span() + async updateOrganization(id: string, organization: Organization): Promise { + await this.organizationRepository.update(id, organization); + + await this.redisService.set(orgCacheKeyGenerate(id), organization, orgCacheTTL); + + return organization; + } + + @Span() + async getOrganizations(): Promise { + return await this.organizationRepository.find(); + } + + @Span() + async setFlag(id: string, flag: string): Promise { + const organization = await this.getOrganizationById(id); + + organization.flags = [flag, ...(organization.flags || [])]; + this.organizationRepository.update(id, { + flags: organization.flags, + }); + + this.redisService.set(orgCacheKeyGenerate(id), organization, orgCacheTTL); + + return organization; + } + + @Span() + async removeFlag(id: string, flag: string): Promise { + const organization = await this.getOrganizationById(id); + + organization.flags = organization.flags.filter((f) => f !== flag); + this.organizationRepository.update(id, { + flags: organization.flags, + }); + + this.redisService.set(orgCacheKeyGenerate(id), organization, orgCacheTTL); + + return organization; + } + + @Span() + async getOrgMembers(id: string): Promise { + const cachedMembers = await this.redisService.get(orgMemberCacheKey(id)); + + if (cachedMembers) { + return cachedMembers; + } + + const organization = await this.orgToUser + .createQueryBuilder('orgToUser') + .leftJoinAndSelect('orgToUser.user', 'user') + .where('orgToUser.organizationId = :id', { id }) + .select(['user.id', 'user.username', 'user.email', 'orgToUser.role']) + .getMany(); + + if (!organization) { + throw new NotFoundException('Organization not found'); + } + + await this.redisService.set(orgMemberCacheKey(id), organization, orgCacheTTL); + + return organization; + } +} diff --git a/src/auth/resources/emails/registration.pug b/src/organization/service/organizationMember.service.ts similarity index 100% rename from src/auth/resources/emails/registration.pug rename to src/organization/service/organizationMember.service.ts diff --git a/src/user/controller/user.controller.ts b/src/user/controller/user.controller.ts index 3a03d00..bffde3e 100644 --- a/src/user/controller/user.controller.ts +++ b/src/user/controller/user.controller.ts @@ -1,12 +1,37 @@ -import { Controller } from '@nestjs/common'; -import { UserService } from '../service/user.service'; +import { Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { UserService } from '../service/user.service'; -@Controller({ - path: 'user', - version: '1', -}) +@Controller('user') @ApiTags('User') export class UserController { constructor(private readonly userService: UserService) {} + + @Get() + // Admin: Paginated list of users. + public async getUsers(): Promise { + //return await this.userService.getUsers(); + } + + @Get(':id') + public async getUser(@Param('id') id: string): Promise { + return await this.userService.getUserById(id); + } + + @Delete(':id') + public async deleteUser(@Param('id') id: string): Promise { + return await this.userService.deleteUser(id); + } + + @Patch(':id') + public async partialUpdateUser(@Param('id') id: string): Promise {} + + @Put(':id') + public async updateUser(@Param('id') id: string): Promise {} + + @Post(':id/change-password') + public async changePassword(@Param('id') id: string): Promise {} + + @Post(':id/change-email') + public async changeEmail(@Param('id') id: string): Promise {} } diff --git a/src/user/jobs/userDeleter.service.ts b/src/user/jobs/userDeleter.service.ts new file mode 100644 index 0000000..132bc19 --- /dev/null +++ b/src/user/jobs/userDeleter.service.ts @@ -0,0 +1,3 @@ +/** + * This service will delete a user from the database after a certain amount of time when the user is marked for deletion. + */ diff --git a/src/user/jobs/userExporter.service.ts b/src/user/jobs/userExporter.service.ts new file mode 100644 index 0000000..2c49758 --- /dev/null +++ b/src/user/jobs/userExporter.service.ts @@ -0,0 +1,5 @@ +/** + * This service will export a user's data to a data package. This is useful for GDPR compliance. This is ran every hour. + * Users will only be able to export their own data once a month. + * TODO: Write this code! + */ diff --git a/src/user/jobs/userIndex.serive.ts b/src/user/jobs/userIndex.serive.ts deleted file mode 100644 index cf04c8b..0000000 --- a/src/user/jobs/userIndex.serive.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 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/jobs/userIndex.service.ts b/src/user/jobs/userIndex.service.ts new file mode 100644 index 0000000..840c40b --- /dev/null +++ b/src/user/jobs/userIndex.service.ts @@ -0,0 +1,5 @@ +/** + * This service indexes all the users into redis search index + * TODO: This service should be ran as a listener job. + * This is only enabled is REDIS_INDEXING is set to true + */ diff --git a/src/auth/resources/views/layout.pug b/src/user/service/manageUser.service.ts similarity index 100% rename from src/auth/resources/views/layout.pug rename to src/user/service/manageUser.service.ts diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index 9ef6e8d..df45a76 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -18,6 +18,7 @@ import { INVALID_CREDENTIALS_ERROR, USER_NOT_FOUND_ERROR, userCacheKeyGenerate, + userCacheTTL, } from '../user.constant'; import { ClsService } from 'nestjs-cls'; @@ -45,7 +46,6 @@ export class UserService { } } const cachedUser = await this.redisService.get(userCacheKeyGenerate(id)); - if (cachedUser && relations.length === 0) { return cachedUser; } @@ -68,14 +68,18 @@ export class UserService { const user = await queryBuilder.getOne(); - if (user && relations.length === 0) { - await this.redisService.set(userCacheKeyGenerate(id), user); - } - if (!user) { throw new NotFoundException(USER_NOT_FOUND_ERROR); } + if ((!user.avatar || user.avatar === '') && user.emailHash) { + user.avatar = `https://www.gravatar.com/avatar/${user.emailHash}?d=identicon`; + } + + if (relations.length === 0) { + await this.redisService.set(userCacheKeyGenerate(id), user, userCacheTTL); + } + return user; } @@ -190,7 +194,7 @@ export class UserService { * @returns Promise */ @Span() - async markEmailVerified(userId: number): Promise { + async markEmailVerified(userId: string): Promise { await this.userRepository.update(userId, { emailVerified: true }); await this.clearUserCache(userId); @@ -224,7 +228,7 @@ export class UserService { * @param userId The user's ID */ @Span() - async markPendingEmailVerified(userId: number): Promise { + async markPendingEmailVerified(userId: string): Promise { const user = await this.getUserById(userId); if (!user.pendingEmail) { @@ -254,12 +258,24 @@ export class UserService { return true; } + /** + * Deletes a user from the database. + * This needs to be ran along side the delete user from auth service to remove all active sessions!!! + * @param userId The user's ID + * @returns Promise + */ + @Span() + async deleteUser(userId: number | string): Promise { + await this.redisService.del(userCacheKeyGenerate(userId)); + await this.userRepository.delete(userId); + } + /** * Clear a cache of a user * @param userId The user's ID */ @Span() - async clearUserCache(userId: number): Promise { + async clearUserCache(userId: string): Promise { await this.redisService.del(userCacheKeyGenerate(userId)); } } diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 22215aa..4b97b59 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { RedisModule } from '../redis/redis.module'; import { UserService } from './service/user.service'; -import { DATABASE_ENTITIES } from 'src/database/database.entities'; +import { DATABASE_ENTITIES } from '../database/database.entities'; @Module({ imports: [TypeOrmModule.forFeature(DATABASE_ENTITIES), RedisModule], diff --git a/views/auth/login.hbs b/views/auth/login.hbs index 84c7641..33b4f68 100644 --- a/views/auth/login.hbs +++ b/views/auth/login.hbs @@ -128,83 +128,87 @@ diff --git a/views/interaction/consent.hbs b/views/interaction/consent.hbs new file mode 100644 index 0000000..95d77dc --- /dev/null +++ b/views/interaction/consent.hbs @@ -0,0 +1,74 @@ + + + + + + Consent Page + + + + + +
+ + +