feat: add oauth
This commit is contained in:
		| @@ -83,6 +83,10 @@ | |||||||
|     "./models": { |     "./models": { | ||||||
|       "import": "./dist/models.mjs", |       "import": "./dist/models.mjs", | ||||||
|       "types": "./dist/models.d.ts" |       "types": "./dist/models.d.ts" | ||||||
|  |     }, | ||||||
|  |     "./oauth": { | ||||||
|  |       "import": "./dist/oauth.mjs", | ||||||
|  |       "types": "./dist/oauth.d.ts" | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @@ -13,20 +13,16 @@ const version = pkgs.version|| '1.0.0'; | |||||||
| const external = [ | const external = [ | ||||||
|   /@kevisual\/router(\/.*)?/, //, // 路由 |   /@kevisual\/router(\/.*)?/, //, // 路由 | ||||||
|   /@kevisual\/use-config(\/.*)?/, // |   /@kevisual\/use-config(\/.*)?/, // | ||||||
|   /@kevisual\/auth(\/.*)?/, // |  | ||||||
|  |  | ||||||
|   'sequelize', // 数据库 orm |   'sequelize', // 数据库 orm | ||||||
|   'ioredis', // redis |   'ioredis', // redis | ||||||
|   'socket.io', // socket.io |  | ||||||
|   'minio', // minio |   'minio', // minio | ||||||
|    |  | ||||||
|   'pm2', |  | ||||||
|    |  | ||||||
|   'pg', // pg |   'pg', // pg | ||||||
|   'pino', // pino |  | ||||||
|   'pino-pretty', // pino-pretty |  | ||||||
|   '@msgpack/msgpack', // msgpack |  | ||||||
| ] | ] | ||||||
|  | const replaceConfig = { | ||||||
|  |   preventAssignment: true, // 防止意外赋值 | ||||||
|  |   DEV_SERVER: JSON.stringify(isDev), // 替换 process.env.NODE_ENV | ||||||
|  |   VERSION: JSON.stringify(version), // 替换版本号 | ||||||
|  | } | ||||||
| /** | /** | ||||||
|  * @type {import('rollup').RollupOptions} |  * @type {import('rollup').RollupOptions} | ||||||
|  */ |  */ | ||||||
| @@ -35,50 +31,11 @@ const config = { | |||||||
|   output: { |   output: { | ||||||
|     dir: './dist', |     dir: './dist', | ||||||
|     entryFileNames: 'lib.mjs', |     entryFileNames: 'lib.mjs', | ||||||
|     chunkFileNames: '[name]-[hash].mjs', |  | ||||||
|     format: 'esm', |     format: 'esm', | ||||||
|   }, |   }, | ||||||
|   plugins: [ |   plugins: [ | ||||||
|     replace({ |     replace(replaceConfig), | ||||||
|       preventAssignment: true, // 防止意外赋值 |  | ||||||
|       DEV_SERVER: JSON.stringify(isDev), // 替换 process.env.NODE_ENV |  | ||||||
|       VERSION: JSON.stringify(version), // 替换版本号 |  | ||||||
|     }), |  | ||||||
|     alias({ |     alias({ | ||||||
|       // only esbuild needs to be configured |  | ||||||
|       entries: [ |  | ||||||
|         { find: '@', replacement: path.resolve('src') }, // 配置 @ 为 src 目录 |  | ||||||
|       ], |  | ||||||
|     }), |  | ||||||
|     resolve({ |  | ||||||
|       preferBuiltins: true, // 强制优先使用内置模块 |  | ||||||
|     }), |  | ||||||
|     commonjs(), |  | ||||||
|     esbuild({ |  | ||||||
|       target: 'node22', // 目标为 Node.js 14 |  | ||||||
|       minify: false, // 启用代码压缩 |  | ||||||
|       tsconfig: 'tsconfig.json', |  | ||||||
|     }), |  | ||||||
|     json(), |  | ||||||
|   ], |  | ||||||
|   external: external, |  | ||||||
| }; |  | ||||||
| const configCjs = { |  | ||||||
|   input: './src/lib.ts', |  | ||||||
|   output: { |  | ||||||
|     dir: './dist', |  | ||||||
|     entryFileNames: 'lib.cjs', |  | ||||||
|     chunkFileNames: '[name]-[hash].cjs', |  | ||||||
|     format: 'cjs', |  | ||||||
|   }, |  | ||||||
|   plugins: [ |  | ||||||
|     replace({ |  | ||||||
|       preventAssignment: true, // 防止意外赋值 |  | ||||||
|       DEV_SERVER: JSON.stringify(isDev), // 替换 process.env.NODE_ENV |  | ||||||
|       VERSION: JSON.stringify(version), // 替换版本号 |  | ||||||
|     }), |  | ||||||
|     alias({ |  | ||||||
|       // only esbuild needs to be configured |  | ||||||
|       entries: [ |       entries: [ | ||||||
|         { find: '@', replacement: path.resolve('src') }, // 配置 @ 为 src 目录 |         { find: '@', replacement: path.resolve('src') }, // 配置 @ 为 src 目录 | ||||||
|       ], |       ], | ||||||
| @@ -96,6 +53,7 @@ const configCjs = { | |||||||
|   ], |   ], | ||||||
|   external: external, |   external: external, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const dtsConfig = { | const dtsConfig = { | ||||||
|   input: './src/lib.ts', |   input: './src/lib.ts', | ||||||
|   output: { |   output: { | ||||||
| @@ -113,15 +71,10 @@ const systemConfig = [ | |||||||
|     output: { |     output: { | ||||||
|         dir: './dist', |         dir: './dist', | ||||||
|         entryFileNames: 'system.mjs', |         entryFileNames: 'system.mjs', | ||||||
|         chunkFileNames: '[name]-[hash].mjs', |  | ||||||
|         format: 'esm', |         format: 'esm', | ||||||
|     }, |     }, | ||||||
|     plugins: [ |     plugins: [ | ||||||
|       replace({ |       replace(replaceConfig), | ||||||
|         preventAssignment: true, // 防止意外赋值 |  | ||||||
|         DEV_SERVER: JSON.stringify(isDev), // 替换 process.env.NODE_ENV |  | ||||||
|         VERSION: JSON.stringify(version), // 替换版本号 |  | ||||||
|       }), |  | ||||||
|       alias({ |       alias({ | ||||||
|         entries: [ |         entries: [ | ||||||
|           { find: '@', replacement: path.resolve('src') }, // 配置 @ 为 src 目录 |           { find: '@', replacement: path.resolve('src') }, // 配置 @ 为 src 目录 | ||||||
| @@ -158,15 +111,10 @@ export const modelConfig = [ | |||||||
|     output: { |     output: { | ||||||
|       dir: './dist', |       dir: './dist', | ||||||
|       entryFileNames: 'models.mjs', |       entryFileNames: 'models.mjs', | ||||||
|       chunkFileNames: '[name]-[hash].mjs', |  | ||||||
|       format: 'esm', |       format: 'esm', | ||||||
|     }, |     }, | ||||||
|     plugins: [ |     plugins: [ | ||||||
|       replace({ |       replace(replaceConfig), | ||||||
|         preventAssignment: true, // 防止意外赋值 |  | ||||||
|         DEV_SERVER: JSON.stringify(isDev), // 替换 process.env.NODE_ENV |  | ||||||
|         VERSION: JSON.stringify(version), // 替换版本号 |  | ||||||
|       }), |  | ||||||
|       alias({ |       alias({ | ||||||
|         entries: [ |         entries: [ | ||||||
|           { find: '@', replacement: path.resolve('src') }, // 配置 @ 为 src 目录 |           { find: '@', replacement: path.resolve('src') }, // 配置 @ 为 src 目录 | ||||||
| @@ -199,4 +147,44 @@ export const modelConfig = [ | |||||||
|     ], |     ], | ||||||
|   }, |   }, | ||||||
| ] | ] | ||||||
| export default [config, dtsConfig, ...systemConfig, ...modelConfig]; | const oauthConfig = [ | ||||||
|  |   { | ||||||
|  |     input: './src/oauth/index.ts', | ||||||
|  |     output: { | ||||||
|  |       dir: './dist', | ||||||
|  |       entryFileNames: 'oauth.mjs', | ||||||
|  |       format: 'esm', | ||||||
|  |     }, | ||||||
|  |     plugins: [ | ||||||
|  |       replace(replaceConfig), | ||||||
|  |       alias({ | ||||||
|  |         entries: [ | ||||||
|  |           { find: '@', replacement: path.resolve('src') }, // 配置 @ 为 src 目录 | ||||||
|  |         ], | ||||||
|  |       }), | ||||||
|  |       resolve({ | ||||||
|  |         preferBuiltins: true, // 强制优先使用内置模块 | ||||||
|  |       }), | ||||||
|  |       commonjs(), | ||||||
|  |       esbuild({ | ||||||
|  |         target: 'node22', // 目标为 Node.js 14 | ||||||
|  |         minify: false, // 启用代码压缩 | ||||||
|  |         tsconfig: 'tsconfig.json', | ||||||
|  |       }), | ||||||
|  |       json(), | ||||||
|  |     ], | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     input: './src/oauth/index.ts', | ||||||
|  |     output: { | ||||||
|  |       dir: './dist', | ||||||
|  |       entryFileNames: 'oauth.d.ts', | ||||||
|  |       format: 'esm', | ||||||
|  |     }, | ||||||
|  |     plugins: [ | ||||||
|  |       dts(), | ||||||
|  |     ], | ||||||
|  |   }, | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | export default [config, dtsConfig, ...systemConfig, ...modelConfig, ...oauthConfig]; | ||||||
|   | |||||||
| @@ -1,4 +1,8 @@ | |||||||
|  | /** | ||||||
|  |  * Sequlize也不要了,只要核心的模块,sequelize自己默认已经有了 | ||||||
|  |  */ | ||||||
| import { UserServices, User, UserInit, UserModel } from './models/user.ts'; | import { UserServices, User, UserInit, UserModel } from './models/user.ts'; | ||||||
| import { Org, OrgInit, OrgModel } from './models/org.ts'; | import { Org, OrgInit, OrgModel } from './models/org.ts'; | ||||||
|  | import { addAuth } from './middleware/auth.ts'; | ||||||
| export { User, Org, UserServices, UserInit, OrgInit, UserModel, OrgModel }; | export { User, Org, UserServices, UserInit, OrgInit, UserModel, OrgModel }; | ||||||
|  | export { addAuth }; | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| /** | /** | ||||||
|  * 自己初始化redis和sequelize,的模块,放到useContextKey当中 |  * @description 自己初始化redis和sequelize,的模块,放到useContextKey当中 | ||||||
|  */ |  */ | ||||||
| import { app } from './app.ts'; | import { app } from './app.ts'; | ||||||
| import { UserServices, UserInit, UserModel, User } from './models/user.ts'; | import { UserServices, UserInit, UserModel, User } from './models/user.ts'; | ||||||
|   | |||||||
							
								
								
									
										53
									
								
								src/middleware/auth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								src/middleware/auth.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | |||||||
|  | import { User } from '../models/user.ts'; | ||||||
|  | import type { App } from '@kevisual/router'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 添加auth中间件, 用于验证token | ||||||
|  |  * @param app | ||||||
|  |  */ | ||||||
|  | export const addAuth = (app: App) => { | ||||||
|  |   app | ||||||
|  |     .route({ | ||||||
|  |       path: 'auth', | ||||||
|  |       id: 'auth', | ||||||
|  |     }) | ||||||
|  |     .define(async (ctx) => { | ||||||
|  |       const token = ctx.query.token; | ||||||
|  |       if (!token) { | ||||||
|  |         app.throw(401, 'Token is required'); | ||||||
|  |       } | ||||||
|  |       const user = await User.getOauthUser(token); | ||||||
|  |       if (!user) { | ||||||
|  |         app.throw(401, 'Token is invalid'); | ||||||
|  |       } | ||||||
|  |       if (ctx.state) { | ||||||
|  |         ctx.state.tokenUser = user; | ||||||
|  |       } else { | ||||||
|  |         ctx.state = { | ||||||
|  |           tokenUser: user, | ||||||
|  |         }; | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  |     .addTo(app); | ||||||
|  |  | ||||||
|  |   app | ||||||
|  |     .route({ | ||||||
|  |       path: 'auth', | ||||||
|  |       key: 'can', | ||||||
|  |       id: 'auth-can', | ||||||
|  |     }) | ||||||
|  |     .define(async (ctx) => { | ||||||
|  |       if (ctx.query?.token) { | ||||||
|  |         const token = ctx.query.token; | ||||||
|  |         const user = await User.getOauthUser(token); | ||||||
|  |         if (ctx.state) { | ||||||
|  |           ctx.state.tokenUser = user; | ||||||
|  |         } else { | ||||||
|  |           ctx.state = { | ||||||
|  |             tokenUser: user, | ||||||
|  |           }; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  |     .addTo(app); | ||||||
|  | }; | ||||||
| @@ -1,13 +1,14 @@ | |||||||
| import { useConfig } from '@kevisual/use-config'; | import { useConfig } from '@kevisual/use-config'; | ||||||
| import { DataTypes, Model, Op, Sequelize } from 'sequelize'; | import { DataTypes, Model, Op, Sequelize } from 'sequelize'; | ||||||
| import { createToken, checkToken } from '@kevisual/auth'; | import { nanoid, customAlphabet } from 'nanoid'; | ||||||
| import { cryptPwd } from '@kevisual/auth'; |  | ||||||
| import { customRandom, nanoid, customAlphabet } from 'nanoid'; |  | ||||||
| import { CustomError } from '@kevisual/router'; | import { CustomError } from '@kevisual/router'; | ||||||
| import { Org } from './org.ts'; | import { Org } from './org.ts'; | ||||||
|  |  | ||||||
| import { useContextKey } from '@kevisual/use-config/context'; | import { useContextKey } from '@kevisual/use-config/context'; | ||||||
| import { Redis } from 'ioredis'; | import { Redis } from 'ioredis'; | ||||||
|  | import { oauth } from '../oauth/auth.ts'; | ||||||
|  | import { cryptPwd } from '../oauth/salt.ts'; | ||||||
|  | import { OauthUser } from '../oauth/oauth.ts'; | ||||||
| export const redis = useContextKey<Redis>('redis'); | export const redis = useContextKey<Redis>('redis'); | ||||||
| const config = useConfig<{ tokenSecret: string }>(); | const config = useConfig<{ tokenSecret: string }>(); | ||||||
|  |  | ||||||
| @@ -25,6 +26,7 @@ export enum UserTypes { | |||||||
|  * 用户模型,在sequelize和Org之后初始化 |  * 用户模型,在sequelize和Org之后初始化 | ||||||
|  */ |  */ | ||||||
| export class User extends Model { | export class User extends Model { | ||||||
|  |   static oauth = oauth; | ||||||
|   declare id: string; |   declare id: string; | ||||||
|   declare username: string; |   declare username: string; | ||||||
|   declare nickname: string; // 昵称 |   declare nickname: string; // 昵称 | ||||||
| @@ -43,44 +45,51 @@ export class User extends Model { | |||||||
|     this.tokenUser = tokenUser; |     this.tokenUser = tokenUser; | ||||||
|   } |   } | ||||||
|   /** |   /** | ||||||
|    * uid 是用于 orgId 的用户id 真实用户的id |    * uid 是用于 orgId 的用户id, 如果uid存在,则表示是用户是组织,其中uid为真实用户 | ||||||
|    * @param uid |    * @param uid | ||||||
|    * @returns |    * @returns | ||||||
|    */ |    */ | ||||||
|   async createToken(uid?: string, loginType?: 'default' | 'plugin' | 'month' | 'season' | 'year') { |   async createToken(uid?: string, loginType?: 'default' | 'plugin' | 'month' | 'season' | 'year', expand: any = {}) { | ||||||
|     const { id, username, type } = this; |     const { id, username, type } = this; | ||||||
|     let expireTime = 60 * 60 * 24 * 7; // 7 days |     const oauthUser: OauthUser = { | ||||||
|     switch (loginType) { |       id, | ||||||
|       case 'plugin': |       username, | ||||||
|         expireTime = 60 * 60 * 24 * 30 * 12; // 365 days |       uid, | ||||||
|         break; |       userId: uid || id, // 必存在,真实用户id | ||||||
|       case 'month': |       type: type as 'user' | 'org', | ||||||
|         expireTime = 60 * 60 * 24 * 30; // 30 days |     }; | ||||||
|         break; |     if (uid) { | ||||||
|       case 'season': |       oauthUser.orgId = id; | ||||||
|         expireTime = 60 * 60 * 24 * 30 * 3; // 90 days |  | ||||||
|         break; |  | ||||||
|       case 'year': |  | ||||||
|         expireTime = 60 * 60 * 24 * 30 * 12; // 365 days |  | ||||||
|         break; |  | ||||||
|     } |     } | ||||||
|     const now = new Date().getTime(); |     const token = await oauth.generateToken(oauthUser, { type: loginType, hasRefreshToken: true, ...expand }); | ||||||
|     const token = await createToken({ id, username, uid, type }, config.tokenSecret); |     return { accessToken: token.accessToken, refreshToken: token.refreshToken, token: token.accessToken }; | ||||||
|     return { token, expireTime: now + expireTime }; |  | ||||||
|   } |   } | ||||||
|  |   /** | ||||||
|  |    * 验证token | ||||||
|  |    * @param token | ||||||
|  |    * @returns | ||||||
|  |    */ | ||||||
|   static async verifyToken(token: string) { |   static async verifyToken(token: string) { | ||||||
|     const ct = await checkToken(token, config.tokenSecret); |     return await oauth.verifyToken(token); | ||||||
|     const tokenUser = ct.payload; |   } | ||||||
|     return tokenUser; |   /** | ||||||
|  |    * 刷新token | ||||||
|  |    * @param refreshToken | ||||||
|  |    * @returns | ||||||
|  |    */ | ||||||
|  |   static async refreshToken(refreshToken: string) { | ||||||
|  |     const token = await oauth.refreshToken(refreshToken); | ||||||
|  |     return { accessToken: token.accessToken, refreshToken: token.refreshToken, token: token.accessToken }; | ||||||
|  |   } | ||||||
|  |   static async getOauthUser(token: string) { | ||||||
|  |     return await oauth.verifyToken(token); | ||||||
|   } |   } | ||||||
|   static async getUserByToken(token: string) { |   static async getUserByToken(token: string) { | ||||||
|     const ct = await checkToken(token, config.tokenSecret); |     const oauthUser = await oauth.verifyToken(token); | ||||||
|     const tokenUser = ct.payload; |     if (!oauthUser) { | ||||||
|     let userId = tokenUser.id; |       throw new CustomError('Token is invalid'); | ||||||
|     if (tokenUser.uid) { |  | ||||||
|       // 如果tokenUser.uid 存在,则表示是token是o用户的user,需要获取o的真实用户 |  | ||||||
|       userId = tokenUser.uid; |  | ||||||
|     } |     } | ||||||
|  |     const userId = oauthUser?.uid || oauthUser.id; | ||||||
|     const user = await User.findByPk(userId); |     const user = await User.findByPk(userId); | ||||||
|     return user; |     return user; | ||||||
|   } |   } | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								src/oauth/auth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/oauth/auth.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | import { OAuth, RedisTokenStore } from './oauth.ts'; | ||||||
|  | import { useContextKey } from '@kevisual/use-config/context'; | ||||||
|  | import { Redis } from 'ioredis'; | ||||||
|  |  | ||||||
|  | export const redis = useContextKey<Redis>('redis'); | ||||||
|  | export const oauth = useContextKey('oauth', () => new OAuth(new RedisTokenStore(redis))); | ||||||
							
								
								
									
										2
									
								
								src/oauth/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/oauth/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | export * from './oauth.ts'; | ||||||
|  | export * from './salt.ts'; | ||||||
							
								
								
									
										238
									
								
								src/oauth/oauth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										238
									
								
								src/oauth/oauth.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,238 @@ | |||||||
|  | /** | ||||||
|  |  * 一个生成和验证token的模块,不使用jwt,使用redis缓存, | ||||||
|  |  * token 分为两种,一种是access_token,一种是refresh_token | ||||||
|  |  * | ||||||
|  |  * access_token 用于验证用户是否登录,过期时间为1小时 | ||||||
|  |  * refresh_token 用于刷新access_token,过期时间为7天 | ||||||
|  |  * | ||||||
|  |  * 生成token时,会根据用户信息生成一个access_token和refresh_token,并缓存到redis中 | ||||||
|  |  * 验证token时,会根据token从redis中获取用户信息 | ||||||
|  |  * 刷新token时,会根据refresh_token生成一个新的access_token和refresh_token,并缓存到redis中 | ||||||
|  |  * | ||||||
|  |  * 并删除旧的access_token和refresh_token | ||||||
|  |  * | ||||||
|  |  * 生成token的方法,使用nanoid生成一个随机字符串 | ||||||
|  |  * 验证token的方法,使用redis的get方法验证token是否存在 | ||||||
|  |  * | ||||||
|  |  * 刷新token的方法,使用redis的set方法刷新token | ||||||
|  |  * | ||||||
|  |  * 缓存和获取都可以不使用redis,只是用可拓展的接口。store.get和store.set去实现。 | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | import { Redis } from 'ioredis'; | ||||||
|  | import { customAlphabet } from 'nanoid'; | ||||||
|  |  | ||||||
|  | export const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; | ||||||
|  | export const randomId16 = customAlphabet(alphabet, 16); | ||||||
|  | export const randomId24 = customAlphabet(alphabet, 24); | ||||||
|  | export const randomId32 = customAlphabet(alphabet, 32); | ||||||
|  | export const randomId64 = customAlphabet(alphabet, 64); | ||||||
|  |  | ||||||
|  | export type OauthUser = { | ||||||
|  |   /** | ||||||
|  |    * 真实用户,非org | ||||||
|  |    */ | ||||||
|  |   id: string; | ||||||
|  |   /** | ||||||
|  |    * 组织id,非必须存在 | ||||||
|  |    */ | ||||||
|  |   orgId?: string; | ||||||
|  |   /** | ||||||
|  |    * 必存在,真实用户id | ||||||
|  |    */ | ||||||
|  |   userId: string; | ||||||
|  |   /** | ||||||
|  |    * 当前用户的id,如果是org,则uid为org的id | ||||||
|  |    */ | ||||||
|  |   uid?: string; | ||||||
|  |   username: string; | ||||||
|  |   type?: 'user' | 'org'; // 用户类型,默认是user,token类型是用于token的扩展 | ||||||
|  |   oauthType?: 'user' | 'token'; // 用户类型,默认是user,token类型是用于token的扩展 | ||||||
|  |   oauthExpand?: UserExpand; | ||||||
|  | }; | ||||||
|  | export type UserExpand = { | ||||||
|  |   createTime?: number; | ||||||
|  |   refreshToken?: string; | ||||||
|  |   [key: string]: any; | ||||||
|  | } & StoreSetOpts; | ||||||
|  |  | ||||||
|  | type StoreSetOpts = { | ||||||
|  |   loginType?: 'default' | 'plugin' | 'month' | 'season' | 'year'; // 登陆类型 'default' | 'plugin' | 'month' | 'season' | 'year' | ||||||
|  |   expire?: number; // 过期时间,单位为秒 | ||||||
|  |   hasRefreshToken?: boolean; | ||||||
|  |   [key: string]: any; | ||||||
|  | }; | ||||||
|  | interface Store<T> { | ||||||
|  |   getObject: (key: string) => Promise<T>; | ||||||
|  |   setObject: (key: string, value: T, opts?: StoreSetOpts) => Promise<void>; | ||||||
|  |   expire: (key: string, ttl?: number) => Promise<void>; | ||||||
|  |   delObject: (key: string, value?: T) => Promise<void>; | ||||||
|  |   setToken: (value: { accessToken: string; refreshToken: string; value?: T }, opts?: StoreSetOpts) => Promise<void>; | ||||||
|  | } | ||||||
|  | export class RedisTokenStore implements Store<OauthUser> { | ||||||
|  |   private redis: Redis; | ||||||
|  |   private prefix: string = 'oauth:'; | ||||||
|  |   constructor(redis: Redis, prefix?: string) { | ||||||
|  |     this.redis = redis; | ||||||
|  |     this.prefix = prefix || this.prefix; | ||||||
|  |   } | ||||||
|  |   async set(key: string, value: string, ttl?: number) { | ||||||
|  |     await this.redis.set(this.prefix + key, value, 'EX', ttl); | ||||||
|  |   } | ||||||
|  |   async get(key: string) { | ||||||
|  |     return await this.redis.get(this.prefix + key); | ||||||
|  |   } | ||||||
|  |   async getObject(key: string) { | ||||||
|  |     try { | ||||||
|  |       const value = await this.get(key); | ||||||
|  |       if (!value) { | ||||||
|  |         return null; | ||||||
|  |       } | ||||||
|  |       return JSON.parse(value); | ||||||
|  |     } catch (error) { | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   async setObject(key: string, value: OauthUser, opts?: StoreSetOpts) { | ||||||
|  |     await this.set(key, JSON.stringify(value), opts?.expire); | ||||||
|  |   } | ||||||
|  |   async expire(key: string, ttl?: number) { | ||||||
|  |     await this.redis.expire(this.prefix + key, ttl); | ||||||
|  |   } | ||||||
|  |   async delObject(key: string, value?: OauthUser) { | ||||||
|  |     await this.redis.del(this.prefix + key); | ||||||
|  |     if (value) { | ||||||
|  |       // await this.redis.del(this.prefix + value.refreshToken); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   async setToken(data: { accessToken: string; refreshToken: string; value?: OauthUser }, opts?: StoreSetOpts) { | ||||||
|  |     const { accessToken, refreshToken, value } = data; | ||||||
|  |     let userPrefix = 'user:' + value?.id; | ||||||
|  |     if (value?.orgId) { | ||||||
|  |       userPrefix = 'org:' + value?.orgId + ':user:' + value?.id; | ||||||
|  |     } | ||||||
|  |     // 计算过期时间,根据opts.expire 和 opts.loginType | ||||||
|  |     // 如果expire存在,则使用expire,否则使用opts.loginType 进行计算; | ||||||
|  |     let expire = opts?.expire; | ||||||
|  |     if (!expire) { | ||||||
|  |       switch (opts.loginType) { | ||||||
|  |         case 'month': | ||||||
|  |           expire = 30 * 24 * 60 * 60; | ||||||
|  |           break; | ||||||
|  |         case 'season': | ||||||
|  |           expire = 90 * 24 * 60 * 60; | ||||||
|  |           break; | ||||||
|  |         default: | ||||||
|  |           expire = 25 * 60 * 60; // 默认过期时间为25小时 | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       expire = Math.min(expire, 60 * 60 * 24 * 30, 60 * 60 * 24 * 90); // 默认的过期时间最大为90天 | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     await this.set(accessToken, JSON.stringify(value), expire); | ||||||
|  |     await this.set(userPrefix + ':token:' + accessToken, accessToken, expire); | ||||||
|  |     if (refreshToken) { | ||||||
|  |       let refreshTokenExpire = Math.min(expire * 7, 60 * 60 * 24 * 30, 60 * 60 * 24 * 365); // 最大为一年 | ||||||
|  |       // 小于7天, 则设置为7天 | ||||||
|  |       if (refreshTokenExpire < 60 * 60 * 24 * 7) { | ||||||
|  |         refreshTokenExpire = 60 * 60 * 24 * 7; | ||||||
|  |       } | ||||||
|  |       await this.set(refreshToken, JSON.stringify(value), refreshTokenExpire); | ||||||
|  |       await this.set(userPrefix + ':refreshToken:' + refreshToken, refreshToken, refreshTokenExpire); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export class OAuth<T extends OauthUser> { | ||||||
|  |   private store: Store<T>; | ||||||
|  |  | ||||||
|  |   constructor(store: Store<T>) { | ||||||
|  |     this.store = store; | ||||||
|  |   } | ||||||
|  |   /** | ||||||
|  |    * 生成token | ||||||
|  |    * @param user | ||||||
|  |    * @returns | ||||||
|  |    */ | ||||||
|  |   async generateToken( | ||||||
|  |     user: T, | ||||||
|  |     expandOpts?: StoreSetOpts, | ||||||
|  |   ): Promise<{ | ||||||
|  |     accessToken: string; | ||||||
|  |     refreshToken?: string; | ||||||
|  |   }> { | ||||||
|  |     // 拥有refreshToken 为 true 时,accessToken 为 st_ 开头,refreshToken 为 rk_开头 | ||||||
|  |     // 意思是secretToken 和 secretKey的缩写 | ||||||
|  |     const accessToken = expandOpts?.hasRefreshToken ? 'st_' + randomId32() : 'sk_' + randomId64(); | ||||||
|  |     const refreshToken = expandOpts?.hasRefreshToken ? 'rk_' + randomId64() : null; | ||||||
|  |     // 初始化 appExpand | ||||||
|  |     user.oauthExpand = user.oauthExpand || {}; | ||||||
|  |     if (expandOpts) { | ||||||
|  |       user.oauthExpand = { | ||||||
|  |         ...user.oauthExpand, | ||||||
|  |         ...expandOpts, | ||||||
|  |         createTime: new Date().getTime(), // | ||||||
|  |       }; | ||||||
|  |       if (expandOpts?.hasRefreshToken) { | ||||||
|  |         user.oauthExpand.refreshToken = refreshToken; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     await this.store.setToken({ accessToken, refreshToken, value: user }, expandOpts); | ||||||
|  |  | ||||||
|  |     return { accessToken, refreshToken }; | ||||||
|  |   } | ||||||
|  |   /** | ||||||
|  |    * 验证token,如果token不存在,返回null | ||||||
|  |    * @param token | ||||||
|  |    * @returns | ||||||
|  |    */ | ||||||
|  |   async verifyToken(token: string) { | ||||||
|  |     return await this.store.getObject(token); | ||||||
|  |   } | ||||||
|  |   /** | ||||||
|  |    * 刷新token | ||||||
|  |    * @param refreshToken | ||||||
|  |    * @returns | ||||||
|  |    */ | ||||||
|  |   async refreshToken(refreshToken: string) { | ||||||
|  |     const user = await this.store.getObject(refreshToken); | ||||||
|  |     if (!user) { | ||||||
|  |       // 过期 | ||||||
|  |       throw new Error('Refresh token not found'); | ||||||
|  |     } | ||||||
|  |     const token = await this.generateToken(user, { | ||||||
|  |       ...user.oauthExpand, | ||||||
|  |       hasRefreshToken: true, | ||||||
|  |     }); | ||||||
|  |     // 删除旧的token | ||||||
|  |     await this.store.delObject(refreshToken, user); | ||||||
|  |     return token; | ||||||
|  |   } | ||||||
|  |   /** | ||||||
|  |    * 刷新token的过期时间 | ||||||
|  |    * expand 为扩展参数,可以扩展到user.oauthExpand中 | ||||||
|  |    * @param token | ||||||
|  |    * @returns | ||||||
|  |    */ | ||||||
|  |   async resetToken(accessToken: string, expand?: Record<string, any>) { | ||||||
|  |     const user = await this.store.getObject(accessToken); | ||||||
|  |     if (!user) { | ||||||
|  |       // 过期 | ||||||
|  |       throw new Error('token not found'); | ||||||
|  |     } | ||||||
|  |     user.oauthExpand = user.oauthExpand || {}; | ||||||
|  |     const refreshToken = user.oauthExpand.refreshToken; | ||||||
|  |     if (refreshToken) { | ||||||
|  |       await this.store.delObject(refreshToken, user); | ||||||
|  |     } | ||||||
|  |     user.oauthExpand = { | ||||||
|  |       ...user.oauthExpand, | ||||||
|  |       ...expand, | ||||||
|  |     }; | ||||||
|  |     const token = await this.generateToken(user, { | ||||||
|  |       ...user.oauthExpand, | ||||||
|  |       hasRefreshToken: true, | ||||||
|  |     }); | ||||||
|  |     return token; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										32
									
								
								src/oauth/salt.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/oauth/salt.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | |||||||
|  | import MD5 from 'crypto-js/md5.js'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 生成随机盐 | ||||||
|  |  * @returns | ||||||
|  |  */ | ||||||
|  | export const getRandomSalt = () => { | ||||||
|  |   return Math.random().toString().slice(2, 7); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 加密密码 | ||||||
|  |  * @param password | ||||||
|  |  * @param salt | ||||||
|  |  * @returns | ||||||
|  |  */ | ||||||
|  | export const cryptPwd = (password: string, salt = '') => { | ||||||
|  |   const saltPassword = password + ':' + salt; | ||||||
|  |   const md5 = MD5(saltPassword); | ||||||
|  |   return md5.toString(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Check password | ||||||
|  |  * @param password | ||||||
|  |  * @param salt | ||||||
|  |  * @param md5 | ||||||
|  |  * @returns | ||||||
|  |  */ | ||||||
|  | export const checkPwd = (password: string, salt: string, md5: string) => { | ||||||
|  |   return cryptPwd(password, salt) === md5; | ||||||
|  | }; | ||||||
		Reference in New Issue
	
	Block a user