From 4573557abcc0f93f45dd4c035e3c63a458fdcbbd Mon Sep 17 00:00:00 2001 From: Izan Gil <66965250+SrIzan10@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:31:16 +0200 Subject: [PATCH] feat: authentication!!!!! --- .../20260331191207_init/migration.sql | 40 ++++++ .../20260331194009_more_tables/migration.sql | 29 ++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 32 +++++ src/http.ts | 136 ++++++++++++++++++ src/index.tsx | 66 +++++++-- src/utils/prisma.ts | 10 ++ 7 files changed, 302 insertions(+), 14 deletions(-) create mode 100644 prisma/migrations/20260331191207_init/migration.sql create mode 100644 prisma/migrations/20260331194009_more_tables/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 src/http.ts create mode 100644 src/utils/prisma.ts diff --git a/prisma/migrations/20260331191207_init/migration.sql b/prisma/migrations/20260331191207_init/migration.sql new file mode 100644 index 0000000..d647b03 --- /dev/null +++ b/prisma/migrations/20260331191207_init/migration.sql @@ -0,0 +1,40 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "publicKey" TEXT NOT NULL, + "slackId" TEXT NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" SERIAL NOT NULL, + "userId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_publicKey_key" ON "User"("publicKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_slackId_key" ON "User"("slackId"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_userId_key" ON "Session"("userId"); + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260331194009_more_tables/migration.sql b/prisma/migrations/20260331194009_more_tables/migration.sql new file mode 100644 index 0000000..882c898 --- /dev/null +++ b/prisma/migrations/20260331194009_more_tables/migration.sql @@ -0,0 +1,29 @@ +/* + Warnings: + + - A unique constraint covering the columns `[hcaId]` on the table `User` will be added. If there are existing duplicate values, this will fail. + - Added the required column `hcaId` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "hcaId" TEXT NOT NULL; + +-- CreateTable +CREATE TABLE "CreateAccount" ( + "id" SERIAL NOT NULL, + "code" TEXT NOT NULL, + "publicKey" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CreateAccount_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CreateAccount_code_key" ON "CreateAccount"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "CreateAccount_publicKey_key" ON "CreateAccount"("publicKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_hcaId_key" ON "User"("hcaId"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 10c4de8..a214c87 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,3 +11,35 @@ generator client { datasource db { provider = "postgresql" } + +model User { + id Int @id @default(autoincrement()) + hcaId String @unique + publicKey String @unique + slackId String @unique + username String @unique + email String @unique + + sessions Session[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// if userid is null, the session is anonymous and will be authenticated. +model Session { + id Int @id @default(autoincrement()) + userId Int? @unique + user User? @relation(fields: [userId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model CreateAccount { + id Int @id @default(autoincrement()) + code String @unique + publicKey String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} \ No newline at end of file diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..aacffc9 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,136 @@ +import { prisma } from "./utils/prisma" + +const issuer = "https://auth.hackclub.com" +const redirectUri = "http://localhost:3001/auth/callback" +const clientId = process.env.HACKCLUB_CLIENT_ID +const clientSecret = process.env.HACKCLUB_CLIENT_SECRET + +const pendingLogins = new Map() + +function renderUsernamePage(code: string) { + return new Response(` + + +

very professional username select

+
+ + + +
+ +`, { + headers: { "content-type": "text/html; charset=utf-8" }, + }) +} + +export function startHttpServer() { + Bun.serve({ + port: 3001, + async fetch(request) { + const url = new URL(request.url) + + if (url.pathname === "/auth/ssh" && request.method === "GET") { + const code = url.searchParams.get("code") + if (!code) return new Response("missing code", { status: 400 }) + + const pending = await prisma.createAccount.findUnique({ where: { code } }) + if (!pending) return new Response("invalid link", { status: 404 }) + + return renderUsernamePage(code) + } + + if (url.pathname === "/auth/ssh" && request.method === "POST") { + const form = await request.formData() + const code = form.get("code")?.toString().trim() + const username = form.get("username")?.toString().trim() + if (!code || !username) return new Response("missing form fields", { status: 400 }) + + const pending = await prisma.createAccount.findUnique({ where: { code } }) + if (!pending) return new Response("invalid link", { status: 404 }) + + const state = crypto.randomUUID() + pendingLogins.set(state, { code, username }) + + const authUrl = new URL("/oauth/authorize", issuer) + authUrl.searchParams.set("client_id", clientId!) + authUrl.searchParams.set("redirect_uri", redirectUri) + authUrl.searchParams.set("response_type", "code") + authUrl.searchParams.set("scope", "openid email slack_id verification_status") + authUrl.searchParams.set("state", state) + + return Response.redirect(authUrl.toString()) + } + + if (url.pathname === "/auth/callback") { + const state = url.searchParams.get("state") + const code = url.searchParams.get("code") + if (!state || !code) return new Response("missing params", { status: 400 }) + + const login = pendingLogins.get(state) + if (!login) return new Response("expired", { status: 400 }) + pendingLogins.delete(state) + + const tokenRes = await fetch(`${issuer}/oauth/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + code, + grant_type: "authorization_code", + }), + }) + + const tokens = await tokenRes.json() as any + if (!tokens.access_token) return new Response("token failed", { status: 400 }) + + const meRes = await fetch(`${issuer}/api/v1/me`, { + headers: { authorization: `Bearer ${tokens.access_token}` }, + }) + + const me = await meRes.json() as any + const id = me.identity?.id + const email = me.identity?.primary_email + const slackId = me.identity?.slack_id + if (!id || !email || !slackId) return new Response("missing profile", { status: 400 }) + + const pending = await prisma.createAccount.findUnique({ where: { code: login.code } }) + if (!pending) return new Response("link used", { status: 404 }) + + const existingUsername = await prisma.user.findUnique({ where: { username: login.username } }) + if (existingUsername && existingUsername.hcaId !== id) { + return new Response("username already taken, to thru the link again and select another one", { status: 409 }) + } + + const user = await prisma.$transaction(async (tx) => { + const u = await tx.user.upsert({ + where: { hcaId: id }, + create: { + hcaId: id, + publicKey: pending.publicKey, + slackId, + username: login.username, + email, + }, + update: { + publicKey: pending.publicKey, + slackId, + username: login.username, + email, + }, + }) + await tx.createAccount.delete({ where: { id: pending.id } }) + return u + }) + + return new Response(`linked ${user.username}. reconnect now.`) + } + + return new Response("not found", { status: 404 }) + }, + }) +} diff --git a/src/index.tsx b/src/index.tsx index 21c3652..286c454 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,26 +1,69 @@ +import { randomBytes } from "crypto" import { render } from "ink" -import { Server, type PseudoTtyInfo, type WindowChangeInfo } from "ssh2" +import { Server, type PseudoTtyInfo, type ServerChannel, type WindowChangeInfo } from "ssh2" import { Robot } from "./app/App" +import { startHttpServer } from "./http" +import { prisma } from "./utils/prisma" import { asInkStdout, createInkStdin, defaultPty } from "./utils/inkStd" if (!await Bun.file('host.key').exists()) { console.log('Host key not found. Creating...') - Bun.spawn(['ssh-keygen', '-t', 'ed25519', '-f', 'host.key', '-N', '']) } +startHttpServer() + new Server({ hostKeys: [await Bun.file('host.key').text()], }, (client) => { - client.on('authentication', (ctx) => { + let authLink: string | null = null + + client.on('authentication', async (ctx) => { + if (ctx.method !== 'publickey') return ctx.reject() + + const key = `${ctx.key.algo} ${ctx.key.data.toString("base64")}` + const user = await prisma.user.findUnique({ where: { publicKey: key } }) + + if (!user) { + const code = randomBytes(24).toString("base64url") + await prisma.createAccount.upsert({ + where: { publicKey: key }, + create: { code, publicKey: key }, + update: { code }, + }) + authLink = `http://localhost:3001/auth/ssh?code=${code}` + return ctx.accept() + } + + authLink = null ctx.accept() }) - client.on('ready', () => { - console.log('Client connected') - }) + + client.on('ready', () => {}) client.on('session', (accept) => { - const session = accept(); + const session = accept() + + if (authLink) { + session.on('pty', (accept) => accept()) + session.on('shell', (accept) => { + const stream = accept() + const msg = `\r\nNo account linked. Authenticate here: ${authLink}\r\n` + stream.stderr?.write?.(msg) || stream.write(msg) + stream.exit(1) + stream.end() + client.end() + }) + session.on('exec', (accept) => { + const stream = accept() + stream.stderr?.write?.(authLink!) || stream.write(authLink!) + stream.exit(1) + stream.end() + client.end() + }) + return + } + let pty: PseudoTtyInfo | WindowChangeInfo = defaultPty session.on('pty', (accept, _reject, info) => { @@ -32,17 +75,12 @@ new Server({ accept() }) session.on('shell', (accept) => { - const stream = accept(); + const stream = accept() const stdin = createInkStdin(stream) const stdout = asInkStdout(stream.stdout, pty) const stderr = asInkStdout(stream.stderr, pty) - const app = render(, { - stdin, - stdout, - stderr, - exitOnCtrlC: true, - }) + const app = render(, { stdin, stdout, stderr, exitOnCtrlC: true }) app.waitUntilExit().finally(() => { stream.end() client.end() diff --git a/src/utils/prisma.ts b/src/utils/prisma.ts new file mode 100644 index 0000000..6f4b32b --- /dev/null +++ b/src/utils/prisma.ts @@ -0,0 +1,10 @@ +import "dotenv/config"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "../generated/prisma/client"; + +const connectionString = `${process.env.DATABASE_URL}`; + +const adapter = new PrismaPg({ connectionString }); +const prisma = new PrismaClient({ adapter }); + +export { prisma }; \ No newline at end of file