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:
Kakious 2024-10-14 18:21:05 -04:00
parent d1e93a3631
commit e3ba6bf1fd
48 changed files with 1150 additions and 174 deletions

View file

@ -3,5 +3,6 @@
"trailingComma": "all", "trailingComma": "all",
"tabWidth": 2, "tabWidth": 2,
"semi": true, "semi": true,
"printWidth": 100 "printWidth": 100,
"endOfLine":"auto"
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View 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',
});
}
}

View file

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

View file

@ -0,0 +1,3 @@
export class AuthViewController {
constructor() {}
}

View file

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

View 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!!

View file

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

View file

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

View file

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

View 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,
});
}
}
}

View file

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

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

View file

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

View file

@ -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,
];

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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);
}
}

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

View file

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class OrgSetFlagDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
flag: string;
}

View 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
*/

View 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`;

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

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

View file

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

View 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.
*/

View 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!
*/

View file

@ -1,4 +0,0 @@
/**
* This service indexes all the users into redis search index
* TODO: This service should be ran as a listener job
*/

View 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
*/

View file

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

View file

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

View file

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

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