mirror of
https://github.com/SrIzan10/hackymarket.git
synced 2026-06-06 00:56:52 +00:00
feat: authentication!!!!!
This commit is contained in:
40
prisma/migrations/20260331191207_init/migration.sql
Normal file
40
prisma/migrations/20260331191207_init/migration.sql
Normal 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;
|
||||||
29
prisma/migrations/20260331194009_more_tables/migration.sql
Normal file
29
prisma/migrations/20260331194009_more_tables/migration.sql
Normal 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");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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"
|
||||||
@@ -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
136
src/http.ts
Normal 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 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
10
src/utils/prisma.ts
Normal 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 };
|
||||||
Reference in New Issue
Block a user