feat: 添加JWKS token支持,更新用户和OAuth相关逻辑
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user