feat: 添加JWKS管理功能,支持基于用户token创建新token
This commit is contained in:
@@ -17,6 +17,23 @@ await Bun.build({
|
|||||||
},
|
},
|
||||||
external,
|
external,
|
||||||
env: 'KEVISUAL_*',
|
env: 'KEVISUAL_*',
|
||||||
|
// 启用模块转换和优化
|
||||||
|
minify: false,
|
||||||
|
splitting: false,
|
||||||
|
sourcemap: 'external',
|
||||||
|
// 处理 CommonJS 到 ESM 的转换
|
||||||
|
plugins: [{
|
||||||
|
name: 'transform-requires',
|
||||||
|
setup(build) {
|
||||||
|
// 转换内置模块为 node: 前缀
|
||||||
|
build.onResolve({ filter: /^(path|fs|module|url|util|crypto|stream|buffer|events|http|https|net|os|querystring|zlib|cluster|child_process|worker_threads|perf_hooks|inspector|dgram|dns|tls|readline|repl|process|assert|vm|timers|constants|string_decoder|punycode|v8)$/ }, args => {
|
||||||
|
return {
|
||||||
|
path: `node:${args.path}`,
|
||||||
|
external: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
// const cmd = `dts -i src/index.ts -o app.d.ts`;
|
// const cmd = `dts -i src/index.ts -o app.d.ts`;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { BailianProvider } from '@kevisual/ai';
|
|||||||
import * as schema from './db/schema.ts';
|
import * as schema from './db/schema.ts';
|
||||||
import { config } from './modules/config.ts'
|
import { config } from './modules/config.ts'
|
||||||
import { db } from './modules/db.ts'
|
import { db } from './modules/db.ts'
|
||||||
|
|
||||||
export const router = useContextKey('router', () => new SimpleRouter());
|
export const router = useContextKey('router', () => new SimpleRouter());
|
||||||
export const runtime = useContextKey('runtime', () => {
|
export const runtime = useContextKey('runtime', () => {
|
||||||
return {
|
return {
|
||||||
@@ -41,4 +42,5 @@ export const ai = useContextKey('ai', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export { schema };
|
export { schema };
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useContextKey } from '@kevisual/context';
|
import { useContextKey } from '@kevisual/context';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { User } from './user.ts';
|
import { User } from './user.ts';
|
||||||
import { oauth } from '../oauth/auth.ts';
|
import { oauth, jwksManager } from '../oauth/auth.ts';
|
||||||
import { OauthUser } from '../oauth/oauth.ts';
|
import { OauthUser } from '../oauth/oauth.ts';
|
||||||
import { db } from '../../modules/db.ts';
|
import { db } from '../../modules/db.ts';
|
||||||
import { cfUserSecrets, cfUser } from '../../db/drizzle/schema.ts';
|
import { cfUserSecrets, cfUser } from '../../db/drizzle/schema.ts';
|
||||||
@@ -53,6 +53,10 @@ export class UserSecret {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
static async verifyToken(token: string) {
|
static async verifyToken(token: string) {
|
||||||
|
if (token?.includes?.('.')) {
|
||||||
|
// 先尝试作为jwt token验证,如果验证成功则直接返回用户信息
|
||||||
|
return await jwksManager.verify(token);
|
||||||
|
}
|
||||||
if (!oauth.isSecretKey(token)) {
|
if (!oauth.isSecretKey(token)) {
|
||||||
return await oauth.verifyToken(token);
|
return await oauth.verifyToken(token);
|
||||||
}
|
}
|
||||||
@@ -62,11 +66,11 @@ export class UserSecret {
|
|||||||
}
|
}
|
||||||
console.log('verifyToken: try to verify as secret key');
|
console.log('verifyToken: try to verify as secret key');
|
||||||
const userSecrets = await db.select().from(userSecretsTable).where(eq(userSecretsTable.token, token)).limit(1);
|
const userSecrets = await db.select().from(userSecretsTable).where(eq(userSecretsTable.token, token)).limit(1);
|
||||||
|
|
||||||
if (userSecrets.length === 0) {
|
if (userSecrets.length === 0) {
|
||||||
return null; // 如果没有找到对应的用户密钥,则返回null
|
return null; // 如果没有找到对应的用户密钥,则返回null
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSecret = new UserSecret(userSecrets[0]);
|
const userSecret = new UserSecret(userSecrets[0]);
|
||||||
if (userSecret.isExpired()) {
|
if (userSecret.isExpired()) {
|
||||||
return null; // 如果用户密钥已过期,则返回null
|
return null; // 如果用户密钥已过期,则返回null
|
||||||
@@ -97,13 +101,13 @@ export class UserSecret {
|
|||||||
*/
|
*/
|
||||||
static async findOne(where: { token?: string; id?: string }): Promise<UserSecret | null> {
|
static async findOne(where: { token?: string; id?: string }): Promise<UserSecret | null> {
|
||||||
let query = db.select().from(userSecretsTable);
|
let query = db.select().from(userSecretsTable);
|
||||||
|
|
||||||
if (where.token) {
|
if (where.token) {
|
||||||
query = query.where(eq(userSecretsTable.token, where.token)) as any;
|
query = query.where(eq(userSecretsTable.token, where.token)) as any;
|
||||||
} else if (where.id) {
|
} else if (where.id) {
|
||||||
query = query.where(eq(userSecretsTable.id, where.id)) as any;
|
query = query.where(eq(userSecretsTable.id, where.id)) as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const secrets = await query.limit(1);
|
const secrets = await query.limit(1);
|
||||||
return secrets.length > 0 ? new UserSecret(secrets[0]) : null;
|
return secrets.length > 0 ? new UserSecret(secrets[0]) : null;
|
||||||
}
|
}
|
||||||
@@ -119,12 +123,12 @@ export class UserSecret {
|
|||||||
owner: usersTable.owner,
|
owner: usersTable.owner,
|
||||||
data: usersTable.data,
|
data: usersTable.data,
|
||||||
}).from(usersTable).where(eq(usersTable.id, this.userId)).limit(1);
|
}).from(usersTable).where(eq(usersTable.id, this.userId)).limit(1);
|
||||||
|
|
||||||
let org: any = null;
|
let org: any = null;
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
return null; // 如果没有找到对应的用户,则返回null
|
return null; // 如果没有找到对应的用户,则返回null
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = users[0];
|
const user = users[0];
|
||||||
const expiredTime = this.expiredTime ? new Date(this.expiredTime).getTime() : null;
|
const expiredTime = this.expiredTime ? new Date(this.expiredTime).getTime() : null;
|
||||||
const oauthUser: Partial<OauthUser> = {
|
const oauthUser: Partial<OauthUser> = {
|
||||||
@@ -142,7 +146,7 @@ export class UserSecret {
|
|||||||
type: usersTable.type,
|
type: usersTable.type,
|
||||||
owner: usersTable.owner,
|
owner: usersTable.owner,
|
||||||
}).from(usersTable).where(eq(usersTable.id, this.orgId)).limit(1);
|
}).from(usersTable).where(eq(usersTable.id, this.orgId)).limit(1);
|
||||||
|
|
||||||
if (orgUsers.length > 0) {
|
if (orgUsers.length > 0) {
|
||||||
org = orgUsers[0];
|
org = orgUsers[0];
|
||||||
oauthUser.id = org.id;
|
oauthUser.id = org.id;
|
||||||
@@ -164,7 +168,7 @@ export class UserSecret {
|
|||||||
const expiredTime = new Date(this.expiredTime);
|
const expiredTime = new Date(this.expiredTime);
|
||||||
return now > expiredTime.getTime(); // 如果当前时间大于过期时间,则认为已过期
|
return now > expiredTime.getTime(); // 如果当前时间大于过期时间,则认为已过期
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否过期,如果过期则更新状态为expired
|
* 检查是否过期,如果过期则更新状态为expired
|
||||||
*
|
*
|
||||||
@@ -225,7 +229,7 @@ export class UserSecret {
|
|||||||
await this.save();
|
await this.save();
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async createToken() {
|
static async createToken() {
|
||||||
let token = oauth.generateSecretKey();
|
let token = oauth.generateSecretKey();
|
||||||
// 确保生成的token是唯一的
|
// 确保生成的token是唯一的
|
||||||
@@ -234,7 +238,7 @@ export class UserSecret {
|
|||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据 unionid 生成redis的key
|
* 根据 unionid 生成redis的key
|
||||||
* `wxmp:unionid:token:${unionid}`
|
* `wxmp:unionid:token:${unionid}`
|
||||||
@@ -244,13 +248,13 @@ export class UserSecret {
|
|||||||
static wxRedisKey(unionid: string) {
|
static wxRedisKey(unionid: string) {
|
||||||
return `wxmp:unionid:token:${unionid}`;
|
return `wxmp:unionid:token:${unionid}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static getExpiredTime(expireDays?: number) {
|
static getExpiredTime(expireDays?: number) {
|
||||||
const defaultExpireDays = expireDays || 365;
|
const defaultExpireDays = expireDays || 365;
|
||||||
const expireTime = defaultExpireDays * 24 * 60 * 60 * 1000;
|
const expireTime = defaultExpireDays * 24 * 60 * 60 * 1000;
|
||||||
return new Date(Date.now() + expireTime);
|
return new Date(Date.now() + expireTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async createSecret(tokenUser: { id: string; uid?: string, title?: string }, expireDays = 365) {
|
static async createSecret(tokenUser: { id: string; uid?: string, title?: string }, expireDays = 365) {
|
||||||
const token = await UserSecret.createToken();
|
const token = await UserSecret.createToken();
|
||||||
let userId = tokenUser.id;
|
let userId = tokenUser.id;
|
||||||
@@ -259,18 +263,18 @@ export class UserSecret {
|
|||||||
userId = tokenUser.uid;
|
userId = tokenUser.uid;
|
||||||
orgId = tokenUser.id;
|
orgId = tokenUser.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertData: Partial<typeof userSecretsTable.$inferInsert> = {
|
const insertData: Partial<typeof userSecretsTable.$inferInsert> = {
|
||||||
userId,
|
userId,
|
||||||
token,
|
token,
|
||||||
title: tokenUser.title || randomString(6),
|
title: tokenUser.title || randomString(6),
|
||||||
expiredTime: UserSecret.getExpiredTime(expireDays).toISOString(),
|
expiredTime: UserSecret.getExpiredTime(expireDays).toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (orgId !== null && orgId !== undefined) {
|
if (orgId !== null && orgId !== undefined) {
|
||||||
insertData.orgId = orgId;
|
insertData.orgId = orgId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inserted = await db.insert(userSecretsTable).values(insertData).returning();
|
const inserted = await db.insert(userSecretsTable).values(insertData).returning();
|
||||||
|
|
||||||
return new UserSecret(inserted[0]);
|
return new UserSecret(inserted[0]);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { nanoid, customAlphabet } from 'nanoid';
|
|||||||
import { CustomError } from '@kevisual/router';
|
import { CustomError } from '@kevisual/router';
|
||||||
import { useContextKey } from '@kevisual/context';
|
import { useContextKey } from '@kevisual/context';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { oauth } from '../oauth/auth.ts';
|
import { oauth, jwksManager } from '../oauth/auth.ts';
|
||||||
import { cryptPwd } from '../oauth/salt.ts';
|
import { cryptPwd } from '../oauth/salt.ts';
|
||||||
import { OauthUser } from '../oauth/oauth.ts';
|
import { OauthUser } from '../oauth/oauth.ts';
|
||||||
import { db } from '../../modules/db.ts';
|
import { db } from '../../modules/db.ts';
|
||||||
@@ -36,6 +36,9 @@ const userSecretsTable = cfUserSecrets;
|
|||||||
|
|
||||||
export const redis = useContextKey<Redis>('redis');
|
export const redis = useContextKey<Redis>('redis');
|
||||||
|
|
||||||
|
type TokenOptions = {
|
||||||
|
expire?: number; // 过期时间,单位秒
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* 用户模型,使用 Drizzle ORM
|
* 用户模型,使用 Drizzle ORM
|
||||||
*/
|
*/
|
||||||
@@ -69,7 +72,7 @@ export class User {
|
|||||||
* @param uid
|
* @param uid
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
async createToken(uid?: string, loginType?: 'default' | 'plugin' | 'month' | 'season' | 'year' | 'week', expand: any = {}) {
|
async createToken(uid?: string, loginType?: 'default' | 'plugin' | 'month' | 'season' | 'year' | 'week' | 'jwks', opts: TokenOptions = {}) {
|
||||||
const { id, username, type } = this;
|
const { id, username, type } = this;
|
||||||
const oauthUser: OauthUser = {
|
const oauthUser: OauthUser = {
|
||||||
id,
|
id,
|
||||||
@@ -81,7 +84,21 @@ export class User {
|
|||||||
if (uid) {
|
if (uid) {
|
||||||
oauthUser.orgId = id;
|
oauthUser.orgId = id;
|
||||||
}
|
}
|
||||||
const token = await oauth.generateToken(oauthUser, { type: loginType, hasRefreshToken: true, ...expand });
|
if (loginType === 'jwks') {
|
||||||
|
const accessToken = await jwksManager.sign({
|
||||||
|
sub: 'user:' + this.id,
|
||||||
|
name: this.username,
|
||||||
|
});
|
||||||
|
const expiresIn = opts?.expire ?? 2 * 3600; // 2 hours
|
||||||
|
return {
|
||||||
|
accessToken: accessToken,
|
||||||
|
refreshToken: null,
|
||||||
|
token: accessToken,
|
||||||
|
refreshTokenExpiresIn: null,
|
||||||
|
accessTokenExpiresIn: expiresIn
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const token = await oauth.generateToken(oauthUser, { type: loginType, hasRefreshToken: true, ...opts });
|
||||||
return {
|
return {
|
||||||
accessToken: token.accessToken,
|
accessToken: token.accessToken,
|
||||||
refreshToken: token.refreshToken,
|
refreshToken: token.refreshToken,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { OAuth, RedisTokenStore } from './oauth.ts';
|
import { OAuth, RedisTokenStore } from './oauth.ts';
|
||||||
import { useContextKey } from '@kevisual/context';
|
import { useContextKey } from '@kevisual/context';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
|
import { manager } from '../models/jwks-manager.ts';
|
||||||
|
|
||||||
export const oauth = useContextKey('oauth', () => {
|
export const oauth = useContextKey('oauth', () => {
|
||||||
const redis = useContextKey<Redis>('redis');
|
const redis = useContextKey<Redis>('redis');
|
||||||
@@ -16,3 +17,7 @@ export const oauth = useContextKey('oauth', () => {
|
|||||||
const oauth = new OAuth(store);
|
const oauth = new OAuth(store);
|
||||||
return oauth;
|
return oauth;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const jwksManager = useContextKey('jwksManager', () => manager);
|
||||||
|
|
||||||
|
await manager.init()
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './oauth.ts';
|
export * from './oauth.ts';
|
||||||
export * from './salt.ts';
|
export * from './salt.ts';
|
||||||
|
export * from './auth.ts';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { router } from '@/app.ts'
|
import { router } from '@/app.ts'
|
||||||
import { manager } from '@/modules/jwks/index.ts'
|
import { manager } from '@/auth/models/jwks-manager.ts'
|
||||||
router.all('/api/convex/jwks.json', async (req, res) => {
|
router.all('/api/convex/jwks.json', async (req, res) => {
|
||||||
const jwks = await manager.getJWKS()
|
const jwks = await manager.getJWKS()
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
|||||||
@@ -17,4 +17,6 @@ import './secret-key/list.ts';
|
|||||||
|
|
||||||
import './wx-login.ts'
|
import './wx-login.ts'
|
||||||
|
|
||||||
import './cnb-login.ts';
|
import './cnb-login.ts';
|
||||||
|
|
||||||
|
import './jwks.ts';
|
||||||
33
src/routes/user/jwks.ts
Normal file
33
src/routes/user/jwks.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { app } from '@/app.ts'
|
||||||
|
import { UserModel } from '@/auth/index.ts';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'user',
|
||||||
|
key: 'token-create',
|
||||||
|
description: '根据用户token创建一个新的token,主要用于临时访问',
|
||||||
|
middleware: ['auth'],
|
||||||
|
metadata: {
|
||||||
|
args: {
|
||||||
|
loginType: z.enum(['jwks']).optional(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const user = await UserModel.getUserByToken(ctx.query.token);
|
||||||
|
const loginType = ctx.query?.loginType ?? 'jwks';
|
||||||
|
if (!user) {
|
||||||
|
ctx.throw(404, 'user not found');
|
||||||
|
}
|
||||||
|
if (loginType !== 'jwks') {
|
||||||
|
ctx.throw(400, 'unsupported login type');
|
||||||
|
}
|
||||||
|
let expire = ctx.query.expire ?? 24 * 3600;
|
||||||
|
// 大于24小时的过期时间需要管理员权限
|
||||||
|
if (expire > 24 * 3600) {
|
||||||
|
expire = 2 * 3600;
|
||||||
|
}
|
||||||
|
const value = await user.createToken(null, loginType, {
|
||||||
|
expire: expire, // 24小时过期
|
||||||
|
})
|
||||||
|
ctx.body = value
|
||||||
|
}).addTo(app)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { manager } from '@/modules/jwks/index.ts'
|
import { manager } from '@/auth/models/jwks-manager.ts'
|
||||||
|
|
||||||
await manager.init()
|
await manager.init()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user