feat: simple auth
This commit is contained in:
parent
24aa95a796
commit
437c681379
|
@ -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": {
|
||||
|
|
9
apps/api/src/controllers/auth/guard.ts
Normal file
9
apps/api/src/controllers/auth/guard.ts
Normal 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" };
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
}),
|
||||
});
|
||||
|
|
87
apps/api/src/controllers/auth/session.ts
Normal file
87
apps/api/src/controllers/auth/session.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
6
apps/api/src/controllers/index.ts
Normal file
6
apps/api/src/controllers/index.ts
Normal 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 });
|
||||
}
|
75
apps/api/src/controllers/setup.ts
Normal file
75
apps/api/src/controllers/setup.ts
Normal 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,
|
||||
};
|
||||
});
|
|
@ -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);
|
||||
|
|
14
apps/api/src/database/models/relations.ts
Normal file
14
apps/api/src/database/models/relations.ts
Normal 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),
|
||||
}));
|
|
@ -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],
|
||||
}),
|
||||
}));
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue