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