489 lines
18 KiB
JavaScript
489 lines
18 KiB
JavaScript
import { generateNewIdAsync } from "oak-domain/lib/utils/uuid";
|
||
import { randomUUID } from "crypto";
|
||
import { composeFileUrl } from "../utils/cos/index.backend";
|
||
import assert from "assert";
|
||
import { checkOauthTokenAvaliable } from "../utils/oauth";
|
||
const oauthTokenEndpoint = {
|
||
name: "获取OAuth Token",
|
||
params: [],
|
||
method: 'post',
|
||
type: "free",
|
||
fn: async (contextBuilder, params, header, req, body) => {
|
||
const context = await contextBuilder();
|
||
const { client_id, client_secret, grant_type, code, redirect_uri } = body;
|
||
const [app] = await context.select("oauthApplication", {
|
||
data: {
|
||
id: 1,
|
||
clientSecret: 1,
|
||
},
|
||
filter: {
|
||
id: client_id,
|
||
}
|
||
}, {});
|
||
if (!app) {
|
||
await context.commit();
|
||
return {
|
||
statusCode: 400,
|
||
data: { error: "invalid_client", error_description: "Client not found", success: false }
|
||
};
|
||
}
|
||
if (app.clientSecret !== client_secret) {
|
||
await context.commit();
|
||
return {
|
||
statusCode: 400,
|
||
data: { error: "invalid_client", error_description: "Client secret mismatch", success: false }
|
||
};
|
||
}
|
||
// grant_type几种类型, 目前只支持authorization_code
|
||
if (grant_type !== "authorization_code") {
|
||
await context.commit();
|
||
return {
|
||
statusCode: 400,
|
||
data: { error: "unsupported_grant_type", error_description: "Only authorization_code grant type is supported", success: false }
|
||
};
|
||
}
|
||
// 找code的记录
|
||
const [authCodeRecord] = await context.select("oauthAuthorizationCode", {
|
||
data: {
|
||
id: 1,
|
||
code: 1,
|
||
redirectUri: 1,
|
||
userId: 1,
|
||
expiresAt: 1,
|
||
usedAt: 1,
|
||
},
|
||
filter: {
|
||
code,
|
||
}
|
||
}, {});
|
||
// 找不到记录
|
||
if (!authCodeRecord) {
|
||
await context.commit();
|
||
return {
|
||
statusCode: 400,
|
||
data: { error: "invalid_grant", error_description: "Invalid authorization code", success: false }
|
||
};
|
||
}
|
||
// 验证redirect_uri
|
||
if (authCodeRecord.redirectUri !== redirect_uri) {
|
||
await context.commit();
|
||
return {
|
||
statusCode: 400,
|
||
data: { error: "invalid_grant", error_description: "Redirect URI mismatch", success: false }
|
||
};
|
||
}
|
||
// 验证过期
|
||
if (authCodeRecord.expiresAt < Date.now()) {
|
||
await context.commit();
|
||
return {
|
||
statusCode: 400,
|
||
data: { error: "invalid_grant", error_description: "Authorization code expired", success: false }
|
||
};
|
||
}
|
||
// 验证是否已使用
|
||
if (authCodeRecord.usedAt) {
|
||
await context.commit();
|
||
return {
|
||
statusCode: 400,
|
||
data: { error: "invalid_grant", error_description: "Authorization code already used", success: false }
|
||
};
|
||
}
|
||
// 如果userId,code里面的appId都一样,则说明是这个用户在这个应用下第二次授权的,直接返回
|
||
const [existingToken] = await context.select("oauthToken", {
|
||
data: {
|
||
id: 1,
|
||
accessToken: 1,
|
||
refreshToken: 1,
|
||
accessExpiresAt: 1,
|
||
refreshExpiresAt: 1,
|
||
},
|
||
filter: {
|
||
userId: authCodeRecord.userId,
|
||
code: {
|
||
oauthAppId: app.id,
|
||
},
|
||
revokedAt: {
|
||
$exists: false,
|
||
}
|
||
}
|
||
}, {});
|
||
// 如果存在且未过期,直接返回
|
||
if (existingToken && (existingToken.accessExpiresAt > Date.now())) {
|
||
console.log("Existing valid token found, returning it directly");
|
||
// 刷新最后一次使用
|
||
await context.operate("oauthToken", {
|
||
id: await generateNewIdAsync(),
|
||
action: "update",
|
||
data: {
|
||
lastUsedAt: Date.now(),
|
||
},
|
||
filter: {
|
||
id: existingToken.id,
|
||
}
|
||
}, {});
|
||
// // 创建记录
|
||
// await context.operate("oauthUserAuthorization", {
|
||
// id: await generateNewIdAsync(),
|
||
// action: "update",
|
||
// data: {
|
||
// tokenId: existingToken.id,
|
||
// usageState: 'granted',
|
||
// },
|
||
// filter: {
|
||
// codeId: authCodeRecord.id,
|
||
// }
|
||
// }, {})
|
||
await context.commit();
|
||
return {
|
||
statusCode: 200,
|
||
data: {
|
||
access_token: existingToken.accessToken,
|
||
token_type: "Bearer",
|
||
expires_in: existingToken.accessExpiresAt - Date.now(),
|
||
refresh_token: existingToken.refreshToken,
|
||
refresh_expires_in: existingToken.refreshExpiresAt - Date.now(),
|
||
success: true,
|
||
}
|
||
};
|
||
}
|
||
const expiresIn = 3600; // 1 hour
|
||
const refreshTokenExpiresIn = 86400 * 30; // 30 days
|
||
// 如果过期就顺带刷新了
|
||
if (existingToken && (existingToken.refreshExpiresAt < Date.now())) {
|
||
console.log("Existing token expired, refreshing it");
|
||
const newAccessToken = randomUUID();
|
||
const newRefreshToken = randomUUID();
|
||
await context.operate("oauthToken", {
|
||
id: await generateNewIdAsync(),
|
||
action: "update",
|
||
data: {
|
||
lastUsedAt: Date.now(),
|
||
accessToken: newAccessToken,
|
||
refreshToken: newRefreshToken,
|
||
accessExpiresAt: Date.now() + expiresIn * 1000,
|
||
refreshExpiresAt: Date.now() + refreshTokenExpiresIn * 1000,
|
||
}
|
||
}, {});
|
||
// // 创建记录
|
||
// await context.operate("oauthUserAuthorization", {
|
||
// id: await generateNewIdAsync(),
|
||
// action: "update",
|
||
// data: {
|
||
// tokenId: existingToken.id,
|
||
// usageState: 'granted',
|
||
// },
|
||
// filter: {
|
||
// codeId: authCodeRecord.id,
|
||
// }
|
||
// }, {})
|
||
await context.commit();
|
||
return {
|
||
statusCode: 200,
|
||
data: {
|
||
access_token: newAccessToken,
|
||
token_type: "Bearer",
|
||
expires_in: expiresIn,
|
||
refresh_token: newRefreshToken,
|
||
refresh_expires_in: refreshTokenExpiresIn,
|
||
success: true,
|
||
}
|
||
};
|
||
}
|
||
// 有一种情况是access和refresh都过期了,但是用户又重新用code来换token
|
||
// 这种情况下不能刷新老的,也要当作新的处理
|
||
// 创建accessToken
|
||
const genaccessToken = randomUUID();
|
||
const refreshToken = randomUUID();
|
||
console.log("Creating new access token and refresh token");
|
||
// create
|
||
const tokenId = await generateNewIdAsync();
|
||
await context.operate("oauthToken", {
|
||
id: await generateNewIdAsync(),
|
||
action: "create",
|
||
data: {
|
||
id: tokenId,
|
||
accessToken: genaccessToken,
|
||
refreshToken: refreshToken,
|
||
userId: authCodeRecord.userId,
|
||
accessExpiresAt: Date.now() + expiresIn * 1000,
|
||
refreshExpiresAt: Date.now() + refreshTokenExpiresIn * 1000,
|
||
codeId: authCodeRecord.id,
|
||
}
|
||
}, {});
|
||
// 创建记录
|
||
await context.operate("oauthUserAuthorization", {
|
||
id: await generateNewIdAsync(),
|
||
action: "update",
|
||
data: {
|
||
tokenId: tokenId,
|
||
usageState: 'granted',
|
||
},
|
||
filter: {
|
||
codeId: authCodeRecord.id,
|
||
}
|
||
}, {});
|
||
// 标记code为已使用
|
||
await context.operate("oauthAuthorizationCode", {
|
||
id: await generateNewIdAsync(),
|
||
action: "update",
|
||
data: {
|
||
usedAt: Date.now(),
|
||
},
|
||
filter: {
|
||
id: authCodeRecord.id,
|
||
}
|
||
}, {});
|
||
await context.commit();
|
||
return {
|
||
statusCode: 200,
|
||
data: {
|
||
access_token: genaccessToken,
|
||
token_type: "Bearer",
|
||
expires_in: expiresIn,
|
||
refresh_token: refreshToken,
|
||
refresh_expires_in: refreshTokenExpiresIn,
|
||
success: true,
|
||
}
|
||
};
|
||
}
|
||
};
|
||
const oauthUserInfoEndpoint = {
|
||
name: "获取OAuth用户信息",
|
||
params: [],
|
||
method: 'get',
|
||
type: "free",
|
||
fn: async (contextBuilder, params, header, req, body) => {
|
||
const context = await contextBuilder();
|
||
const token = header.authorization; // Bearer token
|
||
const checkResult = await checkOauthTokenAvaliable(context, token);
|
||
if (checkResult.error) {
|
||
await context.commit();
|
||
return {
|
||
statusCode: checkResult.statusCode || 401,
|
||
data: { error: checkResult.error, success: false }
|
||
};
|
||
}
|
||
const tokenRecord = checkResult.tokenRecord;
|
||
assert(tokenRecord?.user, "User must be present in token record");
|
||
assert(tokenRecord?.code?.application, "Application must be present in token record");
|
||
const extrafile = tokenRecord.user.extraFile$entity?.[0];
|
||
const application = tokenRecord.code.application;
|
||
let avatarUrl = '';
|
||
if (extrafile) {
|
||
avatarUrl = composeFileUrl(application, extrafile);
|
||
}
|
||
await context.commit();
|
||
return {
|
||
statusCode: 200, data: {
|
||
userInfo: {
|
||
id: tokenRecord.user.id,
|
||
name: tokenRecord.user.name,
|
||
nickname: tokenRecord.user.nickname,
|
||
birth: tokenRecord.user.birth,
|
||
gender: tokenRecord.user.gender,
|
||
avatarUrl: avatarUrl,
|
||
},
|
||
error: null
|
||
}
|
||
};
|
||
}
|
||
};
|
||
const refreshTokenEndpoint = {
|
||
name: "刷新OAuth令牌",
|
||
params: [],
|
||
method: 'post',
|
||
type: "free",
|
||
fn: async (contextBuilder, params, header, req, body) => {
|
||
const { refresh_token, grant_type } = body;
|
||
const { authorization } = header;
|
||
// 暂时只支持 refresh_token 模式
|
||
if (grant_type !== "refresh_token") {
|
||
return {
|
||
statusCode: 400,
|
||
data: { error: "unsupported_grant_type", error_description: "Only refresh_token grant type is supported", success: false }
|
||
};
|
||
}
|
||
// 根据 RFC 6749 规范,请求参数在 Body 中,以表单格式(application/x-www-form-urlencoded)提交。
|
||
// authorization header 中包含 client_id 和 client_secret 的 Base64 编码
|
||
const decodedAuth = Buffer.from((authorization || '').split(' ')[1] || '', 'base64').toString('utf-8');
|
||
const [client_id, client_secret] = decodedAuth.split(':');
|
||
if (!client_id || !client_secret) {
|
||
return {
|
||
statusCode: 401,
|
||
data: { error: "invalid_client", error_description: "Missing client credentials", success: false }
|
||
};
|
||
}
|
||
if (!refresh_token) {
|
||
return {
|
||
statusCode: 400,
|
||
data: { error: "invalid_request", error_description: "Missing refresh_token", success: false }
|
||
};
|
||
}
|
||
const context = await contextBuilder();
|
||
const [oauthApp] = await context.select("oauthApplication", {
|
||
data: {
|
||
id: 1,
|
||
},
|
||
filter: {
|
||
clientSecret: client_secret,
|
||
id: client_id,
|
||
}
|
||
}, {});
|
||
if (!oauthApp) {
|
||
await context.commit();
|
||
return {
|
||
statusCode: 401,
|
||
data: { error: "invalid_client", error_description: "Client authentication failed", success: false }
|
||
};
|
||
}
|
||
const [tokenRecord] = await context.select("oauthToken", {
|
||
data: {
|
||
id: 1,
|
||
userId: 1,
|
||
accessToken: 1,
|
||
accessExpiresAt: 1,
|
||
refreshToken: 1,
|
||
refreshExpiresAt: 1,
|
||
code: {
|
||
applicationId: 1,
|
||
oauthApp: {
|
||
id: 1,
|
||
}
|
||
}
|
||
},
|
||
filter: {
|
||
refreshToken: refresh_token,
|
||
code: {
|
||
oauthAppId: oauthApp.id,
|
||
}
|
||
}
|
||
}, {});
|
||
if (!tokenRecord) {
|
||
await context.commit();
|
||
return {
|
||
statusCode: 400,
|
||
data: { error: "invalid_grant", error_description: "Invalid refresh token", success: false }
|
||
};
|
||
}
|
||
if (tokenRecord.refreshExpiresAt < Date.now()) {
|
||
await context.commit();
|
||
return {
|
||
statusCode: 400,
|
||
data: { error: "invalid_grant", error_description: "Refresh token expired", success: false }
|
||
};
|
||
}
|
||
// 生成新的令牌
|
||
const newAccessToken = randomUUID();
|
||
const newRefreshToken = randomUUID();
|
||
const expiresIn = 3600; // 1 hour
|
||
const refreshTokenExpiresIn = 86400 * 30; // 30 days
|
||
await context.operate("oauthToken", {
|
||
id: await generateNewIdAsync(),
|
||
action: "update",
|
||
data: {
|
||
accessToken: newAccessToken,
|
||
refreshToken: newRefreshToken,
|
||
accessExpiresAt: Date.now() + expiresIn * 1000,
|
||
refreshExpiresAt: Date.now() + refreshTokenExpiresIn * 1000,
|
||
},
|
||
filter: {
|
||
id: tokenRecord.id,
|
||
}
|
||
}, {});
|
||
await context.commit();
|
||
return {
|
||
statusCode: 200,
|
||
data: {
|
||
access_token: newAccessToken,
|
||
token_type: "Bearer",
|
||
expires_in: expiresIn,
|
||
refresh_token: newRefreshToken,
|
||
refresh_expires_in: refreshTokenExpiresIn,
|
||
success: true,
|
||
}
|
||
};
|
||
}
|
||
};
|
||
const oauthRevocationEndpoint = {
|
||
name: "撤销OAuth令牌",
|
||
params: [],
|
||
method: 'post',
|
||
type: "free",
|
||
fn: async (contextBuilder, params, header, req, body) => {
|
||
const { token, token_type_hint } = body;
|
||
const { authorization } = header;
|
||
// 1. 验证请求参数
|
||
if (!token) {
|
||
return {
|
||
statusCode: 400,
|
||
data: { error: "invalid_request", error_description: "Missing token parameter", success: false }
|
||
};
|
||
}
|
||
// 2. 客户端认证(使用 Basic Auth)
|
||
const decodedAuth = Buffer.from((authorization || '').split(' ')[1] || '', 'base64').toString('utf-8');
|
||
const [client_id, client_secret] = decodedAuth.split(':');
|
||
if (!client_id || !client_secret) {
|
||
return {
|
||
statusCode: 401,
|
||
data: { error: "invalid_client", error_description: "Missing client credentials", success: false }
|
||
};
|
||
}
|
||
const context = await contextBuilder();
|
||
const [oauthApp] = await context.select("oauthApplication", {
|
||
data: { id: 1 },
|
||
filter: { clientSecret: client_secret, id: client_id }
|
||
}, {});
|
||
if (!oauthApp) {
|
||
await context.commit();
|
||
return {
|
||
statusCode: 401,
|
||
data: { error: "invalid_client", error_description: "Client authentication failed", success: false }
|
||
};
|
||
}
|
||
// 3. 查找令牌记录
|
||
let tokenRecord = null;
|
||
const tokenProjection = {
|
||
data: { id: 1, code: { oauthAppId: 1 } },
|
||
filter: {}
|
||
};
|
||
// 尝试查找 Refresh Token
|
||
if (!token_type_hint || token_type_hint === 'refresh_token') {
|
||
tokenProjection.filter = { refreshToken: token, code: { oauthAppId: oauthApp.id } };
|
||
[tokenRecord] = await context.select("oauthToken", tokenProjection, {});
|
||
}
|
||
// 如果没找到,且 hint 不是 'refresh_token',则尝试查找 Access Token
|
||
if (!tokenRecord && (!token_type_hint || token_type_hint === 'access_token')) {
|
||
tokenProjection.filter = { accessToken: token, code: { oauthAppId: oauthApp.id } };
|
||
[tokenRecord] = await context.select("oauthToken", tokenProjection, {});
|
||
}
|
||
// 4. 撤销操作(无论找到与否,都返回 200,但如果找到则执行失效操作)
|
||
if (tokenRecord) {
|
||
const pastTime = Date.now() - 1000;
|
||
// 将 Access Token 和 Refresh Token 的过期时间都设为过去,使其立即失效
|
||
await context.operate("oauthToken", {
|
||
id: await generateNewIdAsync(),
|
||
action: "update",
|
||
data: {
|
||
accessExpiresAt: pastTime,
|
||
refreshExpiresAt: pastTime,
|
||
},
|
||
filter: {
|
||
id: tokenRecord.id,
|
||
}
|
||
}, {});
|
||
}
|
||
await context.commit();
|
||
// 5. RFC 7009 规定:令牌撤销成功或令牌无效时,返回 HTTP 200
|
||
return {
|
||
statusCode: 200,
|
||
data: {}
|
||
};
|
||
}
|
||
};
|
||
const endpoints = {
|
||
'oauth/access_token': oauthTokenEndpoint,
|
||
'oauth/userinfo': oauthUserInfoEndpoint,
|
||
'oauth/token': refreshTokenEndpoint,
|
||
};
|
||
export default endpoints;
|