Added auth to server hook.
This commit is contained in:
@ -1,4 +1,4 @@
|
||||
import type { Id } from "./Id";
|
||||
import type { Id } from "../Id";
|
||||
import type { GameData } from "../GameData";
|
||||
import type { State } from "../State";
|
||||
|
||||
|
@ -1 +0,0 @@
|
||||
export type Id = string;
|
@ -1,4 +1,4 @@
|
||||
import type { GameData } from "../GameData";
|
||||
import type { Listing } from "./modifyListing";
|
||||
import type { GameData } from "$lib/GameData";
|
||||
import type { Listing } from "$lib/Listing";
|
||||
|
||||
export const games: Listing<GameData>[] = [];
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import type { Listing } from "$lib/Listing";
|
||||
import { createId } from "$lib/Id";
|
||||
|
||||
export function createNewListing<T>(data: T): Listing<T> {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
id: createId(),
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: null,
|
||||
deleted: false,
|
||||
|
80
src/lib/server/mongo.ts
Normal file
80
src/lib/server/mongo.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import type { Listing } from "$lib/Listing";
|
||||
import { MongoClient, ObjectId, ServerApiVersion, type Document, type WithId } from "mongodb";
|
||||
import type { Id } from "../Id";
|
||||
|
||||
type ListingFromMongo = Omit<Listing, "id"> & { _id: ObjectId };
|
||||
|
||||
const uri = `mongodb://127.0.0.1:27017`;
|
||||
const DATABASE = "ten-thousand";
|
||||
let client: MongoClient | null = null;
|
||||
|
||||
async function init() {
|
||||
const c = new MongoClient(uri, {
|
||||
serverApi: {
|
||||
version: ServerApiVersion.v1,
|
||||
strict: true,
|
||||
deprecationErrors: true
|
||||
}
|
||||
});
|
||||
|
||||
await c.connect();
|
||||
return c;
|
||||
}
|
||||
|
||||
export async function writeListing(col: string, listing: Listing) {
|
||||
if (client === null) {
|
||||
client = await init();
|
||||
}
|
||||
|
||||
await client.db(DATABASE).collection(col).insertOne(fixListingForMongo(listing));
|
||||
}
|
||||
|
||||
export async function readListingById<T>(
|
||||
col: string,
|
||||
id: Id,
|
||||
dataGuard: (target: unknown) => target is T
|
||||
) {}
|
||||
|
||||
export async function readListingByQuery<T>(
|
||||
col: string,
|
||||
query: object,
|
||||
dataGuard: (target: unknown) => target is Listing<T>
|
||||
): Promise<Listing<T> | null> {
|
||||
if (client === null) {
|
||||
client = await init();
|
||||
}
|
||||
|
||||
const res = await client.db(DATABASE).collection(col).findOne(query);
|
||||
|
||||
if (res === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fixListingFromMongo(res, dataGuard);
|
||||
}
|
||||
|
||||
function fixListingForMongo(listing: Listing): ListingFromMongo {
|
||||
// TODO: These two statements are tricky without any. Perhaps id could be optional,
|
||||
// but that's kind of stupid because it's not optional on the type I'm
|
||||
// constructing. Figure out something better here.
|
||||
const adjusted: any = { _id: listing.id, ...listing };
|
||||
delete adjusted.id;
|
||||
return adjusted;
|
||||
}
|
||||
|
||||
function fixListingFromMongo<T>(
|
||||
target: WithId<Document>,
|
||||
dataGuard: (target: unknown) => target is Listing<T>
|
||||
): Listing<T> {
|
||||
// TODO: These two statements are tricky without any. Perhaps id could be optional,
|
||||
// but that's kind of stupid because it's not optional on the type I'm
|
||||
// constructing. Figure out something better here.
|
||||
const adjusted: any = { id: target._id, ...target };
|
||||
delete adjusted._id;
|
||||
|
||||
if (!dataGuard(adjusted)) {
|
||||
throw new Error("the returned document does not conform to the provided type");
|
||||
}
|
||||
|
||||
return adjusted;
|
||||
}
|
157
src/lib/server/requestTools.ts
Normal file
157
src/lib/server/requestTools.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { JWT_SECRET } from "$env/static/private";
|
||||
import type { RequestEvent } from "@sveltejs/kit";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { routeAuth, type Method, type RouteAuthRule } from "./routeAuth";
|
||||
|
||||
export type LocalCredentials =
|
||||
| { kind: "Basic"; payload: { username: string; password: string } }
|
||||
| { kind: "Bearer"; payload: jwt.JwtPayload | string }
|
||||
| { kind: "None" };
|
||||
|
||||
export async function getRequestBody<T = unknown>(
|
||||
req: Request,
|
||||
validation?: (target: unknown) => target is T
|
||||
): Promise<T> {
|
||||
if (req.body === null) {
|
||||
throw new Error("no body is present on the request");
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
if (validation && !validation(body)) {
|
||||
throw new Error("body validation failed");
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function isAuthorized(event: RequestEvent): Promise<LocalCredentials | null> {
|
||||
let path = event.url.pathname;
|
||||
let tokenKind: "Basic" | "Bearer" | "None";
|
||||
let tokenRole: string;
|
||||
let tokenDesc: LocalCredentials;
|
||||
|
||||
const parts = breakupPath(path);
|
||||
|
||||
if (parts[0] !== "api") {
|
||||
// not concerned about requests made to the frontend server
|
||||
return { kind: "None" };
|
||||
}
|
||||
|
||||
const authHeader = event.request.headers.get("authorization");
|
||||
|
||||
// get token kind and role from the header
|
||||
if (!authHeader) {
|
||||
// This is a stranger: they have no token and they will be assigned the default
|
||||
// role.
|
||||
tokenKind = "None";
|
||||
tokenRole = "default";
|
||||
tokenDesc = { kind: "None" };
|
||||
} else {
|
||||
const [kind, token] = authHeader.split(" ");
|
||||
|
||||
if (kind === "Bearer") {
|
||||
tokenKind = "Bearer";
|
||||
|
||||
// The role can be derived from the JWT token.
|
||||
const payload = await jwt.verify(token, JWT_SECRET);
|
||||
if (typeof payload === "string") {
|
||||
// I do not assign, and don't know what to do with, these kinds of
|
||||
// tokens. Perhaps an error should be logged here, since this is a
|
||||
// weird thing to have stumbled on.
|
||||
return null;
|
||||
// user should have a bearer token
|
||||
}
|
||||
|
||||
tokenRole = payload.role;
|
||||
tokenDesc = { kind: "Bearer", payload };
|
||||
|
||||
if (!tokenRole) {
|
||||
// Something has gone wrong: I should not have issued a token without a
|
||||
// role.
|
||||
return null;
|
||||
}
|
||||
} else if (kind === "Basic") {
|
||||
const decoded = Buffer.from(token, "base64").toString("ascii");
|
||||
const [username, password] = decoded.split(":");
|
||||
|
||||
tokenKind = "Basic";
|
||||
tokenRole = "default";
|
||||
|
||||
tokenDesc = { kind: "Basic", payload: { username, password } };
|
||||
} else {
|
||||
// Something the server doesn't recognize was passed as the token kind.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if the role has the ability to hit this endpoint with this method
|
||||
// and the given token.
|
||||
const rules = routeAuth[tokenRole];
|
||||
|
||||
let hasMatchingAllow = false;
|
||||
for (const rule of rules) {
|
||||
console.log("checking rule", rule);
|
||||
if (matchesRequest(tokenKind, parts, event.request.method as Method, rule)) {
|
||||
console.log("match!");
|
||||
if (rule.action === "deny") {
|
||||
// if a request matches any deny rule, then it is denied, regardless
|
||||
// of whether or not it also matches an allow rule.
|
||||
return null;
|
||||
} else if (rule.action === "allow") {
|
||||
hasMatchingAllow = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMatchingAllow) {
|
||||
return tokenDesc;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function breakupPath(path: string): string[] {
|
||||
// remove a leading /
|
||||
if (path[0] === "/") path = path.slice(1);
|
||||
|
||||
return path.split("/");
|
||||
}
|
||||
|
||||
function matchesRequest(
|
||||
requestTokenKind: "Basic" | "Bearer" | "None",
|
||||
requestParts: string[],
|
||||
method: Method,
|
||||
{ endpoint, methods, tokenKind = "Bearer" }: RouteAuthRule
|
||||
): boolean {
|
||||
if (tokenKind !== requestTokenKind) {
|
||||
console.log("token types didn't match", tokenKind, requestTokenKind);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!methods.includes("*") && !methods.includes(method)) {
|
||||
console.log("Bad method", method);
|
||||
return false;
|
||||
}
|
||||
|
||||
const ruleParts = breakupPath(endpoint);
|
||||
for (let i = 0; i < ruleParts.length; i++) {
|
||||
const rulePart = ruleParts[i];
|
||||
const reqPart = requestParts[i];
|
||||
if (rulePart[0] === "[" && rulePart[rulePart.length - 1] === "]") {
|
||||
// This part of the path represents an ID.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i == ruleParts.length - 1 && rulePart === "*") {
|
||||
// Rule has a wildcard, anything after it automatically matches.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (rulePart !== reqPart) {
|
||||
console.log("rule parts do not match", rulePart, reqPart);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
@ -17,3 +17,11 @@ export function notFoundResponse() {
|
||||
export function serverErrorResponse() {
|
||||
return Response.json({ error: "Unexpected Server Error" }, { status: 500 });
|
||||
}
|
||||
|
||||
export function unauthorizedResponse() {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
export function forbiddenResponse() {
|
||||
return Response.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
25
src/lib/server/routeAuth.ts
Normal file
25
src/lib/server/routeAuth.ts
Normal file
@ -0,0 +1,25 @@
|
||||
export type Method = "POST" | "GET" | "PUT" | "DELETE" | "PATCH" | "*";
|
||||
|
||||
export interface RouteAuthRule {
|
||||
action: "allow" | "deny";
|
||||
methods: Method[];
|
||||
endpoint: string;
|
||||
tokenKind?: "None" | "Bearer" | "Basic"; // defaults to "Bearer"
|
||||
}
|
||||
|
||||
export const routeAuth: { [k: string]: RouteAuthRule[] } = {
|
||||
// default is an unknown user. They are allowed to create a new user for themselves
|
||||
// without any token, and they are allowed to access the token endpoint to login with
|
||||
// a Basic token. Other than that, they cannot do anything!
|
||||
default: [
|
||||
{ action: "allow", methods: ["POST"], endpoint: "/api/users", tokenKind: "None" },
|
||||
{ action: "allow", methods: ["POST"], endpoint: "/api/token", tokenKind: "Basic" }
|
||||
],
|
||||
|
||||
// player is anyone else. They are authorized to hit any endpoint, using any method,
|
||||
// with a Bearer token.
|
||||
player: [
|
||||
{ action: "allow", methods: ["*"], endpoint: "*" },
|
||||
{ action: "deny", methods: ["POST"], endpoint: "/api/token" }
|
||||
]
|
||||
};
|
Reference in New Issue
Block a user