diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 2f99baf..a152e95 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -74,9 +74,9 @@ export const images = pgTable("images", { export const users = pgTable("users", { id: uuid().notNull().primaryKey().defaultRandom(), - display_name: varchar({ length: 63 }).notNull(), + display_name: varchar({ length: 63 }).unique().notNull(), - username: varchar({ length: 63 }).notNull(), + username: varchar({ length: 63 }).unique().notNull(), hashed_password: varchar({ length: 255 }).notNull(), logged_in: boolean().notNull().default(false), diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts new file mode 100644 index 0000000..926cc76 --- /dev/null +++ b/backend/src/helpers/auth.ts @@ -0,0 +1,75 @@ +import crypto from 'crypto'; +import JsonWebToken from '../types/JsonWebToken.ts'; + +export function verifySignature(header: string, payload: string, signature: string) { + const signingSecret = process.env.JWT_SECRET!; + const encodedMessage = header + "." + payload; + + const calculatedSignature = crypto + .createHmac('sha256', signingSecret) + .update(encodedMessage) + .digest("base64url"); + + const csBuff = Buffer.from(calculatedSignature); + const sBuff = Buffer.from(signature); + + + return csBuff.length === sBuff.length && + crypto.timingSafeEqual(csBuff, sBuff); +} + +function base64UrlEncode(input: string) { + return Buffer.from(input).toString("base64url"); +} + +export function base64UrlDecode(str: string) { + return Buffer.from(str, "base64url").toString(); +}; + +export function encodeMessage(header: JsonWebToken["header"], payload: JsonWebToken["payload"]): string { + const encodedHeader = base64UrlEncode(JSON.stringify(header)); + const encodedPayload = base64UrlEncode(JSON.stringify(payload)); + return `${encodedHeader}.${encodedPayload}`; +} + +export function decodeJWT(token: string) { + const parts = token.split('.'); + + const header = JSON.parse(base64UrlDecode(parts[0])); + const payload = JSON.parse(base64UrlDecode(parts[1])); + const signature = parts[2]; // Signature remains encoded + + return { header, payload, signature }; +} + +export function generateSignedJWT(userId: string, permissions: number): string { + const signingSecret = process.env.JWT_SECRET!; + + const now = Math.floor(Date.now() / 1000); + // fifteen minutes + const expires = now + (15 * 60); + + const header: JsonWebToken["header"] = { + alg: "HS256", + typ: "JWT", + }; + + const payload: JsonWebToken["payload"] = { + iat: now, + uid: userId, + prm: permissions, + exp: expires + }; + + const encodedMessage = encodeMessage(header, payload); + + const signature = crypto + .createHmac('sha256', signingSecret) + .update(encodedMessage) + .digest("base64url"); + + return `${encodedMessage}.${signature}`; +} + + + diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index f081307..7e96d08 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,25 +1,24 @@ import { Request, Response, NextFunction } from "express"; -import crypto from 'crypto'; -import JsonWebToken from '../types/JsonWebToken.ts'; +import JsonWebToken, { defaultJWT } from '../types/JsonWebToken.ts'; +import { verifySignature, decodeJWT } from '../helpers/auth.ts'; -export async function auth( +export function auth( req: Request, res: Response, next: NextFunction ) { - // read jwt const authHeader = req.header("Authorization"); // If there is no jwt, proceed to request with default permissions if (!authHeader) { - req.jwt = defaultJWT; + req.jwt = defaultJWT(); } else { // otherwise decode the jwt const [scheme, token] = authHeader.split(" "); if (scheme.toLowerCase() != "bearer") { return res.status(401).json({ - error: "Invalid auth scheme (not Bearer)" + error: "Invalid JWT" }); }; @@ -42,11 +41,12 @@ export async function auth( } const validClaims: boolean = - decodedToken.payload.exp < Math.floor(Date.now() / 1000) && + decodedToken.payload.exp >= Math.floor(Date.now() / 1000) && decodedToken.header.alg === "HS256" && decodedToken.header.typ === "JWT"; if (validJWT && validClaims) { + console.log(decodedToken) req.jwt = decodedToken; } else { // maybe attempt token refresh here @@ -59,62 +59,3 @@ export async function auth( next(); }; -function verifySignature(header: string, payload: string, signature: string) { - const signingSecret = process.env.JWT_SECRET!; - const encodedMessage = header + "." + payload; - - const calculatedSignature = crypto - .createHmac('sha256', signingSecret) - .update(encodedMessage) - .digest("base64url"); - - const csBuff = Buffer.from(calculatedSignature); - const sBuff = Buffer.from(signature); - - - return csBuff.length === sBuff.length && - crypto.timingSafeEqual(csBuff, sBuff); -} - -function base64UrlEncode(input: string) { - return Buffer.from(input).toString("base64url"); -} - -function encodeMessage(header: JsonWebToken["header"], payload: JsonWebToken["payload"]) { - const encodedHeader = base64UrlEncode(JSON.stringify(header)); - const encodedPayload = base64UrlEncode(JSON.stringify(payload)); - return encodedHeader + "." + encodedPayload; -} - -function decodeJWT(token: string) { - const parts = token.split('.'); - - const decodeBase64Url = (str: string) => { - return Buffer.from(str, "base64url").toString(); - }; - - const header = JSON.parse(decodeBase64Url(parts[0])); - const payload = JSON.parse(decodeBase64Url(parts[1])); - const signature = parts[2]; // Signature remains encoded - - return { header, payload, signature }; -} - -// default fallback JWT -// works fine for all calls that only need "guest" auth -// individual routes will fail if prm (permissions) -// are insufficient -const defaultJWT: JsonWebToken = -{ - header: { - alg: "none", - typ: "JWT", - }, - payload: { - iat: -1, - uid: "", - prm: 44, - exp: -1, - }, - signature: "" -}; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 2d6a433..003c8e4 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,7 +1,61 @@ import { drizzle } from 'drizzle-orm/node-postgres'; +import crypto from 'crypto'; import { eq } from 'drizzle-orm'; -import { articles } from '../db/schema.ts'; +import { users } from '../db/schema.ts'; import express, { Request, Response } from "express"; +import { generateSignedJWT, base64UrlDecode } from '../helpers/auth.ts'; const db = drizzle(process.env.DATABASE_URL!); +const hashSecret = process.env.PASSWORD_HASH_SECRET!; +const presalt = process.env.PRESALT!; +const postsalt = process.env.POSTSALT!; export const authRouter = express.Router(); + +// username and password => signed jwt +authRouter.get("/login", async (req: Request, res: Response) => { + const authHeader = req.header("Authorization"); + + if (!authHeader) return res.status(401) + .json({ error: "401: Missing Authorization Header" }); + + const [scheme, value] = authHeader.split(" "); + + if (scheme.toLowerCase() != "basic") return res.status(401) + .json({ error: "401: Incorrect Authorization Scheme" }); + + const decodedHeader = base64UrlDecode(value); + + const [username, password] = decodedHeader.split(":"); + + const hashedPassword = crypto + .createHmac('sha256', hashSecret) + .update(`${presalt}${password}${postsalt}`) + .digest("base64url"); + + const user = await db + .select() + .from(users) + .where(eq(users.username, username)) + .limit(1); + + const calculatedBuff = Buffer.from(hashedPassword); + const storedBuff = Buffer.from(user[0].hashed_password); + + const match = calculatedBuff.length === storedBuff.length && + crypto.timingSafeEqual(calculatedBuff, storedBuff); + + if (!match) + return res.status(401).json({ error: "401: failed to authenticate username/password" }); + + const jwt = generateSignedJWT(user[0].id, user[0].permissions) + + res.json({ signed_jwt: jwt }); +}); + +authRouter.get("/logout", async (req: Request, res: Response) => { + +}); + +authRouter.get("/signup", async (req: Request, res: Response) => { + +}); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index e513ded..f4a0027 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -8,10 +8,12 @@ const db = drizzle(process.env.DATABASE_URL!); export const usersRouter = express.Router(); usersRouter.get("/getUsers", auth, async (req: Request, res: Response) => { - if (req.jwt.payload.prm === 44) + if (req.jwt.payload.prm != 77) return res - .status(401) - .json({ error: "Cannot access endpoint with default permissions" }); + .status(403) + .json({ + error: "403: Forbidden" + }); const resp = await db.select().from(users); res.json(resp) diff --git a/backend/src/types/JsonWebToken.ts b/backend/src/types/JsonWebToken.ts index 81fbd37..b3027b2 100644 --- a/backend/src/types/JsonWebToken.ts +++ b/backend/src/types/JsonWebToken.ts @@ -1,3 +1,5 @@ +import { encodeMessage } from '../helpers/auth.ts'; + type JsonWebToken = { header: { alg: string; @@ -14,4 +16,26 @@ type JsonWebToken = { signature: string }; +// default fallback JWT +// works fine for all calls that only need "guest" auth +// individual routes will fail if prm (permissions) +// are insufficient +export function defaultJWT() { + return { + header: { + alg: "none", + typ: "JWT", + }, + payload: { + iat: -1, + uid: "", + prm: 44, + exp: -1 + }, + signature: "" + } +} + + + export default JsonWebToken;