diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..94e6600 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*] +end_of_line = lf +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ebfab9d --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DATABASE_URL="mysql://user:password@localhost:3306/database_name" +PRIVATE_KEY=aNywgbliberhHEre.AccEPtINRangeUTf8 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11ddd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +# Keep environment variables out of version control +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c7eb08 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# `nut` + +An authentication server. + +## How to use (standalone) + +- Copy `.env.example` to `.env` and fill it. +- `bun start` +- Reverse proxy the port, specified by `PORT` enviroment variable. `8080` by default + +## How to use (embedded) + +I should involve, no worries. \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..019cb19 Binary files /dev/null and b/bun.lockb differ diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..af05e62 --- /dev/null +++ b/main.ts @@ -0,0 +1,146 @@ +import { PrismaClient } from "@prisma/client"; +import { ErrorMessage } from "./types/shared"; +import { saltcheck } from "./utils/cipher"; +import { protocol, tokener, verify } from "./trusted"; + +const prisma = new PrismaClient(); + +const mgex = /^[a-zA-Z0-9_]{2,16}$/; + +type FutureResponse = Promise; +type JSONAble = object | boolean | number | string | null | undefined; + +class JSONResponse extends Response { + constructor(body: JSONAble, options?: ResponseInit) { + super(JSON.stringify(body), { + headers: { + "Content-Type": "application/json", + }, + ...options, + }); + } +} + +class ErrorResponse extends JSONResponse { + constructor(body: ErrorMessage, status: number, options?: ResponseInit) { + super(body, { + status, + ...options, + }); + } +} + +module api { + /* Auth */ + /** + * #### This route provide a user-token for user-related service + * > Route accept `urlencoded form` body. `POST` only + * + * `username` is a minecraft username, lowercased + * + * `password` is user's password hashed in sha256 + */ + export async function auth(req: Request): FutureResponse { + const fd = await req.formData(); + + if (!fd.has("username") || !fd.has("password")) + return new ErrorResponse( + { + success: false, + msg: "Missing body parameters", + }, + 400 + ); + const username = (fd.get("username") as string).toLowerCase(); + if (!mgex.test(username)) + return new ErrorResponse( + { + success: false, + msg: "Wrong username format?", + }, + 400 + ); + const fetch = await prisma.credential.findUnique({ + where: { + username: username, + }, + select: { + password: true, + realname: true, + }, + }); + if (fetch) { + const { password, realname } = fetch; + if (saltcheck(fd.get("password") as string, password)) { + const f = protocol.serialize(username); + return new JSONResponse({ + success: true, + msg: "Authenticated as " + realname, + token: tokener.sign(f), + }); + } else + return new ErrorResponse( + { success: false, msg: "Password check failed!", code: "fpwd" }, + 401 + ); + } else { + return new ErrorResponse( + { success: false, msg: "User not found?", code: "nusr" }, + 404 + ); + } + } + + export async function auth_verify(req: Request) { + const to = req.headers.get("Authorization")?.split(" "), + ve = to && to[0] == "Bearer" && verify(to[1]); + return ve + ? new JSONResponse({ + success: true, + msg: "Authenticated as " + ve.username, + data: ve, + }) + : new ErrorResponse( + { + success: false, + msg: "Token invalid", + }, + 401 + ); + } +} + +/* HTTP Flow */ + +class Resolver { + readonly url: URL; + readonly req: Request; + constructor(req: Request) { + this.req = req; + this.url = new URL(req.url); + } + match(matcher: string) { + const pathname = this.url.pathname; + return pathname == matcher; + } + matchMethod(method: string, matcher: string) { + return this.match(matcher) && this.req.method === method; + } +} + +Bun.serve({ + port: 8080, + fetch(req: Request) { + return new Promise((ok, panic) => { + const rs = new Resolver(req); + if (rs.matchMethod("POST", "/api/auth")) + api.auth(req).then(ok).catch(panic); + else if (rs.match("/api/auth/verify")) + api.auth_verify(req).then(ok).catch(panic); + else if (rs.match("/")) ok(new Response("Home")); + else ok(new Response("no?", { status: 404 })); + }); + }, +}); + +export {}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..6e79355 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "nut", + "proto": 1, + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/node": "^20.4.2", + "bun-types": "^0.6.14", + "prisma": "^5.0.0", + "ts-node": "^10.9.1", + "typescript": "^5.1.6" + }, + "dependencies": { + "@prisma/client": "5.0.0", + "serdebin": "^1.1.0" + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..44e6cd2 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,50 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +// Custom + +model Profile { + id Int @id @default(autoincrement()) @db.UnsignedInt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Profile info + + // Rel + credential Credential @relation(fields: [cid], references: [id]) + cid Int @unique @db.UnsignedInt +} + +model Credential { + id Int @id @default(autoincrement()) @db.UnsignedInt + username String @unique @db.Char(16) + realname String @db.Char(16) + password String @default("no") + ip String? + lastlogin BigInt? @db.UnsignedBigInt + w String @default("l") @db.Char(16) + x Float @default(0) @db.Double + y Float @default(0) @db.Double + z Float @default(0) @db.Double + v Float @default(0) @db.Double + h Float @default(0) @db.Double + regdate BigInt @default(0) @db.UnsignedBigInt + regip String? @default("::1") @db.Char(39) + email String? @db.VarChar(255) + isLogged Boolean @default(false) + hasSession Boolean @default(false) + totp String? @db.VarChar(32) + uuid String? @db.Char(32) + Profile Profile? + + @@map(name: "am") +} diff --git a/trusted.ts b/trusted.ts new file mode 100644 index 0000000..4eae4de --- /dev/null +++ b/trusted.ts @@ -0,0 +1,70 @@ +/** + * This interface allow allies in swarm who share the same private key + * in order to verify enemy hash + */ + +import { build, u } from "serdebin/helper"; +import { Deserialization, extract_str, makeFixed } from "serdebin"; +import pkgjson from "./package.json"; +import Engine from "./utils/token"; +import { trimStartPad } from "./utils/bytewise"; + +const pk = process.env.PRIVATE_KEY, + proto = pkgjson.proto; + +if (!pk) + throw "Please run with PRIVATE_KEY enviroment variable, or add it into .env file"; +const tokener = new Engine(pk); + +/** + * To keep long number remain compact, binary is used. + * + * Unfortunaltely, you have to copy the size from serialize function to deserialize function. + * There is no automation + * + * Example, current time in UNIX is 13 character, but under number bit level, it only cost 5 and 1/4 byte. + */ +export module protocol { + /** + * swarmer have anility to grant the token. + * + * however the main job upon the OAuth server + * @param username player's username + */ + export function serialize(username: string) { + return build( + // bit-pad ofuscator + false, + // protocol version + u(7, proto), + // username + makeFixed(extract_str(username), 16 * 8), + // validUntil + u(42, Date.now()) + ); + } + /** + * once data is trustable, deserialize it + * @param reader Data reader + */ + export function deserialize(reader: Deserialization) { + reader.b(); // left pad + return { + proto: reader.u(7), + username: trimStartPad(reader.s(16)), + expire: reader.u(42), + }; + } +} + +/** + * This quantum function may share across trusted NodeJS server + * @param hash hash stored in header + * @returns payloads + */ +export function verify(hash: string) { + const reader = tokener.verify(hash); + return reader && protocol.deserialize(reader); +} + +export { tokener }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4e73169 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + // add Bun type definitions + "types": ["bun-types"], + + // enable latest features + "lib": ["esnext"], + "module": "esnext", + "target": "esnext", + + // if TS 5.x+ + "moduleResolution": "bundler", + // "allowImportingTsExtensions": true, + "moduleDetection": "force", + + "jsx": "react-jsx", // support JSX + "allowJs": true, // allow importing `.js` from `.ts` + "esModuleInterop": true, // allow default imports for CommonJS modules + + // best practices + "strict": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + } +} diff --git a/types/shared.ts b/types/shared.ts new file mode 100644 index 0000000..a3249d6 --- /dev/null +++ b/types/shared.ts @@ -0,0 +1,8 @@ +export type Message = { + success: true; +}; +export type ErrorMessage = { + success: false; + msg: string; + code?: string; +}; diff --git a/utils/bytewise.ts b/utils/bytewise.ts new file mode 100644 index 0000000..3f8800a --- /dev/null +++ b/utils/bytewise.ts @@ -0,0 +1,16 @@ +export function joinu8a(...arrays: Uint8Array[]) { + let length = arrays.reduce((acc, arr) => acc + arr.length, 0); + let mergedArray = new Uint8Array(length); + let offset = 0; + arrays.forEach((arr) => { + mergedArray.set(arr, offset); + offset += arr.length; + }); + return mergedArray.buffer; +} + +export function trimStartPad(d: string) { + var i = 0; + while (d[i] == "\u0000" && i < d.length) i++; + return d.slice(i); +} diff --git a/utils/cipher.ts b/utils/cipher.ts new file mode 100644 index 0000000..510da18 --- /dev/null +++ b/utils/cipher.ts @@ -0,0 +1,8 @@ +import { sha256 } from "./digest"; + +export function saltcheck(phPwd: string, hashd: string): boolean { + // $SHA$salt$hash, where hash := sha256(sha256(password) . salt) + // somehow this thing might work + const [_, __, salt, salten] = hashd.split("$"); + return sha256(phPwd + salt) === salten; +} diff --git a/utils/digest.ts b/utils/digest.ts new file mode 100644 index 0000000..c5e2215 --- /dev/null +++ b/utils/digest.ts @@ -0,0 +1,6 @@ +export function sha256(i: string | Uint8Array | ArrayBuffer): string { + // return crypto.createHash('sha256').update(str).digest('hex'); + const hashr = new Bun.CryptoHasher("sha256") + hashr.update(i) + return hashr.digest("hex"); +} diff --git a/utils/token.ts b/utils/token.ts new file mode 100644 index 0000000..647f8d5 --- /dev/null +++ b/utils/token.ts @@ -0,0 +1,27 @@ +import { Deserialization } from "serdebin"; +import { en8, de8 } from "serdebin/helper"; +import { joinu8a } from "./bytewise"; + +export default class Engine { + private privateKey: Uint8Array; + readonly hasher = new Bun.CryptoHasher("sha256"); + constructor(pk: string) { + this.privateKey = en8(pk); + } + private mac(v: Uint8Array | string) { + if (typeof v == "string") v = en8(v); + return this.hasher + .update(joinu8a(v, this.privateKey)) + .digest("base64") + .replaceAll("=", ""); + } + sign(d: Uint8Array) { + const s = de8(d); + return btoa(s).replaceAll("=", "") + "." + this.mac(s); + } + verify(hash: string): Deserialization | null { + const [v, sign] = hash.split("."), + a = atob(v); + return sign === this.mac(a) ? new Deserialization(en8(a)) : null; + } +}