diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8d2f688 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +TIMEZONE=Asia/Singapore + +# PostgreSQL 配置 +POSTGRES_VERSION=latest + +# PostgreSQL Env +POSTGRES_DB=nice +POSTGRES_USER=nice +POSTGRES_PASSWORD=nice + +# Redis 配置 +REDIS_VERSION=7.2.4 +REDIS_PASSWORD=nice + +# MinIO 配置 +MINIO_VERSION=latest +MINIO_ACCESS_KEY=nice +MINIO_SECRET_KEY=nice123 +# Elasticsearch 配置 +ELASTIC_VERSION=9.0.1 +ELASTIC_PASSWORD=nice_elastic_password diff --git a/.gitignore b/.gitignore index af3063c..edd0df0 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ npm-debug.log* *.pem -packages/db/generated \ No newline at end of file +packages/db/generated +volumes \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index a6050da..029adf6 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -12,6 +12,7 @@ "@types/oidc-provider": "^9.1.0", "hono": "^4.7.10", "ioredis": "5.4.1", + "jose": "^6.0.11", "minio": "7.1.3", "nanoid": "^5.1.5", "node-cron": "^4.0.7", diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index d5bfac3..3550582 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -44,6 +44,7 @@ app.get('/', (c) => { app.use('/oidc/*', async (c, next) => { // @ts-ignore await oidc.callback(c.req.raw, c.res.raw) - return c.finalize() + // return void 也可以 + return }) export default app diff --git a/apps/backend/src/oidc/config.ts b/apps/backend/src/oidc/config.ts index c91b3f9..09c71f6 100644 --- a/apps/backend/src/oidc/config.ts +++ b/apps/backend/src/oidc/config.ts @@ -1,37 +1,108 @@ import { Configuration } from 'oidc-provider'; -import { nanoid } from 'nanoid'; +import { RedisAdapter } from './redis-adapter'; +import { prisma } from '@repo/db'; + +async function getClients() { + const dbClients = await prisma.oidcClient.findMany?.(); + const dbClientList = (dbClients && dbClients.length > 0) + ? dbClients.map(c => ({ + client_id: c.clientId, + client_secret: c.clientSecret, + grant_types: JSON.parse(c.grantTypes), // string -> string[] + redirect_uris: JSON.parse(c.redirectUris), // string -> string[] + response_types: JSON.parse(c.responseTypes), // string -> string[] + scope: c.scope, + })) + : []; + + // 管理后台client,通过环境变量读取 + const defaultClient = { + client_id: process.env.OIDC_CLIENT_ID || 'admin-client', + client_secret: process.env.OIDC_CLIENT_SECRET || 'admin-secret', + grant_types: ['authorization_code', 'refresh_token'], + redirect_uris: [process.env.OIDC_REDIRECT_URI || 'http://localhost:3000/callback'], + response_types: ['code'], + scope: 'openid email profile', + }; + + // 检查是否与数据库client_id重复 + const allClients = [defaultClient, ...dbClientList.filter(c => c.client_id !== defaultClient.client_id)]; + + return allClients; +} + +const OIDC_COOKIE_KEY = process.env.OIDC_COOKIE_KEY || 'HrbEPlzByV0CcjFJhl2pjKV2iG8FgQIc'; const config: Configuration = { - clients: [ - { - client_id: 'example-client', - client_secret: 'example-secret', - grant_types: ['authorization_code', 'refresh_token'], - redirect_uris: ['http://localhost:3000/callback'], - response_types: ['code'], - scope: 'openid email profile', - }, - ], + adapter: RedisAdapter, + // 注意:clients字段现在是Promise,需在Provider初始化时await + clients: await getClients(), pkce: { - required: () => true, // 要求所有客户端使用PKCE + required: () => true, }, features: { - devInteractions: { enabled: false }, // 禁用开发交互界面 - resourceIndicators: { enabled: true }, // 启用资源指示器 - revocation: { enabled: true }, // 启用令牌撤销 - userinfo: { enabled: true }, // 启用用户信息端点 + devInteractions: { enabled: false }, + resourceIndicators: { enabled: true }, + revocation: { enabled: true }, + userinfo: { enabled: true }, + registration: { enabled: true }, }, cookies: { - keys: [nanoid()], // 用于签署和验证cookie + keys: [OIDC_COOKIE_KEY], }, jwks: { - keys: [], // 在实际环境中应该生成并保存密钥 + keys: [ + { + d: 'VEZOsY07JTFzGTqv6cC2Y32vsfChind2I_TTuvV225_-0zrSej3XLRg8iE_u0-3GSgiGi4WImmTwmEgLo4Qp3uEcxCYbt4NMJC7fwT2i3dfRZjtZ4yJwFl0SIj8TgfQ8ptwZbFZUlcHGXZIr4nL8GXyQT0CK8wy4COfmymHrrUoyfZA154ql_OsoiupSUCRcKVvZj2JHL2KILsq_sh_l7g2dqAN8D7jYfJ58MkqlknBMa2-zi5I0-1JUOwztVNml_zGrp27UbEU60RqV3GHjoqwI6m01U7K0a8Q_SQAKYGqgepbAYOA-P4_TLl5KC4-WWBZu_rVfwgSENwWNEhw8oQ', + dp: 'E1Y-SN4bQqX7kP-bNgZ_gEv-pixJ5F_EGocHKfS56jtzRqQdTurrk4jIVpI-ZITA88lWAHxjD-OaoJUh9Jupd_lwD5Si80PyVxOMI2xaGQiF0lbKJfD38Sh8frRpgelZVaK_gm834B6SLfxKdNsP04DsJqGKktODF_fZeaGFPH0', + dq: 'F90JPxevQYOlAgEH0TUt1-3_hyxY6cfPRU2HQBaahyWrtCWpaOzenKZnvGFZdg-BuLVKjCchq3G_70OLE-XDP_ol0UTJmDTT-WyuJQdEMpt_WFF9yJGoeIu8yohfeLatU-67ukjghJ0s9CBzNE_LrGEV6Cup3FXywpSYZAV3iqc', + e: 'AQAB', + kty: 'RSA', + n: 'xwQ72P9z9OYshiQ-ntDYaPnnfwG6u9JAdLMZ5o0dmjlcyrvwQRdoFIKPnO65Q8mh6F_LDSxjxa2Yzo_wdjhbPZLjfUJXgCzm54cClXzT5twzo7lzoAfaJlkTsoZc2HFWqmcri0BuzmTFLZx2Q7wYBm0pXHmQKF0V-C1O6NWfd4mfBhbM-I1tHYSpAMgarSm22WDMDx-WWI7TEzy2QhaBVaENW9BKaKkJklocAZCxk18WhR0fckIGiWiSM5FcU1PY2jfGsTmX505Ub7P5Dz75Ygqrutd5tFrcqyPAtPTFDk8X1InxkkUwpP3nFU5o50DGhwQolGYKPGtQ-ZtmbOfcWQ', + p: '5wC6nY6Ev5FqcLPCqn9fC6R9KUuBej6NaAVOKW7GXiOJAq2WrileGKfMc9kIny20zW3uWkRLm-O-3Yzze1zFpxmqvsvCxZ5ERVZ6leiNXSu3tez71ZZwp0O9gys4knjrI-9w46l_vFuRtjL6XEeFfHEZFaNJpz-lcnb3w0okrbM', + q: '3I1qeEDslZFB8iNfpKAdWtz_Wzm6-jayT_V6aIvhvMj5mnU-Xpj75zLPQSGa9wunMlOoZW9w1wDO1FVuDhwzeOJaTm-Ds0MezeC4U6nVGyyDHb4CUA3ml2tzt4yLrqGYMT7XbADSvuWYADHw79OFjEi4T3s3tJymhaBvy1ulv8M', + qi: 'wSbXte9PcPtr788e713KHQ4waE26CzoXx-JNOgN0iqJMN6C4_XJEX-cSvCZDf4rh7xpXN6SGLVd5ibIyDJi7bbi5EQ5AXjazPbLBjRthcGXsIuZ3AtQyR0CEWNSdM7EyM5TRdyZQ9kftfz9nI03guW3iKKASETqX2vh0Z8XRjyU', + use: 'sig', + }, { + crv: 'P-256', + d: 'K9xfPv773dZR22TVUB80xouzdF7qCg5cWjPjkHyv7Ws', + kty: 'EC', + use: 'sig', + x: 'FWZ9rSkLt6Dx9E3pxLybhdM6xgR5obGsj5_pqmnz5J4', + y: '_n8G69C-A2Xl4xUW2lF0i8ZGZnk_KPYrhv4GbTGu5G4', + }, + ], }, ttl: { - AccessToken: 3600, // 1小时 - AuthorizationCode: 600, // 10分钟 - IdToken: 3600, // 1小时 - RefreshToken: 1209600, // 14天 + AccessToken: 3600, + AuthorizationCode: 600, + IdToken: 3600, + RefreshToken: 1209600, + BackchannelAuthenticationRequest: 600, + ClientCredentials: 600, + DeviceCode: 600, + Grant: 1209600, + Interaction: 3600, + Session: 1209600, + RegistrationAccessToken: 3600, + DPoPProof: 300, + PushedAuthorizationRequest: 600, + ReplayDetection: 3600, + LogoutToken: 600, + }, + findAccount: async (ctx, id) => { + const user = await prisma.user.findUnique({ where: { id } }); + if (!user) return undefined; + return { + accountId: user.id, + async claims() { + return { + sub: user.id, + email: user.email, + name: user.name, + }; + }, + }; }, }; diff --git a/apps/backend/src/oidc/provider.ts b/apps/backend/src/oidc/provider.ts index cc725b9..aca144d 100644 --- a/apps/backend/src/oidc/provider.ts +++ b/apps/backend/src/oidc/provider.ts @@ -1,6 +1,6 @@ import { Provider } from 'oidc-provider'; import config from './config'; -const oidc = new Provider('http://localhost:4000', config); +const oidc = new Provider('http://localhost:3000', config); export default oidc; \ No newline at end of file diff --git a/apps/backend/src/oidc/redis-adapter.ts b/apps/backend/src/oidc/redis-adapter.ts new file mode 100644 index 0000000..d62fe20 --- /dev/null +++ b/apps/backend/src/oidc/redis-adapter.ts @@ -0,0 +1,86 @@ +import type { Adapter, AdapterPayload } from 'oidc-provider'; +import redis from '../redis'; + +export class RedisAdapter implements Adapter { + name: string; + constructor(name: string) { + this.name = name; + } + + key(id: string) { + return `${this.name}:${id}`; + } + + async upsert(id: string, payload: AdapterPayload, expiresIn: number) { + const key = this.key(id); + await redis.set(key, JSON.stringify(payload), 'EX', expiresIn); + if (payload && payload.grantId) { + // 记录grantId到id的映射,便于revokeByGrantId + await redis.sadd(this.grantKey(payload.grantId), id); + await redis.expire(this.grantKey(payload.grantId), expiresIn); + } + if (payload && payload.userCode) { + await redis.set(this.userCodeKey(payload.userCode), id, 'EX', expiresIn); + } + if (payload && payload.uid) { + await redis.set(this.uidKey(payload.uid), id, 'EX', expiresIn); + } + } + + async find(id: string) { + const data = await redis.get(this.key(id)); + return data ? JSON.parse(data) : undefined; + } + + async findByUserCode(userCode: string) { + const id = await redis.get(this.userCodeKey(userCode)); + return id ? this.find(id) : undefined; + } + + async findByUid(uid: string) { + const id = await redis.get(this.uidKey(uid)); + return id ? this.find(id) : undefined; + } + + async destroy(id: string) { + const data = await this.find(id); + await redis.del(this.key(id)); + if (data && data.grantId) { + await redis.srem(this.grantKey(data.grantId), id); + } + if (data && data.userCode) { + await redis.del(this.userCodeKey(data.userCode)); + } + if (data && data.uid) { + await redis.del(this.uidKey(data.uid)); + } + } + + async revokeByGrantId(grantId: string) { + const key = this.grantKey(grantId); + const ids = await redis.smembers(key); + if (ids && ids.length) { + await Promise.all(ids.map((id) => this.destroy(id))); + } + await redis.del(key); + } + + async consume(id: string) { + const key = this.key(id); + const data = await this.find(id); + if (data) { + data.consumed = Math.floor(Date.now() / 1000); + await redis.set(key, JSON.stringify(data)); + } + } + + grantKey(grantId: string) { + return `${this.name}:grant:${grantId}`; + } + userCodeKey(userCode: string) { + return `${this.name}:userCode:${userCode}`; + } + uidKey(uid: string) { + return `${this.name}:uid:${uid}`; + } +} diff --git a/apps/backend/src/redis.ts b/apps/backend/src/redis.ts index b2aebe3..494dc10 100644 --- a/apps/backend/src/redis.ts +++ b/apps/backend/src/redis.ts @@ -4,7 +4,7 @@ import Redis from 'ioredis'; const redis = new Redis({ host: 'localhost', // 根据实际情况配置 port: 6379, - // password: 'yourpassword', // 如有需要 + password: process.env.REDIS_PASSWORD }); export default redis; \ No newline at end of file diff --git a/apps/backend/src/trpc.ts b/apps/backend/src/trpc.ts index db54440..9e751a2 100644 --- a/apps/backend/src/trpc.ts +++ b/apps/backend/src/trpc.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { initTRPC } from '@trpc/server' +import { userRouter } from './user/user.trpc' const t = initTRPC.create() @@ -7,9 +8,7 @@ export const publicProcedure = t.procedure export const router = t.router export const appRouter = router({ - hello: publicProcedure.input(z.string().nullish()).query(({ input }) => { - return `Hello ${input ?? 'World'}!` - }), + user: userRouter }) export type AppRouter = typeof appRouter \ No newline at end of file diff --git a/apps/backend/src/user/userindex.ts b/apps/backend/src/user/user.index.ts similarity index 100% rename from apps/backend/src/user/userindex.ts rename to apps/backend/src/user/user.index.ts diff --git a/apps/backend/src/user/routes.ts b/apps/backend/src/user/user.rest.ts similarity index 57% rename from apps/backend/src/user/routes.ts rename to apps/backend/src/user/user.rest.ts index 51031a0..7cfb3ad 100644 --- a/apps/backend/src/user/routes.ts +++ b/apps/backend/src/user/user.rest.ts @@ -1,17 +1,17 @@ import { Hono } from "hono"; -import { createUser, searchUser } from "./userindex"; +import { createUser, searchUser } from "./user.index"; -const userRoute = new Hono(); +const userRest = new Hono(); -userRoute.post('/', async (c) => { +userRest.post('/', async (c) => { const user = await c.req.json(); const result = await createUser(user); return c.json(result); }); -userRoute.get('/search', async (c) => { +userRest.get('/search', async (c) => { const q = c.req.query('q') || ''; const result = await searchUser(q); return c.json(result.hits.hits); }); -export default userRoute; \ No newline at end of file +export default userRest; \ No newline at end of file diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index c9841e8..b76f59b 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,46 +1,47 @@ 'use client'; -import { api } from '@repo/client'; -import { Button } from '@repo/ui/components/button'; -import { useState } from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { httpBatchLink } from '@trpc/client'; +// import { api } from '@repo/client'; +// import { Button } from '@repo/ui/components/button'; +// import { useState } from 'react'; +// import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +// import { httpBatchLink } from '@trpc/client'; -function HomeContent() { - const [name, setName] = useState(''); - const helloQuery = api.hello.useQuery(name || undefined); +// function HomeContent() { +// const [name, setName] = useState(''); +// const helloQuery = api.hello.useQuery(name || undefined); - return ( -