init studio auth backend
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.env.*
|
||||||
|
coverage
|
||||||
+14
@@ -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"]
|
||||||
@@ -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 <accessToken>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refresh
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /auth/refresh
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"refreshToken": "<refreshToken>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Refresh tokens are rotated on every use.
|
||||||
|
|
||||||
|
### Logout
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /auth/logout
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"refreshToken": "<refreshToken>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create App Handoff Token
|
||||||
|
|
||||||
|
Launcher calls this before launching ADS IDE or another app.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /auth/handoff
|
||||||
|
Authorization: Bearer <launcherAccessToken>
|
||||||
|
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.
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
@@ -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;
|
||||||
@@ -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 '사용자 소속 연결 수정 일시';
|
||||||
@@ -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 '공지 수정 일시';
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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 '라이선스 부여 이력 생성 시각.';
|
||||||
@@ -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 '프로필 썸네일 마지막 수정 시각.';
|
||||||
@@ -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 $$;
|
||||||
@@ -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이면 무기한.';
|
||||||
@@ -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');
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TYPE "LicenseCode" RENAME VALUE 'EDUCATION' TO 'EDU';
|
||||||
|
ALTER TYPE "LicenseCode" RENAME VALUE 'PROFESSIONAL' TO 'PRO';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
provider = "postgresql"
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
+137
@@ -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<void> {
|
||||||
|
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()
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AuthUser> {
|
||||||
|
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<AuthUser> {
|
||||||
|
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<void> {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { passwordHash: await hashPassword(password) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeUserPassword(userId: string, currentPassword: string, newPassword: string): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
profileImageData: Buffer.from(imageData),
|
||||||
|
profileImageMime: mimeType,
|
||||||
|
profileImageUpdatedAt: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserProfileImage(userId: string): Promise<void> {
|
||||||
|
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<AuthUser | null> {
|
||||||
|
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<void> {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { lastLoginAt: loggedInAt }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isLoginBlocked(email: string): Promise<boolean> {
|
||||||
|
const attempts = Number(await redis.get(loginAttemptKey(email)))
|
||||||
|
return Number.isFinite(attempts) && attempts >= config.maxFailedLoginAttempts
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordFailedLogin(email: string): Promise<void> {
|
||||||
|
const attempts = await redis.incr(loginAttemptKey(email))
|
||||||
|
if (attempts === 1) {
|
||||||
|
await redis.expire(loginAttemptKey(email), config.loginAttemptWindowSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearLoginAttempts(email: string): Promise<void> {
|
||||||
|
await redis.del(loginAttemptKey(email))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeEmailVerificationCode(email: string, code: string): Promise<void> {
|
||||||
|
await redis.set(
|
||||||
|
emailVerificationKey(email),
|
||||||
|
JSON.stringify({ code, email: email.toLowerCase() }),
|
||||||
|
'EX',
|
||||||
|
config.emailVerificationTtlSeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyEmailVerificationCode(email: string, code: string): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
await redis.set(
|
||||||
|
passwordResetKey(email),
|
||||||
|
JSON.stringify({ code, email: email.toLowerCase() }),
|
||||||
|
'EX',
|
||||||
|
config.emailVerificationTtlSeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPasswordResetCode(email: string, code: string): Promise<boolean> {
|
||||||
|
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<string> {
|
||||||
|
const token = createOpaqueToken()
|
||||||
|
await redis.set(refreshTokenKey(token), JSON.stringify({ userId }), 'EX', config.refreshTokenTtlSeconds)
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotateRefreshToken(refreshToken: string): Promise<AuthUser | null> {
|
||||||
|
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<void> {
|
||||||
|
await redis.del(refreshTokenKey(refreshToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createHandoffToken(userId: string, audienceApp: string): Promise<string> {
|
||||||
|
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<AuthUser | null> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<unknown> } }) => {
|
||||||
|
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))
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
})
|
||||||
+39
@@ -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<typeof createTransporter> | null = null
|
||||||
|
|
||||||
|
export async function sendEmailVerificationCode(to: string, code: string): Promise<void> {
|
||||||
|
transporter ??= createTransporter()
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: config.smtpFrom,
|
||||||
|
to,
|
||||||
|
subject: '[Autonomy Studio] 이메일 인증코드',
|
||||||
|
text: `Autonomy Studio 이메일 인증코드는 ${code} 입니다. ${Math.floor(config.emailVerificationTtlSeconds / 60)}분 안에 입력해주세요.`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family:Arial,sans-serif;line-height:1.5;color:#111827">
|
||||||
|
<h2 style="margin:0 0 12px">Autonomy Studio 이메일 인증</h2>
|
||||||
|
<p style="margin:0 0 16px">아래 인증코드를 회원가입 화면에 입력해주세요.</p>
|
||||||
|
<div style="font-size:28px;font-weight:700;letter-spacing:6px;padding:16px 18px;background:#f3f4f6;border-radius:8px;display:inline-block">${code}</div>
|
||||||
|
<p style="margin:16px 0 0;color:#6b7280;font-size:13px">${Math.floor(config.emailVerificationTtlSeconds / 60)}분 후 만료됩니다.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient()
|
||||||
@@ -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<void> {
|
||||||
|
redis.close()
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
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<boolean> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
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<AccessTokenClaims> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user