untested WIP
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
export default class AuthenticationError extends Error {
|
||||
constructor(message: string, cause?: string) {
|
||||
super();
|
||||
this.name = "AuthenticationError";
|
||||
this.message = message;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export default class AuthorizationError extends Error {
|
||||
public status: number;
|
||||
|
||||
constructor(message: string, cause?: string, status?: number) {
|
||||
super();
|
||||
this.name = "AuthorizationError";
|
||||
this.status = status ?? 401;
|
||||
this.message = message;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default class BodyError extends Error {
|
||||
constructor(message: string, cause?: string) {
|
||||
super();
|
||||
this.name = "BodyError";
|
||||
this.message = message;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default class ExpiredAccessTokenError extends Error {
|
||||
constructor(cause?: string) {
|
||||
super();
|
||||
this.name = "ExpiredAccessTokenError";
|
||||
this.message = "The provided access token has expired.";
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default class ExpiredRefreshTokenError extends Error {
|
||||
constructor(cause?: string) {
|
||||
super();
|
||||
this.name = "ExpiredRefreshTokenError";
|
||||
this.message = "The provided refresh token has expired.";
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export default class GenericError extends Error {
|
||||
public status: number;
|
||||
|
||||
constructor(info: {
|
||||
name: string;
|
||||
message: string;
|
||||
cause?: string;
|
||||
status?: number;
|
||||
}) {
|
||||
super();
|
||||
this.name = info.name;
|
||||
this.status = info.status ?? 400;
|
||||
this.message = info.message;
|
||||
this.cause = info.cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export default class InsufficientPermission extends Error {
|
||||
public status: number;
|
||||
|
||||
constructor(message: string, cause?: string) {
|
||||
super();
|
||||
this.name = "InsufficientPermission";
|
||||
this.status = 403;
|
||||
this.message = message;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export default class MissingBodyValue extends Error {
|
||||
constructor(valueName: string) {
|
||||
super();
|
||||
this.name = "MissingBodyValue";
|
||||
this.message = `Value '${valueName}' is missing from the body.`;
|
||||
this.cause =
|
||||
"A value that was required by the body of this request is missing.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default class PermissionsVerificationError extends Error {
|
||||
constructor(message: string, cause?: string) {
|
||||
super();
|
||||
this.name = "PermissionsVerificationError";
|
||||
this.message = message;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default class RoleError extends Error {
|
||||
constructor(message: string, cause?: string) {
|
||||
super();
|
||||
this.name = "RoleError";
|
||||
this.message = message;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default class SessionError extends Error {
|
||||
constructor(message: string, cause?: string) {
|
||||
super();
|
||||
this.name = "SessionError";
|
||||
this.message = message;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default class SessionTokenError extends Error {
|
||||
constructor(message: string, cause?: string) {
|
||||
super();
|
||||
this.name = "SessionTokenError";
|
||||
this.message = message;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default class UserError extends Error {
|
||||
constructor(message: string, cause?: string) {
|
||||
super();
|
||||
this.name = "UserError";
|
||||
this.message = message;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
/generated/prisma
|
||||
@@ -0,0 +1,12 @@
|
||||
// This file was generated by Prisma, and assumes you run Prisma commands using `bun --bun run prisma [command]`.
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Hono } from "hono";
|
||||
import { apiResponse } from "../modules/api-utils/apiResponse";
|
||||
import { ZodError } from "zod";
|
||||
import { cors } from "hono/cors";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import teapot from "./teapot";
|
||||
|
||||
const app = new Hono();
|
||||
const v1 = new Hono();
|
||||
|
||||
app.onError((err, ctx) => {
|
||||
const errClassName = err.constructor.name;
|
||||
|
||||
if (
|
||||
errClassName.toLowerCase().includes("prisma") ||
|
||||
err.message.toLowerCase().includes("prisma") ||
|
||||
err.name.toLowerCase().includes("prisma")
|
||||
) {
|
||||
console.trace(err);
|
||||
return ctx.json(apiResponse.internalError(), 500);
|
||||
}
|
||||
|
||||
if (err instanceof ZodError) {
|
||||
return ctx.json(
|
||||
apiResponse.zodError(err),
|
||||
//@ts-ignore
|
||||
apiResponse.zodError(err).status
|
||||
);
|
||||
}
|
||||
|
||||
const response = apiResponse.error(err);
|
||||
return ctx.json(response, response.status);
|
||||
});
|
||||
|
||||
app.use("*", cors());
|
||||
|
||||
app.notFound((c) => {
|
||||
const response = apiResponse.error(
|
||||
new GenericError({
|
||||
name: "NotFound",
|
||||
message: `Cannot ${c.req.method.toUpperCase()} ${c.req.path}`,
|
||||
status: 404,
|
||||
cause: "Unknown",
|
||||
})
|
||||
);
|
||||
return c.json(response, response.status);
|
||||
});
|
||||
|
||||
v1.route("/teapot", teapot);
|
||||
|
||||
app.route("/v1", v1);
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Hono } from "hono/tiny";
|
||||
import { createRoute } from "../modules/api-utils/createRoute";
|
||||
|
||||
/* /v1/teapot */
|
||||
export default createRoute("get", ["/"], (c) => {
|
||||
c.status(418);
|
||||
return c.json({
|
||||
status: 418,
|
||||
message: "I'm not a teapot",
|
||||
successful: true,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
import { PrismaClient } from '../generated/prisma/client'
|
||||
|
||||
const connectionString = `${process.env.DATABASE_URL}`
|
||||
const adapter = new PrismaPg({ connectionString })
|
||||
|
||||
|
||||
interface EnvKey {
|
||||
PORT: number;
|
||||
};
|
||||
|
||||
// ENV CONSTANTS
|
||||
|
||||
export const PORT = process.env.PORT;
|
||||
|
||||
export const prisma = new PrismaClient({ adapter })
|
||||
|
||||
export const sessionDuration = 30 * 24 * 60 * 60000;
|
||||
export const accessTokenDuration = "10min";
|
||||
export const refreshTokenDuration = "30d";
|
||||
|
||||
export const accessTokenPrivateKey = readFileSync(
|
||||
`${import.meta.dir}/../.accessToken.key`
|
||||
).toString();
|
||||
export const refreshTokenPrivateKey = readFileSync(
|
||||
`${import.meta.dir}/../.refreshToken.key`
|
||||
).toString();
|
||||
export const permissionsPrivateKey = readFileSync(
|
||||
`${import.meta.dir}/../.permissions.key`
|
||||
);
|
||||
export const apiKeyTokenPrivateKey = readFileSync(
|
||||
`${import.meta.dir}/../.apiKeyToken.key`
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }))!;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -1 +1,7 @@
|
||||
console.log("Hello via Bun!");
|
||||
import app from "./api/server";
|
||||
import { PORT } from "./constants";
|
||||
|
||||
Bun.serve({
|
||||
port: PORT,
|
||||
fetch: app.fetch
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import RoleError from "../Errors/RoleError";
|
||||
import { prisma } from "../constants";
|
||||
import { RoleController } from "../controllers/RoleController";
|
||||
import { signPermissions } from "../modules/permission-utils/signPermissions";
|
||||
import cuid from "cuid";
|
||||
import UserController from "../controllers/UserController";
|
||||
import InsufficientPermission from "../Errors/InsufficientPermission";
|
||||
import { z } from "zod";
|
||||
|
||||
export const roles = {
|
||||
/**
|
||||
* Create a role
|
||||
*
|
||||
* @param data - Data required to make a role
|
||||
* @returns {RoleController} - The new role
|
||||
*/
|
||||
async create(data: {
|
||||
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, "Cannot have a blank permission node."))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
data = await schema.parseAsync(data);
|
||||
|
||||
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 id = cuid();
|
||||
const newRole = await prisma.role.create({
|
||||
data: {
|
||||
id,
|
||||
title: data.title,
|
||||
moniker: data.moniker,
|
||||
permissions: signPermissions({
|
||||
issuer: "roles",
|
||||
subject: id,
|
||||
permissions: data.permissions ?? [],
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
users: {
|
||||
include: {
|
||||
roles: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const controller = new RoleController(newRole);
|
||||
|
||||
return controller;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a role
|
||||
*
|
||||
* Fetch a role using either it's id or it's moniker.
|
||||
*
|
||||
* @param identifier - Role `id` or `moniker`
|
||||
* @param identifier - Options for fetching a role.
|
||||
* @returns {RoleController} - Role Controller
|
||||
*/
|
||||
async fetch(identifier:string, opt?: { requestingUser?: UserController }) {
|
||||
const roleData = await prisma.role.findFirst({
|
||||
where: { OR: [{ id: identifier }, { moniker: identifier }] },
|
||||
include: {
|
||||
users: {
|
||||
include: {
|
||||
roles: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!roleData)
|
||||
throw new GenericError({
|
||||
name: "UnknownRole",
|
||||
message: "Unknown role...",
|
||||
status: 404,
|
||||
});
|
||||
|
||||
if (
|
||||
opt?.requestingUser &&
|
||||
!(await opt.requestingUser.hasPermission(
|
||||
this._buildPermissionNode(roleData.id, "read")
|
||||
))
|
||||
)
|
||||
throw new InsufficientPermission(
|
||||
"You do not have permission to access this role."
|
||||
);
|
||||
const controller = new RoleController(roleData);
|
||||
|
||||
return controller;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all roles
|
||||
*
|
||||
* This will give you all of the roles and their respective controllers
|
||||
*
|
||||
* @param opt - Options for fetching all roles.
|
||||
* @returns {Collection<string, RoleController>} A collection of all the roles and their id's
|
||||
*/
|
||||
async fetchAllRoles(opt?: { requestingUser?: UserController }) {
|
||||
let collection = new Collection<string, RoleController>();
|
||||
const roles = await prisma.role.findMany({
|
||||
include: { users: { include: { roles: true } } },
|
||||
});
|
||||
|
||||
roles. map((v:any) => collection.set(v.id, new RoleController(v)));
|
||||
|
||||
if (opt?.requestingUser) {
|
||||
const permittedRoles = await Promise.all(
|
||||
collection.map(async (v) =>
|
||||
(await opt.requestingUser?.hasPermission(
|
||||
this._buildPermissionNode(v.id, "read")
|
||||
))
|
||||
? v.id
|
||||
: null
|
||||
)
|
||||
);
|
||||
collection = collection.filter((v) =>
|
||||
permittedRoles.filter((x) => x !== null).includes(v.id)
|
||||
);
|
||||
}
|
||||
|
||||
return collection;
|
||||
},
|
||||
|
||||
/**
|
||||
* Build Permissino Node
|
||||
*
|
||||
* Build a role centric permission node with a role id and permission scope.
|
||||
*
|
||||
* **FORMAT**: `roles.{id}.{scope}
|
||||
*
|
||||
* @param id - Role ID
|
||||
* @param scope - Scope of the permission node
|
||||
* @returns {string} Constructed Permission Node
|
||||
*/
|
||||
_buildPermissionNode(id: string, scope: "read" | "write") {
|
||||
return ["roles", id, scope].join(".");
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @TODO automatic string transformation for monikers to be all lower case with no spaces and only underscores.
|
||||
*
|
||||
* @TODO create and inheritance and ordering system @see https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations
|
||||
*
|
||||
* @TODO go through and make sure all the zod schemas have mins, maxes, etc.
|
||||
*
|
||||
* @TODO Limit those who can give out the `*` permission to those with the `*` permission.
|
||||
* - Maybe also limit the use of `*` all together?
|
||||
*/
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
prisma,
|
||||
refreshTokenDuration,
|
||||
sessionDuration,
|
||||
accessTokenDuration,
|
||||
accessTokenPrivateKey,
|
||||
refreshTokenPrivateKey,
|
||||
} from "../constants";
|
||||
import UserController from "../controllers/UserController";
|
||||
import {
|
||||
SessionController,
|
||||
DecodedSession,
|
||||
SessionPayloadObject,
|
||||
SessionTokensObject,
|
||||
} from "../controllers/SessionController";
|
||||
import jwt from "jsonwebtoken";
|
||||
import SessionError from "../Errors/SessionError";
|
||||
import SessionTokenError from "../Errors/SessionTokenError";
|
||||
import ExpiredAccessTokenError from "../Errors/ExpiredAccessTokenError";
|
||||
import ExpiredRefreshTokenError from "../Errors/ExpiredRefreshTokenError";
|
||||
import { events } from "../modules/globalEvents";
|
||||
|
||||
export interface SessionCreationData {
|
||||
user: UserController;
|
||||
}
|
||||
|
||||
export const sessions = {
|
||||
/**
|
||||
* Create a session
|
||||
*
|
||||
* This will create a session instance in the databse that will be linked to both the session token
|
||||
* and the refresh token.
|
||||
*
|
||||
* @param data - The params needed to create the session tokens
|
||||
* @returns {Promise<SessionTokensObject>} Session Token and the Refresh Token
|
||||
*/
|
||||
async create(data: SessionCreationData): Promise<SessionTokensObject> {
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
expires: new Date(Date.now() + sessionDuration),
|
||||
userId: data.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const controller = new SessionController(session);
|
||||
|
||||
// Trigger Global Event
|
||||
events.emit("session:created", {
|
||||
user: data.user,
|
||||
session: controller,
|
||||
});
|
||||
|
||||
let tokens: SessionTokensObject = await controller.generateTokens();
|
||||
|
||||
return tokens;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a session
|
||||
*
|
||||
* This method is designed to be as versitile as possible, if you are looking for a session, use this method with
|
||||
* your choice of `accessToken`, `refreshToken`, `id`, or `sessionKey`. The identifier value is a partial type.
|
||||
*
|
||||
* @param identifier - An object allowing you to put either session token, sessionKey, or id in to fetch the desired session.
|
||||
* @returns {Promise<SessionController>} The controller for the desired session.
|
||||
*/
|
||||
async fetch(
|
||||
identifier: Partial<{
|
||||
refreshToken: string;
|
||||
accessToken: string;
|
||||
id: string;
|
||||
sessionKey: string;
|
||||
}>
|
||||
) {
|
||||
if (identifier.refreshToken || identifier.accessToken) {
|
||||
const decodedJWT = identifier.refreshToken
|
||||
? ((await new Promise((res, rej) =>
|
||||
jwt.verify(
|
||||
identifier.refreshToken!,
|
||||
refreshTokenPrivateKey,
|
||||
{
|
||||
algorithms: ["RS256"],
|
||||
},
|
||||
async (err, decode) => {
|
||||
if (
|
||||
err &&
|
||||
(err.name == "TokenExpiredError" ||
|
||||
err.message == "invalid signature")
|
||||
) {
|
||||
let sessionDat = await prisma.session.findFirst({
|
||||
where: {
|
||||
sessionKey: (
|
||||
jwt.decode(identifier.refreshToken!) as DecodedSession
|
||||
).sessionKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!sessionDat)
|
||||
return rej(new SessionError("Invalid session."));
|
||||
let session = new SessionController(sessionDat);
|
||||
|
||||
await session.terminate();
|
||||
if (err.message == "invalid signature")
|
||||
return rej(new SessionError("Invalid session."));
|
||||
|
||||
return rej(new ExpiredRefreshTokenError("It epired."));
|
||||
}
|
||||
if (err) return rej(err);
|
||||
|
||||
return res(decode as DecodedSession);
|
||||
}
|
||||
)
|
||||
)) as DecodedSession)
|
||||
: ((await new Promise((res, rej) =>
|
||||
jwt.verify(
|
||||
identifier.accessToken!,
|
||||
accessTokenPrivateKey,
|
||||
{
|
||||
algorithms: ["RS256"],
|
||||
},
|
||||
(err, decode) => {
|
||||
if (err && err.name == "TokenExpiredError")
|
||||
return rej(new ExpiredAccessTokenError());
|
||||
if (err) return rej(err);
|
||||
return res(decode as DecodedSession);
|
||||
}
|
||||
)
|
||||
)) as DecodedSession);
|
||||
|
||||
const sessionData = await prisma.session.findFirst({
|
||||
where: { sessionKey: decodedJWT.sessionKey },
|
||||
});
|
||||
|
||||
if (!sessionData) throw new SessionError("Invalid Session");
|
||||
if (identifier.accessToken && decodedJWT.exp > Date.now())
|
||||
throw new ExpiredAccessTokenError();
|
||||
|
||||
if (identifier.refreshToken && decodedJWT.exp > Date.now()) {
|
||||
let sess = new SessionController(sessionData);
|
||||
await sess.terminate();
|
||||
throw new SessionError("Invalid Session...", "Expired Refresh Token");
|
||||
}
|
||||
|
||||
return new SessionController(sessionData);
|
||||
}
|
||||
|
||||
const sessionData = await prisma.session.findFirst({
|
||||
where: { OR: [{ sessionKey: identifier.sessionKey, id: identifier.id }] },
|
||||
});
|
||||
|
||||
if (!sessionData) throw new SessionError("Invalid Session");
|
||||
|
||||
return new SessionController(sessionData);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @TODO As a consequence of the above, need to setup pgBoss for cron and event loop.
|
||||
*/
|
||||
@@ -0,0 +1,118 @@
|
||||
import { User } from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { SessionTokensObject } from "../controllers/SessionController";
|
||||
import UserController from "../controllers/UserController";
|
||||
|
||||
export const users = {
|
||||
/**
|
||||
* Authenticate The User
|
||||
*
|
||||
* If the user has already been registered, this function will supply them with a session id, otherwise
|
||||
* this method will create an account, then create a session id.
|
||||
*
|
||||
* @summary It creates a user if one doesn't exist and will supply a session id
|
||||
*
|
||||
* @async
|
||||
* @param ghCode - The code supplied in the callback url of a GitHub oAuth transaction
|
||||
*/
|
||||
/* async authenticate(ghCode: string): Promise<SessionTokensObject> {
|
||||
const token = await ghApp.oauth.createToken({ code: ghCode }).catch((e) => {
|
||||
throw new AuthenticationError("Invalid OAuth code...");
|
||||
});
|
||||
const userOK = await ghApp.oauth.getUserOctokit({
|
||||
token: token.authentication.token,
|
||||
});
|
||||
const ghUser = await userOK.request("GET /user");
|
||||
let user =
|
||||
(await this.fetchUser({ userId: ghUser.data.id })) ??
|
||||
(await this.createUser(token.authentication.token));
|
||||
|
||||
const tokens = await sessions.create({ user });
|
||||
events.emit("user:authenticated", { user, tokens });
|
||||
|
||||
return tokens;
|
||||
}, */
|
||||
|
||||
/**
|
||||
* Check to see if the user exists
|
||||
*
|
||||
* @param partial - A partial object that you can feed any value from the database into.
|
||||
* @returns {Promise<boolean>} Does the user exist?
|
||||
*/
|
||||
async userExists(partial: Partial<User>): Promise<boolean> {
|
||||
const match = await prisma.user.findFirst({ where: partial });
|
||||
return !!match;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a user
|
||||
*
|
||||
* This method takes in a unique identifier for the user you are trying to fetch, and returns
|
||||
* the controller for the user if the user exists. If the user doesn't exist, or no identifier
|
||||
* was provided, this will reutrn `null`.
|
||||
*
|
||||
* @param identifier - A partial identifier for a user (e.g. id, email, userID, etc.)
|
||||
* @returns {Promise<UserController>} The controller for the user
|
||||
*/
|
||||
async fetchUser(
|
||||
identifier: Partial<{
|
||||
id: string;
|
||||
email: string;
|
||||
login: string;
|
||||
userId: number;
|
||||
}>
|
||||
) {
|
||||
if (Object.keys(identifier).length == 0) return null;
|
||||
const userData = await prisma.user.findFirst({
|
||||
where: {
|
||||
//@ts-ignore
|
||||
OR: Object.keys(identifier).map((v) => ({ [v]: identifier[v] })),
|
||||
},
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!userData) return null;
|
||||
|
||||
return new UserController(userData);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*
|
||||
* This method will poll GitHub and get all the information on the user to then create the
|
||||
* record in our database. On top of that it also pushes it into the user cache.
|
||||
*
|
||||
* @param token - The Github token provided by the auth method
|
||||
* @returns {Promise<UserController>} The new user controller for the user
|
||||
*/
|
||||
async createUser(token: string): Promise<UserController> {
|
||||
const ghUser = await (
|
||||
await ghApp.oauth.getUserOctokit({ token })
|
||||
).request("GET /user");
|
||||
|
||||
const emails = await (
|
||||
await ghApp.oauth.getUserOctokit({ token })
|
||||
).request("GET /user/emails");
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
userId: ghUser.data.id,
|
||||
email: emails.data[0].email,
|
||||
image: ghUser.data.avatar_url,
|
||||
name: ghUser.data.name,
|
||||
login: ghUser.data.login,
|
||||
token,
|
||||
},
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
let controller = new UserController(newUser);
|
||||
events.emit("user:created", controller);
|
||||
|
||||
return controller;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @TODO Figure out default permissions
|
||||
*/
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ZodError } from "zod";
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
export const apiResponse = {
|
||||
successful: (message: string, data?: any) => ({
|
||||
status: 200,
|
||||
message,
|
||||
data,
|
||||
successful: true,
|
||||
meta: {
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
created: (message: string, data?: any) => ({
|
||||
status: 201,
|
||||
message,
|
||||
data,
|
||||
successful: true,
|
||||
meta: {
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
error: (err: Error) => ({
|
||||
// @ts-ignore
|
||||
status: err["status"] ?? 400,
|
||||
message: err.message,
|
||||
error: err.name,
|
||||
successful: false,
|
||||
meta: {
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
internalError: () => ({
|
||||
status: 500,
|
||||
message: "An Internal Server Error has occured...",
|
||||
error: "InternalServerError",
|
||||
successful: false,
|
||||
meta: {
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
zodError: (err: ZodError) => {
|
||||
const data = JSON.parse(err.message);
|
||||
return {
|
||||
status: 400,
|
||||
message: "TypeError",
|
||||
error: data,
|
||||
successful: false,
|
||||
meta: {
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Handler, Hono, MiddlewareHandler } from "hono";
|
||||
import { Variables } from "../../types/HonoTypes";
|
||||
|
||||
/**
|
||||
* Create a route.
|
||||
*
|
||||
* This method exists to serve the purpose of allowing us to split all of our api routes into different files and
|
||||
* easily and quickly be able to rope them back into the main api server instance.
|
||||
*
|
||||
* One of the sortfallings of this method is that I was not able to figure out how to integrate the middleware to come
|
||||
* before the handler, so if somebody feels upto it please figure out a way to have the middleware come before the handler
|
||||
* method naturally as you would if you using a plain hono method.
|
||||
*
|
||||
* @TODO Move middleware handlers to come before primary handler naturally.
|
||||
*
|
||||
* @param method - HTTP Method
|
||||
* @param path - URL Path
|
||||
* @param handler - Handler function for Hono
|
||||
* @param middleware - Array of Middleware Handlers for Hono
|
||||
* @returns {Hono} - A new Hono instance containing the newly created route.
|
||||
*/
|
||||
export function createRoute(
|
||||
method: string | string[],
|
||||
path: string[],
|
||||
handler: Handler<{
|
||||
Variables: Variables;
|
||||
}>,
|
||||
...middleware: MiddlewareHandler<{
|
||||
Variables: Variables;
|
||||
}>[]
|
||||
): Hono<{ Variables: Variables }> {
|
||||
if (middleware)
|
||||
return new Hono<{ Variables: Variables }>().on(
|
||||
method as any,
|
||||
path,
|
||||
...middleware,
|
||||
handler
|
||||
);
|
||||
return new Hono<{ Variables: Variables }>().on(method as any, path, handler);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Eventra } from "@duxcore/eventra";
|
||||
import UserController from "../controllers/UserController";
|
||||
import {
|
||||
SessionController,
|
||||
SessionTokensObject,
|
||||
} from "../controllers/SessionController";
|
||||
import { RoleController } from "../controllers/RoleController";
|
||||
import { JsonWebTokenError } from "jsonwebtoken";
|
||||
import { User } from "../../generated/prisma/client";
|
||||
|
||||
interface EventTypes {
|
||||
"api:started": () => void;
|
||||
"user:created": (user: UserController) => void;
|
||||
"user:updated": (data: {
|
||||
user: UserController;
|
||||
updatedValues: Partial<User>;
|
||||
}) => void;
|
||||
"user:authenticated": (data: {
|
||||
user: UserController;
|
||||
tokens: SessionTokensObject;
|
||||
}) => void;
|
||||
"session:created": (data: {
|
||||
user: UserController;
|
||||
session: SessionController;
|
||||
}) => void;
|
||||
"session:tokens_generated": (data: {
|
||||
session: SessionController;
|
||||
tokens: SessionTokensObject;
|
||||
}) => void;
|
||||
"session:token_refresh": (data: {
|
||||
session: SessionController;
|
||||
tokens: SessionTokensObject;
|
||||
}) => void;
|
||||
"session:invalidated": (session: SessionController) => void;
|
||||
"session:terminated": (session: SessionController) => void;
|
||||
"role:created": (role: RoleController) => void;
|
||||
"role:deleted": (role: RoleController) => void;
|
||||
"role:updated": (data: {
|
||||
role: RoleController;
|
||||
updateData: Parameters<typeof RoleController.prototype.update>["0"];
|
||||
}) => void;
|
||||
"role:permissions:updated": (data: {
|
||||
previous: string[];
|
||||
previousSigned: string;
|
||||
current: string[];
|
||||
currentSigned: string;
|
||||
action: "set" | "added" | "removed";
|
||||
role: RoleController;
|
||||
}) => void;
|
||||
"role:permissions:verification_error": (data: {
|
||||
currentSigned: string;
|
||||
attemptedVerification: string;
|
||||
err: Error;
|
||||
role: RoleController;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const events = new Eventra<EventTypes>();
|
||||
|
||||
export function setupEventDebugger() {
|
||||
events.any((eventName, ...args) => {
|
||||
console.log(`[ Event Debugger ] (${eventName})`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function genImplicitPerm(
|
||||
resource: string,
|
||||
resourceId: string,
|
||||
userId: string
|
||||
) {
|
||||
return ["resource", resource, resourceId, "user", userId, "implicit"].join(
|
||||
"."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Permission Validator
|
||||
*
|
||||
* This method is used for validaing user and role permissions. This method is given a single or and array
|
||||
* of permission nodes that the user has and it is also given the permission node that is required for whatever
|
||||
* query they are trying to execute, and this will determine if any of the permission nodes match or will
|
||||
* verify the given permission node.
|
||||
*
|
||||
* Special token types:
|
||||
* - Asterisk (*): verifies it's token and all following tokens.
|
||||
* - Question Mark (?): verifies it's token and only it's token.
|
||||
* - Inclusive List ([a,b,c]): verifies only the tokens in the list.
|
||||
* - Exclusive List (<a,b,c>): verifies all tokens except for the ones in the list.
|
||||
*
|
||||
* @param permission - The required permission
|
||||
* @param permissionExpressions - The owned permission(s)
|
||||
* @returns {boolean} Does the user have the permission?
|
||||
*/
|
||||
export function permissionValidator(
|
||||
permission: string,
|
||||
permissionExpressions: string | string[]
|
||||
): boolean {
|
||||
if (typeof permissionExpressions === "string") {
|
||||
// If the second parameter is a string, treat it as a single expression
|
||||
permissionExpressions = [permissionExpressions];
|
||||
}
|
||||
|
||||
// Iterate over each expression in the array and check if any of them match the permission
|
||||
for (const expression of permissionExpressions) {
|
||||
const rx = expression
|
||||
.replace(/\./g, "\\.")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".")
|
||||
.replace(/\[([^\]\[]*)\]/g, "($1)")
|
||||
.replace(/<([^<>]+)>/g, "(?:(?!$1)[^.])*")
|
||||
.replace(/,/g, "|");
|
||||
|
||||
if (new RegExp(`^${rx}$`).test(permission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @TODO It's okay, you can't always get everything done and that is fine.
|
||||
* Just take a breath and move on, come back if you feel upto it.
|
||||
* What you make is good and whilst you can always do more,
|
||||
* you can't do everything. Nothing will ever be perfect,
|
||||
* so stop trying to be perfect and allow your self to move on
|
||||
* even if you know there is more you can do.
|
||||
*/
|
||||
@@ -0,0 +1,24 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { permissionsPrivateKey } from "../../constants";
|
||||
import { PermissionIssuers } from "../../types/PermissionTypes";
|
||||
|
||||
/**
|
||||
* Sign Permissions
|
||||
*
|
||||
* This will sign the array of permissions with the private key for permissions, and then return
|
||||
* a JWT which will be stored in the databse.
|
||||
*
|
||||
* @param permissions - All the permissions to be signed
|
||||
* @returns {string} - The signed permissions object
|
||||
*/
|
||||
export function signPermissions(data: {
|
||||
issuer: PermissionIssuers;
|
||||
subject: string;
|
||||
permissions: string[];
|
||||
}) {
|
||||
return jwt.sign({ permissions: data.permissions }, permissionsPrivateKey, {
|
||||
algorithm: "RS256",
|
||||
issuer: data.issuer,
|
||||
subject: data.subject,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { blake2sHex } from "blakets";
|
||||
import crypto from "crypto";
|
||||
|
||||
export default class Password {
|
||||
public static generateSalt(options?: GenerateSaltOptions): string {
|
||||
const length = options?.length ?? 12;
|
||||
const randomBytes = crypto.randomBytes(Math.ceil(length / 2));
|
||||
return randomBytes.toString("hex").slice(0, length);
|
||||
}
|
||||
|
||||
public static hash(password: string, options?: HashPasswordOptions): string {
|
||||
const salt =
|
||||
options?.overrideSalt ?? Password.generateSalt(options?.saltOpts);
|
||||
const hash = blake2sHex(`$BLAKE2s$${password}$${salt}`);
|
||||
return `BLAKE2s$${hash}$${salt}`;
|
||||
}
|
||||
|
||||
public static validate(newPass: string, hashed: string): boolean {
|
||||
const [algo, oldHash, salt] = hashed.split(/\$/g);
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(hashed),
|
||||
Buffer.from(Password.hash(newPass, { overrideSalt: salt }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface HashPasswordOptions {
|
||||
overrideSalt?: string;
|
||||
saltOpts?: GenerateSaltOptions;
|
||||
}
|
||||
|
||||
export interface GenerateSaltOptions {
|
||||
length?: number; // default 12
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const mergeArrays = (a, b, predicate = (a, b) => a === b) => {
|
||||
const c = [...a]; // copy to avoid side effects
|
||||
// add all items from B to copy C if they're not already present
|
||||
b.forEach((bItem) =>
|
||||
c.some((cItem) => predicate(bItem, cItem)) ? null : c.push(bItem)
|
||||
);
|
||||
return c;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export type Variables = {
|
||||
foo: "bar"
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export type PermissionIssuers = "roles" | "user" | "api_key";
|
||||
export interface DecodedPermissionsBlock {
|
||||
permissions: string[];
|
||||
iat: number; // Issued at
|
||||
iss: PermissionIssuers; // Issuer
|
||||
sub: string; // Subject
|
||||
}
|
||||
Reference in New Issue
Block a user