untested WIP

This commit is contained in:
2026-01-24 16:59:50 -06:00
parent 935c7296f6
commit 4be36e6ca0
56 changed files with 8645 additions and 3 deletions
+338
View File
@@ -0,0 +1,338 @@
import UserController from "./UserController";
import { Collection } from "@discordjs/collection";
import jwt, { JsonWebTokenError } from "jsonwebtoken";
import { permissionsPrivateKey, prisma } from "../constants";
import PermissionsVerificationError from "../Errors/PermissionsVerificationError";
import { mergeArrays } from "../modules/tools/mergeArrays";
import { signPermissions } from "../modules/permission-utils/signPermissions";
import { DecodedPermissionsBlock } from "../types/PermissionTypes";
import { permissionValidator } from "../modules/permission-utils/permissionValidator";
import { z } from "zod";
import { roles } from "../managers/roles";
import GenericError from "../Errors/GenericError";
import RoleError from "../Errors/RoleError";
import { Role, User } from "../../generated/prisma/client";
import { events } from "../modules/globalEvents";
/**
* Roles
*
* Roles are for adding onto a users permissions. They are not for defining a default users permissions.
*
* Roles have two forms of identifiers, titles and monikers. The title is something that can be capitalized and publicly displayed,
* so if you make modifications, and you need to be able to see how they were able to do that without having explicit permission to
* make the modifications, the title is what would be shown to you. The moniker is the human readable identifier. So in an api
* response where you need to get the roles of a user, instead of being given a bunch of id's, you will be given a bunch of monikers
* which are easy to read and easy to identify.
*/
export class RoleController {
public readonly id: string;
public title: string;
public moniker: string; // e.g. admin, super_admin, moderator
private _permissionsToken: string;
private _users: (User & { roles: Role[] })[];
public readonly createdAt: Date;
public updatedAt: Date;
public deleted: boolean = false;
constructor(roledata: Role & { users: (User & { roles: Role[] })[] }) {
this.id = roledata.id;
this.title = roledata.title;
this.moniker = roledata.moniker;
this._permissionsToken = roledata.permissions;
this._users = roledata.users;
this.createdAt = roledata.createdAt;
this.updatedAt = roledata.updatedAt;
}
private _signPermissions = signPermissions;
/**
* Verify Permissions
*
* This method is vital to maintining the security of this system. This method is here to ensure
* that the permissions object is authentic and signed by our system.
*
* @param permissionsToken - Signed permissions JWT
* @returns - Verified object with permissions in it.
*/
private _verifyPermissions(permissionsToken: string) {
let perms: DecodedPermissionsBlock;
try {
perms = jwt.verify(permissionsToken, permissionsPrivateKey, {
algorithms: ["RS256"],
issuer: "roles",
subject: this.id,
}) as DecodedPermissionsBlock;
} catch (err) {
events.emit("role:permissions:verification_error", {
currentSigned: this._permissionsToken,
attemptedVerification: permissionsToken,
err: err as Error,
role: this,
});
throw new PermissionsVerificationError(
`Unable to verify permissions for role '${this.title}, it is recommended that you override and rewrite these permissions immediately.`,
(err as Error).message
);
}
return perms as { permissions: string[] };
}
/**
* Get Users
*
* This will get all the users that have this role and return it as a collection dictionary where the key is
* the `id` of the user and the value is the users `UserController`.
*
* @returns {Collection<string, UserController>} - A collection of all the users that are assigned to this role
*/
public getUsers() {
const collection = new Collection<string, UserController>();
this._users.map((v) => collection.set(v.id, new UserController(v)));
return collection;
}
/**
* Check Permission
*
* Check to see if a role has a specified set of permissions.
*
* @param permission - The permission to check for
* @returns {boolean} Does this role have the specified permission
*/
public checkPermission(permission: string): boolean {
const permissions = this._verifyPermissions(this._permissionsToken);
return permissionValidator(permission, permissions.permissions);
}
/**
* Set permissions
*
* This will remove all existing permissions and replate them with the permissions defined in the params
* of this method.
*
* @param permissions - Array of all Permissions
* @returns {void}
*/
public async setPermissions(...permissions: string[]) {
/*
Make sure the current permissions are verified before updating them again. Basically speaking if the permissions
are tampered with in any way, this bricks the further modification of the permissions
*/
const previous = this._verifyPermissions(this._permissionsToken);
const newPermissionsToken = this._signPermissions({
issuer: "roles",
subject: this.id,
permissions,
});
const newRaw = await prisma.role.update({
where: { id: this.id },
data: { permissions: newPermissionsToken },
});
events.emit("role:permissions:updated", {
current: permissions,
currentSigned: newPermissionsToken,
previous: previous.permissions,
previousSigned: this._permissionsToken,
action: "set",
role: this,
});
this._permissionsToken = newPermissionsToken;
this.updatedAt = newRaw.updatedAt;
return;
}
/**
* Add Permissions
*
* This will take the permissions provided in the params of the method and combine them into the pre-existing
* permissions of this role.
*
* @param permissions - Array of added Permissions
* @returns {void}
*/
public async addPermissions(...permissions: string[]) {
/*
Make sure the current permissions are verified before updating them again. Basically speaking if the permissions
are tampered with in any way, this bricks the further modification of the permissions
*/
const previous = this._verifyPermissions(this._permissionsToken);
const newPermissionsToken = this._signPermissions({
issuer: "roles",
subject: this.id,
permissions: mergeArrays(previous.permissions, permissions),
});
const newRaw = await prisma.role.update({
where: { id: this.id },
data: { permissions: newPermissionsToken },
});
events.emit("role:permissions:updated", {
current: permissions,
currentSigned: newPermissionsToken,
previous: previous.permissions,
previousSigned: this._permissionsToken,
action: "added",
role: this,
});
this._permissionsToken = newPermissionsToken;
this.updatedAt = newRaw.updatedAt;
return;
}
/**
* Remove Permissions
*
* This will take the permissions provided in the params of the method and remove them from the exisitng
* permissions object for this role.
*
* @param permissions - Array of removed Permissions
* @returns {void}
*/
public async removePermissions(...permissions: string[]) {
/*
Make sure the current permissions are verified before updating them again. Basically speaking if the permissions
are tampered with in any way, this bricks the further modification of the permissions
*/
const previous = this._verifyPermissions(this._permissionsToken);
const newPermissionsToken = this._signPermissions({
issuer: "roles",
subject: this.id,
permissions: previous.permissions.filter((v) => !permissions.includes(v)),
});
const newRaw = await prisma.role.update({
where: { id: this.id },
data: { permissions: newPermissionsToken },
});
events.emit("role:permissions:updated", {
current: permissions,
currentSigned: newPermissionsToken,
previous: previous.permissions,
previousSigned: this._permissionsToken,
action: "removed",
role: this,
});
this._permissionsToken = newPermissionsToken;
this.updatedAt = newRaw.updatedAt;
return;
}
/**
* Get Current Permissions
*
* Get all of the permissions for this role.
* @returns {string[]} - Existing permissions in an array
*/
public getPermissions() {
const permissions = this._verifyPermissions(this._permissionsToken!);
return permissions;
}
public async update(
data: Partial<{
title: string;
moniker: string;
permissions: string[];
}>
) {
const schema = z
.object({
title: z.string().min(1, "Title cannot be empty."),
moniker: z.string().min(1, "Moniker cannot be empty."),
permissions: z.array(
z.string().min(1, "Permission node cannot be empty")
),
})
.partial()
.strict();
data = schema.parse(data);
if (data.moniker) {
const checkMoniker = await prisma.role.findFirst({
where: { moniker: data.moniker },
});
if (checkMoniker)
throw new RoleError(
"Moniker is already taken.",
"Another role with this moniker already exists in the databse."
);
}
const updatedRole = await prisma.role.update({
where: { id: this.id },
data: {
...data,
permissions: undefined,
},
});
if (data.permissions) await this.setPermissions(...data.permissions);
events.emit("role:updated", { role: this, updateData: data });
this.title = data.title ?? this.title;
this.moniker = data.moniker ?? this.moniker;
return this;
}
/**
* Delete Role
*
* @returns {Promise<RoleController>} The Remains of a deleted role.
*/
public async delete() {
const deletedData = await prisma.role.delete({ where: { id: this.id } });
this.deleted = true;
events.emit("role:deleted", this);
return this;
}
/**
* To JSON
*
* Create a JSON object that can be used in things like API responses.
*
* @param opts - Optional values to include in the response.
* @returns - JSON-Friendly object.
*/
public toJson(opts?: { viewUsers?: boolean; viewPermissions?: boolean }) {
let object = {
id: this.id,
title: this.title,
moniker: this.moniker,
permissions: opts?.viewPermissions
? this._verifyPermissions(this._permissionsToken).permissions
: undefined,
users: opts?.viewUsers
? this._users.map((v) => ({
id: v.id,
name: v.name,
login: v.login,
roles: v.roles.map((r:any) => r.id),
}))
: undefined,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
return object;
}
}
+228
View File
@@ -0,0 +1,228 @@
import jwt from "jsonwebtoken";
import {
prisma,
refreshTokenDuration,
accessTokenDuration,
accessTokenPrivateKey,
refreshTokenPrivateKey,
} from "../constants";
import SessionTokenError from "../Errors/SessionTokenError";
import { events } from "../modules/globalEvents";
import UserController from "./UserController";
import { users } from "../managers/users";
import { Session } from "../../generated/prisma/client";
export interface SessionPayloadObject {
userID: string;
sessionKey: string;
}
export interface SessionTokensObject {
accessToken: string;
refreshToken: string;
}
export type DecodedSession = SessionPayloadObject & {
iat: number;
exp: number;
};
/**
* Session Controller
*
* This class is for create a controller that can manage, generate and refresh tokens, self terminate and self delete
* all sessions. This also allows you to access all data about a session.
*/
export class SessionController {
public readonly id: string;
public readonly sessionKey: string;
public readonly userId: string;
public readonly expires: Date;
public refreshedAt: Date | null;
public invalidatedAt: Date | null;
public terminated: boolean = false;
private _refreshTokenGenrated: boolean;
constructor(sessionData: Session) {
this.id = sessionData.id;
this.sessionKey = sessionData.sessionKey;
this.userId = sessionData.userId;
this.expires = sessionData.expires;
this.refreshedAt = sessionData.refreshedAt;
this.invalidatedAt = sessionData.invalidatedAt;
this._refreshTokenGenrated = sessionData.refreshTokenGenerated;
}
/** @ignore */
private _generateAccessToken() {
const payload: SessionPayloadObject = {
sessionKey: this.sessionKey,
userID: this.userId,
};
return jwt.sign(payload, accessTokenPrivateKey, {
algorithm: "RS256",
expiresIn: accessTokenDuration,
});
}
/** @ignore */
private _generateRefreshToken() {
const payload: SessionPayloadObject = {
sessionKey: this.sessionKey,
userID: this.userId,
};
return jwt.sign(payload, refreshTokenPrivateKey, {
algorithm: "RS256",
expiresIn: refreshTokenDuration,
});
}
/**
* Invalidate the Session
*
* The purpose for this function is if you wanted to be able to listen for somebody using an invalid session,
* you just have to invalidate it with this function and go from there.
*
* @returns {Promise<void>} - nothing
*/
public async invalidate() {
const invalidationDate = new Date();
if (this.invalidatedAt)
throw new Error("Session has already been invalidated.");
await prisma.session.update({
data: { invalidatedAt: invalidationDate },
where: { id: this.id },
});
this.invalidatedAt = invalidationDate;
events.emit("session:invalidated", this);
return;
}
/**
* Terminate the session
*
* Terminating the session will immediately delete the session making it impossible for it to be referenced again.
*
* @returns {Promise<void>} - nothing
*/
public async terminate() {
await prisma.session.delete({ where: { id: this.id } });
events.emit("session:terminated", this);
this.terminated = true;
return;
}
/**
* Generate Tokens
*
* **NOTE**: This method can only be ran once per session.
*
* Running this function will allow you to generate an accessToken and a refreshToken. Each token
* will be generated with their own respective private keys.
*
*
* @returns {Promise<SessionTokensObject>} An object containing the `accessToken` and `refreshToken`
*/
public async generateTokens(): Promise<SessionTokensObject> {
if (this._refreshTokenGenrated)
throw new Error("Tokens have alredy been generated for this session.");
const accessToken = this._generateAccessToken();
const refreshToken = this._generateRefreshToken();
const newRefreshDate = new Date();
await prisma.session.update({
data: { refreshTokenGenerated: true, refreshedAt: newRefreshDate },
where: { id: this.id },
});
this._refreshTokenGenrated = true;
this.refreshedAt = newRefreshDate;
let tokens = { accessToken, refreshToken };
events.emit("session:tokens_generated", { session: this, tokens });
return tokens;
}
/**
* Refresh the session
*
* Refreshing the session will generate a new accessToken for the user to authenticate their requests with.
*
* **NOTE**: Best practice when implementing token refreshing into the UI is that if for any reason this method
* throws an error, imediately purge the existing tokens and have the user login again. This way you don't hold
* them up any longer than necessary.
*
* @param refreshToken - The refresh token provided at session generation
* @returns {Promise<string>} The new access token.
*/
public refresh(refreshToken: string): Promise<string> {
return new Promise(async (res, rej) => {
if (this.expires.getTime() <= Date.now()) {
await this.terminate();
throw new SessionTokenError("Session has Expired.");
}
jwt.verify(
refreshToken,
refreshTokenPrivateKey,
{
algorithms: ["RS256"],
},
async (err, decode) => {
if (err) {
if (
err.name == "TokenExpiredError" ||
err.message == "invalid signature"
)
this.terminate();
rej(err);
}
const data: DecodedSession = decode as DecodedSession;
if (
data.sessionKey !== this.sessionKey ||
data.userID !== this.userId
)
rej(
new SessionTokenError(
"Refresh token does not match this session."
)
);
await prisma.session.update({
data: { refreshedAt: new Date() },
where: { id: this.id },
});
const newToken = this._generateAccessToken();
events.emit("session:token_refresh", {
session: this,
tokens: { accessToken: newToken, refreshToken },
});
return res(newToken);
}
);
});
}
/**
* Fetch Session User
*
* Fetch the user controller of the user that created the session.
*
* @returns {Promise<UserController>} The user that created this session.
*/
public async fetchUser(): Promise<UserController> {
return (await users.fetchUser({ id: this.userId }))!;
}
}
+200
View File
@@ -0,0 +1,200 @@
import { Collection } from "@discordjs/collection";
import { z } from "zod";
import { Role } from "../../generated/prisma/client";
import { User } from "../../generated/prisma/browser";
import { SessionTokensObject } from "./SessionController";
import { sessions } from "../managers/sessions";
import BodyError from "../Errors/BodyError";
import { prisma } from "../constants";
import { events } from "../modules/globalEvents";
import { RoleController } from "./RoleController";
import { roles } from "../managers/roles";
export default class UserController {
public id: string;
public name: string | null;
public login: string;
public email: string;
public image: string | null;
private _roles: Collection<string, Role>;
public createdAt: Date;
public updatedAt: Date;
constructor(userdata: User & { roles: Role[] }) {
this.id = userdata.id;
this.name = userdata.name;
this.login = userdata.login;
this.email = userdata.email;
this.image = userdata.image;
this.updatedAt = userdata.updatedAt;
this.createdAt = userdata.createdAt;
this._roles = (() => {
let collection = new Collection<string, Role>();
userdata.roles.map((v: any) => collection.set(v.id, v));
return collection;
})();
}
/**
* Update the internal values
*
* This is an internal method used to update all the internal values when we query the database. This way
* everything stays upto date even when we pass around the user controller.
*
* @param userdata - User object from Prisma
*/
private _updateInternalValues(userdata: User) {
this.id = userdata.id;
this.name = userdata.name;
this.login = userdata.login;
this.email = userdata.email;
this.image = userdata.image;
this.updatedAt = userdata.updatedAt;
this.createdAt = userdata.createdAt;
}
/**
* Create Session
*
* This will create a session in the database that is linked to the user and will then create a pair of access and refresh
* tokens to provide to the user such that they can authorized their api requests.
*
* @returns {Promise<SessionTokensObject>} - Object with an access token and a refresh token.
*/
public async createSession(): Promise<SessionTokensObject> {
return sessions.create({ user: this });
}
/**
* Update the user
*
* Take in a partial of the user data and validate it then updated it if it passes validation and return
* the updated `UserController` object.
*
* @param data - A partial of the user data
* @returns {Promise<UserController>} - The updated user controller
*/
public async update(data: Partial<User>) {
// Parsed Data With Schema
const pData = z
.object({
name: z.string().optional(),
image: z.string().optional(),
})
.strict()
.parse(data);
if (Object.keys(data).length == 0)
throw new BodyError("Body cannot be empty.");
const updatedUser = await prisma.user.update({
where: { id: this.id },
data: pData,
});
this._updateInternalValues(updatedUser);
events.emit("user:updated", { user: this, updatedValues: data });
return this;
}
/**
* Fetch Roles
*
* This method will fetch all of the roles that a user belongs to and will return each of their controllers in a collection
* of role id's and RoleControllers.
*
* @returns {Promise<Collection<string, RoleController>>} A collection of all the roles a user has
*/
public async fetchRoles(): Promise<Collection<string, RoleController>> {
const collection = new Collection<string, RoleController>();
await Promise.all(
this._roles.map(async (v) =>
collection.set(v.id, await roles.fetch(v.id))
)
);
return collection;
}
/**
* Check Permission
*
* Check if this user has this specific permission. This method will not only check explicit permissions defined in
* the database under users and roles, but will also generate implicit permissions for resources that the user has
* access to but doesn't specifically have defined under any given permissions object.
*
* @param permission - The permission to check for
* @returns {boolean} Does this user have the specified permission
*/
public async hasPermission(permission: string) {
let resources = await prisma.user.findFirst({
where: { id: this.id },
select: {
sessions: {
select: { id: true },
},
apiKeys: {
select: { id: true },
},
projects: {
select: { id: true },
},
services: {
select: { id: true },
},
},
});
const implicitPermissions = Object.keys(resources ?? {})
.filter((v) => resources![v].length > 0)
.map(
(v) =>
`resource.${v}.[${(resources![v] as { id: string }[])
.map((o) => o.id)
.join(",")}].user.${this.id}.implicit`
);
console.log(implicitPermissions);
let checks = [
(await this.fetchRoles()).map((v) => v.checkPermission(permission)),
].flatMap((v) => v);
return checks.includes(true);
}
/**
* To JSON
*
* Create an object that can be safely returned to the user of an api request such that when you
* need to return data to the end user, you don't accidently return data that could be harmful
* if leaked.
*
* Options:
* - Safe return is to return only data that is considered "safe", and not detrimental to pass around
*
* @param opts - Options to change the output
* @returns - An object that is JSON friendly
*/
public toJson(opts?: { safeReturn: boolean }) {
return {
id: this.id,
name: this.name,
roles: opts?.safeReturn
? undefined
: this._roles.size > 0
? this._roles.map((v) => v.moniker)
: undefined,
login: opts?.safeReturn ? undefined : this.login,
email: opts?.safeReturn ? undefined : this.email,
image: this.image,
};
}
}