Compare commits
5 Commits
eca7405974
...
0622aa3948
| Author | SHA1 | Date | |
|---|---|---|---|
| 0622aa3948 | |||
| 8ec68b02bd | |||
| 21c778fed9 | |||
| eddba49893 | |||
| 387a9a36f3 |
2
.env.test
Normal file
2
.env.test
Normal file
@@ -0,0 +1,2 @@
|
||||
DATABASE_URL=postgres://admin:iiyama12@192.168.1.166:5432/bgAppTest
|
||||
JWT_SECRET_KEY=MySecret
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.idea/
|
||||
Dockerfile
|
||||
node_modules/
|
||||
bun.lock
|
||||
package-lock.json
|
||||
.dockerignore
|
||||
bgapp
|
||||
@@ -2,11 +2,11 @@ POST http://localhost:3000/api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "jd2",
|
||||
"username": "jd",
|
||||
"password": "Foobar"
|
||||
}
|
||||
|
||||
###
|
||||
GET http://localhost:3000/api/auth/test
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIyIiwiY2xhaW1zIjpbIlVTRVJTX1NFTEZfUkVBRCJdLCJpYXQiOjE3NzA5MzgwNzcsImV4cCI6MTc3MTAyNDQ3N30.T_LInbvYJkv1beS39TSuC3asGtbx6gO2bKnFDk52qXM
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxIiwiY2xhaW1zIjpbIkFETUlOIiwiVVNFUlNfQ1JFQVRFIiwiVVNFUlNfT1RIRVJfVVBEQVRFIiwiVVNFUlNfU0VMRl9SRUFEIiwiVVNFUlNfU0VMRl9VUERBVEUiLCJVU0VSU19PVEhFUl9SRUFEIiwiVVNFUlNfU0VMRl9ERUxFVEUiXSwiaWF0IjoxNzcxMDEyNDM5LCJleHAiOjE3NzEwOTg4Mzl9.__EHi3dO_uG1mtCVhmRqVKTkbTkOzM5Hu-4gMrIfu7I
|
||||
|
||||
@@ -9,4 +9,4 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxIiwiY
|
||||
|
||||
###
|
||||
GET http://localhost:3000/api/user/2
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIyIiwiY2xhaW1zIjpbIlVTRVJTX1NFTEZfUkVBRCJdLCJpYXQiOjE3NzA5MzE1NzYsImV4cCI6MTc3MTAxNzk3Nn0.6ITZfX3_vtAFaf6qTuX7Fk_RLomgKbeto9IWhGMdN-k
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxIiwiY2xhaW1zIjpbIkFETUlOIiwiVVNFUlNfQ1JFQVRFIiwiVVNFUlNfT1RIRVJfVVBEQVRFIiwiVVNFUlNfU0VMRl9SRUFEIiwiVVNFUlNfU0VMRl9VUERBVEUiLCJVU0VSU19PVEhFUl9SRUFEIiwiVVNFUlNfU0VMRl9ERUxFVEUiXSwiaWF0IjoxNzcxMDE5NzMzLCJleHAiOjE3NzExMDYxMzN9.V4La9Sv13M15lubtxWGESUksWldhlyG8AhiIE4zAtWo
|
||||
5
bunfig.toml
Normal file
5
bunfig.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
telemetry = false
|
||||
|
||||
[test]
|
||||
root = "tests"
|
||||
preload = ["./src/tests/test-setup.ts", "./src/tests/global-mocks.ts"]
|
||||
@@ -1,29 +0,0 @@
|
||||
import {BunRequest as Request} from "bun";
|
||||
import {Claims, orm} from "../utilities/orm.ts";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
async function login(request: Request): Promise<Response> {
|
||||
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
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import {Claims, orm} from "../utilities/orm";
|
||||
import {BunRequest as Request} from 'bun';
|
||||
|
||||
async function create (request: Request, claims: Claims): Promise<Response> {
|
||||
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<Response> {
|
||||
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,
|
||||
}
|
||||
10
package.json
10
package.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bgApp",
|
||||
"name": "bgapp",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.ts",
|
||||
"main": "src/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"
|
||||
"test": "bun --env-file=.env.test test ./src/tests/",
|
||||
"build": "bun build src/index.ts --target bun",
|
||||
"dev": "bun --env-file=.env.dev run src/index.ts"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
||||
239
scripts/dbCreate.sql
Normal file
239
scripts/dbCreate.sql
Normal file
@@ -0,0 +1,239 @@
|
||||
--
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
-- Dumped from database version 18.1 (Debian 18.1-1.pgdg13+2)
|
||||
-- Dumped by pg_dump version 18.1
|
||||
|
||||
-- Started on 2026-02-13 18:14:42 GMT
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET transaction_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
SET default_tablespace = '';
|
||||
|
||||
SET default_table_access_method = heap;
|
||||
|
||||
--
|
||||
-- TOC entry 224 (class 1259 OID 16431)
|
||||
-- Name: claims; Type: TABLE; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.claims (
|
||||
id integer NOT NULL,
|
||||
name character varying(60) NOT NULL,
|
||||
is_default boolean DEFAULT false NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.claims OWNER TO admin;
|
||||
|
||||
--
|
||||
-- TOC entry 223 (class 1259 OID 16430)
|
||||
-- Name: claims_id_seq; Type: SEQUENCE; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.claims_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER SEQUENCE public.claims_id_seq OWNER TO admin;
|
||||
|
||||
--
|
||||
-- TOC entry 3465 (class 0 OID 0)
|
||||
-- Dependencies: 223
|
||||
-- Name: claims_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.claims_id_seq OWNED BY public.claims.id;
|
||||
|
||||
|
||||
--
|
||||
-- TOC entry 225 (class 1259 OID 16439)
|
||||
-- Name: user_claims; Type: TABLE; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.user_claims (
|
||||
userid bigint,
|
||||
claimid integer
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.user_claims OWNER TO admin;
|
||||
|
||||
--
|
||||
-- TOC entry 222 (class 1259 OID 16392)
|
||||
-- Name: users; Type: TABLE; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.users (
|
||||
id bigint NOT NULL,
|
||||
username character varying(20) NOT NULL,
|
||||
pass_hash text NOT NULL,
|
||||
is_active boolean DEFAULT true CONSTRAINT users_active_not_null NOT NULL,
|
||||
is_admin boolean DEFAULT false NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.users OWNER TO admin;
|
||||
|
||||
--
|
||||
-- TOC entry 219 (class 1259 OID 16389)
|
||||
-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.users_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER SEQUENCE public.users_id_seq OWNER TO admin;
|
||||
|
||||
--
|
||||
-- TOC entry 3466 (class 0 OID 0)
|
||||
-- Dependencies: 219
|
||||
-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id;
|
||||
|
||||
|
||||
--
|
||||
-- TOC entry 221 (class 1259 OID 16391)
|
||||
-- Name: users_pass_hash_seq; Type: SEQUENCE; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.users_pass_hash_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER SEQUENCE public.users_pass_hash_seq OWNER TO admin;
|
||||
|
||||
--
|
||||
-- TOC entry 3467 (class 0 OID 0)
|
||||
-- Dependencies: 221
|
||||
-- Name: users_pass_hash_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.users_pass_hash_seq OWNED BY public.users.pass_hash;
|
||||
|
||||
|
||||
--
|
||||
-- TOC entry 220 (class 1259 OID 16390)
|
||||
-- Name: users_username_seq; Type: SEQUENCE; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.users_username_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER SEQUENCE public.users_username_seq OWNER TO admin;
|
||||
|
||||
--
|
||||
-- TOC entry 3468 (class 0 OID 0)
|
||||
-- Dependencies: 220
|
||||
-- Name: users_username_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.users_username_seq OWNED BY public.users.username;
|
||||
|
||||
|
||||
--
|
||||
-- TOC entry 3305 (class 2604 OID 16434)
|
||||
-- Name: claims id; Type: DEFAULT; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.claims ALTER COLUMN id SET DEFAULT nextval('public.claims_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- TOC entry 3300 (class 2604 OID 16395)
|
||||
-- Name: users id; Type: DEFAULT; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- TOC entry 3301 (class 2604 OID 16405)
|
||||
-- Name: users username; Type: DEFAULT; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users ALTER COLUMN username SET DEFAULT nextval('public.users_username_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- TOC entry 3302 (class 2604 OID 16411)
|
||||
-- Name: users pass_hash; Type: DEFAULT; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users ALTER COLUMN pass_hash SET DEFAULT nextval('public.users_pass_hash_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- TOC entry 3310 (class 2606 OID 16438)
|
||||
-- Name: claims claims_pkey; Type: CONSTRAINT; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.claims
|
||||
ADD CONSTRAINT claims_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- TOC entry 3308 (class 2606 OID 16403)
|
||||
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.users
|
||||
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- TOC entry 3311 (class 2606 OID 16447)
|
||||
-- Name: user_claims user_claims_claimid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.user_claims
|
||||
ADD CONSTRAINT user_claims_claimid_fkey FOREIGN KEY (claimid) REFERENCES public.claims(id);
|
||||
|
||||
|
||||
--
|
||||
-- TOC entry 3312 (class 2606 OID 16442)
|
||||
-- Name: user_claims user_claims_userid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.user_claims
|
||||
ADD CONSTRAINT user_claims_userid_fkey FOREIGN KEY (userid) REFERENCES public.users(id);
|
||||
|
||||
|
||||
-- Completed on 2026-02-13 18:14:43 GMT
|
||||
|
||||
--
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
31
src/endpoints/auth.ts
Normal file
31
src/endpoints/auth.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {orm} from "../orm/orm";
|
||||
import jwt from "jsonwebtoken";
|
||||
import {UnwrappedRequest} from "../utilities/guard";
|
||||
import {ErrorResponse} from "../utilities/responseHelper";
|
||||
import {Claims} from "../orm/claims";
|
||||
import {UnauthorizedError} from "../utilities/errors";
|
||||
|
||||
async function login(request: UnwrappedRequest): Promise<Response> {
|
||||
try {
|
||||
const requestBody = 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, claims: claims}, {status: 200});
|
||||
}
|
||||
|
||||
throw new UnauthorizedError('Invalid credentials');
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
async function test(request: UnwrappedRequest) {
|
||||
return Response.json(request.claims, {status: 200});
|
||||
}
|
||||
|
||||
export default {
|
||||
login,
|
||||
test
|
||||
};
|
||||
38
src/endpoints/user.ts
Normal file
38
src/endpoints/user.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {orm} from "../orm/orm";
|
||||
import {UnwrappedRequest} from "../utilities/guard";
|
||||
import {ErrorResponse} from "../utilities/responseHelper";
|
||||
|
||||
async function create(request: UnwrappedRequest): Promise<Response> {
|
||||
try {
|
||||
const requestBody = request.json;
|
||||
|
||||
const newUser = await orm.users.create(requestBody.username, requestBody.password, request.claims);
|
||||
if(!newUser) {
|
||||
return new Response(null,{status: 201})
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
...newUser
|
||||
},
|
||||
{status: 201}
|
||||
);
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function get(request: UnwrappedRequest): Promise<Response> {
|
||||
try {
|
||||
return Response.json({
|
||||
...(await orm.users.get(request.params.id, request.claims))
|
||||
}, {status: 200});
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
get,
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import argon2 from "argon2";
|
||||
import {guard} from './utilities/guard';
|
||||
import {unwrapMethod, 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,
|
||||
POST: unwrapMethod(auth.login),
|
||||
},
|
||||
"/api/auth/test": {
|
||||
GET: guard(auth.test, ['ADMIN', 'USERS_OTHER_DELETE'])
|
||||
@@ -20,7 +19,7 @@ const server = Bun.serve({
|
||||
},
|
||||
|
||||
// (optional) fallback for unmatched routes:
|
||||
fetch(request: Request): Response {
|
||||
fetch(): Response {
|
||||
return Response.json({message: "Not found"}, {status: 404});
|
||||
},
|
||||
});
|
||||
30
src/orm/claims.ts
Normal file
30
src/orm/claims.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export class ClaimsOrm {
|
||||
async getByUserId(userId: string): Promise<Claims> {
|
||||
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<number[]> {
|
||||
const dbResults: any[] = await sql`SELECT id
|
||||
FROM claims
|
||||
WHERE is_default = true;`;
|
||||
return dbResults.map(x => x.id);
|
||||
}
|
||||
}
|
||||
16
src/orm/orm.ts
Normal file
16
src/orm/orm.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {ClaimsOrm} from "./claims";
|
||||
import {UsersOrm} from "./user";
|
||||
|
||||
|
||||
class Orm {
|
||||
claims: ClaimsOrm;
|
||||
users: UsersOrm;
|
||||
|
||||
constructor() {
|
||||
this.claims = new ClaimsOrm();
|
||||
this.users = new UsersOrm(this.claims);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const orm = new Orm();
|
||||
@@ -1,37 +1,10 @@
|
||||
import argon2 from "argon2";
|
||||
import {first} from "lodash";
|
||||
import {Claims, ClaimsOrm} from "./claims";
|
||||
import {sql} from "bun";
|
||||
import {first} from "lodash";
|
||||
import argon2 from "argon2";
|
||||
import {BadRequestError, NotFoundError, UnauthorizedError} from "../utilities/errors";
|
||||
|
||||
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<Claims> {
|
||||
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<number[]> {
|
||||
const dbResults: any[] = await sql`SELECT id
|
||||
FROM claims
|
||||
WHERE is_default = true;`;
|
||||
return dbResults.map(x => x.id);
|
||||
}
|
||||
}
|
||||
|
||||
class User {
|
||||
export class User {
|
||||
id: string;
|
||||
name: string;
|
||||
isAdmin: boolean;
|
||||
@@ -45,7 +18,7 @@ class User {
|
||||
}
|
||||
}
|
||||
|
||||
class UsersOrm {
|
||||
export class UsersOrm {
|
||||
#claims: ClaimsOrm;
|
||||
|
||||
constructor(claims: ClaimsOrm) {
|
||||
@@ -58,13 +31,20 @@ class UsersOrm {
|
||||
Claims.test(claims, 'USERS_OTHER_READ') ||
|
||||
(Claims.test(claims, 'USERS_SELF_READ') && id === claims.userId)
|
||||
)) {
|
||||
throw new Error('Unauthorized');
|
||||
throw new
|
||||
UnauthorizedError();
|
||||
}
|
||||
|
||||
const dbResult: any = first(await sql`select *
|
||||
from users
|
||||
where id = ${id}
|
||||
and is_active = true limit 1`);
|
||||
and is_active = true
|
||||
limit 1`);
|
||||
|
||||
if(!dbResult) {
|
||||
throw new NotFoundError('No matching user exists');
|
||||
}
|
||||
|
||||
return new User(dbResult.id, dbResult.username, dbResult.is_admin);
|
||||
}
|
||||
|
||||
@@ -72,24 +52,25 @@ class UsersOrm {
|
||||
try {
|
||||
const dbResult: any = first(await sql`select *
|
||||
from users
|
||||
where username = ${username} limit 1`);
|
||||
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<User> {
|
||||
async create(username: string, password: string, claims: Claims): Promise<User | null> {
|
||||
const existingUser: any = first(await sql`SELECT id
|
||||
FROM users
|
||||
WHERE username = ${username} LIMIT 1`);
|
||||
WHERE username = ${username}
|
||||
LIMIT 1`);
|
||||
if (existingUser) {
|
||||
throw new Error(`User with id ${existingUser.id} already exists`);
|
||||
throw new BadRequestError(`User ${username} already exists`);
|
||||
}
|
||||
|
||||
const defaultClaims: number[] = await this.#claims.getDefaultClaims();
|
||||
@@ -103,19 +84,14 @@ class UsersOrm {
|
||||
VALUES (${newUserId}, ${defaultClaims[i]})`;
|
||||
}
|
||||
})
|
||||
|
||||
if (!(
|
||||
Claims.test(claims, 'ADMIN') ||
|
||||
Claims.test(claims, 'USERS_OTHER_READ')
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
2
src/tests/global-mocks.ts
Normal file
2
src/tests/global-mocks.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import {expect, test, beforeAll} from 'bun:test';
|
||||
import {sql} from "bun";
|
||||
35
src/tests/test-setup.ts
Normal file
35
src/tests/test-setup.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {beforeAll} from 'bun:test';
|
||||
import Bun from 'bun';
|
||||
import {sql} from "bun";
|
||||
|
||||
beforeAll(async () => {
|
||||
console.log(process.env.DATABASE_URL);
|
||||
const scriptFile = await Bun.file('./scripts/dbCreate.sql').text();
|
||||
|
||||
// Drop the database in preparation for rebuild
|
||||
await sql`DROP SCHEMA public CASCADE`;
|
||||
await sql`CREATE SCHEMA public`;
|
||||
|
||||
// Run DB build script
|
||||
await sql.unsafe(scriptFile);
|
||||
|
||||
// Populate initial data
|
||||
await sql`SET search_path TO showfinder,public`;
|
||||
await sql`INSERT INTO claims(name, is_default) VALUES ('ADMIN', false)`;
|
||||
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_CREATE', false)`;
|
||||
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_OTHER_UPDATE', false)`;
|
||||
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_OTHER_DELETE', true)`;
|
||||
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_SELF_READ', true)`;
|
||||
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_SELF_UPDATE', true)`;
|
||||
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_OTHER_READ', true)`;
|
||||
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_SELF_DELETE', false)`;
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
130
src/tests/user.test.ts
Normal file
130
src/tests/user.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {expect, test} from 'bun:test';
|
||||
import user from '../endpoints/user';
|
||||
import {UnwrappedRequest} from "../utilities/guard";
|
||||
import {Claims} from "../orm/claims";
|
||||
|
||||
test('Create user as admin', async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push('ADMIN');
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
json: {
|
||||
username: 'test1',
|
||||
password: 'test123',
|
||||
},
|
||||
params: {},
|
||||
});
|
||||
|
||||
const response = await user.create(request);
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toBeDefined();
|
||||
});
|
||||
|
||||
test('Create user without read access', async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push('USERS_CREATE');
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
json: {
|
||||
username: 'test2',
|
||||
password: 'test123',
|
||||
},
|
||||
params: {},
|
||||
});
|
||||
|
||||
const response = await user.create(request);
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toBeNull();
|
||||
});
|
||||
|
||||
test('Create user that already exists', async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push('USERS_CREATE');
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
json: {
|
||||
username: 'test2',
|
||||
password: 'test123',
|
||||
},
|
||||
params: {},
|
||||
});
|
||||
|
||||
const response = await user.create(request);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test('Get user', async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push('USERS_OTHER_READ');
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
params: {
|
||||
id: 1
|
||||
},
|
||||
});
|
||||
|
||||
const response = await user.get(request);
|
||||
const retrievedUser = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(retrievedUser.id).toBe('1');
|
||||
});
|
||||
|
||||
test('Get user self with only self read permission', async () => {
|
||||
const claims = new Claims();
|
||||
claims.userId = "1";
|
||||
claims.claims.push('USERS_OTHER_READ');
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
params: {
|
||||
id: 1
|
||||
},
|
||||
});
|
||||
|
||||
const response = await user.get(request);
|
||||
const retrievedUser = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(retrievedUser.id).toBe('1');
|
||||
});
|
||||
|
||||
test('Get other user without read permissions', async () => {
|
||||
const claims = new Claims();
|
||||
claims.userId = "2";
|
||||
claims.claims.push('USERS_SELF_READ');
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
params: {
|
||||
id: 1
|
||||
},
|
||||
});
|
||||
|
||||
const response = await user.get(request);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test('Get user that doesn\'t exist', async () => {
|
||||
const claims = new Claims();
|
||||
claims.claims.push('ADMIN');
|
||||
|
||||
const request = new UnwrappedRequest({
|
||||
claims,
|
||||
request: null,
|
||||
params: {
|
||||
id: 101
|
||||
},
|
||||
});
|
||||
|
||||
const response = await user.get(request);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
17
src/utilities/errors.ts
Normal file
17
src/utilities/errors.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export class BadRequestError extends Error {
|
||||
constructor(message: string | undefined = undefined) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor(message: string | undefined = undefined) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
constructor(message: string | undefined = undefined) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
58
src/utilities/guard.ts
Normal file
58
src/utilities/guard.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {BunRequest as Request} from 'bun';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import {ErrorResponse} from "./responseHelper";
|
||||
import {UnauthorizedError} from "./errors";
|
||||
import {Claims} from "../orm/claims";
|
||||
|
||||
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): (r: Request) => Promise<Response> {
|
||||
return async (request: Request): Promise<Response> => {
|
||||
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: string): boolean => guardedClaims.includes(x))) {
|
||||
throw new UnauthorizedError('Unauthorized');
|
||||
}
|
||||
return method(await unwrap(request, userClaims));
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class UnwrappedRequest {
|
||||
readonly json: any;
|
||||
readonly request: Request;
|
||||
readonly params: { [x: string]: string };
|
||||
readonly claims: Claims;
|
||||
|
||||
constructor(input: any) {
|
||||
this.json = input.json;
|
||||
this.request = input.request;
|
||||
this.claims = input.claims;
|
||||
this.params = input.params;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unwrap(request: Request, claims: Claims | null = null) {
|
||||
return new UnwrappedRequest({
|
||||
request,
|
||||
claims,
|
||||
json: request.body ? await request.json() : null,
|
||||
params: request.params,
|
||||
})
|
||||
}
|
||||
|
||||
export function unwrapMethod(methodToUnwrap: ((r: UnwrappedRequest) => Response) | ((r: UnwrappedRequest) => Promise<Response>)): (r: Request) => Promise<Response> {
|
||||
return async (request: Request) => {
|
||||
const unwrappedRequest = await unwrap(request);
|
||||
return await methodToUnwrap(unwrappedRequest);
|
||||
};
|
||||
}
|
||||
18
src/utilities/responseHelper.ts
Normal file
18
src/utilities/responseHelper.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {BadRequestError, NotFoundError, UnauthorizedError} from "./errors";
|
||||
|
||||
export class ErrorResponse extends Response {
|
||||
//@ts-ignore
|
||||
constructor(error: Error) {
|
||||
if(error instanceof BadRequestError) {
|
||||
return Response.json({message: error.message}, {status: 400});
|
||||
}
|
||||
else if(error instanceof UnauthorizedError){
|
||||
return Response.json({message: error.message}, {status: 401});
|
||||
}
|
||||
else if(error instanceof NotFoundError){
|
||||
return Response.json({message: error.message}, {status: 404});
|
||||
}
|
||||
|
||||
return Response.json({message: error.message}, {status: 500});
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user