feat: simple auth

This commit is contained in:
Derock 2023-09-18 13:18:58 -04:00
parent 24aa95a796
commit 437c681379
No known key found for this signature in database
12 changed files with 296 additions and 47 deletions

View file

@ -7,12 +7,14 @@
"dev": "bun --watch src/index.ts"
},
"dependencies": {
"@elysiajs/cookie": "^0.6.1",
"@elysiajs/cookie": "latest",
"@elysiajs/swagger": "^0.6.2",
"@paralleldrive/cuid2": "^2.2.2",
"@t3-oss/env-core": "^0.6.1",
"drizzle-orm": "^0.28.6",
"elysia": "latest",
"ipaddr.js": "^2.1.0",
"otp-io": "^1.2.6",
"zod": "^3.22.2"
},
"devDependencies": {

View file

@ -0,0 +1,9 @@
import type { BaseElysia } from "../..";
type Context = Parameters<Parameters<BaseElysia["post"]>[1]>[0];
export function isSignedIn(ctx: Context) {
if (!ctx.user) {
ctx.set.status = 401;
return { error: "Unauthorized" };
}
}

View file

@ -1,24 +1,56 @@
import { Elysia, t } from "elysia";
import type { BaseElysiaContext } from "../..";
import { t } from "elysia";
import { sessionModel } from "../../database/models/session";
import { createController } from "..";
import { isSignedIn } from "./guard";
import { db } from "../../database";
import { userModel as dbUser } from "../../database/models/user";
import { eq } from "drizzle-orm";
export const authController = new Elysia<"/auth", BaseElysiaContext>({
prefix: "/auth",
})
export const authController = createController("/auth")
.post(
"/login",
async (ctx) => {
// grab the user from the db and check the hashed password
const user = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.id, ctx.body.username),
});
const [user] = await db
.select({ id: dbUser.id, password: dbUser.password })
.from(dbUser)
.where(eq(dbUser.username, ctx.body.username))
.limit(1)
.execute();
if (!user) {
// fake a slow response to prevent knowing if the user exists
// not 100% sure if this is necessary, but it can't hurt
await new Promise((resolve) => setTimeout(resolve, 50));
ctx.set.status = 401;
return { error: "Invalid username or password" };
return { error: "Invalid username or password", success: false };
}
// check the password
// validate password
if (!Bun.password.verify(ctx.body.password, user.password)) {
ctx.set.status = 401;
return { error: "Invalid username or password", success: false };
}
// create a session
const [sessionToken] = await db
.insert(sessionModel)
.values({
userId: user.id,
lastIp: ctx.ip,
})
.returning({ id: sessionModel.id })
.execute();
// set the cookie
ctx.setCookie("session", sessionToken.id, {
httpOnly: true,
sameSite: "strict",
secure: true,
});
return { success: true };
},
{
body: t.Object({
@ -27,7 +59,17 @@ export const authController = new Elysia<"/auth", BaseElysiaContext>({
}),
}
)
.get("/cookie", (...args) => {
console.log(args);
return "cookie";
.get(
"/protected",
async (ctx) => {
return { success: true };
},
{
beforeHandle: [isSignedIn],
}
)
.post("/setup-totp", async (ctx) => {}, {
body: t.Object({
password: t.String(),
}),
});

View file

@ -0,0 +1,87 @@
import cookie from "@elysiajs/cookie";
import Elysia from "elysia";
import type { ExtractContext } from "../..";
import { db } from "../../database";
import { userModel } from "../../database/models/user";
import { sessionModel } from "../../database/models/session";
import { eq } from "drizzle-orm";
import ipaddrjs from "ipaddr.js";
type CookieReturn = ReturnType<typeof cookie>;
type CookieContext = ExtractContext<CookieReturn>;
export const sessionMiddleware = new Elysia<"", CookieContext>()
.derive((ctx) => {
// TODO: cloudflare
// apparently you can't get the remote ip in bun...
// https://github.com/oven-sh/bun/issues/3540
let ip = "127.0.0.1";
// check the x-forwarded-for header since this app is behind a proxy
const realIPHeader = ctx.request.headers.get("x-forwarded-for");
// we need to select the left-most IP that is valid and non-private
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
if (realIPHeader) {
for (let ip of realIPHeader.split(",")) {
// trim whitespace
ip = ip.trim();
// and check validity
if (ipaddrjs.isValid(ip) && ipaddrjs.process(ip).range() != "private") {
ip = ipaddrjs.process(ip).toString();
break;
}
}
}
return {
ip,
};
})
.derive(async (ctx) => {
// lookup session for the user
if (!ctx.cookie.session) {
return {
user: null,
};
}
// find session and user
const [user] = await db
.select({
id: userModel.id,
username: userModel.username,
permissions: userModel.permissions,
// get the last ip from the session
lastIp: sessionModel.lastIp,
})
.from(sessionModel)
.where(eq(sessionModel.id, ctx.cookie.session))
.innerJoin(userModel, eq(userModel.id, sessionModel.userId))
.limit(1)
.execute();
// if the IP is different, we should update the session details
if (user.lastIp !== ctx.ip) {
// update the database asynchronously
(async () =>
db
.update(sessionModel)
.set({
lastIp: ctx.ip,
})
.where(eq(sessionModel.id, ctx.cookie.session))
.execute())();
}
return {
user: {
id: user.id,
username: user.username,
permissions: user.permissions,
},
};
});

View file

@ -0,0 +1,6 @@
import Elysia from "elysia";
import { BaseElysiaContext } from "..";
export function createController<T extends string = "">(prefix?: T) {
return new Elysia<T, BaseElysiaContext>({ prefix });
}

View file

@ -0,0 +1,75 @@
import { t } from "elysia";
import { createController } from ".";
import { db } from "../database";
import { userModel } from "../database/models/user";
import { sessionModel } from "../database/models/session";
import { sql } from "drizzle-orm";
export const setupController = createController("/setup")
.post(
"/",
async (ctx) => {
// if a user exists, we're already setup
if (
(
await db
.select({ count: sql<number>`count(*)` })
.from(userModel)
.limit(1)
.execute()
)[0].count > 0
) {
ctx.set.status = 400;
return { error: "Setup already completed.", success: false };
}
console.log("a");
// create the administrator user
const [adminUser] = await db
.insert(userModel)
.values({
username: ctx.body.username,
password: await Bun.password.hash(ctx.body.password),
permissions: BigInt(1),
})
.returning({ id: userModel.id })
.execute();
// and set the session
const [sessionToken] = await db
.insert(sessionModel)
.values({
userId: adminUser.id,
lastIp: ctx.ip,
})
.returning()
.execute();
// set the cookie
ctx.setCookie("session", sessionToken.id, {
httpOnly: true,
sameSite: "strict",
secure: true,
});
return { success: true };
},
{
body: t.Object({
username: t.String(),
password: t.String(),
}),
}
)
.get("/status", async () => {
return {
setupCompleted:
(
await db
.select({ count: sql<number>`count(*)` })
.from(userModel)
.limit(1)
.execute()
)[0].count > 0,
};
});

View file

@ -5,6 +5,8 @@ import { env } from "../env";
import fs from "fs/promises";
import * as session from "./models/session";
import * as user from "./models/user";
import * as relations from "./models/relations";
import Elysia from "elysia";
// build database path
const dbPath = path.join(process.cwd(), env.dbPath);
@ -26,5 +28,8 @@ const sqlite = new Database(path.join(process.cwd(), env.dbPath));
sqlite.run("pragma journal_mode = WAL");
export const db = drizzle(sqlite, {
schema: { ...user, ...session },
schema: { ...user, ...session, ...relations },
});
// plugin to decorate `ctx.db` with the database
export const database = new Elysia().state("db", db);

View file

@ -0,0 +1,14 @@
import { relations } from "drizzle-orm";
import { sessionModel } from "./session";
import { userModel } from "./user";
export const sessionRelations = relations(sessionModel, ({ one }) => ({
user: one(userModel, {
fields: [sessionModel.userId],
references: [userModel.id],
}),
}));
export const userRelations = relations(sessionModel, ({ many }) => ({
sessions: many(sessionModel),
}));

View file

@ -1,19 +1,11 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { createSessionId } from "../../utils/crypto";
import { relations } from "drizzle-orm";
import { user } from "./user";
export const session = sqliteTable("session", {
export const sessionModel = sqliteTable("session", {
id: text("id")
.primaryKey()
.$defaultFn(() => createSessionId()),
userId: text("user_id").notNull().unique(),
userId: text("user_id").notNull(),
lastIp: text("last_ip").notNull(),
});
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));

View file

@ -1,21 +1,24 @@
import { blob, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { blob, index, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { createId } from "@paralleldrive/cuid2";
import { relations } from "drizzle-orm";
import { session } from "./session";
export const user = sqliteTable("user", {
id: text("id", { length: 128 })
.primaryKey()
.$defaultFn(() => createId()),
export const userModel = sqliteTable(
"user",
{
id: text("id", { length: 128 })
.primaryKey()
.$defaultFn(() => createId()),
username: text("username").notNull(),
password: text("password").notNull(),
username: text("username").notNull().unique(),
password: text("password").notNull(),
mfaSecret: text("mfaSecret"),
// incase we need a larger number in the future, use a blob that can hold a bigint
// using $defaultFn otherwise drizzle will try to JSON.stringify the bigint during migrations
permissions: blob("permissions", { mode: "bigint" }).$defaultFn(() => 0n),
});
export const userRelations = relations(session, ({ many }) => ({
sessions: many(session),
}));
// incase we need a larger number in the future, use a blob that can hold a bigint
// using $defaultFn otherwise drizzle will try to JSON.stringify the bigint during migrations
permissions: blob("permissions", { mode: "bigint" }).$defaultFn(() => 0n),
},
(table) => {
return {
usernameIdx: index("username_idx").on(table.username),
};
}
);

View file

@ -3,17 +3,31 @@ import { cookie } from "@elysiajs/cookie";
import { authController } from "./controllers/auth";
import { env } from "./env";
import { prepDatabase } from "./database/migrate";
import { database } from "./database";
import { sessionMiddleware } from "./controllers/auth/session";
import { setupController } from "./controllers/setup";
import swagger from "@elysiajs/swagger";
// start by prepping the database
prepDatabase();
// base elysia app with all the plugins
const baseApp = new Elysia().use(cookie());
type ExtractContext<T> = T extends Elysia<infer _U, infer V> ? V : never;
export type BaseElysiaContext = ExtractContext<typeof baseApp>;
const baseApp = new Elysia()
.use(cookie())
.use(database)
.use(sessionMiddleware)
.use(swagger())
.onError(({ code, error, ...ctx }) => {
console.error(`error ${code} in ${ctx.request}: `, error);
return new Response(error.toString());
});
export type ExtractContext<T> = T extends Elysia<infer _U, infer V> ? V : never;
export type BaseElysia = typeof baseApp;
export type BaseElysiaContext = ExtractContext<BaseElysia>;
// add in our routes
const withRoutes = baseApp.use(authController);
const withRoutes = baseApp.use(authController).use(setupController);
export type ElysiaWithRoutes = typeof withRoutes;
// if this is the main module, start the server

BIN
bun.lockb

Binary file not shown.