feat: 添加JWKS管理功能,支持基于用户token创建新token
This commit is contained in:
130
src/auth/models/jwks-manager.ts
Normal file
130
src/auth/models/jwks-manager.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { signJWT, decodeJWT, type JWTPayload, verifyJWT } from '@kevisual/auth'
|
||||
import { generate } from '@kevisual/auth'
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
export const getPath = async (dir: string) => {
|
||||
const JWKS_PATH = path.join(dir, 'jwks.json');
|
||||
const PRIVATE_JWK_PATH = path.join(dir, 'privateKey.json');
|
||||
const PRIVATE_KEY_PATH = path.join(dir, 'privateKey.txt');
|
||||
const PUBLIC_KEY_PATH = path.join(dir, 'publicKey.txt');
|
||||
return {
|
||||
JWKS_PATH,
|
||||
PRIVATE_JWK_PATH,
|
||||
PRIVATE_KEY_PATH,
|
||||
PUBLIC_KEY_PATH,
|
||||
}
|
||||
}
|
||||
|
||||
export const jwksGenerate = async (opts: { dir: string }) => {
|
||||
const dir = path.isAbsolute(opts.dir) ? opts.dir : path.join(process.cwd(), opts.dir);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const { JWKS_PATH, PRIVATE_JWK_PATH, PRIVATE_KEY_PATH, PUBLIC_KEY_PATH } = await getPath(dir);
|
||||
const { jwks, privateJWK, privatePEM, publicPEM } = await generate();
|
||||
fs.writeFileSync(PUBLIC_KEY_PATH, publicPEM);
|
||||
fs.writeFileSync(PRIVATE_KEY_PATH, privatePEM);
|
||||
fs.writeFileSync(PRIVATE_JWK_PATH, JSON.stringify(privateJWK, null, 2));
|
||||
fs.writeFileSync(JWKS_PATH, JSON.stringify(jwks, null, 2));
|
||||
console.log(`Keys 已保存到目录: ${dir}`);
|
||||
}
|
||||
|
||||
|
||||
interface JWKSPaths {
|
||||
JWKS_PATH: string
|
||||
PRIVATE_JWK_PATH: string
|
||||
PRIVATE_KEY_PATH: string
|
||||
PUBLIC_KEY_PATH: string
|
||||
}
|
||||
|
||||
interface JWKSContent {
|
||||
jwks: string
|
||||
privateJWK: string
|
||||
privateKey: string
|
||||
publicKey: string
|
||||
}
|
||||
|
||||
export class JWKSManager {
|
||||
private paths: JWKSPaths | null = null
|
||||
private content: JWKSContent | null = null
|
||||
|
||||
constructor(private basePath?: string) {
|
||||
this.basePath = basePath || path.join(process.cwd(), 'storage/jwks')
|
||||
}
|
||||
|
||||
async init() {
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(this.basePath!)) {
|
||||
fs.mkdirSync(this.basePath!, { recursive: true })
|
||||
}
|
||||
|
||||
// 获取所有路径
|
||||
this.paths = await getPath(this.basePath!)
|
||||
|
||||
// 如果 JWKS 文件不存在,则生成
|
||||
if (!fs.existsSync(this.paths.JWKS_PATH)) {
|
||||
await jwksGenerate({ dir: this.basePath! })
|
||||
console.log(`JWKS 创建成功,路径: ${this.paths.JWKS_PATH}`)
|
||||
}
|
||||
|
||||
// 加载所有内容到内存
|
||||
await this.loadContent()
|
||||
|
||||
return this
|
||||
}
|
||||
async checkInit() {
|
||||
if (!this.content) {
|
||||
await this.init()
|
||||
}
|
||||
}
|
||||
private async loadContent() {
|
||||
if (!this.paths) {
|
||||
await this.init()
|
||||
}
|
||||
|
||||
this.content = {
|
||||
jwks: fs.readFileSync(this.paths.JWKS_PATH, 'utf-8'),
|
||||
privateJWK: fs.readFileSync(this.paths.PRIVATE_JWK_PATH, 'utf-8'),
|
||||
privateKey: fs.readFileSync(this.paths.PRIVATE_KEY_PATH, 'utf-8'),
|
||||
publicKey: fs.readFileSync(this.paths.PUBLIC_KEY_PATH, 'utf-8')
|
||||
}
|
||||
}
|
||||
|
||||
async sign(payload: JWTPayload): Promise<string> {
|
||||
await this.checkInit()
|
||||
return signJWT(payload, this.content.privateKey)
|
||||
}
|
||||
async verify(token: string) {
|
||||
await this.checkInit()
|
||||
return verifyJWT(token, this.content.publicKey)
|
||||
}
|
||||
async decode(token: string) {
|
||||
await this.checkInit()
|
||||
return decodeJWT(token)
|
||||
}
|
||||
async getJWKS() {
|
||||
await this.checkInit()
|
||||
return JSON.parse(this.content.jwks)
|
||||
}
|
||||
|
||||
async getPrivateJWK() {
|
||||
await this.checkInit()
|
||||
return JSON.parse(this.content.privateJWK)
|
||||
}
|
||||
|
||||
async getPublicKey() {
|
||||
await this.checkInit()
|
||||
return this.content.publicKey
|
||||
}
|
||||
|
||||
async getPrivateKey() {
|
||||
await this.checkInit()
|
||||
return this.content.privateKey
|
||||
}
|
||||
|
||||
getPaths() {
|
||||
return this.paths
|
||||
}
|
||||
}
|
||||
|
||||
export const manager = new JWKSManager()
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import { Redis } from 'ioredis';
|
||||
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 { db } from '../../modules/db.ts';
|
||||
import { cfUserSecrets, cfUser } from '../../db/drizzle/schema.ts';
|
||||
@@ -53,6 +53,10 @@ export class UserSecret {
|
||||
* @returns
|
||||
*/
|
||||
static async verifyToken(token: string) {
|
||||
if (token?.includes?.('.')) {
|
||||
// 先尝试作为jwt token验证,如果验证成功则直接返回用户信息
|
||||
return await jwksManager.verify(token);
|
||||
}
|
||||
if (!oauth.isSecretKey(token)) {
|
||||
return await oauth.verifyToken(token);
|
||||
}
|
||||
@@ -62,11 +66,11 @@ export class UserSecret {
|
||||
}
|
||||
console.log('verifyToken: try to verify as secret key');
|
||||
const userSecrets = await db.select().from(userSecretsTable).where(eq(userSecretsTable.token, token)).limit(1);
|
||||
|
||||
|
||||
if (userSecrets.length === 0) {
|
||||
return null; // 如果没有找到对应的用户密钥,则返回null
|
||||
}
|
||||
|
||||
|
||||
const userSecret = new UserSecret(userSecrets[0]);
|
||||
if (userSecret.isExpired()) {
|
||||
return null; // 如果用户密钥已过期,则返回null
|
||||
@@ -97,13 +101,13 @@ export class UserSecret {
|
||||
*/
|
||||
static async findOne(where: { token?: string; id?: string }): Promise<UserSecret | null> {
|
||||
let query = db.select().from(userSecretsTable);
|
||||
|
||||
|
||||
if (where.token) {
|
||||
query = query.where(eq(userSecretsTable.token, where.token)) as any;
|
||||
} else if (where.id) {
|
||||
query = query.where(eq(userSecretsTable.id, where.id)) as any;
|
||||
}
|
||||
|
||||
|
||||
const secrets = await query.limit(1);
|
||||
return secrets.length > 0 ? new UserSecret(secrets[0]) : null;
|
||||
}
|
||||
@@ -119,12 +123,12 @@ export class UserSecret {
|
||||
owner: usersTable.owner,
|
||||
data: usersTable.data,
|
||||
}).from(usersTable).where(eq(usersTable.id, this.userId)).limit(1);
|
||||
|
||||
|
||||
let org: any = null;
|
||||
if (users.length === 0) {
|
||||
return null; // 如果没有找到对应的用户,则返回null
|
||||
}
|
||||
|
||||
|
||||
const user = users[0];
|
||||
const expiredTime = this.expiredTime ? new Date(this.expiredTime).getTime() : null;
|
||||
const oauthUser: Partial<OauthUser> = {
|
||||
@@ -142,7 +146,7 @@ export class UserSecret {
|
||||
type: usersTable.type,
|
||||
owner: usersTable.owner,
|
||||
}).from(usersTable).where(eq(usersTable.id, this.orgId)).limit(1);
|
||||
|
||||
|
||||
if (orgUsers.length > 0) {
|
||||
org = orgUsers[0];
|
||||
oauthUser.id = org.id;
|
||||
@@ -164,7 +168,7 @@ export class UserSecret {
|
||||
const expiredTime = new Date(this.expiredTime);
|
||||
return now > expiredTime.getTime(); // 如果当前时间大于过期时间,则认为已过期
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 检查是否过期,如果过期则更新状态为expired
|
||||
*
|
||||
@@ -225,7 +229,7 @@ export class UserSecret {
|
||||
await this.save();
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
static async createToken() {
|
||||
let token = oauth.generateSecretKey();
|
||||
// 确保生成的token是唯一的
|
||||
@@ -234,7 +238,7 @@ export class UserSecret {
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 根据 unionid 生成redis的key
|
||||
* `wxmp:unionid:token:${unionid}`
|
||||
@@ -244,13 +248,13 @@ export class UserSecret {
|
||||
static wxRedisKey(unionid: string) {
|
||||
return `wxmp:unionid:token:${unionid}`;
|
||||
}
|
||||
|
||||
|
||||
static getExpiredTime(expireDays?: number) {
|
||||
const defaultExpireDays = expireDays || 365;
|
||||
const expireTime = defaultExpireDays * 24 * 60 * 60 * 1000;
|
||||
return new Date(Date.now() + expireTime);
|
||||
}
|
||||
|
||||
|
||||
static async createSecret(tokenUser: { id: string; uid?: string, title?: string }, expireDays = 365) {
|
||||
const token = await UserSecret.createToken();
|
||||
let userId = tokenUser.id;
|
||||
@@ -259,18 +263,18 @@ export class UserSecret {
|
||||
userId = tokenUser.uid;
|
||||
orgId = tokenUser.id;
|
||||
}
|
||||
|
||||
|
||||
const insertData: Partial<typeof userSecretsTable.$inferInsert> = {
|
||||
userId,
|
||||
token,
|
||||
title: tokenUser.title || randomString(6),
|
||||
expiredTime: UserSecret.getExpiredTime(expireDays).toISOString(),
|
||||
};
|
||||
|
||||
|
||||
if (orgId !== null && orgId !== undefined) {
|
||||
insertData.orgId = orgId;
|
||||
}
|
||||
|
||||
|
||||
const inserted = await db.insert(userSecretsTable).values(insertData).returning();
|
||||
|
||||
return new UserSecret(inserted[0]);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { nanoid, customAlphabet } from 'nanoid';
|
||||
import { CustomError } from '@kevisual/router';
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import { Redis } from 'ioredis';
|
||||
import { oauth } from '../oauth/auth.ts';
|
||||
import { oauth, jwksManager } from '../oauth/auth.ts';
|
||||
import { cryptPwd } from '../oauth/salt.ts';
|
||||
import { OauthUser } from '../oauth/oauth.ts';
|
||||
import { db } from '../../modules/db.ts';
|
||||
@@ -36,6 +36,9 @@ const userSecretsTable = cfUserSecrets;
|
||||
|
||||
export const redis = useContextKey<Redis>('redis');
|
||||
|
||||
type TokenOptions = {
|
||||
expire?: number; // 过期时间,单位秒
|
||||
}
|
||||
/**
|
||||
* 用户模型,使用 Drizzle ORM
|
||||
*/
|
||||
@@ -69,7 +72,7 @@ export class User {
|
||||
* @param uid
|
||||
* @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 oauthUser: OauthUser = {
|
||||
id,
|
||||
@@ -81,7 +84,21 @@ export class User {
|
||||
if (uid) {
|
||||
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 {
|
||||
accessToken: token.accessToken,
|
||||
refreshToken: token.refreshToken,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { OAuth, RedisTokenStore } from './oauth.ts';
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import { Redis } from 'ioredis';
|
||||
import { manager } from '../models/jwks-manager.ts';
|
||||
|
||||
export const oauth = useContextKey('oauth', () => {
|
||||
const redis = useContextKey<Redis>('redis');
|
||||
@@ -16,3 +17,7 @@ export const oauth = useContextKey('oauth', () => {
|
||||
const oauth = new OAuth(store);
|
||||
return oauth;
|
||||
});
|
||||
|
||||
export const jwksManager = useContextKey('jwksManager', () => manager);
|
||||
|
||||
await manager.init()
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './oauth.ts';
|
||||
export * from './salt.ts';
|
||||
export * from './salt.ts';
|
||||
export * from './auth.ts';
|
||||
Reference in New Issue
Block a user