commit eca7405974f5228e4a1680907f15e4bca8b755e2 Author: jd Date: Fri Feb 13 00:07:19 2026 +0000 Initial diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..64772e8 --- /dev/null +++ b/.env.dev @@ -0,0 +1,2 @@ +DATABASE_URL=postgres://admin:iiyama12@192.168.1.166:5432/bgApp +JWT_SECRET_KEY=MySecret \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/API Tests/Auth.http b/API Tests/Auth.http new file mode 100644 index 0000000..afe8092 --- /dev/null +++ b/API Tests/Auth.http @@ -0,0 +1,12 @@ +POST http://localhost:3000/api/auth/login +Content-Type: application/json + +{ + "username": "jd2", + "password": "Foobar" +} + +### +GET http://localhost:3000/api/auth/test +Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIyIiwiY2xhaW1zIjpbIlVTRVJTX1NFTEZfUkVBRCJdLCJpYXQiOjE3NzA5MzgwNzcsImV4cCI6MTc3MTAyNDQ3N30.T_LInbvYJkv1beS39TSuC3asGtbx6gO2bKnFDk52qXM diff --git a/API Tests/User.http b/API Tests/User.http new file mode 100644 index 0000000..68d5f31 --- /dev/null +++ b/API Tests/User.http @@ -0,0 +1,12 @@ +POST http://localhost:3000/api/user +Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxIiwiY2xhaW1zIjpbIlVTRVJTX1NFTEZfUkVBRCIsIkFETUlOIiwiVVNFUlNfQ1JFQVRFIiwiVVNFUlNfT1RIRVJfUkVBRCIsIlVTRVJTX1NFTEZfREVMRVRFIiwiVVNFUlNfT1RIRVJfVVBEQVRFIiwiVVNFUlNfU0VMRl9VUERBVEUiXSwiaWF0IjoxNzcwOTE1NjY4LCJleHAiOjE3NzEwMDIwNjh9.7ZWfIcT9vBIpqYY4PhJspRPrCtyBkqQ5jmSjOrCgzWI + +{ + "username": "jd8", + "password": "Foobar2" +} + +### +GET http://localhost:3000/api/user/2 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIyIiwiY2xhaW1zIjpbIlVTRVJTX1NFTEZfUkVBRCJdLCJpYXQiOjE3NzA5MzE1NzYsImV4cCI6MTc3MTAxNzk3Nn0.6ITZfX3_vtAFaf6qTuX7Fk_RLomgKbeto9IWhGMdN-k \ No newline at end of file diff --git a/endpoints/auth.ts b/endpoints/auth.ts new file mode 100644 index 0000000..bf320d1 --- /dev/null +++ b/endpoints/auth.ts @@ -0,0 +1,29 @@ +import {BunRequest as Request} from "bun"; +import {Claims, orm} from "../utilities/orm.ts"; +import jwt from "jsonwebtoken"; + +async function login(request: Request): Promise { + try { + const requestBody = await request.json(); + console.log(`/api/auth/login: username=${requestBody.username}`); + const claims: Claims | null = await orm.users.verify(requestBody.username, requestBody.password); + console.log(claims); + if (claims) { + const token = jwt.sign({...claims}, process.env.JWT_SECRET_KEY as string, {expiresIn: "24h"}); + return Response.json({token: token}, {status: 200}); + } + + return Response.json({token: null}, {status: 401}); + } catch (e) { + console.log(e); + return Response.json({message: e}, {status: 401}); + } +} +async function test(request: Request, claims: Claims) { + return Response.json(claims, {status: 200}); +} + +export default { + login, + test +}; \ No newline at end of file diff --git a/endpoints/user.ts b/endpoints/user.ts new file mode 100644 index 0000000..997255c --- /dev/null +++ b/endpoints/user.ts @@ -0,0 +1,28 @@ +import {Claims, orm} from "../utilities/orm"; +import {BunRequest as Request} from 'bun'; + +async function create (request: Request, claims: Claims): Promise { + try { + const requestBody = await request.json(); + return Response.json({ + ...(await orm.users.create(requestBody.username, requestBody.password, claims)) + }, {status: 200}); + } catch (e: any) { + return Response.json({message: e.message}, {status: 500}); + } +} + +async function get(request: Request, claims:Claims): Promise { + try { + return Response.json({ + ...(await orm.users.get(request.params.id, claims)) + }, {status: 200}); + } catch (e: any) { + return Response.json({message: e.message}, {status: 500}); + } +} + +export default { + create, + get, +} \ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..09d5ac8 --- /dev/null +++ b/index.ts @@ -0,0 +1,28 @@ +import argon2 from "argon2"; +import {guard} from './utilities/guard'; +import auth from "./endpoints/auth"; +import user from "./endpoints/user"; + +const server = Bun.serve({ + routes: { + "/api/auth/login": { + POST: auth.login, + }, + "/api/auth/test": { + GET: guard(auth.test, ['ADMIN', 'USERS_OTHER_DELETE']) + }, + "/api/user": { + POST: guard(user.create, ['ADMIN', 'USERS_CREATE']) + }, + "/api/user/:id": { + GET: guard(user.get, ['ADMIN', 'USERS_OTHERS_READ', 'USERS_SELF_READ']) + }, + }, + + // (optional) fallback for unmatched routes: + fetch(request: Request): Response { + return Response.json({message: "Not found"}, {status: 404}); + }, +}); + +console.log(`Server running at ${server.url}`); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1a69d11 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "bgApp", + "version": "1.0.0", + "description": "", + "main": "index.ts", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "bun build index.ts --target bun", + "dev": "bun --env-file=.env.dev run index.ts" + }, + "private": true, + "dependencies": { + "@types/jsonwebtoken": "^9.0.10", + "@types/lodash": "^4.17.23", + "argon2": "^0.44.0", + "jsonwebtoken": "^9.0.3", + "lodash": "^4.17.23" + }, + "devDependencies": { + "@types/bun": "^1.3.9" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5354af3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true + } +} diff --git a/utilities/guard.ts b/utilities/guard.ts new file mode 100644 index 0000000..559f16f --- /dev/null +++ b/utilities/guard.ts @@ -0,0 +1,25 @@ +import jwt from 'jsonwebtoken'; +import {Claims} from "./orm"; + +export function guardRedirect(method: Function, redirectMethod: Function, guardedClaims: string[] | undefined = undefined) { + try { + return guard(method, guardedClaims); + } catch (e) { + return redirectMethod(); + } +} + +export function guard(method: Function, guardedClaims: string[] | undefined = undefined) { + return (request: Request): any => { + const authHeader: string | null = request.headers.get('Authorization')?.replace(/^Bearer /, '') as string ?? null; + try { + const userClaims: Claims = jwt.verify(authHeader as string, process.env.JWT_SECRET_KEY as string) as Claims; + if (guardedClaims !== undefined && !userClaims.claims.some(x => guardedClaims.includes(x))) { + throw new Error('Unauthorized'); + } + return method(request, userClaims); + } catch (e) { + return Response.json({message: 'Authentication failed.'}, {status: 401}) + } + } +} diff --git a/utilities/orm.ts b/utilities/orm.ts new file mode 100644 index 0000000..b394ab4 --- /dev/null +++ b/utilities/orm.ts @@ -0,0 +1,121 @@ +import argon2 from "argon2"; +import {first} from "lodash"; +import {sql} from "bun"; + +export class Claims { + userId: string | undefined; + claims: string[] = []; + + public static test(userClaims: Claims, guardClaim: string): Boolean { + return userClaims.claims.some(x => x === guardClaim); + } +} + +class ClaimsOrm { + async getByUserId(userId: string): Promise { + const dbResults: any[] = await sql`SELECT c.name + from user_claims as uc + JOIN claims as c on uc.claimid = c.id + where uc.userid = ${userId};`; + const claims = new Claims(); + claims.userId = userId; + claims.claims = dbResults.map(x => x.name); + return claims; + } + + async getDefaultClaims(): Promise { + const dbResults: any[] = await sql`SELECT id + FROM claims + WHERE is_default = true;`; + return dbResults.map(x => x.id); + } +} + +class User { + id: string; + name: string; + isAdmin: boolean; + isActive: boolean; + + constructor(id: string, name: string, isAdmin: boolean = false, isActive: boolean = true) { + this.id = id; + this.name = name; + this.isAdmin = isAdmin; + this.isActive = isActive; + } +} + +class UsersOrm { + #claims: ClaimsOrm; + + constructor(claims: ClaimsOrm) { + this.#claims = claims; + } + + async get(id: string, claims: Claims): Promise { + if (!( + Claims.test(claims, 'ADMIN') || + Claims.test(claims, 'USERS_OTHER_READ') || + (Claims.test(claims, 'USERS_SELF_READ') && id === claims.userId) + )) { + throw new Error('Unauthorized'); + } + + const dbResult: any = first(await sql`select * + from users + where id = ${id} + and is_active = true limit 1`); + return new User(dbResult.id, dbResult.username, dbResult.is_admin); + } + + async verify(username: string, password: string): Promise { + try { + const dbResult: any = first(await sql`select * + from users + where username = ${username} limit 1`); + if (!await argon2.verify(dbResult.pass_hash, password)) { + return null; + } + + return this.#claims.getByUserId(dbResult.id); + } catch (error) { + console.log(error); + throw error; + } + } + + async create(username: string, password: string, claims: Claims): Promise { + const existingUser: any = first(await sql`SELECT id + FROM users + WHERE username = ${username} LIMIT 1`); + if (existingUser) { + throw new Error(`User with id ${existingUser.id} already exists`); + } + + const defaultClaims: number[] = await this.#claims.getDefaultClaims(); + const passwordHash = await argon2.hash(password); + await sql`INSERT INTO users (username, pass_hash) + VALUES (${username}, ${passwordHash})`; + const newUserId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string; + await sql.transaction(async (tx) => { + for (let i in defaultClaims) { + await tx`INSERT INTO user_claims (userid, claimid) + VALUES (${newUserId}, ${defaultClaims[i]})`; + } + }) + return await this.get(newUserId, claims); + } +} + +class Orm { + claims: ClaimsOrm; + users: UsersOrm; + + constructor() { + this.claims = new ClaimsOrm(); + this.users = new UsersOrm(this.claims); + } + +} + +export const orm = new Orm(); \ No newline at end of file