First Commit
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import type { BUN_SQLITE_WGUI_USER_TYPES } from "@/db/types/db";
|
||||
import type { UserType } from "@/src/types";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
type Params = {
|
||||
includes?: UserType[];
|
||||
excludes?: UserType[];
|
||||
user_types?: BUN_SQLITE_WGUI_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
export default function checkUserAccess({
|
||||
user_types,
|
||||
excludes,
|
||||
includes,
|
||||
}: Params): APIResponseObject {
|
||||
if (!user_types?.[0]) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `No User Types Provided`,
|
||||
};
|
||||
}
|
||||
|
||||
if (user_types.find((t) => t.user_type === "super_admin")) {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
const userTypeSet = new Set(user_types.map((t) => t.user_type));
|
||||
|
||||
if (excludes) {
|
||||
for (let i = 0; i < excludes.length; i++) {
|
||||
const exclude = excludes[i] as any;
|
||||
if (userTypeSet.has(exclude)) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `User Excluded`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includes) {
|
||||
const is_user_included = includes.find((i) =>
|
||||
userTypeSet.has(i as any),
|
||||
);
|
||||
|
||||
if (!is_user_included) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `User not included`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!includes?.[0] && !excludes?.[0]) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `No includes or excludes provided`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function generateCSRF() {
|
||||
return (
|
||||
Math.random().toString(36).substring(2) +
|
||||
"-" +
|
||||
Math.random().toString(36).substring(2)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_USER_TYPES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import type { UserAuthReturn } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
export default async function grabUserAuthTypes({
|
||||
user_id,
|
||||
}: Params): Promise<UserAuthReturn> {
|
||||
try {
|
||||
const user_types =
|
||||
(
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_USER_TYPES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_types",
|
||||
query: {
|
||||
query: {
|
||||
user_id: {
|
||||
value: user_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
if (user_types.find((ty) => ty.user_type == "revoked")) {
|
||||
return {
|
||||
success: false,
|
||||
msg: "User Access Revoked",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user_types,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.msg,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
import _ from "lodash";
|
||||
import userAuth from "./user-auth";
|
||||
import checkUserAccess from "./check-user-access";
|
||||
import grabUsers from "../db/users/grab-users";
|
||||
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
query,
|
||||
}) => {
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Unauthorized`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const is_admin = checkUserAccess({
|
||||
user_types,
|
||||
includes: ["admin"],
|
||||
});
|
||||
|
||||
if (
|
||||
!is_admin.success &&
|
||||
(!query?.user_id || query.user_id !== user.id)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Unauthorized`,
|
||||
};
|
||||
}
|
||||
|
||||
return await grabUsers({
|
||||
query,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { setCookies } from "@/src/utils/cookies";
|
||||
import { encrypt } from "@/src/utils/crypt";
|
||||
import grabUsers from "../db/users/grab-users";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_MEDIA,
|
||||
BUN_SQLITE_WGUI_SSO_LOGIN_CODES,
|
||||
BUN_SQLITE_WGUI_USER_TYPES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import generateCSRF from "./gen-csrf";
|
||||
import type { User } from "@/src/types";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import grabMediaURL from "../media/grab-media-url";
|
||||
|
||||
type Params = {
|
||||
user_id: string | number;
|
||||
};
|
||||
|
||||
export default async function loginUser({
|
||||
user_id,
|
||||
}: Params): Promise<Response> {
|
||||
const now = Date.now();
|
||||
|
||||
const target_user_res = await grabUsers({
|
||||
query: { user_id },
|
||||
});
|
||||
|
||||
const target_user = target_user_res.singleRes;
|
||||
|
||||
if (!target_user?.id) {
|
||||
throw new Error(`Couldn't Find user with ID ${user_id}`);
|
||||
}
|
||||
|
||||
const target_user_types = (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_USER_TYPES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_types",
|
||||
query: { query: { user_id: { value: target_user.id } } },
|
||||
})
|
||||
).payload;
|
||||
|
||||
if (!target_user_types?.[0]) {
|
||||
throw new Error(`No User types found for this user.`);
|
||||
}
|
||||
|
||||
if (target_user_types.find((ty) => ty.user_type == "revoked")) {
|
||||
throw new Error(`User access Revoked`);
|
||||
}
|
||||
|
||||
let csrf_key = generateCSRF();
|
||||
|
||||
const profile_media = target_user.profile_media_id
|
||||
? (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_MEDIA,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "media",
|
||||
targetId: target_user.profile_media_id,
|
||||
})
|
||||
).singleRes
|
||||
: undefined;
|
||||
|
||||
const { media_url, media_thumbnail_url } = profile_media?.id
|
||||
? grabMediaURL({ media: profile_media })
|
||||
: {};
|
||||
|
||||
const new_logged_in_user: User = {
|
||||
id: target_user.id,
|
||||
csrf: csrf_key,
|
||||
date: now,
|
||||
first_name: target_user.first_name || "",
|
||||
last_name: target_user.last_name || "",
|
||||
email: target_user.email || "",
|
||||
image: media_url,
|
||||
image_thumbnail: media_thumbnail_url,
|
||||
verification_status: 1,
|
||||
logged_in_status: true,
|
||||
roles: target_user.user_types || "none",
|
||||
};
|
||||
|
||||
const user_payload_json = JSON.stringify(new_logged_in_user);
|
||||
const encrypted_key = await encrypt(user_payload_json);
|
||||
|
||||
const res_obj: APIResponseObject = {
|
||||
success: true,
|
||||
singleRes: new_logged_in_user,
|
||||
};
|
||||
|
||||
const res = new Response(JSON.stringify(res_obj), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const maxAge = AppData["AuthExpiryDays"] * 24 * 60 * 60;
|
||||
|
||||
setCookies(res, [
|
||||
{
|
||||
name: AppData["AuthKeyCookieName"],
|
||||
value: encrypted_key,
|
||||
maxAge,
|
||||
},
|
||||
{
|
||||
name: AppData["AuthCSRFCookieName"],
|
||||
value: csrf_key,
|
||||
maxAge,
|
||||
},
|
||||
]);
|
||||
|
||||
const delete_all_user_sso_codes = await BunSQLite.delete<
|
||||
BUN_SQLITE_WGUI_SSO_LOGIN_CODES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "sso_login_codes",
|
||||
query: {
|
||||
query: {
|
||||
user_id: {
|
||||
value: new_logged_in_user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`User #${new_logged_in_user.id} [${new_logged_in_user.first_name} ${new_logged_in_user.last_name}] logged in successfully.`,
|
||||
);
|
||||
console.log(
|
||||
`Date: ${Date()}\n==============================================================`,
|
||||
);
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { getCookie } from "@/src/utils/cookies";
|
||||
import { decrypt } from "@/src/utils/crypt";
|
||||
import grabUserAuthTypes from "./grab-user-auth-types";
|
||||
import type { User, UserAuthReturn } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
req: Request;
|
||||
};
|
||||
|
||||
export default async function userAuth({
|
||||
req,
|
||||
}: Params): Promise<UserAuthReturn> {
|
||||
try {
|
||||
const auth_key_cookie = getCookie(req, AppData["AuthKeyCookieName"]);
|
||||
const auth_csrf_cookie = getCookie(req, AppData["AuthCSRFCookieName"]);
|
||||
const now = Date.now();
|
||||
|
||||
if (!auth_key_cookie) throw new Error(`Auth Key not found!`);
|
||||
|
||||
if (!auth_csrf_cookie) throw new Error(`Auth CSRF not found!`);
|
||||
|
||||
const decrypted_key = await decrypt(auth_key_cookie);
|
||||
const decrypted_user_payload = JSON.parse(decrypted_key) as User;
|
||||
|
||||
if (!decrypted_user_payload?.id) throw new Error(`Invalid Key`);
|
||||
if (!decrypted_user_payload.date) throw new Error(`Key has no date!`);
|
||||
|
||||
const time_elapsed = now - decrypted_user_payload.date;
|
||||
const expiry_time = AppData["AuthExpiryDays"] * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (time_elapsed > expiry_time) {
|
||||
throw new Error(`Key Expired`);
|
||||
}
|
||||
|
||||
const user_auth_types = await grabUserAuthTypes({
|
||||
user_id: decrypted_user_payload.id,
|
||||
});
|
||||
|
||||
if (!user_auth_types.success) {
|
||||
return user_auth_types;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: decrypted_user_payload,
|
||||
user_types: user_auth_types.user_types,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.msg,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
type GoogleJWTHeader = {
|
||||
alg?: string;
|
||||
kid?: string;
|
||||
typ?: string;
|
||||
};
|
||||
|
||||
type GoogleJWKS = {
|
||||
keys?: GoogleJWK[];
|
||||
};
|
||||
|
||||
type GoogleJWK = JsonWebKey & {
|
||||
kid?: string;
|
||||
};
|
||||
|
||||
type GoogleIDTokenPayload = {
|
||||
aud?: string | string[];
|
||||
email?: string;
|
||||
email_verified?: boolean | string;
|
||||
exp?: number | string;
|
||||
iss?: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
sub?: string;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
clientId: string;
|
||||
idToken: string;
|
||||
};
|
||||
|
||||
type GoogleIDTokenUser = Required<Pick<GoogleIDTokenPayload, "email" | "sub">> &
|
||||
Pick<GoogleIDTokenPayload, "name" | "picture">;
|
||||
|
||||
const GoogleJWKSUrl = "https://www.googleapis.com/oauth2/v3/certs";
|
||||
|
||||
let cachedGoogleKeys: {
|
||||
expiresAt: number;
|
||||
keys: GoogleJWK[];
|
||||
} | null = null;
|
||||
|
||||
function decodeBase64Url(value: string) {
|
||||
const normalizedValue = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padding = normalizedValue.length % 4;
|
||||
const paddedValue =
|
||||
padding === 0
|
||||
? normalizedValue
|
||||
: normalizedValue.padEnd(
|
||||
normalizedValue.length + (4 - padding),
|
||||
"=",
|
||||
);
|
||||
|
||||
return Buffer.from(paddedValue, "base64");
|
||||
}
|
||||
|
||||
function parseGoogleTokenPart<T>(value: string): T {
|
||||
return JSON.parse(decodeBase64Url(value).toString("utf8")) as T;
|
||||
}
|
||||
|
||||
async function getGoogleSigningKeys() {
|
||||
if (cachedGoogleKeys && cachedGoogleKeys.expiresAt > Date.now()) {
|
||||
return cachedGoogleKeys.keys;
|
||||
}
|
||||
|
||||
const response = await fetch(GoogleJWKSUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Couldn't fetch Google's signing keys`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GoogleJWKS;
|
||||
|
||||
if (!data.keys?.length) {
|
||||
throw new Error(`Google didn't return any signing keys`);
|
||||
}
|
||||
|
||||
const maxAge =
|
||||
Number(
|
||||
response.headers.get("cache-control")?.match(/max-age=(\d+)/)?.[1],
|
||||
) || 300;
|
||||
|
||||
cachedGoogleKeys = {
|
||||
expiresAt: Date.now() + maxAge * 1000,
|
||||
keys: data.keys,
|
||||
};
|
||||
|
||||
return data.keys;
|
||||
}
|
||||
|
||||
function hasMatchingAudience(
|
||||
aud: GoogleIDTokenPayload["aud"],
|
||||
clientId: string,
|
||||
) {
|
||||
if (Array.isArray(aud)) {
|
||||
return aud.includes(clientId);
|
||||
}
|
||||
|
||||
return aud === clientId;
|
||||
}
|
||||
|
||||
export default async function verifyGoogleIdToken({
|
||||
clientId,
|
||||
idToken,
|
||||
}: Params): Promise<GoogleIDTokenUser> {
|
||||
const tokenParts = idToken.split(".");
|
||||
|
||||
if (tokenParts.length !== 3) {
|
||||
throw new Error(`Invalid Google token`);
|
||||
}
|
||||
|
||||
const [encodedHeader, encodedPayload, encodedSignature] = tokenParts;
|
||||
|
||||
if (!encodedHeader) {
|
||||
throw new Error(`No encodedHeader`);
|
||||
}
|
||||
|
||||
if (!encodedPayload) {
|
||||
throw new Error(`No encodedPayload`);
|
||||
}
|
||||
|
||||
if (!encodedSignature) {
|
||||
throw new Error(`No encodedSignature`);
|
||||
}
|
||||
|
||||
const header = parseGoogleTokenPart<GoogleJWTHeader>(encodedHeader);
|
||||
const payload = parseGoogleTokenPart<GoogleIDTokenPayload>(encodedPayload);
|
||||
|
||||
if (header.alg !== "RS256" || !header.kid) {
|
||||
throw new Error(`Unsupported Google token signature`);
|
||||
}
|
||||
|
||||
const signingKeys = await getGoogleSigningKeys();
|
||||
const signingKey = signingKeys.find((key) => key.kid === header.kid);
|
||||
|
||||
if (!signingKey) {
|
||||
throw new Error(`Unknown Google signing key`);
|
||||
}
|
||||
|
||||
const importedKey = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
signingKey,
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
hash: "SHA-256",
|
||||
},
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
|
||||
const isValidSignature = await crypto.subtle.verify(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
importedKey,
|
||||
decodeBase64Url(encodedSignature),
|
||||
new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`),
|
||||
);
|
||||
|
||||
if (!isValidSignature) {
|
||||
throw new Error(`Invalid Google token signature`);
|
||||
}
|
||||
|
||||
if (!hasMatchingAudience(payload.aud, clientId)) {
|
||||
throw new Error(`Google token was issued for a different app`);
|
||||
}
|
||||
|
||||
if (
|
||||
payload.iss !== "https://accounts.google.com" &&
|
||||
payload.iss !== "accounts.google.com"
|
||||
) {
|
||||
throw new Error(`Invalid Google token issuer`);
|
||||
}
|
||||
|
||||
const expiresAt = Number(payload.exp || 0) * 1000;
|
||||
|
||||
if (!expiresAt || expiresAt <= Date.now()) {
|
||||
throw new Error(`Google token has expired`);
|
||||
}
|
||||
|
||||
const emailVerified =
|
||||
payload.email_verified === true || payload.email_verified === "true";
|
||||
|
||||
if (!payload.email || !emailVerified || !payload.sub) {
|
||||
throw new Error(`Google account email is not verified`);
|
||||
}
|
||||
|
||||
return {
|
||||
email: payload.email,
|
||||
sub: payload.sub,
|
||||
name: payload.name,
|
||||
picture: payload.picture,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user