feat: authentication!!!!!

This commit is contained in:
2026-03-31 23:31:16 +02:00
parent 6baa0b2822
commit 4573557abc
7 changed files with 302 additions and 14 deletions

View File

@@ -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;

View File

@@ -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");

View File

@@ -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"

View File

@@ -11,3 +11,35 @@ generator client {
datasource db { datasource db {
provider = "postgresql" 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
}

136
src/http.ts Normal file
View File

@@ -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<string, { code: string, username: string }>()
function renderUsernamePage(code: string) {
return new Response(`<!doctype html>
<html lang="en">
<body>
<h1>very professional username select</h1>
<form method="post" action="/auth/ssh">
<label>
Username
<input name="username" autocomplete="username" required />
</label>
<input type="hidden" name="code" value="${code}" />
<button type="submit">Continue</button>
</form>
</body>
</html>`, {
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 })
},
})
}

View File

@@ -1,26 +1,69 @@
import { randomBytes } from "crypto"
import { render } from "ink" 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 { Robot } from "./app/App"
import { startHttpServer } from "./http"
import { prisma } from "./utils/prisma"
import { asInkStdout, createInkStdin, defaultPty } from "./utils/inkStd" import { asInkStdout, createInkStdin, defaultPty } from "./utils/inkStd"
if (!await Bun.file('host.key').exists()) { if (!await Bun.file('host.key').exists()) {
console.log('Host key not found. Creating...') console.log('Host key not found. Creating...')
Bun.spawn(['ssh-keygen', '-t', 'ed25519', '-f', 'host.key', '-N', '']) Bun.spawn(['ssh-keygen', '-t', 'ed25519', '-f', 'host.key', '-N', ''])
} }
startHttpServer()
new Server({ new Server({
hostKeys: [await Bun.file('host.key').text()], hostKeys: [await Bun.file('host.key').text()],
}, (client) => { }, (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() ctx.accept()
}) })
client.on('ready', () => {
console.log('Client connected') client.on('ready', () => {})
})
client.on('session', (accept) => { 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 let pty: PseudoTtyInfo | WindowChangeInfo = defaultPty
session.on('pty', (accept, _reject, info) => { session.on('pty', (accept, _reject, info) => {
@@ -32,17 +75,12 @@ new Server({
accept() accept()
}) })
session.on('shell', (accept) => { session.on('shell', (accept) => {
const stream = accept(); const stream = accept()
const stdin = createInkStdin(stream) const stdin = createInkStdin(stream)
const stdout = asInkStdout(stream.stdout, pty) const stdout = asInkStdout(stream.stdout, pty)
const stderr = asInkStdout(stream.stderr, pty) const stderr = asInkStdout(stream.stderr, pty)
const app = render(<Robot />, { const app = render(<Robot />, { stdin, stdout, stderr, exitOnCtrlC: true })
stdin,
stdout,
stderr,
exitOnCtrlC: true,
})
app.waitUntilExit().finally(() => { app.waitUntilExit().finally(() => {
stream.end() stream.end()
client.end() client.end()

10
src/utils/prisma.ts Normal file
View File

@@ -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 };