feat: 添加JWKS token支持,更新用户和OAuth相关逻辑

This commit is contained in:
2026-02-21 06:29:11 +08:00
parent 672208ab6b
commit 71c238f953
6 changed files with 120 additions and 33 deletions

View File

@@ -38,6 +38,11 @@ export const redis = useContextKey<Redis>('redis');
type TokenOptions = { type TokenOptions = {
expire?: number; // 过期时间,单位秒 expire?: number; // 过期时间,单位秒
ip?: string; // 用户ID默认为当前用户ID
browser?: string; // 浏览器信息
host?: string; // 主机信息
wx?: any;
loginWith?: string; // 登录方式,如 'cli', 'web', 'plugin' 等
} }
/** /**
* 用户模型,使用 Drizzle ORM * 用户模型,使用 Drizzle ORM
@@ -85,21 +90,25 @@ export class User {
oauthUser.orgId = id; oauthUser.orgId = id;
} }
if (loginType === 'jwks') { if (loginType === 'jwks') {
const expiresIn = opts?.expire ?? 2 * 3600; // 2 hours
const accessToken = await jwksManager.sign({ const accessToken = await jwksManager.sign({
sub: 'user:' + this.id, sub: 'user:' + this.id,
name: this.username, name: this.username,
exp: Math.floor(Date.now() / 1000) + expiresIn,
}); });
const expiresIn = opts?.expire ?? 2 * 3600; // 2 hours await oauth.setJwksToken(accessToken, { id: this.id, expire: expiresIn });
return { return {
type: 'jwks',
accessToken: accessToken, accessToken: accessToken,
refreshToken: null, refreshToken: accessToken,
token: accessToken, token: accessToken,
refreshTokenExpiresIn: null, refreshTokenExpiresIn: expiresIn,
accessTokenExpiresIn: expiresIn accessTokenExpiresIn: expiresIn
}; };
} }
const token = await oauth.generateToken(oauthUser, { type: loginType, hasRefreshToken: true, ...opts }); const token = await oauth.generateToken(oauthUser, { type: loginType, hasRefreshToken: true, ...opts });
return { return {
type: 'default',
accessToken: token.accessToken, accessToken: token.accessToken,
refreshToken: token.refreshToken, refreshToken: token.refreshToken,
token: token.accessToken, token: token.accessToken,
@@ -121,8 +130,73 @@ export class User {
* @returns * @returns
*/ */
static async refreshToken(refreshToken: string) { static async refreshToken(refreshToken: string) {
if (refreshToken?.includes?.('.')) {
// 可能是 jwks token
const jwksToken = await oauth.getJwksToken(refreshToken);
if (!jwksToken) {
throw new CustomError('Invalid refresh token');
}
const decoded = await jwksManager.decode(refreshToken);
const sub = decoded.sub;
const username = decoded.name;
const expiresIn = 2 * 3600; // 2 hours
const newToken = await jwksManager.sign({
sub,
name: username,
exp: Math.floor(Date.now() / 1000) + expiresIn,
});
oauth.setJwksToken(newToken, { id: sub.replace('user:', ''), expire: expiresIn });
return {
type: 'jwks',
accessToken: newToken,
refreshToken: newToken,
token: newToken,
refreshTokenExpiresIn: expiresIn,
accessTokenExpiresIn: expiresIn,
}
}
const token = await oauth.refreshToken(refreshToken); const token = await oauth.refreshToken(refreshToken);
return { accessToken: token.accessToken, refreshToken: token.refreshToken, token: token.accessToken }; return {
type: 'default',
accessToken: token.accessToken,
refreshToken: token.refreshToken,
token: token.accessToken,
accessTokenExpiresIn: token.accessTokenExpiresIn,
refreshTokenExpiresIn: token.refreshTokenExpiresIn,
};
}
/**
* 重置token立即过期token
* @param token
* @returns
*/
static async resetToken(refreshToken: string, expand?: Record<string, any>) {
if (refreshToken?.includes?.('.')) {
// 可能是 jwks token
const jwksToken = await oauth.getJwksToken(refreshToken);
if (!jwksToken) {
throw new CustomError('Invalid refresh token');
}
const decoded = await jwksManager.decode(refreshToken);
const sub = decoded.sub;
const username = decoded.name;
const expiresIn = 2 * 3600; // 2 hours
const newToken = await jwksManager.sign({
sub,
name: username,
exp: Math.floor(Date.now() / 1000) + expiresIn,
});
oauth.setJwksToken(newToken, { id: sub.replace('user:', ''), expire: expiresIn });
return {
type: 'jwks',
accessToken: newToken,
refreshToken: newToken,
token: newToken,
refreshTokenExpiresIn: expiresIn,
accessTokenExpiresIn: expiresIn,
};
}
return await oauth.resetToken(refreshToken, expand);
} }
static async getOauthUser(token: string) { static async getOauthUser(token: string) {
return await UserSecret.verifyToken(token); return await UserSecret.verifyToken(token);

View File

@@ -74,7 +74,7 @@ interface Store<T> {
delKeys: (keys: string[]) => Promise<number>; delKeys: (keys: string[]) => Promise<number>;
} }
type TokenData = { export type TokenData = {
accessToken: string; accessToken: string;
accessTokenExpiresIn?: number; accessTokenExpiresIn?: number;
refreshToken?: string; refreshToken?: string;
@@ -401,4 +401,24 @@ export class OAuth<T extends OauthUser> {
const tokens = await this.store.keys('*'); const tokens = await this.store.keys('*');
await this.store.delKeys(tokens); await this.store.delKeys(tokens);
} }
/**
* 设置 jwks token 用于jwt的验证 过期时间为2小时
*/
async setJwksToken(token: string, opts: { id: string; expire: number }) {
const expire = opts.expire ?? 2 * 3600; // 2 hours
const id = opts.id || '';
// jwks token的过期时间比accessToken多3天确保3天内可以用来refresh token
const addExpire = 3 * 24 * 3600;
await this.store.redis.set('user:jwks:' + token, id, 'EX', expire + addExpire);
}
async deleteJwsToken(token: string) {
await this.store.redis.expire('user:jwks:' + token, 0);
}
async getJwksToken(token: string) {
const id = await this.store.redis.get('user:jwks:' + token);
if (id) {
this.deleteJwsToken(token);
}
return id;
}
} }

View File

@@ -5,3 +5,5 @@ import { useKey } from "@kevisual/use-config";
* 用来放cookie的域名 * 用来放cookie的域名
*/ */
export const proxyDomain = useKey('PROXY_DOMAIN') || ''; // 请在这里填写你的域名 export const proxyDomain = useKey('PROXY_DOMAIN') || ''; // 请在这里填写你的域名
export const baseProxyUrl = proxyDomain ? `https://${proxyDomain}` : 'https://kevisual.cn';

View File

@@ -6,6 +6,7 @@ import { logger } from '../logger.ts';
import { getLoginUser } from '@/modules/auth.ts'; import { getLoginUser } from '@/modules/auth.ts';
import { createStudioAppListHtml } from '../html/studio-app-list/index.ts'; import { createStudioAppListHtml } from '../html/studio-app-list/index.ts';
import { omit } from 'es-toolkit'; import { omit } from 'es-toolkit';
import { baseProxyUrl, proxyDomain } from '../domain.ts';
type ProxyOptions = { type ProxyOptions = {
createNotFoundPage: (msg?: string) => any; createNotFoundPage: (msg?: string) => any;
@@ -70,7 +71,7 @@ export const UserV1Proxy = async (req: IncomingMessage, res: ServerResponse, opt
const path = searchParams.get('path'); const path = searchParams.get('path');
if (!path) { if (!path) {
// 显示前端页面 // 显示前端页面
const html = fetch('https://kevisual.cn/root/router-studio/index.html').then(res => res.text()); const html = fetch(`${baseProxyUrl}/root/router-studio/index.html`).then(res => res.text());
res.writeHead(200, { 'Content-Type': 'text/html' }); res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(await html); res.end(await html);
return true; return true;

View File

@@ -9,7 +9,7 @@ import z from 'zod';
* @param ctx * @param ctx
* @returns * @returns
*/ */
export const createCookie = (token: { accessToken?: string; token?: string }, ctx: any) => { export const createCookie = (token: { accessToken?: string; token?: string, type?: string; }, ctx: any) => {
if (!domain) { if (!domain) {
return; return;
} }
@@ -17,6 +17,11 @@ export const createCookie = (token: { accessToken?: string; token?: string }, ct
logger.debug('登陆用户没有请求对象不需要创建cookie'); logger.debug('登陆用户没有请求对象不需要创建cookie');
return return
} }
// if (!token.type || token.type === 'jwks') {
// // 如果是jwks类型的token不创建cookie
// // 因为jwks类型的token自己就能检测是否过期了不需要依赖cookie了
// return;
// }
//TODO, 获取访问的 hostname 如果访问的和 domain 的不一致也创建cookie //TODO, 获取访问的 hostname 如果访问的和 domain 的不一致也创建cookie
const browser = ctx?.req?.headers['user-agent']; const browser = ctx?.req?.headers['user-agent'];
const isBrowser = browser.includes('Mozilla'); // 浏览器 const isBrowser = browser.includes('Mozilla'); // 浏览器
@@ -139,7 +144,7 @@ app
} }
if (tokenUser.id === user.id) { if (tokenUser.id === user.id) {
// 自己刷新自己的token // 自己刷新自己的token
const token = await User.oauth.resetToken(oldToken, { const token = await User.resetToken(oldToken, {
...tokenUser.oauthExpand, ...tokenUser.oauthExpand,
}); });
createCookie(token, ctx); createCookie(token, ctx);
@@ -156,9 +161,7 @@ app
browser: someInfo['user-agent'], browser: someInfo['user-agent'],
host: someInfo.host, host: someInfo.host,
}); });
createCookie({ createCookie(token, ctx);
token: token.accessToken
}, ctx);
ctx.body = token; ctx.body = token;
}) })
.addTo(app); .addTo(app);
@@ -263,10 +266,8 @@ app
const accessUser = await User.verifyToken(accessToken); const accessUser = await User.verifyToken(accessToken);
const refreshToken = accessUser.oauthExpand?.refreshToken; const refreshToken = accessUser.oauthExpand?.refreshToken;
if (refreshToken) { if (refreshToken) {
const result = await User.oauth.refreshToken(refreshToken); const result = await User.refreshToken(refreshToken);
createCookie({ createCookie(token, ctx);
token: result.accessToken
}, ctx);
ctx.body = result; ctx.body = result;
return; return;
@@ -276,9 +277,7 @@ app
...accessUser.oauthExpand, ...accessUser.oauthExpand,
hasRefreshToken: true, hasRefreshToken: true,
}); });
createCookie({ createCookie(result, ctx);
token: result.accessToken
}, ctx);
ctx.body = result; ctx.body = result;
return; return;
} }
@@ -332,18 +331,13 @@ app
const orgsList = [tokenUser.username, user.username, , ...orgs]; const orgsList = [tokenUser.username, user.username, , ...orgs];
if (orgsList.includes(username)) { if (orgsList.includes(username)) {
if (tokenUsername === username) { if (tokenUsername === username) {
const result = await User.oauth.resetToken(token); const result = await User.resetToken(token);
createCookie({ createCookie(result, ctx);
token: result.accessToken,
}, ctx);
await User.oauth.delToken(token);
ctx.body = result; ctx.body = result;
} else { } else {
const user = await User.findOne({ username }); const user = await User.findOne({ username });
const result = await user.createToken(userId, 'default'); const result = await user.createToken(userId, 'default');
createCookie({ createCookie(result, ctx);
token: result.accessToken,
}, ctx);
ctx.body = result; ctx.body = result;
} }
} else { } else {
@@ -371,12 +365,10 @@ app
if (!refreshToken) { if (!refreshToken) {
ctx.throw(400, 'Refresh Token is required'); ctx.throw(400, 'Refresh Token is required');
} }
const result = await User.oauth.refreshToken(refreshToken); const result = await User.refreshToken(refreshToken);
if (result) { if (result) {
console.log('refreshToken result', result); console.log('refreshToken result', result);
createCookie({ createCookie(result, ctx);
token: result.accessToken,
}, ctx);
ctx.body = result; ctx.body = result;
} else { } else {
ctx.throw(500, 'Refresh Token Failed, please login again'); ctx.throw(500, 'Refresh Token Failed, please login again');

View File

@@ -119,9 +119,7 @@ app
const token = JSON.parse(data); const token = JSON.parse(data);
if (token.accessToken) { if (token.accessToken) {
ctx.body = token; ctx.body = token;
createCookie({ createCookie(token, ctx);
token: token.accessToken,
}, ctx);
} else { } else {
ctx.throw(500, 'Checked error Failed, login failed, please login again'); ctx.throw(500, 'Checked error Failed, login failed, please login again');
} }