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",
"tabWidth": 2,
"semi": true,
"printWidth": 100
"printWidth": 100,
"endOfLine":"auto"
}

View file

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

View file

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

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

View file

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

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

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

View file

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

View file

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

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 { Request, Response, NextFunction } from 'express';
import { OidcService } from '../oidc/core.service';
import { OidcService } from '../oidc/service/core.service';
import { ClsService } from 'nestjs-cls';
@Injectable()

View file

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

View file

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

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

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}'],
};
console.log(__dirname + '/../database/*.model{.ts,.js}');
// Error throwing
if (!conf.host) {
throw new Error('DATABASE_HOST is not set');

View file

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

View file

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

View file

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

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

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

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

View file

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

View file

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

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

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

View file

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

View file

@ -128,7 +128,7 @@
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async function(event) {
document.getElementById('loginForm').addEventListener('submit', async function(event) {
event.preventDefault();
const form = event.target;
@ -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);
@ -204,7 +208,7 @@
errorMessageDiv.classList.remove('hidden');
errorMessageDiv.textContent = 'An error occurred. Please try again later.';
}
});
});
</script>
</body>
</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>