feat: implement consent system
feat: implemented basic org system feat: implement oidc client code chore: begain implementing async job code chore: base code cleanup
This commit is contained in:
parent
d1e93a3631
commit
e3ba6bf1fd
48 changed files with 1150 additions and 174 deletions
|
@ -3,5 +3,6 @@
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"printWidth": 100
|
"printWidth": 100,
|
||||||
|
"endOfLine":"auto"
|
||||||
}
|
}
|
|
@ -7,22 +7,14 @@ services:
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: password
|
MYSQL_ROOT_PASSWORD: password
|
||||||
MYSQL_DATABASE: waterwolf-auth
|
MYSQL_DATABASE: waterwolf-auth
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- mysql-data:/var/lib/mysql
|
- mysql-data:/var/lib/mysql
|
||||||
redis:
|
redis:
|
||||||
image: redis/redis-stack-server:6.2.2-v5
|
image: redis/redis-stack-server:6.2.2-v5
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
waterwolf-auth:
|
ports:
|
||||||
depends_on:
|
- "6379:6379"
|
||||||
- mysql
|
|
||||||
- redis
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
target: all-source-stage
|
|
||||||
restart: unless-stopped
|
|
||||||
expose:
|
|
||||||
- '3000'
|
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql-data:
|
mysql-data:
|
|
@ -18,6 +18,7 @@ import { ClsModule } from 'nestjs-cls';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import { BullConfigService } from './redis/service/bull-config.service';
|
import { BullConfigService } from './redis/service/bull-config.service';
|
||||||
import { RedisService } from './redis/service/redis.service';
|
import { RedisService } from './redis/service/redis.service';
|
||||||
|
import { OrganizationModule } from './organization/organization.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -66,6 +67,7 @@ import { RedisService } from './redis/service/redis.service';
|
||||||
MailModule,
|
MailModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
OrganizationModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|
|
@ -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 USER_TO_VERIFY_CACHE_KEY = 'ww-auth:user-to-verify:';
|
||||||
|
|
||||||
export const getEmailVerifyKey = (code: string): string => `${EMAIL_VERIFY_CACHE_KEY}${code}`;
|
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}`;
|
`${USER_TO_VERIFY_CACHE_KEY}${userId}`;
|
||||||
|
|
||||||
// Failed Login Attempts Const
|
// Failed Login Attempts Const
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { OidcController } from './controllers/oidc.controller';
|
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 { UserModule } from '../user/user.module';
|
||||||
import { RedisModule } from '../redis/redis.module';
|
import { RedisModule } from '../redis/redis.module';
|
||||||
import { AuthController } from './controllers/auth.controller';
|
import { AuthController } from './controllers/auth.controller';
|
||||||
|
@ -11,6 +11,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { OidcSession } from '../database/models/oidc_session.model';
|
import { OidcSession } from '../database/models/oidc_session.model';
|
||||||
import { OidcClient } from '../database/models/oidc_client.model';
|
import { OidcClient } from '../database/models/oidc_client.model';
|
||||||
import { OidcClientPermission } from '../database/models/oidc_client_permissions.model';
|
import { OidcClientPermission } from '../database/models/oidc_client_permissions.model';
|
||||||
|
import { InteractionService } from './oidc/service/interaction.service';
|
||||||
|
import { InteractionController } from './controllers/interaction.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -19,8 +21,8 @@ import { OidcClientPermission } from '../database/models/oidc_client_permissions
|
||||||
MailModule,
|
MailModule,
|
||||||
TypeOrmModule.forFeature([OidcSession, OidcClient, OidcClientPermission]),
|
TypeOrmModule.forFeature([OidcSession, OidcClient, OidcClientPermission]),
|
||||||
],
|
],
|
||||||
controllers: [OidcController, AuthController],
|
controllers: [OidcController, AuthController, InteractionController],
|
||||||
providers: [ConfigService, OidcService, AuthService],
|
providers: [ConfigService, OidcService, AuthService, InteractionService],
|
||||||
exports: [OidcService],
|
exports: [OidcService, InteractionService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|
|
@ -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 { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { AuthService } from '../services/auth.service';
|
import { AuthService } from '../services/auth.service';
|
||||||
import { ForgotPasswordDto } from '../dto/forgotPassword.dto';
|
import { ForgotPasswordDto } from '../dto/forgotPassword.dto';
|
||||||
import { CreateUserDto } from '../dto/register.dto';
|
import { CreateUserDto } from '../dto/register.dto';
|
||||||
import { LoginUserDto } from '../dto/loginUser.dto';
|
import { LoginUserDto } from '../dto/loginUser.dto';
|
||||||
import { Response } from 'express';
|
|
||||||
import { User } from '../decorators/user.decorator';
|
import { User } from '../decorators/user.decorator';
|
||||||
import { LoginGuard } from '../guard/login.guard';
|
import { LoginGuard } from '../guard/login.guard';
|
||||||
|
import { Response, Request } from 'express';
|
||||||
|
|
||||||
// TODO: Implement RateLimit
|
// TODO: Implement RateLimit
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
|
@ -19,6 +19,7 @@ export class AuthController {
|
||||||
public async postLogin(
|
public async postLogin(
|
||||||
@Body() body: LoginUserDto,
|
@Body() body: LoginUserDto,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
@Req() request: Request,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const sessionData = await this.authService.login(body.username, body.password);
|
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);
|
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')
|
@Post('register')
|
||||||
|
@ -54,6 +71,7 @@ export class AuthController {
|
||||||
return {
|
return {
|
||||||
forgot_password: 'forgot-password',
|
forgot_password: 'forgot-password',
|
||||||
register: 'register',
|
register: 'register',
|
||||||
|
login_url: '/auth/login',
|
||||||
//background_image: 'https://waterwolf.club/static/img/portal/portal7.jpg',
|
//background_image: 'https://waterwolf.club/static/img/portal/portal7.jpg',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -99,7 +117,7 @@ export class AuthController {
|
||||||
error_message:
|
error_message:
|
||||||
'The verification code provided is invalid. Please try sending your verification email again.',
|
'The verification code provided is invalid. Please try sending your verification email again.',
|
||||||
button_name: 'Go Back to Login',
|
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:
|
error_message:
|
||||||
'The verification code provided is invalid. Please try sending your verification email again.',
|
'The verification code provided is invalid. Please try sending your verification email again.',
|
||||||
button_name: 'Go Back to Login',
|
button_name: 'Go Back to Login',
|
||||||
button_link: '/auth/login',
|
button_link: 'api/v1/auth/login',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
response.redirect('/auth/login');
|
response.redirect('/auth/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: Work on interaction view.
|
@Get('auth-test')
|
||||||
@Get('interaction/:id')
|
|
||||||
@ApiExcludeEndpoint()
|
@ApiExcludeEndpoint()
|
||||||
public async getInteraction(@User() user: any): Promise<any> {
|
public async getAuthTest(@User() user: any): Promise<any> {
|
||||||
// TODO: If user is not logged in. Set a cookie to redirect to this page after login.
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
136
src/auth/controllers/interaction.controller.ts
Normal file
136
src/auth/controllers/interaction.controller.ts
Normal file
|
@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import { All, Controller, Req, Res, UseInterceptors } from '@nestjs/common';
|
import { All, Controller, Req, Res, UseInterceptors } from '@nestjs/common';
|
||||||
import { ApiExcludeController } from '@nestjs/swagger';
|
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';
|
import { ExpressResErrorInterceptor } from '../../interceptor/express_res_error.interceptor';
|
||||||
|
|
||||||
@UseInterceptors(new ExpressResErrorInterceptor())
|
@UseInterceptors(new ExpressResErrorInterceptor())
|
||||||
|
|
3
src/auth/controllers/view.controller.ts
Normal file
3
src/auth/controllers/view.controller.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export class AuthViewController {
|
||||||
|
constructor() {}
|
||||||
|
}
|
|
@ -1,7 +1,8 @@
|
||||||
import { createParamDecorator } from '@nestjs/common';
|
import { createParamDecorator } from '@nestjs/common';
|
||||||
import { ClsServiceManager } from 'nestjs-cls';
|
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 cls = ClsServiceManager.getClsService();
|
||||||
const authType = cls.get('authType');
|
const authType = cls.get('authType');
|
||||||
|
|
||||||
|
|
5
src/auth/jobs/oidc-CleanUp.job.ts
Normal file
5
src/auth/jobs/oidc-CleanUp.job.ts
Normal file
|
@ -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!!
|
|
@ -1,6 +1,6 @@
|
||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { OidcService } from '../oidc/core.service';
|
import { OidcService } from '../oidc/service/core.service';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
|
@ -438,6 +438,10 @@ export const createOidcAdapter: (db: DataSource, redis: RedisService, baseUrl: s
|
||||||
client.post_logout_redirect_uris = [];
|
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);
|
await redis.set(this.key(id), client, globalCacheTTL);
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { promises as fs } from 'fs';
|
||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
import psl from 'psl';
|
import psl from 'psl';
|
||||||
import type { Configuration, errors, KoaContextWithOIDC } from 'oidc-provider';
|
import type { Configuration, errors, KoaContextWithOIDC } from 'oidc-provider';
|
||||||
import { createOidcAdapter } from './adapter';
|
import { createOidcAdapter } from '../adapter';
|
||||||
import wildcard from 'wildcard';
|
import wildcard from 'wildcard';
|
||||||
import {
|
import {
|
||||||
ACCESS_TOKEN_LIFE,
|
ACCESS_TOKEN_LIFE,
|
||||||
|
@ -23,17 +23,17 @@ import {
|
||||||
PUSHED_AUTH_REQ_LIFE,
|
PUSHED_AUTH_REQ_LIFE,
|
||||||
REFRESH_TOKEN_LIFE,
|
REFRESH_TOKEN_LIFE,
|
||||||
SESSION_LIFE,
|
SESSION_LIFE,
|
||||||
} from './oidc.const';
|
} from '../oidc.const';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { RedisService } from '../../redis/service/redis.service';
|
import { RedisService } from '../../../redis/service/redis.service';
|
||||||
import { UserService } from '../../user/service/user.service';
|
import { UserService } from '../../../user/service/user.service';
|
||||||
import { Span } from 'nestjs-otel';
|
import { Span } from 'nestjs-otel';
|
||||||
import generateId from './helper/nanoid.helper';
|
import generateId from '../helper/nanoid.helper';
|
||||||
import { context, trace } from '@opentelemetry/api';
|
import { context, trace } from '@opentelemetry/api';
|
||||||
import * as KeyGrip from 'keygrip';
|
import * as KeyGrip from 'keygrip';
|
||||||
import { getEpochTime } from '../../util/time.util';
|
import { getEpochTime } from '../../../util/time.util';
|
||||||
import { VerifiedSessionFromRequest } from './types/session.type';
|
import { VerifiedSessionFromRequest } from '../types/session.type';
|
||||||
import { Request, Response } from 'express';
|
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.
|
// 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,
|
conformIdTokenClaims: false,
|
||||||
renderError(ctx, out, _error) {
|
renderError(ctx, out, _error) {
|
||||||
|
console.log(out);
|
||||||
|
console.log(_error);
|
||||||
|
|
||||||
let statusCode = 500;
|
let statusCode = 500;
|
||||||
let errorMessage = 'Internal Server Error';
|
let errorMessage = 'Internal Server Error';
|
||||||
// Look at the first error in the out object
|
// Look at the first error in the out object
|
||||||
|
@ -413,9 +416,9 @@ export class OidcService implements OnModuleInit {
|
||||||
|
|
||||||
private getKeysFolder(): string {
|
private getKeysFolder(): string {
|
||||||
if (process.env.NODE_ENV === 'production') {
|
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 cookies = [sessionCookie];
|
||||||
const [pre, ...post] = sessionCookie.split(';');
|
const [pre, ...post] = sessionCookie.split(';');
|
||||||
cookies.push([`_session.sig=${keyGrip.sign(pre)}`, ...post].join(';'));
|
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 = [
|
const cookiesForms = [
|
||||||
{
|
{
|
||||||
name: '_session',
|
name: '_session',
|
||||||
|
@ -505,7 +507,7 @@ export class OidcService implements OnModuleInit {
|
||||||
value: sessionId,
|
value: sessionId,
|
||||||
options: {
|
options: {
|
||||||
expires: expire,
|
expires: expire,
|
||||||
sameSite: 'None',
|
sameSite: 'strict',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -531,7 +533,10 @@ export class OidcService implements OnModuleInit {
|
||||||
* @returns any
|
* @returns any
|
||||||
*/
|
*/
|
||||||
@Span()
|
@Span()
|
||||||
async verifyByRequest(req: Request, res: Response): Promise<VerifiedSessionFromRequest> {
|
async verifyByRequest(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
): Promise<VerifiedSessionFromRequest | undefined> {
|
||||||
try {
|
try {
|
||||||
const ctx = this.provider.app.createContext(req, res);
|
const ctx = this.provider.app.createContext(req, res);
|
||||||
const session = await this.provider.Session.get(ctx);
|
const session = await this.provider.Session.get(ctx);
|
||||||
|
@ -557,6 +562,18 @@ export class OidcService implements OnModuleInit {
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} 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(
|
this.logger.error(
|
||||||
err,
|
err,
|
||||||
'There was an error while trying to verify session, purging session cookies',
|
'There was an error while trying to verify session, purging session cookies',
|
218
src/auth/oidc/service/interaction.service.ts
Normal file
218
src/auth/oidc/service/interaction.service.ts
Normal file
|
@ -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<Interaction> {
|
||||||
|
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<void> {
|
||||||
|
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<void>
|
||||||
|
*/
|
||||||
|
@Span()
|
||||||
|
public async consent(req: Request, res: Response, autoApprove = false): Promise<void> {
|
||||||
|
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<void | string> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,7 +10,7 @@ import {
|
||||||
PASSWORD_RESET_CACHE_KEY,
|
PASSWORD_RESET_CACHE_KEY,
|
||||||
PASSWORD_RESET_EXPIRATION,
|
PASSWORD_RESET_EXPIRATION,
|
||||||
} from '../auth.const';
|
} from '../auth.const';
|
||||||
import { OidcService } from '../oidc/core.service';
|
import { OidcService } from '../oidc/service/core.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
@ -70,6 +70,22 @@ export class AuthService {
|
||||||
return this.oidcService.createSession(user.id);
|
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<string> {
|
||||||
|
const user = await this.userService.authenticate(username, password);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new BadRequestException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.id;
|
||||||
|
}
|
||||||
|
|
||||||
// == Password Reset Logic == //
|
// == Password Reset Logic == //
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -179,7 +195,7 @@ export class AuthService {
|
||||||
* @param userId The user ID associated with the code
|
* @param userId The user ID associated with the code
|
||||||
* @returns void
|
* @returns void
|
||||||
*/
|
*/
|
||||||
private async storeEmailVerifyCode(code: string, userId: number): Promise<void> {
|
private async storeEmailVerifyCode(code: string, userId: string): Promise<void> {
|
||||||
await this.cleanupOldEmailVerificationCode(userId);
|
await this.cleanupOldEmailVerificationCode(userId);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
@ -193,8 +209,8 @@ export class AuthService {
|
||||||
* @param code The email verify code
|
* @param code The email verify code
|
||||||
* @returns The user ID associated with the code
|
* @returns The user ID associated with the code
|
||||||
*/
|
*/
|
||||||
private async getEmailVerifyCode(code: string): Promise<number | null> {
|
private async getEmailVerifyCode(code: string): Promise<string | null> {
|
||||||
return this.redisService.get<number>(getEmailVerifyKey(code));
|
return this.redisService.get<string>(getEmailVerifyKey(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -202,7 +218,7 @@ export class AuthService {
|
||||||
* @param userId The user ID
|
* @param userId The user ID
|
||||||
* @returns void
|
* @returns void
|
||||||
*/
|
*/
|
||||||
public async cleanupOldEmailVerificationCode(userId: number): Promise<void> {
|
public async cleanupOldEmailVerificationCode(userId: string): Promise<void> {
|
||||||
const code = await this.redisService.get(getUserToVerifyKey(userId));
|
const code = await this.redisService.get(getUserToVerifyKey(userId));
|
||||||
|
|
||||||
if (code) {
|
if (code) {
|
||||||
|
|
13
src/client/client.module.ts
Normal file
13
src/client/client.module.ts
Normal file
|
@ -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 {}
|
|
@ -17,7 +17,6 @@ export default registerAs('database', () => {
|
||||||
entities: [__dirname + '/../database/*.model{.ts,.js}'],
|
entities: [__dirname + '/../database/*.model{.ts,.js}'],
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(__dirname + '/../database/*.model{.ts,.js}');
|
|
||||||
// Error throwing
|
// Error throwing
|
||||||
if (!conf.host) {
|
if (!conf.host) {
|
||||||
throw new Error('DATABASE_HOST is not set');
|
throw new Error('DATABASE_HOST is not set');
|
||||||
|
|
|
@ -1,5 +1,16 @@
|
||||||
import { ApiKey } from './models/api_keys.model';
|
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';
|
import { User } from './models/user.model';
|
||||||
|
|
||||||
// Database Entities Array
|
// Database Entities Array
|
||||||
export const DATABASE_ENTITIES = [User, ApiKey];
|
export const DATABASE_ENTITIES = [
|
||||||
|
User,
|
||||||
|
ApiKey,
|
||||||
|
Organization,
|
||||||
|
OidcGrant,
|
||||||
|
OidcRefreshToken,
|
||||||
|
OrganizationToUser,
|
||||||
|
];
|
||||||
|
|
|
@ -51,8 +51,8 @@ export class OidcClient {
|
||||||
@Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: false })
|
@Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: false })
|
||||||
application_type: 'web' | 'native';
|
application_type: 'web' | 'native';
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: false })
|
@Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: true })
|
||||||
logo_uri: string;
|
logo_uri?: string;
|
||||||
|
|
||||||
@Column({ type: 'boolean', nullable: false, default: false })
|
@Column({ type: 'boolean', nullable: false, default: false })
|
||||||
restricted: boolean;
|
restricted: boolean;
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import type { AdapterPayload } from 'oidc-provider';
|
import type { AdapterPayload } from 'oidc-provider';
|
||||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||||
|
|
||||||
import {
|
import { convertFromNumberToTime, convertFromTimeToNumber } from '../../util/time.util';
|
||||||
convertFromNumberToTime,
|
|
||||||
convertFromTimeToNumber,
|
|
||||||
} from '../../util/time.util';
|
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class OidcGrant implements AdapterPayload {
|
export class OidcGrant implements AdapterPayload {
|
||||||
|
|
|
@ -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 { MAX_STRING_LENGTH } from '../database.const';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { OrganizationToUser } from './organization_to_user.model';
|
||||||
|
|
||||||
@Entity('organization')
|
@Entity('organization')
|
||||||
export class Organization {
|
export class Organization {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryColumn({
|
||||||
id: number;
|
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 })
|
@Column({ length: MAX_STRING_LENGTH })
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -25,7 +40,10 @@ export class Organization {
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@Column({ name: 'owner_id' })
|
@Column({ name: 'owner_id' })
|
||||||
ownerId: number;
|
ownerId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'set_flags', type: 'simple-array', default: () => "('')" })
|
||||||
|
flags: string[];
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
name: 'created_at',
|
name: 'created_at',
|
||||||
|
@ -41,4 +59,7 @@ export class Organization {
|
||||||
onUpdate: 'CURRENT_TIMESTAMP',
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
})
|
})
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => OrganizationToUser, (organizationToUser) => organizationToUser.organization)
|
||||||
|
public organizationToUser: OrganizationToUser[];
|
||||||
}
|
}
|
||||||
|
|
57
src/database/models/organization_to_user.model.ts
Normal file
57
src/database/models/organization_to_user.model.ts
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -1,20 +1,28 @@
|
||||||
import {
|
import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryColumn } from 'typeorm';
|
||||||
BeforeInsert,
|
|
||||||
BeforeUpdate,
|
|
||||||
Column,
|
|
||||||
Entity,
|
|
||||||
OneToMany,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { MAX_STRING_LENGTH, UserRole } from '../database.const';
|
import { MAX_STRING_LENGTH, UserRole } from '../database.const';
|
||||||
import { ApiKey } from './api_keys.model';
|
import { ApiKey } from './api_keys.model';
|
||||||
|
import { OrganizationToUser } from './organization_to_user.model';
|
||||||
|
|
||||||
@Entity('user')
|
@Entity('user')
|
||||||
export class User {
|
export class User {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryColumn({
|
||||||
id: number;
|
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 })
|
@Column({ length: MAX_STRING_LENGTH, unique: true })
|
||||||
username: string;
|
username: string;
|
||||||
|
@ -49,6 +57,16 @@ export class User {
|
||||||
@Column({ name: 'disabled', default: false })
|
@Column({ name: 'disabled', default: false })
|
||||||
disabled: boolean;
|
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
|
// This is for Gravatar Support
|
||||||
@Column({ name: 'email_hash', length: MAX_STRING_LENGTH, nullable: true })
|
@Column({ name: 'email_hash', length: MAX_STRING_LENGTH, nullable: true })
|
||||||
emailHash: string;
|
emailHash: string;
|
||||||
|
@ -57,7 +75,7 @@ export class User {
|
||||||
@BeforeInsert()
|
@BeforeInsert()
|
||||||
@BeforeUpdate()
|
@BeforeUpdate()
|
||||||
updateEmailHashOnUpdate() {
|
updateEmailHashOnUpdate() {
|
||||||
this.emailHash = createHash('sha256').update(this.email).digest('hex');
|
this.emailHash = createHash('sha256').update(this.email.trim().toLowerCase()).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relationship Mapping
|
// Relationship Mapping
|
||||||
|
@ -80,4 +98,7 @@ export class User {
|
||||||
onUpdate: 'CURRENT_TIMESTAMP',
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
})
|
})
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => OrganizationToUser, (organizationToUser) => organizationToUser.user)
|
||||||
|
organizations: OrganizationToUser[];
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<void> {}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
|
@ -65,7 +65,7 @@ export class MailService {
|
||||||
public async sendVerificationEmail(email: string, code: string): Promise<void> {
|
public async sendVerificationEmail(email: string, code: string): Promise<void> {
|
||||||
this.logger.debug(`Sending verification email to ${email}`);
|
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
|
//handlebar render email template
|
||||||
const { html: templateHtml, text: templateText } = await this.fetchTemplate('verify-email');
|
const { html: templateHtml, text: templateText } = await this.fetchTemplate('verify-email');
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
import type {
|
import type { PostalModuleOptions, PostalModuleOptionsFactory } from 'nestjs-postal-client';
|
||||||
PostalModuleOptions,
|
|
||||||
PostalModuleOptionsFactory,
|
|
||||||
} from 'nestjs-postal-client';
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
@ -14,8 +11,7 @@ export class PostalConfigService implements PostalModuleOptionsFactory {
|
||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
createPostalOptions(): Promise<PostalModuleOptions> | PostalModuleOptions {
|
createPostalOptions(): Promise<PostalModuleOptions> | PostalModuleOptions {
|
||||||
const postalConfig =
|
const postalConfig = this.configService.getOrThrow<PostalConfig>(POSTAL_CONFIG_KEY);
|
||||||
this.configService.getOrThrow<PostalConfig>(POSTAL_CONFIG_KEY);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
http: {
|
http: {
|
||||||
|
|
21
src/main.ts
21
src/main.ts
|
@ -3,7 +3,7 @@ import { NestFactory } from '@nestjs/core';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { RequestMethod, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||||
import * as cookieParser from 'cookie-parser';
|
import * as cookieParser from 'cookie-parser';
|
||||||
import { ClsMiddleware } from 'nestjs-cls';
|
import { ClsMiddleware } from 'nestjs-cls';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
@ -11,6 +11,21 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(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.useGlobalPipes(new ValidationPipe());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
@ -24,16 +39,16 @@ async function bootstrap() {
|
||||||
);
|
);
|
||||||
|
|
||||||
//Swagger Documentation
|
//Swagger Documentation
|
||||||
|
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('Waterwolf Identity Provider')
|
.setTitle('Waterwolf Identity Provider')
|
||||||
.setDescription('An OpenSource Identity Provider written by Waterwolf')
|
.setDescription('An OpenSource Identity Provider written by Waterwolf')
|
||||||
.setVersion('1.0')
|
.setVersion('1.0')
|
||||||
.addTag('Authentication', 'Inital login and registration')
|
.addTag('Authentication', 'Initial login and registration')
|
||||||
.addTag('Client')
|
.addTag('Client')
|
||||||
.addTag('Organization')
|
.addTag('Organization')
|
||||||
.addTag('User')
|
.addTag('User')
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('api', app, document);
|
SwaggerModule.setup('api', app, document);
|
||||||
|
|
||||||
|
|
72
src/organization/controller/organization.controller.ts
Normal file
72
src/organization/controller/organization.controller.ts
Normal file
|
@ -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<any> {
|
||||||
|
return await this.organizationService.getOrganizations();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
public async getOrganization(@Param('id') id: string): Promise<any> {
|
||||||
|
return await this.organizationService.getOrganizationById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/members')
|
||||||
|
public async getOrganizationMembers(@Param('id') id: string): Promise<any> {
|
||||||
|
return await this.organizationService.getOrgMembers(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
public async deleteOrganization(@Param('id') id: string): Promise<any> {
|
||||||
|
return await this.organizationService.deleteOrganization(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
public async partialUpdateOrganization(@Param('id') id: string): Promise<any> {}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
public async updateOrganization(@Param('id') id: string): Promise<any> {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
public async createOrganization(
|
||||||
|
@Body() body: CreateOrgDto,
|
||||||
|
@User() user: UserObject,
|
||||||
|
): Promise<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
return await this.organizationService.removeFlag(id, param.flag);
|
||||||
|
}
|
||||||
|
}
|
18
src/organization/dto/orgCreate.dto.ts
Normal file
18
src/organization/dto/orgCreate.dto.ts
Normal file
|
@ -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;
|
||||||
|
}
|
9
src/organization/dto/orgSetFlag.dto.ts
Normal file
9
src/organization/dto/orgSetFlag.dto.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class OrgSetFlagDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
flag: string;
|
||||||
|
}
|
5
src/organization/jobs/orgIndex.service.ts
Normal file
5
src/organization/jobs/orgIndex.service.ts
Normal file
|
@ -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
|
||||||
|
*/
|
18
src/organization/organization.const.ts
Normal file
18
src/organization/organization.const.ts
Normal file
|
@ -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`;
|
15
src/organization/organization.module.ts
Normal file
15
src/organization/organization.module.ts
Normal file
|
@ -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 {}
|
158
src/organization/service/organization.service.ts
Normal file
158
src/organization/service/organization.service.ts
Normal file
|
@ -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<Organization>,
|
||||||
|
@InjectRepository(OrganizationToUser)
|
||||||
|
private readonly orgToUser: Repository<OrganizationToUser>,
|
||||||
|
private readonly redisService: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Span()
|
||||||
|
async getOrganizationById(id: string): Promise<Organization> {
|
||||||
|
const cachedOrganization = await this.redisService.get<Organization>(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<Organization> {
|
||||||
|
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<void> {
|
||||||
|
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<Organization> {
|
||||||
|
await this.organizationRepository.update(id, organization);
|
||||||
|
|
||||||
|
await this.redisService.set(orgCacheKeyGenerate(id), organization, orgCacheTTL);
|
||||||
|
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Span()
|
||||||
|
async getOrganizations(): Promise<Organization[]> {
|
||||||
|
return await this.organizationRepository.find();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Span()
|
||||||
|
async setFlag(id: string, flag: string): Promise<Organization> {
|
||||||
|
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<Organization> {
|
||||||
|
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<any> {
|
||||||
|
const cachedMembers = await this.redisService.get<Organization>(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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,37 @@
|
||||||
import { Controller } from '@nestjs/common';
|
import { Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common';
|
||||||
import { UserService } from '../service/user.service';
|
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { UserService } from '../service/user.service';
|
||||||
|
|
||||||
@Controller({
|
@Controller('user')
|
||||||
path: 'user',
|
|
||||||
version: '1',
|
|
||||||
})
|
|
||||||
@ApiTags('User')
|
@ApiTags('User')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
constructor(private readonly userService: UserService) {}
|
constructor(private readonly userService: UserService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
// Admin: Paginated list of users.
|
||||||
|
public async getUsers(): Promise<any> {
|
||||||
|
//return await this.userService.getUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
public async getUser(@Param('id') id: string): Promise<any> {
|
||||||
|
return await this.userService.getUserById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
public async deleteUser(@Param('id') id: string): Promise<any> {
|
||||||
|
return await this.userService.deleteUser(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
public async partialUpdateUser(@Param('id') id: string): Promise<any> {}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
public async updateUser(@Param('id') id: string): Promise<any> {}
|
||||||
|
|
||||||
|
@Post(':id/change-password')
|
||||||
|
public async changePassword(@Param('id') id: string): Promise<any> {}
|
||||||
|
|
||||||
|
@Post(':id/change-email')
|
||||||
|
public async changeEmail(@Param('id') id: string): Promise<any> {}
|
||||||
}
|
}
|
||||||
|
|
3
src/user/jobs/userDeleter.service.ts
Normal file
3
src/user/jobs/userDeleter.service.ts
Normal file
|
@ -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.
|
||||||
|
*/
|
5
src/user/jobs/userExporter.service.ts
Normal file
5
src/user/jobs/userExporter.service.ts
Normal file
|
@ -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!
|
||||||
|
*/
|
|
@ -1,4 +0,0 @@
|
||||||
/**
|
|
||||||
* This service indexes all the users into redis search index
|
|
||||||
* TODO: This service should be ran as a listener job
|
|
||||||
*/
|
|
5
src/user/jobs/userIndex.service.ts
Normal file
5
src/user/jobs/userIndex.service.ts
Normal file
|
@ -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
|
||||||
|
*/
|
|
@ -18,6 +18,7 @@ import {
|
||||||
INVALID_CREDENTIALS_ERROR,
|
INVALID_CREDENTIALS_ERROR,
|
||||||
USER_NOT_FOUND_ERROR,
|
USER_NOT_FOUND_ERROR,
|
||||||
userCacheKeyGenerate,
|
userCacheKeyGenerate,
|
||||||
|
userCacheTTL,
|
||||||
} from '../user.constant';
|
} from '../user.constant';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
|
||||||
|
@ -45,7 +46,6 @@ export class UserService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cachedUser = await this.redisService.get<User>(userCacheKeyGenerate(id));
|
const cachedUser = await this.redisService.get<User>(userCacheKeyGenerate(id));
|
||||||
|
|
||||||
if (cachedUser && relations.length === 0) {
|
if (cachedUser && relations.length === 0) {
|
||||||
return cachedUser;
|
return cachedUser;
|
||||||
}
|
}
|
||||||
|
@ -68,14 +68,18 @@ export class UserService {
|
||||||
|
|
||||||
const user = await queryBuilder.getOne();
|
const user = await queryBuilder.getOne();
|
||||||
|
|
||||||
if (user && relations.length === 0) {
|
|
||||||
await this.redisService.set(userCacheKeyGenerate(id), user);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException(USER_NOT_FOUND_ERROR);
|
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;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -190,7 +194,7 @@ export class UserService {
|
||||||
* @returns Promise<void>
|
* @returns Promise<void>
|
||||||
*/
|
*/
|
||||||
@Span()
|
@Span()
|
||||||
async markEmailVerified(userId: number): Promise<void> {
|
async markEmailVerified(userId: string): Promise<void> {
|
||||||
await this.userRepository.update(userId, { emailVerified: true });
|
await this.userRepository.update(userId, { emailVerified: true });
|
||||||
|
|
||||||
await this.clearUserCache(userId);
|
await this.clearUserCache(userId);
|
||||||
|
@ -224,7 +228,7 @@ export class UserService {
|
||||||
* @param userId The user's ID
|
* @param userId The user's ID
|
||||||
*/
|
*/
|
||||||
@Span()
|
@Span()
|
||||||
async markPendingEmailVerified(userId: number): Promise<void> {
|
async markPendingEmailVerified(userId: string): Promise<void> {
|
||||||
const user = await this.getUserById(userId);
|
const user = await this.getUserById(userId);
|
||||||
|
|
||||||
if (!user.pendingEmail) {
|
if (!user.pendingEmail) {
|
||||||
|
@ -254,12 +258,24 @@ export class UserService {
|
||||||
return true;
|
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<void>
|
||||||
|
*/
|
||||||
|
@Span()
|
||||||
|
async deleteUser(userId: number | string): Promise<void> {
|
||||||
|
await this.redisService.del(userCacheKeyGenerate(userId));
|
||||||
|
await this.userRepository.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear a cache of a user
|
* Clear a cache of a user
|
||||||
* @param userId The user's ID
|
* @param userId The user's ID
|
||||||
*/
|
*/
|
||||||
@Span()
|
@Span()
|
||||||
async clearUserCache(userId: number): Promise<void> {
|
async clearUserCache(userId: string): Promise<void> {
|
||||||
await this.redisService.del(userCacheKeyGenerate(userId));
|
await this.redisService.del(userCacheKeyGenerate(userId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { RedisModule } from '../redis/redis.module';
|
import { RedisModule } from '../redis/redis.module';
|
||||||
import { UserService } from './service/user.service';
|
import { UserService } from './service/user.service';
|
||||||
import { DATABASE_ENTITIES } from 'src/database/database.entities';
|
import { DATABASE_ENTITIES } from '../database/database.entities';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature(DATABASE_ENTITIES), RedisModule],
|
imports: [TypeOrmModule.forFeature(DATABASE_ENTITIES), RedisModule],
|
||||||
|
|
|
@ -128,83 +128,87 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('loginForm').addEventListener('submit', async function(event) {
|
document.getElementById('loginForm').addEventListener('submit', async function(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
const loginData = {
|
const loginData = {
|
||||||
username: formData.get('username'),
|
username: formData.get('username'),
|
||||||
password: formData.get('password'),
|
password: formData.get('password'),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(form.action, {
|
const response = await fetch(form.action, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(loginData),
|
body: JSON.stringify(loginData),
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorMessageDiv = document.getElementById('username-error');
|
|
||||||
const usernameInput = form.querySelector('input[name="username"]');
|
|
||||||
const passwordInput = form.querySelector('input[name="password"]');
|
|
||||||
errorMessageDiv.classList.remove('hidden');
|
|
||||||
usernameInput.classList.add('border-red-600', 'shake');
|
|
||||||
passwordInput.classList.add('border-red-600', 'shake');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
usernameInput.classList.remove('shake');
|
|
||||||
passwordInput.classList.remove('shake');
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
if (response.status === 401) {
|
|
||||||
errorMessageDiv.textContent = 'Incorrect Password/Username';
|
|
||||||
} else if (response.status === 403) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
if (errorData.message === 'Account is disabled') {
|
|
||||||
errorMessageDiv.textContent = 'Account is disabled';
|
|
||||||
} else if (errorData.message === 'Email requires verification') {
|
|
||||||
errorMessageDiv.innerHTML = 'Email requires verification. <button id="resend-verification" class="text-indigo-500 hover:text-indigo-400">Resend verification email</button>';
|
|
||||||
document.getElementById('resend-verification').addEventListener('click', async function() {
|
|
||||||
try {
|
|
||||||
const emailResponse = await fetch('/auth/email-verification', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: loginData.username }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (emailResponse.status === 429) {
|
|
||||||
errorMessageDiv.textContent = 'Rate limit exceeded. Please wait.';
|
|
||||||
} else if (emailResponse.ok) {
|
|
||||||
alert('Verification email resent!');
|
|
||||||
} else {
|
|
||||||
errorMessageDiv.textContent = 'Failed to resend verification email. Please try again later.';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
errorMessageDiv.textContent = 'An error occurred. Please try again later.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
errorMessageDiv.textContent = 'An unknown error occurred';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Handle successful login
|
|
||||||
alert('Login successful!');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
const errorMessageDiv = document.getElementById('username-error');
|
|
||||||
errorMessageDiv.classList.remove('hidden');
|
|
||||||
errorMessageDiv.textContent = 'An error occurred. Please try again later.';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessageDiv = document.getElementById('username-error');
|
||||||
|
const usernameInput = form.querySelector('input[name="username"]');
|
||||||
|
const passwordInput = form.querySelector('input[name="password"]');
|
||||||
|
errorMessageDiv.classList.remove('hidden');
|
||||||
|
usernameInput.classList.add('border-red-600', 'shake');
|
||||||
|
passwordInput.classList.add('border-red-600', 'shake');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
usernameInput.classList.remove('shake');
|
||||||
|
passwordInput.classList.remove('shake');
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
errorMessageDiv.textContent = 'Incorrect Password/Username';
|
||||||
|
} else if (response.status === 403) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.message === 'Account is disabled') {
|
||||||
|
errorMessageDiv.textContent = 'Account is disabled';
|
||||||
|
} else if (errorData.message === 'Email requires verification') {
|
||||||
|
errorMessageDiv.innerHTML = 'Email requires verification. <button id="resend-verification" class="text-indigo-500 hover:text-indigo-400">Resend verification email</button>';
|
||||||
|
document.getElementById('resend-verification').addEventListener('click', async function() {
|
||||||
|
try {
|
||||||
|
const emailResponse = await fetch('/auth/email-verification', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email: loginData.username }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (emailResponse.status === 429) {
|
||||||
|
errorMessageDiv.textContent = 'Rate limit exceeded. Please wait.';
|
||||||
|
} else if (emailResponse.ok) {
|
||||||
|
alert('Verification email resent!');
|
||||||
|
} else {
|
||||||
|
errorMessageDiv.textContent = 'Failed to resend verification email. Please try again later.';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
errorMessageDiv.textContent = 'An error occurred. Please try again later.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMessageDiv.textContent = 'An unknown error occurred';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const responseData = await response.json();
|
||||||
|
if (responseData.status === 'interactionRedirect' && responseData.redirectUrl) {
|
||||||
|
window.location.href = responseData.redirectUrl;
|
||||||
|
} else {
|
||||||
|
window.location.href = '/auth/auth-test';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
const errorMessageDiv = document.getElementById('username-error');
|
||||||
|
errorMessageDiv.classList.remove('hidden');
|
||||||
|
errorMessageDiv.textContent = 'An error occurred. Please try again later.';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
74
views/interaction/consent.hbs
Normal file
74
views/interaction/consent.hbs
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Consent 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;
|
||||||
|
}
|
||||||
|
.consent-prompt {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
position: static;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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 consent-prompt">
|
||||||
|
<div class="flex items-center justify-center mb-4">
|
||||||
|
<img src="{{client.clientLogo}}" alt="Client Logo" class="w-16 h-16 rounded-full">
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold mb-2 text-white text-center">{{client.clientName}} wants to access your account</h2>
|
||||||
|
<p class="text-gray-400 mb-6 text-center">This application will be able to:</p>
|
||||||
|
<ul class="list-disc list-inside text-gray-400 mb-6">
|
||||||
|
{{#each scopes}}
|
||||||
|
<li>{{this}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<form action="/interaction/{{uid}}/consent" method="POST" class="w-1/2">
|
||||||
|
<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-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">Allow</button>
|
||||||
|
</form>
|
||||||
|
<form action="/interaction/{{uid}}/deny" method="POST" class="w-1/2">
|
||||||
|
<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-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">Deny</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 text-sm text-center text-gray-400">
|
||||||
|
<img src="{{user.avatar}}" alt="User Avatar" class="w-10 h-10 rounded-full mx-auto mb-2">
|
||||||
|
<span>{{user.displayName}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in a new issue