Initial
This commit is contained in:
2
.env.dev
Normal file
2
.env.dev
Normal file
@@ -0,0 +1,2 @@
|
||||
DATABASE_URL=postgres://admin:iiyama12@192.168.1.166:5432/bgApp
|
||||
JWT_SECRET_KEY=MySecret
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -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/
|
||||
12
API Tests/Auth.http
Normal file
12
API Tests/Auth.http
Normal file
@@ -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
|
||||
12
API Tests/User.http
Normal file
12
API Tests/User.http
Normal file
@@ -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
|
||||
29
endpoints/auth.ts
Normal file
29
endpoints/auth.ts
Normal file
@@ -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<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
|
||||
};
|
||||
28
endpoints/user.ts
Normal file
28
endpoints/user.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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,
|
||||
}
|
||||
28
index.ts
Normal file
28
index.ts
Normal file
@@ -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}`);
|
||||
22
package.json
Normal file
22
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
}
|
||||
25
utilities/guard.ts
Normal file
25
utilities/guard.ts
Normal file
@@ -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})
|
||||
}
|
||||
}
|
||||
}
|
||||
121
utilities/orm.ts
Normal file
121
utilities/orm.ts
Normal file
@@ -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<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 {
|
||||
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<User> {
|
||||
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<Claims | null> {
|
||||
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<User> {
|
||||
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();
|
||||
Reference in New Issue
Block a user