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",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"printWidth": 100
|
||||
"printWidth": 100,
|
||||
"endOfLine":"auto"
|
||||
}
|
|
@ -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:
|
|
@ -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],
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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<any> {
|
||||
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<any> {
|
||||
// TODO: If user is not logged in. Set a cookie to redirect to this page after login.
|
||||
public async getAuthTest(@User() user: any): Promise<any> {
|
||||
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 { 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())
|
||||
|
|
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 { 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');
|
||||
|
||||
|
|
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 { Request, Response, NextFunction } from 'express';
|
||||
import { OidcService } from '../oidc/core.service';
|
||||
import { OidcService } from '../oidc/service/core.service';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
|
||||
@Injectable()
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<VerifiedSessionFromRequest> {
|
||||
async verifyByRequest(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<VerifiedSessionFromRequest | undefined> {
|
||||
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',
|
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_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<string> {
|
||||
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<void> {
|
||||
private async storeEmailVerifyCode(code: string, userId: string): Promise<void> {
|
||||
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<number | null> {
|
||||
return this.redisService.get<number>(getEmailVerifyKey(code));
|
||||
private async getEmailVerifyCode(code: string): Promise<string | null> {
|
||||
return this.redisService.get<string>(getEmailVerifyKey(code));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -202,7 +218,7 @@ export class AuthService {
|
|||
* @param userId The user ID
|
||||
* @returns void
|
||||
*/
|
||||
public async cleanupOldEmailVerificationCode(userId: number): Promise<void> {
|
||||
public async cleanupOldEmailVerificationCode(userId: string): Promise<void> {
|
||||
const code = await this.redisService.get(getUserToVerifyKey(userId));
|
||||
|
||||
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}'],
|
||||
};
|
||||
|
||||
console.log(__dirname + '/../database/*.model{.ts,.js}');
|
||||
// Error throwing
|
||||
if (!conf.host) {
|
||||
throw new Error('DATABASE_HOST is not set');
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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[];
|
||||
}
|
||||
|
|
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 {
|
||||
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[];
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
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');
|
||||
|
|
|
@ -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> | PostalModuleOptions {
|
||||
const postalConfig =
|
||||
this.configService.getOrThrow<PostalConfig>(POSTAL_CONFIG_KEY);
|
||||
const postalConfig = this.configService.getOrThrow<PostalConfig>(POSTAL_CONFIG_KEY);
|
||||
|
||||
return {
|
||||
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 { 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<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.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);
|
||||
|
||||
|
|
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 { 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<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,
|
||||
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<User>(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<void>
|
||||
*/
|
||||
@Span()
|
||||
async markEmailVerified(userId: number): Promise<void> {
|
||||
async markEmailVerified(userId: string): Promise<void> {
|
||||
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<void> {
|
||||
async markPendingEmailVerified(userId: string): Promise<void> {
|
||||
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<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
|
||||
* @param userId The user's ID
|
||||
*/
|
||||
@Span()
|
||||
async clearUserCache(userId: number): Promise<void> {
|
||||
async clearUserCache(userId: string): Promise<void> {
|
||||
await this.redisService.del(userCacheKeyGenerate(userId));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -195,8 +195,12 @@
|
|||
errorMessageDiv.textContent = 'An unknown error occurred';
|
||||
}
|
||||
} else {
|
||||
// Handle successful login
|
||||
alert('Login successful!');
|
||||
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);
|
||||
|
|
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