commit 87f6f46d3c586631c16b365cddd548dbc4daf061 Author: chyoo Date: Wed Jul 1 17:36:34 2026 +0900 init studio auth backend diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9161f66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +.env +dist +*.log +.env.* +coverage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0b1d168 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM oven/bun:1.2 + +WORKDIR /app + +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile || bun install + +COPY prisma ./prisma +RUN bunx prisma generate + +COPY src ./src + +EXPOSE 4100 +CMD ["bun", "src/index.ts"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..07999de --- /dev/null +++ b/README.md @@ -0,0 +1,171 @@ +# ATNMY STDO Auth + +Central JWT auth server for Autonomy Studio and launched apps. + +## Stack + +- Bun +- Hono +- PostgreSQL +- Redis +- Prisma +- JWT access tokens +- Opaque refresh tokens stored in Redis +- One-time handoff tokens for app SSO stored in Redis + +## Local Run + +```bash +cp .env.example .env +docker compose up -d postgres redis +bun install +bun run db:generate +bun run db:migrate +bun run db:seed +bun run dev +``` + +Default server: + +```text +http://localhost:4100 +``` + +Default backing services: + +```text +PostgreSQL: localhost:5434 +Redis: localhost:6380 +``` + +Default seeded admin: + +```text +admin@futuredrive.net / admin1234 +``` + +Change this before using the service outside local development. + +## API + +### Login + +```http +POST /auth/login +Content-Type: application/json + +{ + "email": "admin@futuredrive.net", + "password": "admin1234" +} +``` + +Returns: + +```json +{ + "accessToken": "...", + "accessTokenExpiresIn": 900, + "refreshToken": "...", + "refreshTokenExpiresIn": 2592000, + "user": { + "id": "...", + "email": "admin@futuredrive.net", + "name": "ATNMY Studio Admin", + "role": "ADMIN", + "roles": ["ADMIN"] + } +} +``` + +### Current User + +```http +GET /auth/me +Authorization: Bearer +``` + +### Refresh + +```http +POST /auth/refresh +Content-Type: application/json + +{ + "refreshToken": "" +} +``` + +Refresh tokens are rotated on every use. + +### Logout + +```http +POST /auth/logout +Content-Type: application/json + +{ + "refreshToken": "" +} +``` + +### Create App Handoff Token + +Launcher calls this before launching ADS IDE or another app. + +```http +POST /auth/handoff +Authorization: Bearer +Content-Type: application/json + +{ + "audienceApp": "ADS IDE" +} +``` + +Returns a short-lived one-time token: + +```json +{ + "handoffToken": "...", + "expiresIn": 60, + "audienceApp": "ADS IDE" +} +``` + +### Exchange App Handoff Token + +The launched app calls this on startup. + +```http +POST /auth/handoff/exchange +Content-Type: application/json + +{ + "handoffToken": "...", + "audienceApp": "ADS IDE" +} +``` + +Returns the launched app's own access/refresh token pair. + +## Launcher Integration Sketch + +```ts +const handoff = await fetch(`${AUTH_URL}/auth/handoff`, { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ audienceApp: 'ADS IDE' }) +}).then((r) => r.json()) + +spawn(execBin, [ + `--url=${encodeURIComponent(url)}`, + `--handoff-token=${encodeURIComponent(handoff.handoffToken)}`, + `--auth-url=${encodeURIComponent(AUTH_URL)}` +]) +``` + +ADS IDE then calls `/auth/handoff/exchange` and stores the returned session. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..13b6005 --- /dev/null +++ b/bun.lock @@ -0,0 +1,116 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "atnmy-stdo-auth", + "dependencies": { + "@hono/node-server": "^1.14.4", + "@prisma/client": "^6.9.0", + "@types/nodemailer": "^8.0.0", + "hono": "^4.7.11", + "jose": "^6.0.11", + "nodemailer": "^8.0.10", + "zod": "^3.25.48", + }, + "devDependencies": { + "@types/bun": "^1.2.15", + "prisma": "^6.9.0", + "typescript": "^5.8.3", + }, + }, + }, + "packages": { + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@prisma/client": ["@prisma/client@6.19.3", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg=="], + + "@prisma/config": ["@prisma/config@6.19.3", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.21.0", "empathic": "2.0.0" } }, "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ=="], + + "@prisma/debug": ["@prisma/debug@6.19.3", "", {}, "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw=="], + + "@prisma/engines": ["@prisma/engines@6.19.3", "", { "dependencies": { "@prisma/debug": "6.19.3", "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "@prisma/fetch-engine": "6.19.3", "@prisma/get-platform": "6.19.3" } }, "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g=="], + + "@prisma/engines-version": ["@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "", {}, "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@6.19.3", "", { "dependencies": { "@prisma/debug": "6.19.3", "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "@prisma/get-platform": "6.19.3" } }, "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA=="], + + "@prisma/get-platform": ["@prisma/get-platform@6.19.3", "", { "dependencies": { "@prisma/debug": "6.19.3" } }, "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "@types/nodemailer": ["@types/nodemailer@8.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "effect": ["effect@3.21.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ=="], + + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + + "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], + + "hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "nodemailer": ["nodemailer@8.0.10", "", {}, "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ=="], + + "nypm": ["nypm@0.6.6", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + + "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + + "prisma": ["prisma@6.19.3", "", { "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg=="], + + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f3468fb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + postgres: + image: postgres:17-alpine + container_name: atnmy-stdo-auth-postgres + command: postgres -c timezone=Asia/Seoul + environment: + POSTGRES_DB: atnmy_auth + POSTGRES_USER: atnmy_auth + POSTGRES_PASSWORD: future1234!@ + ports: + - "5434:5432" + volumes: + - atnmy-auth-postgres-17:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U atnmy_auth -d atnmy_auth"] + interval: 5s + timeout: 3s + retries: 20 + + redis: + image: redis:7-alpine + container_name: atnmy-stdo-auth-redis + command: ["redis-server", "--appendonly", "yes"] + ports: + - "6380:6379" + volumes: + - atnmy-auth-redis:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + +volumes: + atnmy-auth-postgres-17: + atnmy-auth-redis: diff --git a/package.json b/package.json new file mode 100644 index 0000000..1976901 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "atnmy-stdo-auth", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --hot src/index.ts", + "start": "bun src/index.ts", + "typecheck": "tsc --noEmit", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:deploy": "prisma migrate deploy", + "db:seed": "bun prisma/seed.ts" + }, + "dependencies": { + "@hono/node-server": "^1.14.4", + "@prisma/client": "^6.9.0", + "@types/nodemailer": "^8.0.0", + "hono": "^4.7.11", + "jose": "^6.0.11", + "nodemailer": "^8.0.10", + "zod": "^3.25.48" + }, + "devDependencies": { + "@types/bun": "^1.2.15", + "prisma": "^6.9.0", + "typescript": "^5.8.3" + }, + "prisma": { + "seed": "bun prisma/seed.ts" + } +} diff --git a/prisma/migrations/20260601000000_init_auth/migration.sql b/prisma/migrations/20260601000000_init_auth/migration.sql new file mode 100644 index 0000000..ec917c9 --- /dev/null +++ b/prisma/migrations/20260601000000_init_auth/migration.sql @@ -0,0 +1,47 @@ +CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER'); + +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password_hash" TEXT NOT NULL, + "name" TEXT, + "role" "UserRole" NOT NULL DEFAULT 'USER', + "is_active" BOOLEAN NOT NULL DEFAULT true, + "last_login_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "refresh_tokens" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "revoked_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "handoff_tokens" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "audience_app" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "used_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "handoff_tokens_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); +CREATE UNIQUE INDEX "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash"); +CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id"); +CREATE UNIQUE INDEX "handoff_tokens_token_hash_key" ON "handoff_tokens"("token_hash"); +CREATE INDEX "handoff_tokens_user_id_idx" ON "handoff_tokens"("user_id"); + +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "handoff_tokens" ADD CONSTRAINT "handoff_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260601030000_add_login_notices/migration.sql b/prisma/migrations/20260601030000_add_login_notices/migration.sql new file mode 100644 index 0000000..98668aa --- /dev/null +++ b/prisma/migrations/20260601030000_add_login_notices/migration.sql @@ -0,0 +1,16 @@ +CREATE TABLE "login_notices" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "link_url" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "starts_at" TIMESTAMP(3), + "ends_at" TIMESTAMP(3), + "priority" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "login_notices_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "login_notices_is_active_priority_idx" ON "login_notices"("is_active", "priority"); diff --git a/prisma/migrations/20260602020000_add_user_affiliations/migration.sql b/prisma/migrations/20260602020000_add_user_affiliations/migration.sql new file mode 100644 index 0000000..49630a0 --- /dev/null +++ b/prisma/migrations/20260602020000_add_user_affiliations/migration.sql @@ -0,0 +1,49 @@ +-- CreateEnum +CREATE TYPE "OrganizationType" AS ENUM ('COMPANY', 'SCHOOL', 'GOVERNMENT', 'LAB', 'OTHER'); + +-- CreateTable +CREATE TABLE "organizations" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "type" "OrganizationType" NOT NULL DEFAULT 'OTHER', + "domain" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "organizations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_affiliations" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "organization_id" TEXT NOT NULL, + "department" TEXT, + "position" TEXT, + "is_primary" BOOLEAN NOT NULL DEFAULT false, + "starts_at" TIMESTAMP(3), + "ends_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_affiliations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "organizations_name_type_key" ON "organizations"("name", "type"); + +-- CreateIndex +CREATE INDEX "organizations_is_active_type_idx" ON "organizations"("is_active", "type"); + +-- CreateIndex +CREATE INDEX "user_affiliations_user_id_is_primary_idx" ON "user_affiliations"("user_id", "is_primary"); + +-- CreateIndex +CREATE INDEX "user_affiliations_organization_id_idx" ON "user_affiliations"("organization_id"); + +-- AddForeignKey +ALTER TABLE "user_affiliations" ADD CONSTRAINT "user_affiliations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_affiliations" ADD CONSTRAINT "user_affiliations_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260602021000_add_user_affiliation_comments/migration.sql b/prisma/migrations/20260602021000_add_user_affiliation_comments/migration.sql new file mode 100644 index 0000000..e6d27b7 --- /dev/null +++ b/prisma/migrations/20260602021000_add_user_affiliation_comments/migration.sql @@ -0,0 +1,22 @@ +COMMENT ON TYPE "OrganizationType" IS '사용자 소속 유형. 회사, 학교, 공공기관, 연구소, 기타를 구분한다.'; + +COMMENT ON TABLE "organizations" IS '사용자에게 부여할 수 있는 소속 마스터. 회사, 학교, 기관, 연구소 등을 통합 관리한다.'; +COMMENT ON COLUMN "organizations"."id" IS '소속 고유 ID'; +COMMENT ON COLUMN "organizations"."name" IS '소속명'; +COMMENT ON COLUMN "organizations"."type" IS '소속 유형'; +COMMENT ON COLUMN "organizations"."domain" IS '소속 이메일/웹 도메인. 선택 입력값'; +COMMENT ON COLUMN "organizations"."is_active" IS '소속 사용 여부'; +COMMENT ON COLUMN "organizations"."created_at" IS '소속 생성 일시'; +COMMENT ON COLUMN "organizations"."updated_at" IS '소속 수정 일시'; + +COMMENT ON TABLE "user_affiliations" IS '사용자와 소속의 연결 정보. 사용자 1명은 여러 소속을 가질 수 있다.'; +COMMENT ON COLUMN "user_affiliations"."id" IS '사용자 소속 연결 고유 ID'; +COMMENT ON COLUMN "user_affiliations"."user_id" IS '사용자 ID'; +COMMENT ON COLUMN "user_affiliations"."organization_id" IS '소속 ID'; +COMMENT ON COLUMN "user_affiliations"."department" IS '부서, 학과, 조직 단위명'; +COMMENT ON COLUMN "user_affiliations"."position" IS '직책, 직급, 역할명'; +COMMENT ON COLUMN "user_affiliations"."is_primary" IS '대표 소속 여부'; +COMMENT ON COLUMN "user_affiliations"."starts_at" IS '소속 시작 일시'; +COMMENT ON COLUMN "user_affiliations"."ends_at" IS '소속 종료 일시. NULL이면 현재 유효한 소속'; +COMMENT ON COLUMN "user_affiliations"."created_at" IS '사용자 소속 연결 생성 일시'; +COMMENT ON COLUMN "user_affiliations"."updated_at" IS '사용자 소속 연결 수정 일시'; diff --git a/prisma/migrations/20260602022000_add_existing_table_comments/migration.sql b/prisma/migrations/20260602022000_add_existing_table_comments/migration.sql new file mode 100644 index 0000000..1729e45 --- /dev/null +++ b/prisma/migrations/20260602022000_add_existing_table_comments/migration.sql @@ -0,0 +1,41 @@ +COMMENT ON TYPE "UserRole" IS 'auth 사용자 권한. 관리자와 일반 사용자를 구분한다.'; + +COMMENT ON TABLE "users" IS '중앙 인증 사용자 계정'; +COMMENT ON COLUMN "users"."id" IS '사용자 고유 ID'; +COMMENT ON COLUMN "users"."email" IS '로그인 이메일'; +COMMENT ON COLUMN "users"."password_hash" IS '해시 처리된 비밀번호'; +COMMENT ON COLUMN "users"."name" IS '사용자 이름'; +COMMENT ON COLUMN "users"."role" IS '사용자 권한'; +COMMENT ON COLUMN "users"."is_active" IS '계정 활성 여부'; +COMMENT ON COLUMN "users"."last_login_at" IS '마지막 로그인 일시'; +COMMENT ON COLUMN "users"."created_at" IS '계정 생성 일시'; +COMMENT ON COLUMN "users"."updated_at" IS '계정 수정 일시'; + +COMMENT ON TABLE "refresh_tokens" IS '로그인 세션 유지를 위한 refresh token 저장소'; +COMMENT ON COLUMN "refresh_tokens"."id" IS 'refresh token 레코드 고유 ID'; +COMMENT ON COLUMN "refresh_tokens"."user_id" IS '사용자 ID'; +COMMENT ON COLUMN "refresh_tokens"."token_hash" IS 'refresh token 해시값'; +COMMENT ON COLUMN "refresh_tokens"."expires_at" IS 'refresh token 만료 일시'; +COMMENT ON COLUMN "refresh_tokens"."revoked_at" IS 'refresh token 폐기 일시'; +COMMENT ON COLUMN "refresh_tokens"."created_at" IS 'refresh token 생성 일시'; + +COMMENT ON TABLE "handoff_tokens" IS '런처에서 개별 앱으로 인증 상태를 전달하기 위한 일회성 토큰'; +COMMENT ON COLUMN "handoff_tokens"."id" IS 'handoff token 레코드 고유 ID'; +COMMENT ON COLUMN "handoff_tokens"."user_id" IS '사용자 ID'; +COMMENT ON COLUMN "handoff_tokens"."token_hash" IS 'handoff token 해시값'; +COMMENT ON COLUMN "handoff_tokens"."audience_app" IS '토큰을 사용할 대상 앱 식별자'; +COMMENT ON COLUMN "handoff_tokens"."expires_at" IS 'handoff token 만료 일시'; +COMMENT ON COLUMN "handoff_tokens"."used_at" IS 'handoff token 사용 일시'; +COMMENT ON COLUMN "handoff_tokens"."created_at" IS 'handoff token 생성 일시'; + +COMMENT ON TABLE "login_notices" IS '런처 로그인 화면에 노출할 공지사항'; +COMMENT ON COLUMN "login_notices"."id" IS '로그인 공지 고유 ID'; +COMMENT ON COLUMN "login_notices"."title" IS '공지 제목'; +COMMENT ON COLUMN "login_notices"."body" IS '공지 본문'; +COMMENT ON COLUMN "login_notices"."link_url" IS '자세히보기 링크 URL'; +COMMENT ON COLUMN "login_notices"."is_active" IS '공지 활성 여부'; +COMMENT ON COLUMN "login_notices"."starts_at" IS '공지 노출 시작 일시'; +COMMENT ON COLUMN "login_notices"."ends_at" IS '공지 노출 종료 일시'; +COMMENT ON COLUMN "login_notices"."priority" IS '공지 우선순위. 값이 높을수록 우선 노출'; +COMMENT ON COLUMN "login_notices"."created_at" IS '공지 생성 일시'; +COMMENT ON COLUMN "login_notices"."updated_at" IS '공지 수정 일시'; diff --git a/prisma/migrations/20260602023000_seed_dummy_organizations/migration.sql b/prisma/migrations/20260602023000_seed_dummy_organizations/migration.sql new file mode 100644 index 0000000..2458680 --- /dev/null +++ b/prisma/migrations/20260602023000_seed_dummy_organizations/migration.sql @@ -0,0 +1,56 @@ +INSERT INTO "organizations" ("id", "name", "type", "domain", "is_active", "created_at", "updated_at") +VALUES + ('11111111-1111-4111-8111-111111111111', '퓨처드라이브', 'COMPANY', 'futuredrive.net', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('22222222-2222-4222-8222-222222222222', '영남대학교', 'SCHOOL', 'yu.ac.kr', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT ("name", "type") DO UPDATE +SET + "domain" = EXCLUDED."domain", + "is_active" = true, + "updated_at" = CURRENT_TIMESTAMP; + +UPDATE "user_affiliations" +SET "is_primary" = false, + "updated_at" = CURRENT_TIMESTAMP +WHERE "ends_at" IS NULL; + +UPDATE "user_affiliations" +SET "is_primary" = true, + "updated_at" = CURRENT_TIMESTAMP +WHERE "organization_id" = '11111111-1111-4111-8111-111111111111' + AND "ends_at" IS NULL; + +INSERT INTO "user_affiliations" ( + "id", + "user_id", + "organization_id", + "department", + "position", + "is_primary", + "starts_at", + "created_at", + "updated_at" +) +SELECT + lower( + substr(md5("users"."id" || ':futuredrive'), 1, 8) || '-' || + substr(md5("users"."id" || ':futuredrive'), 9, 4) || '-4' || + substr(md5("users"."id" || ':futuredrive'), 14, 3) || '-8' || + substr(md5("users"."id" || ':futuredrive'), 18, 3) || '-' || + substr(md5("users"."id" || ':futuredrive'), 21, 12) + ), + "users"."id", + '11111111-1111-4111-8111-111111111111', + NULL, + NULL, + true, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM "users" +WHERE NOT EXISTS ( + SELECT 1 + FROM "user_affiliations" + WHERE "user_affiliations"."user_id" = "users"."id" + AND "user_affiliations"."organization_id" = '11111111-1111-4111-8111-111111111111' + AND "user_affiliations"."ends_at" IS NULL +); diff --git a/prisma/migrations/20260602024000_randomize_dummy_organization_ids/migration.sql b/prisma/migrations/20260602024000_randomize_dummy_organization_ids/migration.sql new file mode 100644 index 0000000..c4af8a3 --- /dev/null +++ b/prisma/migrations/20260602024000_randomize_dummy_organization_ids/migration.sql @@ -0,0 +1,9 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +UPDATE "organizations" +SET "id" = gen_random_uuid()::text, + "updated_at" = CURRENT_TIMESTAMP +WHERE "id" IN ( + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222' +); diff --git a/prisma/migrations/20260602025000_convert_ids_to_uuid/migration.sql b/prisma/migrations/20260602025000_convert_ids_to_uuid/migration.sql new file mode 100644 index 0000000..937243c --- /dev/null +++ b/prisma/migrations/20260602025000_convert_ids_to_uuid/migration.sql @@ -0,0 +1,57 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +UPDATE "users" +SET "id" = gen_random_uuid()::text +WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'; + +UPDATE "organizations" +SET "id" = gen_random_uuid()::text +WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'; + +UPDATE "refresh_tokens" +SET "id" = gen_random_uuid()::text +WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'; + +UPDATE "handoff_tokens" +SET "id" = gen_random_uuid()::text +WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'; + +UPDATE "user_affiliations" +SET "id" = gen_random_uuid()::text +WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'; + +UPDATE "login_notices" +SET "id" = gen_random_uuid()::text +WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'; + +ALTER TABLE "refresh_tokens" DROP CONSTRAINT "refresh_tokens_user_id_fkey"; +ALTER TABLE "handoff_tokens" DROP CONSTRAINT "handoff_tokens_user_id_fkey"; +ALTER TABLE "user_affiliations" DROP CONSTRAINT "user_affiliations_user_id_fkey"; +ALTER TABLE "user_affiliations" DROP CONSTRAINT "user_affiliations_organization_id_fkey"; + +ALTER TABLE "users" + ALTER COLUMN "id" TYPE uuid USING "id"::uuid; + +ALTER TABLE "organizations" + ALTER COLUMN "id" TYPE uuid USING "id"::uuid; + +ALTER TABLE "refresh_tokens" + ALTER COLUMN "id" TYPE uuid USING "id"::uuid, + ALTER COLUMN "user_id" TYPE uuid USING "user_id"::uuid; + +ALTER TABLE "handoff_tokens" + ALTER COLUMN "id" TYPE uuid USING "id"::uuid, + ALTER COLUMN "user_id" TYPE uuid USING "user_id"::uuid; + +ALTER TABLE "user_affiliations" + ALTER COLUMN "id" TYPE uuid USING "id"::uuid, + ALTER COLUMN "user_id" TYPE uuid USING "user_id"::uuid, + ALTER COLUMN "organization_id" TYPE uuid USING "organization_id"::uuid; + +ALTER TABLE "login_notices" + ALTER COLUMN "id" TYPE uuid USING "id"::uuid; + +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "handoff_tokens" ADD CONSTRAINT "handoff_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "user_affiliations" ADD CONSTRAINT "user_affiliations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "user_affiliations" ADD CONSTRAINT "user_affiliations_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260602033000_add_user_licenses/migration.sql b/prisma/migrations/20260602033000_add_user_licenses/migration.sql new file mode 100644 index 0000000..c6f7c42 --- /dev/null +++ b/prisma/migrations/20260602033000_add_user_licenses/migration.sql @@ -0,0 +1,68 @@ +CREATE TYPE "LicenseCode" AS ENUM ('FREE', 'EDUCATION', 'PROFESSIONAL'); + +CREATE TABLE "licenses" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "code" "LicenseCode" NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "licenses_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "user_licenses" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "license_id" UUID NOT NULL, + "starts_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3), + "assigned_by" UUID, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_licenses_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "licenses_code_key" ON "licenses"("code"); +CREATE INDEX "licenses_is_active_idx" ON "licenses"("is_active"); +CREATE INDEX "user_licenses_user_id_starts_at_idx" ON "user_licenses"("user_id", "starts_at"); +CREATE INDEX "user_licenses_license_id_idx" ON "user_licenses"("license_id"); + +ALTER TABLE "user_licenses" ADD CONSTRAINT "user_licenses_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "user_licenses" ADD CONSTRAINT "user_licenses_license_id_fkey" FOREIGN KEY ("license_id") REFERENCES "licenses"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +INSERT INTO "licenses" ("code", "name", "description", "updated_at") +VALUES + ('FREE', 'Free', '기본 무료 라이선스', CURRENT_TIMESTAMP), + ('EDUCATION', 'Education', '교육기관용 라이선스', CURRENT_TIMESTAMP), + ('PROFESSIONAL', 'Professional', '전문가용 라이선스', CURRENT_TIMESTAMP); + +INSERT INTO "user_licenses" ("user_id", "license_id", "starts_at") +SELECT u."id", l."id", CURRENT_TIMESTAMP +FROM "users" u +CROSS JOIN "licenses" l +WHERE l."code" = 'PROFESSIONAL' + AND NOT EXISTS ( + SELECT 1 + FROM "user_licenses" ul + WHERE ul."user_id" = u."id" + ); + +COMMENT ON TYPE "LicenseCode" IS '라이선스 코드. 무료, 교육용, 전문가용 플랜을 구분한다.'; +COMMENT ON TABLE "licenses" IS 'Autonomy Studio 라이선스 마스터. 플랜 코드와 표시명을 관리한다.'; +COMMENT ON COLUMN "licenses"."id" IS '라이선스 UUID.'; +COMMENT ON COLUMN "licenses"."code" IS '라이선스 코드.'; +COMMENT ON COLUMN "licenses"."name" IS '화면에 표시할 라이선스명.'; +COMMENT ON COLUMN "licenses"."description" IS '라이선스 설명.'; +COMMENT ON COLUMN "licenses"."is_active" IS '라이선스 사용 가능 여부.'; +COMMENT ON COLUMN "licenses"."created_at" IS '라이선스 생성 시각.'; +COMMENT ON COLUMN "licenses"."updated_at" IS '라이선스 수정 시각.'; +COMMENT ON TABLE "user_licenses" IS '사용자별 라이선스 부여 이력. 현재 유효 라이선스와 기간을 관리한다.'; +COMMENT ON COLUMN "user_licenses"."id" IS '사용자 라이선스 UUID.'; +COMMENT ON COLUMN "user_licenses"."user_id" IS '라이선스를 부여받은 사용자 UUID.'; +COMMENT ON COLUMN "user_licenses"."license_id" IS '부여된 라이선스 UUID.'; +COMMENT ON COLUMN "user_licenses"."starts_at" IS '라이선스 시작 시각.'; +COMMENT ON COLUMN "user_licenses"."expires_at" IS '라이선스 만료 시각. NULL이면 무기한.'; +COMMENT ON COLUMN "user_licenses"."assigned_by" IS '라이선스를 부여한 관리자 UUID.'; +COMMENT ON COLUMN "user_licenses"."created_at" IS '라이선스 부여 이력 생성 시각.'; diff --git a/prisma/migrations/20260602034000_add_user_profile_image/migration.sql b/prisma/migrations/20260602034000_add_user_profile_image/migration.sql new file mode 100644 index 0000000..84a34db --- /dev/null +++ b/prisma/migrations/20260602034000_add_user_profile_image/migration.sql @@ -0,0 +1,8 @@ +ALTER TABLE "users" +ADD COLUMN "profile_image_data" BYTEA, +ADD COLUMN "profile_image_mime" TEXT, +ADD COLUMN "profile_image_updated_at" TIMESTAMP(3); + +COMMENT ON COLUMN "users"."profile_image_data" IS '사용자 프로필 썸네일 이미지 바이너리. 128px WebP 압축본만 저장한다.'; +COMMENT ON COLUMN "users"."profile_image_mime" IS '프로필 썸네일 MIME 타입.'; +COMMENT ON COLUMN "users"."profile_image_updated_at" IS '프로필 썸네일 마지막 수정 시각.'; diff --git a/prisma/migrations/20260604090000_use_timestamptz/migration.sql b/prisma/migrations/20260604090000_use_timestamptz/migration.sql new file mode 100644 index 0000000..4bac5a4 --- /dev/null +++ b/prisma/migrations/20260604090000_use_timestamptz/migration.sql @@ -0,0 +1,19 @@ +DO $$ +DECLARE + column_record record; +BEGIN + FOR column_record IN + SELECT table_schema, table_name, column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND data_type = 'timestamp without time zone' + LOOP + EXECUTE format( + 'ALTER TABLE %I.%I ALTER COLUMN %I TYPE timestamptz USING %I AT TIME ZONE ''UTC''', + column_record.table_schema, + column_record.table_name, + column_record.column_name, + column_record.column_name + ); + END LOOP; +END $$; diff --git a/prisma/migrations/20260604100000_add_license_default_duration/migration.sql b/prisma/migrations/20260604100000_add_license_default_duration/migration.sql new file mode 100644 index 0000000..ceb1743 --- /dev/null +++ b/prisma/migrations/20260604100000_add_license_default_duration/migration.sql @@ -0,0 +1,12 @@ +ALTER TABLE "licenses" +ADD COLUMN "default_duration_days" INTEGER DEFAULT 365; + +UPDATE "licenses" +SET "default_duration_days" = 365 +WHERE "default_duration_days" IS NULL; + +UPDATE "user_licenses" +SET "expires_at" = "starts_at" + INTERVAL '365 days' +WHERE "expires_at" IS NULL; + +COMMENT ON COLUMN "licenses"."default_duration_days" IS '사용자에게 라이선스를 부여할 때 기본 적용할 기간(일). NULL이면 무기한.'; diff --git a/prisma/migrations/20260610090000_add_user_app_roles/migration.sql b/prisma/migrations/20260610090000_add_user_app_roles/migration.sql new file mode 100644 index 0000000..13d46ae --- /dev/null +++ b/prisma/migrations/20260610090000_add_user_app_roles/migration.sql @@ -0,0 +1,40 @@ +CREATE TABLE "user_app_roles" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "user_id" uuid NOT NULL, + "app_key" text NOT NULL, + "role_code" text NOT NULL, + "assigned_by" uuid, + "assigned_at" timestamptz(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "starts_at" timestamptz(3), + "ends_at" timestamptz(3), + "created_at" timestamptz(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" timestamptz(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "user_app_roles_pkey" PRIMARY KEY ("id"), + CONSTRAINT "user_app_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX "user_app_roles_user_id_app_key_role_code_key" ON "user_app_roles"("user_id", "app_key", "role_code"); +CREATE INDEX "user_app_roles_app_key_role_code_idx" ON "user_app_roles"("app_key", "role_code"); +CREATE INDEX "user_app_roles_user_id_app_key_idx" ON "user_app_roles"("user_id", "app_key"); + +COMMENT ON TABLE "user_app_roles" IS '사용자별 앱 역할. 전역 계정 역할과 별도로 LMS 강사, 학생, ADS IDE 개발자 같은 앱 단위 역할을 관리한다.'; +COMMENT ON COLUMN "user_app_roles"."id" IS '앱 역할 매핑 UUID'; +COMMENT ON COLUMN "user_app_roles"."user_id" IS '역할을 부여받은 auth 사용자 ID'; +COMMENT ON COLUMN "user_app_roles"."app_key" IS '역할이 적용되는 앱 식별자. 예: LMS, ADS IDE'; +COMMENT ON COLUMN "user_app_roles"."role_code" IS '앱 내부 역할 코드. 예: INSTRUCTOR, STUDENT, DEVELOPER'; +COMMENT ON COLUMN "user_app_roles"."assigned_by" IS '역할을 부여한 관리자 사용자 ID'; +COMMENT ON COLUMN "user_app_roles"."assigned_at" IS '역할 부여 일시'; +COMMENT ON COLUMN "user_app_roles"."starts_at" IS '역할 유효 시작 일시. NULL이면 즉시 유효'; +COMMENT ON COLUMN "user_app_roles"."ends_at" IS '역할 유효 종료 일시. NULL이면 무기한'; +COMMENT ON COLUMN "user_app_roles"."created_at" IS '생성 일시'; +COMMENT ON COLUMN "user_app_roles"."updated_at" IS '수정 일시'; + +INSERT INTO "user_app_roles" ("user_id", "app_key", "role_code") +SELECT "id", 'LMS', 'INSTRUCTOR' +FROM "users" +WHERE "email" IN ('teacher@futuredrive.net', 'teacher@yu.ac.kr') +ON CONFLICT ("user_id", "app_key", "role_code") DO NOTHING; + +UPDATE "users" +SET "role" = 'USER' +WHERE "email" IN ('teacher@futuredrive.net', 'teacher@yu.ac.kr'); diff --git a/prisma/migrations/20260624000000_rename_license_codes/migration.sql b/prisma/migrations/20260624000000_rename_license_codes/migration.sql new file mode 100644 index 0000000..c32d259 --- /dev/null +++ b/prisma/migrations/20260624000000_rename_license_codes/migration.sql @@ -0,0 +1,2 @@ +ALTER TYPE "LicenseCode" RENAME VALUE 'EDUCATION' TO 'EDU'; +ALTER TYPE "LicenseCode" RENAME VALUE 'PROFESSIONAL' TO 'PRO'; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..82e2820 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,176 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum UserRole { + ADMIN + USER +} + +enum OrganizationType { + COMPANY + SCHOOL + GOVERNMENT + LAB + OTHER +} + +enum LicenseCode { + FREE + EDU + PRO +} + +model User { + id String @id @default(uuid()) @db.Uuid + email String @unique + passwordHash String @map("password_hash") + name String? + role UserRole @default(USER) + isActive Boolean @default(true) @map("is_active") + lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(3) + profileImageData Bytes? @map("profile_image_data") + profileImageMime String? @map("profile_image_mime") + profileImageUpdatedAt DateTime? @map("profile_image_updated_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + refreshTokens RefreshToken[] + handoffTokens HandoffToken[] + affiliations UserAffiliation[] + userLicenses UserLicense[] + appRoles UserAppRole[] + + @@map("users") +} + +model UserAppRole { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + appKey String @map("app_key") + roleCode String @map("role_code") + assignedById String? @map("assigned_by") @db.Uuid + assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(3) + startsAt DateTime? @map("starts_at") @db.Timestamptz(3) + endsAt DateTime? @map("ends_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, appKey, roleCode]) + @@index([appKey, roleCode]) + @@index([userId, appKey]) + @@map("user_app_roles") +} + +model License { + id String @id @default(uuid()) @db.Uuid + code LicenseCode @unique + name String + description String? + defaultDurationDays Int? @default(365) @map("default_duration_days") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + userLicenses UserLicense[] + + @@index([isActive]) + @@map("licenses") +} + +model UserLicense { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + licenseId String @map("license_id") @db.Uuid + startsAt DateTime @default(now()) @map("starts_at") @db.Timestamptz(3) + expiresAt DateTime? @map("expires_at") @db.Timestamptz(3) + assignedById String? @map("assigned_by") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + license License @relation(fields: [licenseId], references: [id], onDelete: Restrict) + + @@index([userId, startsAt]) + @@index([licenseId]) + @@map("user_licenses") +} + +model Organization { + id String @id @default(uuid()) @db.Uuid + name String + type OrganizationType @default(OTHER) + domain String? + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + affiliations UserAffiliation[] + + @@unique([name, type]) + @@index([isActive, type]) + @@map("organizations") +} + +model UserAffiliation { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + organizationId String @map("organization_id") @db.Uuid + department String? + position String? + isPrimary Boolean @default(false) @map("is_primary") + startsAt DateTime? @map("starts_at") @db.Timestamptz(3) + endsAt DateTime? @map("ends_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + + @@index([userId, isPrimary]) + @@index([organizationId]) + @@map("user_affiliations") +} + +model RefreshToken { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + tokenHash String @unique @map("token_hash") + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("refresh_tokens") +} + +model HandoffToken { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + tokenHash String @unique @map("token_hash") + audienceApp String @map("audience_app") + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + usedAt DateTime? @map("used_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("handoff_tokens") +} + +model LoginNotice { + id String @id @default(uuid()) @db.Uuid + title String + body String + linkUrl String? @map("link_url") + isActive Boolean @default(true) @map("is_active") + startsAt DateTime? @map("starts_at") @db.Timestamptz(3) + endsAt DateTime? @map("ends_at") @db.Timestamptz(3) + priority Int @default(0) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([isActive, priority]) + @@map("login_notices") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..e25d68f --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,137 @@ +import { LicenseCode, PrismaClient, UserRole } from '@prisma/client' +import { config } from '../src/config' +import { hashPassword } from '../src/security/password' + +const prisma = new PrismaClient() + +async function main(): Promise { + const professionalLicense = await prisma.license.upsert({ + where: { code: LicenseCode.PRO }, + create: { + code: LicenseCode.PRO, + name: 'Professional', + description: '전문가용 라이선스', + defaultDurationDays: 365 + }, + update: { + name: 'Professional', + description: '전문가용 라이선스', + defaultDurationDays: 365, + isActive: true + } + }) + + await prisma.license.upsert({ + where: { code: LicenseCode.FREE }, + create: { + code: LicenseCode.FREE, + name: 'Free', + description: '기본 무료 라이선스', + defaultDurationDays: 365 + }, + update: { + name: 'Free', + description: '기본 무료 라이선스', + defaultDurationDays: 365, + isActive: true + } + }) + + await prisma.license.upsert({ + where: { code: LicenseCode.EDU }, + create: { + code: LicenseCode.EDU, + name: 'Education', + description: '교육기관용 라이선스', + defaultDurationDays: 365 + }, + update: { + name: 'Education', + description: '교육기관용 라이선스', + defaultDurationDays: 365, + isActive: true + } + }) + + const admin = await prisma.user.upsert({ + where: { email: config.seedAdminEmail.toLowerCase() }, + create: { + email: config.seedAdminEmail.toLowerCase(), + name: 'ATNMY Studio Admin', + role: UserRole.ADMIN, + passwordHash: await hashPassword(config.seedAdminPassword) + }, + update: { + name: 'ATNMY Studio Admin', + role: UserRole.ADMIN, + isActive: true + } + }) + + const existingAdminLicense = await prisma.userLicense.findFirst({ + where: { + userId: admin.id, + expiresAt: null + } + }) + + if (!existingAdminLicense) { + await prisma.userLicense.create({ + data: { + userId: admin.id, + licenseId: professionalLicense.id + } + }) + } + + await prisma.user.updateMany({ + where: { + email: { + in: ['teacher@futuredrive.net', 'teacher@yu.ac.kr'] + } + }, + data: { + role: UserRole.USER + } + }) + + const instructors = await prisma.user.findMany({ + where: { + email: { + in: ['teacher@futuredrive.net', 'teacher@yu.ac.kr'] + } + }, + select: { id: true } + }) + + for (const instructor of instructors) { + await prisma.userAppRole.upsert({ + where: { + userId_appKey_roleCode: { + userId: instructor.id, + appKey: 'LMS', + roleCode: 'INSTRUCTOR' + } + }, + create: { + userId: instructor.id, + appKey: 'LMS', + roleCode: 'INSTRUCTOR' + }, + update: { + endsAt: null + } + }) + } + + console.log(`Seeded admin user: ${config.seedAdminEmail}`) +} + +main() + .catch((error) => { + console.error(error) + process.exitCode = 1 + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts new file mode 100644 index 0000000..5f314db --- /dev/null +++ b/src/auth/middleware.ts @@ -0,0 +1,27 @@ +import type { MiddlewareHandler } from 'hono' +import { findUserById } from './repository' +import { verifyAccessToken } from '../security/tokens' +import type { AuthUser } from '../types' + +export type AuthVariables = { + user: AuthUser +} + +export const authRequired: MiddlewareHandler<{ Variables: AuthVariables }> = async (c, next) => { + const header = c.req.header('authorization') + const token = header?.match(/^Bearer\s+(.+)$/i)?.[1] + + if (!token) { + return c.json({ error: 'missing_authorization' }, 401) + } + + try { + const claims = await verifyAccessToken(token) + const user = await findUserById(claims.sub) + if (!user) return c.json({ error: 'invalid_authorization' }, 401) + c.set('user', user) + return next() + } catch { + return c.json({ error: 'invalid_authorization' }, 401) + } +} diff --git a/src/auth/repository.ts b/src/auth/repository.ts new file mode 100644 index 0000000..fbbe8c9 --- /dev/null +++ b/src/auth/repository.ts @@ -0,0 +1,534 @@ +import { config } from '../config' +import { prisma } from '../prisma' +import { hashPassword, verifyPassword } from '../security/password' +import { redis } from '../redis' +import { createOpaqueToken, hashToken } from '../security/tokens' +import type { AuthUser } from '../types' + +type UserWithAffiliation = { + id: string + email: string + name: string | null + role: string + createdAt: Date + lastLoginAt: Date | null + appRoles?: Array<{ + appKey: string + roleCode: string + }> + affiliations?: Array<{ + id: string + organizationId: string + department: string | null + position: string | null + organization: { + name: string + type: string + } + }> + userLicenses?: Array<{ + id: string + startsAt: Date + expiresAt: Date | null + license: { + code: string + name: string + } + }> +} + +const activeLicenseWhere = () => { + const now = new Date() + return { + startsAt: { lte: now }, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + license: { isActive: true } + } +} + +const currentLicenseRelation = () => ({ + where: activeLicenseWhere(), + orderBy: { startsAt: 'desc' }, + take: 1, + select: { + id: true, + startsAt: true, + expiresAt: true, + license: { + select: { + code: true, + name: true + } + } + } +}) as const + +const userSelect = () => { + const now = new Date() + return { + id: true, + email: true, + name: true, + role: true, + createdAt: true, + lastLoginAt: true, + affiliations: { + where: { isPrimary: true, endsAt: null }, + take: 1, + select: { + id: true, + organizationId: true, + department: true, + position: true, + organization: { + select: { + name: true, + type: true + } + } + } + }, + userLicenses: currentLicenseRelation(), + appRoles: { + where: { + AND: [ + { OR: [{ startsAt: null }, { startsAt: { lte: now } }] }, + { OR: [{ endsAt: null }, { endsAt: { gt: now } }] } + ] + }, + select: { + appKey: true, + roleCode: true + } + } + } +} + +const toAuthUser = (user: UserWithAffiliation): AuthUser => { + const primaryAffiliation = user.affiliations?.[0] + const currentLicense = user.userLicenses?.[0] + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + appRoles: user.appRoles ?? [], + createdAt: user.createdAt, + lastLoginAt: user.lastLoginAt, + primaryAffiliation: primaryAffiliation + ? { + id: primaryAffiliation.id, + organizationId: primaryAffiliation.organizationId, + organizationName: primaryAffiliation.organization.name, + organizationType: primaryAffiliation.organization.type, + department: primaryAffiliation.department, + position: primaryAffiliation.position + } + : null, + currentLicense: currentLicense + ? { + id: currentLicense.id, + code: currentLicense.license.code, + name: currentLicense.license.name, + startsAt: currentLicense.startsAt, + expiresAt: currentLicense.expiresAt + } + : null + } +} + +export async function findUserByEmail(email: string) { + return prisma.user.findUnique({ + where: { email: email.toLowerCase() }, + include: { + affiliations: { + where: { isPrimary: true, endsAt: null }, + take: 1, + include: { organization: true } + }, + userLicenses: { + where: activeLicenseWhere(), + orderBy: { startsAt: 'desc' }, + take: 1, + include: { license: true } + }, + appRoles: { + where: { + AND: [ + { OR: [{ startsAt: null }, { startsAt: { lte: new Date() } }] }, + { OR: [{ endsAt: null }, { endsAt: { gt: new Date() } }] } + ] + }, + select: { + appKey: true, + roleCode: true + } + } + } + }) +} + +export async function createUser(input: { email: string; password: string; name?: string | null }): Promise { + const freeLicense = await prisma.license.findUnique({ + where: { code: 'FREE' }, + select: { defaultDurationDays: true } + }) + const licenseStartsAt = new Date() + const licenseExpiresAt = calculateLicenseExpiresAt(licenseStartsAt, freeLicense?.defaultDurationDays) + + const user = await prisma.user.create({ + data: { + email: input.email.toLowerCase(), + passwordHash: await hashPassword(input.password), + name: input.name?.trim() || null, + userLicenses: { + create: { + startsAt: licenseStartsAt, + expiresAt: licenseExpiresAt, + license: { + connect: { code: 'FREE' } + } + } + } + }, + select: userSelect() + }) + + return toAuthUser(user) +} + +function calculateLicenseExpiresAt(startsAt: Date, durationDays: number | null | undefined): Date | null { + if (!durationDays) return null + + const expiresAt = new Date(startsAt) + expiresAt.setDate(expiresAt.getDate() + durationDays) + return expiresAt +} + +export async function createManagedUser(input: { + email: string + password: string + name?: string | null + organizationId: string + department?: string | null + position?: string | null + licenseCode?: 'FREE' | 'EDU' | 'PRO' +}): Promise { + const user = await prisma.user.create({ + data: { + email: input.email.toLowerCase(), + passwordHash: await hashPassword(input.password), + name: input.name?.trim() || null, + role: 'USER', + affiliations: { + create: { + organizationId: input.organizationId, + department: input.department?.trim() || null, + position: input.position?.trim() || null, + isPrimary: true + } + }, + userLicenses: { + create: { + license: { + connect: { code: input.licenseCode ?? 'EDU' } + } + } + } + }, + select: userSelect() + }) + + return toAuthUser(user) +} + +export async function updateUserPassword(userId: string, password: string): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { passwordHash: await hashPassword(password) } + }) +} + +export async function changeUserPassword(userId: string, currentPassword: string, newPassword: string): Promise { + const user = await prisma.user.findFirst({ + where: { id: userId, isActive: true }, + select: { passwordHash: true } + }) + + if (!user) return false + + const passwordOk = await verifyPassword(currentPassword, user.passwordHash) + if (!passwordOk) return false + + await updateUserPassword(userId, newPassword) + return true +} + +export async function updateUserProfileImage(userId: string, imageData: Uint8Array, mimeType: string): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { + profileImageData: Buffer.from(imageData), + profileImageMime: mimeType, + profileImageUpdatedAt: new Date() + } + }) +} + +export async function deleteUserProfileImage(userId: string): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { + profileImageData: null, + profileImageMime: null, + profileImageUpdatedAt: null + } + }) +} + +export async function findUserProfileImage(userId: string): Promise<{ data: Buffer; mimeType: string; updatedAt: Date | null } | null> { + const user = await prisma.user.findFirst({ + where: { id: userId, isActive: true }, + select: { + profileImageData: true, + profileImageMime: true, + profileImageUpdatedAt: true + } + }) + + if (!user?.profileImageData || !user.profileImageMime) return null + + return { + data: Buffer.from(user.profileImageData), + mimeType: user.profileImageMime, + updatedAt: user.profileImageUpdatedAt + } +} + +export async function findUserById(id: string): Promise { + const user = await prisma.user.findFirst({ + where: { id, isActive: true }, + select: userSelect() + }) + + return user ? toAuthUser(user) : null +} + +export async function listOrganizations() { + return prisma.organization.findMany({ + where: { isActive: true }, + orderBy: [{ type: 'asc' }, { name: 'asc' }], + select: { + id: true, + name: true, + type: true, + domain: true, + createdAt: true + } + }) +} + +export async function createOrganization(input: { name: string; type: 'COMPANY' | 'SCHOOL' | 'GOVERNMENT' | 'LAB' | 'OTHER'; domain?: string | null }) { + return prisma.organization.create({ + data: { + name: input.name.trim(), + type: input.type, + domain: input.domain?.trim() || null + }, + select: { + id: true, + name: true, + type: true, + domain: true, + createdAt: true + } + }) +} + +export async function assignUserAffiliation(input: { + userId: string + organizationId: string + department?: string | null + position?: string | null + isPrimary?: boolean +}) { + return prisma.$transaction(async (tx) => { + if (input.isPrimary) { + await tx.userAffiliation.updateMany({ + where: { userId: input.userId, endsAt: null }, + data: { isPrimary: false } + }) + } + + return tx.userAffiliation.create({ + data: { + userId: input.userId, + organizationId: input.organizationId, + department: input.department?.trim() || null, + position: input.position?.trim() || null, + isPrimary: input.isPrimary ?? true + }, + select: { + id: true, + userId: true, + organizationId: true, + department: true, + position: true, + isPrimary: true, + organization: { + select: { + name: true, + type: true + } + } + } + }) + }) +} + +export async function markLastLogin(userId: string, loggedInAt = new Date()): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { lastLoginAt: loggedInAt } + }) +} + +export async function isLoginBlocked(email: string): Promise { + const attempts = Number(await redis.get(loginAttemptKey(email))) + return Number.isFinite(attempts) && attempts >= config.maxFailedLoginAttempts +} + +export async function recordFailedLogin(email: string): Promise { + const attempts = await redis.incr(loginAttemptKey(email)) + if (attempts === 1) { + await redis.expire(loginAttemptKey(email), config.loginAttemptWindowSeconds) + } +} + +export async function clearLoginAttempts(email: string): Promise { + await redis.del(loginAttemptKey(email)) +} + +export async function storeEmailVerificationCode(email: string, code: string): Promise { + await redis.set( + emailVerificationKey(email), + JSON.stringify({ code, email: email.toLowerCase() }), + 'EX', + config.emailVerificationTtlSeconds + ) +} + +export async function verifyEmailVerificationCode(email: string, code: string): Promise { + const key = emailVerificationKey(email) + const tokenJson = await redis.get(key) + if (typeof tokenJson !== 'string') return false + + const token = parseStoredEmailVerification(tokenJson) + if (!token || token.email !== email.toLowerCase() || token.code !== code) return false + + await redis.del(key) + return true +} + +export async function storePasswordResetCode(email: string, code: string): Promise { + await redis.set( + passwordResetKey(email), + JSON.stringify({ code, email: email.toLowerCase() }), + 'EX', + config.emailVerificationTtlSeconds + ) +} + +export async function verifyPasswordResetCode(email: string, code: string): Promise { + const key = passwordResetKey(email) + const tokenJson = await redis.get(key) + if (typeof tokenJson !== 'string') return false + + const token = parseStoredEmailVerification(tokenJson) + if (!token || token.email !== email.toLowerCase() || token.code !== code) return false + + await redis.del(key) + return true +} + +export async function createRefreshToken(userId: string): Promise { + const token = createOpaqueToken() + await redis.set(refreshTokenKey(token), JSON.stringify({ userId }), 'EX', config.refreshTokenTtlSeconds) + return token +} + +export async function rotateRefreshToken(refreshToken: string): Promise { + const tokenJson = await redis.send('GETDEL', [refreshTokenKey(refreshToken)]) + if (typeof tokenJson !== 'string') return null + + const token = parseStoredRefreshToken(tokenJson) + if (!token) return null + + return findUserById(token.userId) +} + +export async function revokeRefreshToken(refreshToken: string): Promise { + await redis.del(refreshTokenKey(refreshToken)) +} + +export async function createHandoffToken(userId: string, audienceApp: string): Promise { + const token = createOpaqueToken() + await redis.set( + handoffTokenKey(token), + JSON.stringify({ userId, audienceApp }), + 'EX', + config.handoffTokenTtlSeconds + ) + return token +} + +export async function exchangeHandoffToken(handoffToken: string, audienceApp?: string): Promise { + const tokenJson = await redis.send('GETDEL', [handoffTokenKey(handoffToken)]) + if (typeof tokenJson !== 'string') return null + + const token = parseStoredHandoffToken(tokenJson) + if (!token || (audienceApp && token.audienceApp !== audienceApp)) return null + + return findUserById(token.userId) +} + +const refreshTokenKey = (token: string): string => `auth:refresh:${hashToken(token)}` + +const handoffTokenKey = (token: string): string => `auth:handoff:${hashToken(token)}` + +const loginAttemptKey = (email: string): string => `auth:login_attempt:${hashToken(email.toLowerCase())}` + +const emailVerificationKey = (email: string): string => `auth:email_verify:${hashToken(email.toLowerCase())}` + +const passwordResetKey = (email: string): string => `auth:password_reset:${hashToken(email.toLowerCase())}` + +const parseStoredRefreshToken = (value: string): { userId: string } | null => { + try { + const parsed = JSON.parse(value) + return typeof parsed.userId === 'string' ? { userId: parsed.userId } : null + } catch { + return null + } +} + +const parseStoredHandoffToken = (value: string): { userId: string; audienceApp: string } | null => { + try { + const parsed = JSON.parse(value) + return typeof parsed.userId === 'string' && typeof parsed.audienceApp === 'string' + ? { userId: parsed.userId, audienceApp: parsed.audienceApp } + : null + } catch { + return null + } +} + +const parseStoredEmailVerification = (value: string): { email: string; code: string } | null => { + try { + const parsed = JSON.parse(value) + return typeof parsed.email === 'string' && typeof parsed.code === 'string' + ? { email: parsed.email, code: parsed.code } + : null + } catch { + return null + } +} diff --git a/src/auth/routes.ts b/src/auth/routes.ts new file mode 100644 index 0000000..4fd4ccd --- /dev/null +++ b/src/auth/routes.ts @@ -0,0 +1,555 @@ +import { Hono } from 'hono' +import { randomInt } from 'crypto' +import { z } from 'zod' +import { config } from '../config' +import { sendEmailVerificationCode } from '../mail' +import { verifyPassword } from '../security/password' +import { signAccessToken } from '../security/tokens' +import type { AuthUser } from '../types' +import { authRequired, type AuthVariables } from './middleware' +import { + clearLoginAttempts, + assignUserAffiliation, + changeUserPassword, + createManagedUser, + createOrganization, + createHandoffToken, + createRefreshToken, + createUser, + deleteUserProfileImage, + exchangeHandoffToken, + findUserProfileImage, + findUserByEmail, + listOrganizations, + isLoginBlocked, + markLastLogin, + recordFailedLogin, + revokeRefreshToken, + rotateRefreshToken, + storeEmailVerificationCode, + storePasswordResetCode, + updateUserPassword, + updateUserProfileImage, + verifyEmailVerificationCode, + verifyPasswordResetCode +} from './repository' + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1) +}) + +const signupSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + name: z.string().trim().min(1).max(80), + verificationCode: z.string().regex(/^\d{6}$/) +}) + +const emailVerificationSendSchema = z.object({ + email: z.string().email() +}) + +const passwordResetSendSchema = z.object({ + email: z.string().email() +}) + +const passwordResetConfirmSchema = z.object({ + email: z.string().email(), + verificationCode: z.string().regex(/^\d{6}$/), + password: z.string().min(8) +}) + +const passwordChangeSchema = z.object({ + currentPassword: z.string().min(1), + newPassword: z.string().min(8) +}).refine((value) => value.currentPassword !== value.newPassword, { + path: ['newPassword'], + message: 'new_password_must_be_different' +}) + +const organizationTypeSchema = z.enum(['COMPANY', 'SCHOOL', 'GOVERNMENT', 'LAB', 'OTHER']) + +const createOrganizationSchema = z.object({ + name: z.string().trim().min(1).max(120), + type: organizationTypeSchema.default('OTHER'), + domain: z.string().trim().max(120).optional().nullable() +}) + +const assignAffiliationSchema = z.object({ + organizationId: z.string().uuid(), + department: z.string().trim().max(120).optional().nullable(), + position: z.string().trim().max(120).optional().nullable(), + isPrimary: z.boolean().optional() +}) + +const adminCreateUserSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + name: z.string().trim().min(1).max(80), + organizationId: z.string().uuid(), + department: z.string().trim().max(120).optional().nullable(), + position: z.string().trim().max(120).optional().nullable(), + licenseCode: z.enum(['FREE', 'EDU', 'PRO']).optional() +}) + +const refreshSchema = z.object({ + refreshToken: z.string().min(1).optional() +}) + +const logoutSchema = z.object({ + refreshToken: z.string().min(1).optional() +}) + +const handoffSchema = z.object({ + audienceApp: z.string().min(1).max(120) +}) + +const handoffExchangeSchema = z.object({ + handoffToken: z.string().min(1), + audienceApp: z.string().min(1).max(120).optional() +}) + +const AVATAR_MAX_BYTES = 80 * 1024 +const AVATAR_ALLOWED_MIME_TYPES = new Set(['image/webp', 'image/png', 'image/jpeg']) + +const userResponse = (user: AuthUser) => ({ + id: user.id, + email: user.email, + name: user.name, + role: user.role, + roles: [...new Set([user.role, ...(user.appRoles ?? []).map((appRole) => appRole.roleCode)])], + appRoles: user.appRoles ?? [], + createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : user.createdAt, + lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt, + primaryAffiliation: user.primaryAffiliation ?? null, + currentLicense: user.currentLicense + ? { + ...user.currentLicense, + startsAt: user.currentLicense.startsAt instanceof Date + ? user.currentLicense.startsAt.toISOString() + : user.currentLicense.startsAt, + expiresAt: user.currentLicense.expiresAt instanceof Date + ? user.currentLicense.expiresAt.toISOString() + : user.currentLicense.expiresAt + } + : null +}) + +const requireAdmin = (user: AuthUser): boolean => user.role === 'ADMIN' + +const accessTokenResponse = async (user: AuthUser) => ({ + accessToken: await signAccessToken(user), + accessTokenExpiresIn: config.accessTokenTtlSeconds, + user: userResponse(user) +}) + +const tokenPairResponse = async (user: AuthUser) => ({ + ...(await accessTokenResponse(user)), + refreshToken: await createRefreshToken(user.id), + refreshTokenExpiresIn: config.refreshTokenTtlSeconds +}) + +const cookieAttributes = (maxAgeSeconds: number): string[] => [ + 'HttpOnly', + 'Path=/', + 'SameSite=Lax', + `Max-Age=${maxAgeSeconds}`, + ...(config.refreshCookieSecure ? ['Secure'] : []) +] + +const setRefreshCookie = (c: { header: (name: string, value: string) => void }, refreshToken: string): void => { + c.header( + 'Set-Cookie', + `${config.refreshCookieName}=${encodeURIComponent(refreshToken)}; ${cookieAttributes(config.refreshTokenTtlSeconds).join('; ')}` + ) +} + +const clearRefreshCookie = (c: { header: (name: string, value: string) => void }): void => { + c.header('Set-Cookie', `${config.refreshCookieName}=; ${cookieAttributes(0).join('; ')}`) +} + +const readCookie = (cookieHeader: string | undefined, name: string): string | undefined => { + if (!cookieHeader) return undefined + + const prefix = `${name}=` + const cookie = cookieHeader + .split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix)) + + return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : undefined +} + +const readRefreshToken = async (c: { req: { header: (name: string) => string | undefined; json: () => Promise } }) => { + const cookieToken = readCookie(c.req.header('cookie'), config.refreshCookieName) + if (cookieToken) return cookieToken + + const body = refreshSchema.safeParse(await c.req.json().catch(() => ({}))) + return body.success ? body.data.refreshToken : undefined +} + +export const authRoutes = new Hono<{ Variables: AuthVariables }>() + +authRoutes.post('/email-verification/send', async (c) => { + const body = emailVerificationSendSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) + + const email = body.data.email.toLowerCase() + const existingUser = await findUserByEmail(email) + if (existingUser) return c.json({ error: 'email_already_registered' }, 409) + + const code = String(randomInt(100000, 1000000)) + await storeEmailVerificationCode(email, code) + + try { + await sendEmailVerificationCode(email, code) + } catch (error) { + console.error('Failed to send email verification code', error) + return c.json({ error: 'email_send_failed' }, 502) + } + + return c.json({ + ok: true, + expiresIn: config.emailVerificationTtlSeconds + }) +}) + +authRoutes.post('/password-reset/send', async (c) => { + const body = passwordResetSendSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) + + const email = body.data.email.toLowerCase() + const existingUser = await findUserByEmail(email) + + if (!existingUser?.isActive) { + return c.json({ error: 'email_not_registered' }, 404) + } + + const code = String(randomInt(100000, 1000000)) + await storePasswordResetCode(email, code) + + try { + await sendEmailVerificationCode(email, code) + } catch (error) { + console.error('Failed to send password reset code', error) + return c.json({ error: 'email_send_failed' }, 502) + } + + return c.json({ + ok: true, + expiresIn: config.emailVerificationTtlSeconds + }) +}) + +authRoutes.post('/password-reset/confirm', async (c) => { + const body = passwordResetConfirmSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) + + const email = body.data.email.toLowerCase() + const existingUser = await findUserByEmail(email) + if (!existingUser?.isActive) return c.json({ error: 'invalid_password_reset_code' }, 400) + + const codeVerified = await verifyPasswordResetCode(email, body.data.verificationCode) + if (!codeVerified) return c.json({ error: 'invalid_password_reset_code' }, 400) + + await updateUserPassword(existingUser.id, body.data.password) + await clearLoginAttempts(email) + + return c.json({ ok: true }) +}) + +authRoutes.post('/password/change', authRequired, async (c) => { + const body = passwordChangeSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) + + const changed = await changeUserPassword( + c.get('user').id, + body.data.currentPassword, + body.data.newPassword + ) + + if (!changed) return c.json({ error: 'invalid_current_password' }, 400) + + return c.json({ ok: true }) +}) + +authRoutes.post('/login', async (c) => { + const body = loginSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) + + const email = body.data.email.toLowerCase() + if (await isLoginBlocked(email)) return c.json({ error: 'too_many_login_attempts' }, 429) + + const dbUser = await findUserByEmail(email) + if (!dbUser || !dbUser.isActive) { + await recordFailedLogin(email) + return c.json({ error: 'invalid_credentials' }, 401) + } + + const passwordOk = await verifyPassword(body.data.password, dbUser.passwordHash) + if (!passwordOk) { + await recordFailedLogin(email) + return c.json({ error: 'invalid_credentials' }, 401) + } + + const loggedInAt = new Date() + const user: AuthUser = { + id: dbUser.id, + email: dbUser.email, + name: dbUser.name, + role: dbUser.role, + appRoles: dbUser.appRoles ?? [], + createdAt: dbUser.createdAt, + lastLoginAt: loggedInAt, + primaryAffiliation: dbUser.affiliations?.[0] + ? { + id: dbUser.affiliations[0].id, + organizationId: dbUser.affiliations[0].organizationId, + organizationName: dbUser.affiliations[0].organization.name, + organizationType: dbUser.affiliations[0].organization.type, + department: dbUser.affiliations[0].department, + position: dbUser.affiliations[0].position + } + : null, + currentLicense: dbUser.userLicenses?.[0] + ? { + id: dbUser.userLicenses[0].id, + code: dbUser.userLicenses[0].license.code, + name: dbUser.userLicenses[0].license.name, + startsAt: dbUser.userLicenses[0].startsAt, + expiresAt: dbUser.userLicenses[0].expiresAt + } + : null + } + + await markLastLogin(user.id, loggedInAt) + await clearLoginAttempts(email) + + const refreshToken = await createRefreshToken(user.id) + setRefreshCookie(c, refreshToken) + + return c.json(await accessTokenResponse(user)) +}) + +authRoutes.post('/signup', async (c) => { + const body = signupSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) + + const email = body.data.email.toLowerCase() + const existingUser = await findUserByEmail(email) + if (existingUser) return c.json({ error: 'email_already_registered' }, 409) + + const emailVerified = await verifyEmailVerificationCode(email, body.data.verificationCode) + if (!emailVerified) return c.json({ error: 'invalid_email_verification_code' }, 400) + + const user = await createUser({ + email, + password: body.data.password, + name: body.data.name + }) + + const refreshToken = await createRefreshToken(user.id) + setRefreshCookie(c, refreshToken) + + return c.json(await accessTokenResponse(user), 201) +}) + +authRoutes.post('/refresh', async (c) => { + const refreshToken = await readRefreshToken(c) + if (!refreshToken) return c.json({ error: 'missing_refresh_token' }, 401) + + const user = await rotateRefreshToken(refreshToken) + if (!user) { + clearRefreshCookie(c) + return c.json({ error: 'invalid_refresh_token' }, 401) + } + + const nextRefreshToken = await createRefreshToken(user.id) + setRefreshCookie(c, nextRefreshToken) + + return c.json(await accessTokenResponse(user)) +}) + +authRoutes.post('/logout', async (c) => { + const body = logoutSchema.safeParse(await c.req.json().catch(() => ({}))) + const refreshToken = body.success && body.data.refreshToken + ? body.data.refreshToken + : readCookie(c.req.header('cookie'), config.refreshCookieName) + + if (refreshToken) { + await revokeRefreshToken(refreshToken) + } + + clearRefreshCookie(c) + return c.json({ ok: true }) +}) + +authRoutes.get('/me', authRequired, (c) => { + return c.json({ user: userResponse(c.get('user')) }) +}) + +authRoutes.get('/profile/avatar', authRequired, async (c) => { + const image = await findUserProfileImage(c.get('user').id) + if (!image) return c.body(null, 404) + + c.header('content-type', image.mimeType) + c.header('cache-control', 'no-store') + if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString()) + + return c.body(new Uint8Array(image.data)) +}) + +authRoutes.get('/users/:userId/avatar', authRequired, async (c) => { + if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403) + + const userId = c.req.param('userId') + const image = await findUserProfileImage(userId) + if (!image) return c.body(null, 404) + + c.header('content-type', image.mimeType) + c.header('cache-control', 'no-store') + if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString()) + + return c.body(new Uint8Array(image.data)) +}) + +authRoutes.get('/users/avatar', authRequired, async (c) => { + if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403) + + const email = c.req.query('email')?.toLowerCase() + if (!email) return c.json({ error: 'email_required' }, 400) + + const user = await findUserByEmail(email) + if (!user) return c.body(null, 404) + + const image = await findUserProfileImage(user.id) + if (!image) return c.body(null, 404) + + c.header('content-type', image.mimeType) + c.header('cache-control', 'no-store') + if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString()) + + return c.body(new Uint8Array(image.data)) +}) + +authRoutes.put('/profile/avatar', authRequired, async (c) => { + const contentType = c.req.header('content-type')?.split(';')[0]?.trim().toLowerCase() ?? '' + if (!AVATAR_ALLOWED_MIME_TYPES.has(contentType)) { + return c.json({ error: 'unsupported_image_type' }, 415) + } + + const body = new Uint8Array(await c.req.arrayBuffer()) + if (body.length === 0) return c.json({ error: 'empty_image' }, 400) + if (body.length > AVATAR_MAX_BYTES) return c.json({ error: 'image_too_large', maxBytes: AVATAR_MAX_BYTES }, 413) + + await updateUserProfileImage(c.get('user').id, body, contentType) + return c.json({ ok: true }) +}) + +authRoutes.delete('/profile/avatar', authRequired, async (c) => { + await deleteUserProfileImage(c.get('user').id) + return c.json({ ok: true }) +}) + +authRoutes.get('/admin/organizations', authRequired, async (c) => { + if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403) + + const organizations = await listOrganizations() + return c.json({ + organizations: organizations.map((organization) => ({ + ...organization, + createdAt: organization.createdAt.toISOString() + })) + }) +}) + +authRoutes.post('/admin/organizations', authRequired, async (c) => { + if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403) + + const body = createOrganizationSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) + + try { + const organization = await createOrganization(body.data) + return c.json({ + organization: { + ...organization, + createdAt: organization.createdAt.toISOString() + } + }, 201) + } catch { + return c.json({ error: 'organization_already_exists' }, 409) + } +}) + +authRoutes.post('/admin/users', authRequired, async (c) => { + if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403) + + const body = adminCreateUserSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) + + const email = body.data.email.toLowerCase() + const existingUser = await findUserByEmail(email) + if (existingUser) return c.json({ error: 'email_already_registered' }, 409) + + try { + const user = await createManagedUser({ + email, + password: body.data.password, + name: body.data.name, + organizationId: body.data.organizationId, + department: body.data.department, + position: body.data.position, + licenseCode: body.data.licenseCode + }) + + return c.json({ user: userResponse(user) }, 201) + } catch { + return c.json({ error: 'user_create_failed' }, 400) + } +}) + +authRoutes.post('/admin/users/:userId/affiliations', authRequired, async (c) => { + if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403) + + const userId = c.req.param('userId') + const body = assignAffiliationSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) + + try { + const affiliation = await assignUserAffiliation({ + userId, + organizationId: body.data.organizationId, + department: body.data.department, + position: body.data.position, + isPrimary: body.data.isPrimary + }) + + return c.json({ affiliation }, 201) + } catch { + return c.json({ error: 'affiliation_assign_failed' }, 400) + } +}) + +authRoutes.post('/handoff', authRequired, async (c) => { + const body = handoffSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) + + const token = await createHandoffToken(c.get('user').id, body.data.audienceApp) + return c.json({ + handoffToken: token, + expiresIn: config.handoffTokenTtlSeconds, + audienceApp: body.data.audienceApp + }) +}) + +authRoutes.post('/handoff/exchange', async (c) => { + const body = handoffExchangeSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) + + const user = await exchangeHandoffToken(body.data.handoffToken, body.data.audienceApp) + if (!user) return c.json({ error: 'invalid_handoff_token' }, 401) + + return c.json(await tokenPairResponse(user)) +}) diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..0bf59ce --- /dev/null +++ b/src/config.ts @@ -0,0 +1,38 @@ +const toInt = (value: string | undefined, fallback: number): number => { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback +} + +const required = (name: string, fallback?: string): string => { + const value = process.env[name] ?? fallback + if (!value) throw new Error(`Missing required env: ${name}`) + return value +} + +export const config = { + port: toInt(process.env.PORT, 4100), + databaseUrl: required('DATABASE_URL', 'postgres://atnmy_auth:future1234!%40@localhost:5434/atnmy_auth'), + redisUrl: required('REDIS_URL', 'redis://localhost:6380'), + jwtSecret: required('JWT_SECRET', 'dev-only-change-this-secret-at-least-32-bytes'), + jwtIssuer: process.env.JWT_ISSUER ?? 'atnmy-stdo-auth', + accessTokenTtlSeconds: toInt(process.env.ACCESS_TOKEN_TTL_SECONDS, 60 * 60), + refreshTokenTtlSeconds: toInt(process.env.REFRESH_TOKEN_TTL_SECONDS, 30 * 24 * 60 * 60), + handoffTokenTtlSeconds: toInt(process.env.HANDOFF_TOKEN_TTL_SECONDS, 60), + maxFailedLoginAttempts: toInt(process.env.MAX_FAILED_LOGIN_ATTEMPTS, 5), + loginAttemptWindowSeconds: toInt(process.env.LOGIN_ATTEMPT_WINDOW_SECONDS, 15 * 60), + emailVerificationTtlSeconds: toInt(process.env.EMAIL_VERIFICATION_TTL_SECONDS, 5 * 60), + refreshCookieName: process.env.REFRESH_COOKIE_NAME ?? 'atnmy_refresh', + refreshCookieSecure: process.env.REFRESH_COOKIE_SECURE === 'true', + corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:4200,http://localhost:4300,http://localhost:4400') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean), + seedAdminEmail: process.env.SEED_ADMIN_EMAIL ?? 'admin@futuredrive.net', + seedAdminPassword: process.env.SEED_ADMIN_PASSWORD ?? 'admin1234', + smtpHost: process.env.SMTP_HOST, + smtpPort: toInt(process.env.SMTP_PORT, 587), + smtpSecure: process.env.SMTP_SECURE === 'true', + smtpUser: process.env.SMTP_USER, + smtpPass: process.env.SMTP_PASS, + smtpFrom: process.env.SMTP_FROM +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..dc6897b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,40 @@ +import { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { logger } from 'hono/logger' +import { config } from './config' +import { prisma } from './prisma' +import { closeRedis, redis } from './redis' +import { authRoutes } from './auth/routes' + +const app = new Hono() + +app.use('*', logger()) +app.use( + '*', + cors({ + origin: (origin) => { + if (!origin) return null + return config.corsOrigins.includes(origin) ? origin : null + }, + allowHeaders: ['authorization', 'content-type'], + allowMethods: ['GET', 'POST', 'PUT', 'OPTIONS'], + credentials: true + }) +) + +app.get('/health', async (c) => { + const redisOk = (await redis.send('PING', [])) === 'PONG' + return c.json({ ok: true, service: 'atnmy-stdo-auth', redis: redisOk ? 'ok' : 'error' }, redisOk ? 200 : 503) +}) +app.route('/auth', authRoutes) + +process.on('SIGINT', async () => { + await closeRedis() + await prisma.$disconnect() + process.exit(0) +}) + +serve({ fetch: app.fetch, port: config.port }, (info) => { + console.log(`Auth server listening on http://localhost:${info.port}`) +}) diff --git a/src/mail.ts b/src/mail.ts new file mode 100644 index 0000000..8776884 --- /dev/null +++ b/src/mail.ts @@ -0,0 +1,39 @@ +import nodemailer from 'nodemailer' +import { config } from './config' + +const createTransporter = () => { + if (!config.smtpHost || !config.smtpUser || !config.smtpPass || !config.smtpFrom) { + throw new Error('SMTP configuration is incomplete') + } + + return nodemailer.createTransport({ + host: config.smtpHost, + port: config.smtpPort, + secure: config.smtpSecure, + auth: { + user: config.smtpUser, + pass: config.smtpPass + } + }) +} + +let transporter: ReturnType | null = null + +export async function sendEmailVerificationCode(to: string, code: string): Promise { + transporter ??= createTransporter() + + await transporter.sendMail({ + from: config.smtpFrom, + to, + subject: '[Autonomy Studio] 이메일 인증코드', + text: `Autonomy Studio 이메일 인증코드는 ${code} 입니다. ${Math.floor(config.emailVerificationTtlSeconds / 60)}분 안에 입력해주세요.`, + html: ` +
+

Autonomy Studio 이메일 인증

+

아래 인증코드를 회원가입 화면에 입력해주세요.

+
${code}
+

${Math.floor(config.emailVerificationTtlSeconds / 60)}분 후 만료됩니다.

+
+ ` + }) +} diff --git a/src/prisma.ts b/src/prisma.ts new file mode 100644 index 0000000..6260dd0 --- /dev/null +++ b/src/prisma.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from '@prisma/client' + +export const prisma = new PrismaClient() diff --git a/src/redis.ts b/src/redis.ts new file mode 100644 index 0000000..f6b51fb --- /dev/null +++ b/src/redis.ts @@ -0,0 +1,11 @@ +import { RedisClient } from 'bun' +import { config } from './config' + +export const redis = new RedisClient(config.redisUrl, { + autoReconnect: true, + enableOfflineQueue: true +}) + +export async function closeRedis(): Promise { + redis.close() +} diff --git a/src/security/password.ts b/src/security/password.ts new file mode 100644 index 0000000..efc5e8d --- /dev/null +++ b/src/security/password.ts @@ -0,0 +1,25 @@ +import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'crypto' +import { promisify } from 'util' + +const scrypt = promisify(scryptCallback) +const KEY_LENGTH = 64 + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16).toString('hex') + const derivedKey = (await scrypt(password, salt, KEY_LENGTH)) as Buffer + + return `scrypt:${salt}:${derivedKey.toString('hex')}` +} + +export async function verifyPassword(password: string, passwordHash: string): Promise { + const [algorithm, salt, key] = passwordHash.split(':') + + if (algorithm !== 'scrypt' || !salt || !key) { + return false + } + + const storedKey = Buffer.from(key, 'hex') + const derivedKey = (await scrypt(password, salt, storedKey.length)) as Buffer + + return storedKey.length === derivedKey.length && timingSafeEqual(storedKey, derivedKey) +} diff --git a/src/security/tokens.ts b/src/security/tokens.ts new file mode 100644 index 0000000..c40fb06 --- /dev/null +++ b/src/security/tokens.ts @@ -0,0 +1,42 @@ +import { createHash, randomBytes } from 'crypto' +import { jwtVerify, SignJWT } from 'jose' +import { config } from '../config' +import type { AuthUser } from '../types' + +const secret = new TextEncoder().encode(config.jwtSecret) + +export interface AccessTokenClaims { + sub: string + email: string + role: string +} + +export const hashToken = (token: string): string => createHash('sha256').update(token).digest('hex') + +export const createOpaqueToken = (): string => randomBytes(32).toString('base64url') + +export async function signAccessToken(user: AuthUser): Promise { + return new SignJWT({ + email: user.email, + role: user.role + }) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setIssuer(config.jwtIssuer) + .setSubject(user.id) + .setIssuedAt() + .setExpirationTime(`${config.accessTokenTtlSeconds}s`) + .sign(secret) +} + +export async function verifyAccessToken(token: string): Promise { + const { payload } = await jwtVerify(token, secret, { issuer: config.jwtIssuer }) + if (!payload.sub || typeof payload.email !== 'string' || typeof payload.role !== 'string') { + throw new Error('Invalid access token claims') + } + + return { + sub: payload.sub, + email: payload.email, + role: payload.role + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..41faff8 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,27 @@ +export interface AuthUser { + id: string + email: string + name: string | null + role: string + appRoles?: Array<{ + appKey: string + roleCode: string + }> + createdAt?: Date | string + lastLoginAt?: Date | string | null + primaryAffiliation?: { + id: string + organizationId: string + organizationName: string + organizationType: string + department: string | null + position: string | null + } | null + currentLicense?: { + id: string + code: string + name: string + startsAt: Date | string + expiresAt: Date | string | null + } | null +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..be89453 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "types": ["bun"], + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*.ts"] +}