bfe39e45a9
5. 修改统一认证登录和管理员登录是通过接口形式进行,存储返回的accessToken。 6. 修改交叉评查的部分样式
907 lines
30 KiB
TypeScript
907 lines
30 KiB
TypeScript
/**
|
||
* 认证服务核心实现
|
||
*
|
||
* 这个文件包含了用户认证、会话管理、Token 刷新等核心功能的具体实现。
|
||
*
|
||
* 主要功能:
|
||
* - 基于 Cookie 的会话管理
|
||
* - OAuth2.0 Token 自动刷新
|
||
* - 用户登录状态检查
|
||
* - 会话创建和销毁
|
||
* - 用户信息保存到数据库
|
||
*
|
||
* 技术栈:
|
||
* - Remix Session Storage (Cookie-based)
|
||
* - OAuth2.0 Token Management
|
||
* - TypeScript 类型安全
|
||
*
|
||
*/
|
||
|
||
import { createCookieSessionStorage } from "@remix-run/node";
|
||
import { tokenManager } from "./token-manager.server";
|
||
import { postgrestGet, postgrestPost, postgrestPut } from "../postgrest-client";
|
||
import { JWTUtils, type UserInfoForJWT } from "~/utils/jwt";
|
||
import { OAUTH_CONFIG, API_BASE_URL } from "~/config/api-config";
|
||
|
||
/**
|
||
* 用户角色类型定义
|
||
*
|
||
* @typedef {string} UserRole
|
||
* @property {'common'} common - 普通用户,有基本的系统访问权限
|
||
* @property {'developer'} developer - 开发者/管理员,有完整的系统管理权限
|
||
*/
|
||
export type UserRole = 'common' | 'admin' | 'deptLeader' | 'groupLeader' | string;
|
||
|
||
/**
|
||
* 用户信息接口,对应 sso_users 表结构
|
||
*/
|
||
export interface UserInfo {
|
||
sub: string; // IDaaS用户唯一标识
|
||
username?: string; // 显示用户名称/工号
|
||
nick_name?: string; // 用户真实姓名
|
||
nickname?: string; // OAuth返回的昵称字段
|
||
name?: string; // 用户姓名(通常映射到 nick_name)
|
||
phone_number?: string; // 手机号
|
||
email?: string; // 邮箱地址
|
||
ou_id?: string; // 所属组织单位ID
|
||
ou_name?: string; // 所属部门名称
|
||
status?: number; // 账户状态: 0=正常, 1=禁用
|
||
is_leader?: boolean; // 是否为部门负责人
|
||
area?: string; // 用户所属地区
|
||
id?: string | number; // 临时的用户id
|
||
}
|
||
|
||
/**
|
||
* sso_users 表记录接口
|
||
*/
|
||
export interface SsoUser {
|
||
id?: string;
|
||
sub: string;
|
||
username: string;
|
||
nick_name: string;
|
||
phone_number?: string;
|
||
email?: string;
|
||
ou_id: string;
|
||
ou_name: string;
|
||
status: number;
|
||
is_leader: boolean;
|
||
area?: string;
|
||
created_at?: string;
|
||
updated_at?: string;
|
||
deleted_at?: string;
|
||
}
|
||
|
||
/**
|
||
* 会话存储配置
|
||
*
|
||
* 使用 Remix 的 Cookie Session Storage 来管理用户会话。
|
||
* Cookie 存储方式的优势:
|
||
* - 服务器端控制,安全性高
|
||
* - 自动处理过期时间
|
||
* - 支持 HttpOnly,防止 XSS 攻击
|
||
*
|
||
* 配置说明:
|
||
* - name: Cookie 名称,用于标识会话
|
||
* - httpOnly: 防止客户端 JavaScript 访问,提高安全性
|
||
* - sameSite: CSRF 保护
|
||
* - secrets: 用于签名和加密 Cookie 内容
|
||
* - maxAge: 会话有效期,与 OAuth Token 过期时间保持一致
|
||
*/
|
||
export const sessionStorage = createCookieSessionStorage({
|
||
cookie: {
|
||
name: "__lgsession", // 登录会话 Cookie 名称
|
||
httpOnly: true, // 仅服务器端可访问,防止 XSS
|
||
path: "/", // Cookie 作用域为整个应用
|
||
sameSite: "lax", // CSRF 保护,允许顶级导航
|
||
secrets: ["s3cr3t"], // TODO: 应该从环境变量读取
|
||
maxAge: 60 * 60 * 2, // 2小时,与 OAuth Token 同步
|
||
secure: false, // 开发环境中禁用 HTTPS 要求
|
||
},
|
||
});
|
||
|
||
/**
|
||
* 获取会话对象
|
||
*
|
||
* 从请求中提取 Cookie 并获取对应的会话对象。
|
||
* 这是一个底层函数,通常不直接使用,而是通过 getUserSession 使用。
|
||
*
|
||
* @param request - Remix Request 对象,包含 HTTP 请求信息
|
||
* @returns 会话对象,可以用来读取和设置会话数据
|
||
*/
|
||
export async function getSession(request: Request) {
|
||
const cookie = request.headers.get("Cookie");
|
||
return sessionStorage.getSession(cookie);
|
||
}
|
||
|
||
/**
|
||
* 获取用户登录状态(带自动Token刷新)
|
||
*
|
||
* 这是认证系统的核心函数,负责:
|
||
* 1. 检查用户是否已登录
|
||
* 2. 验证 OAuth Token 是否有效
|
||
* 3. 自动刷新即将过期的 Token
|
||
* 4. 返回用户信息和认证状态
|
||
*
|
||
* Token 刷新机制:
|
||
* - 检查 access_token 是否即将过期(距离过期时间 < 5 分钟)
|
||
* - 如果即将过期,使用 refresh_token 自动获取新的 access_token
|
||
* - 如果刷新失败,则认为用户未登录
|
||
*
|
||
* @param request - Remix Request 对象
|
||
* @returns 包含以下字段的对象:
|
||
* - isAuthenticated: 是否已认证
|
||
* - userRole: 用户角色 ('common' | 'developer')
|
||
* - accessToken: OAuth 访问令牌
|
||
* - refreshToken: OAuth 刷新令牌
|
||
* - userInfo: 用户详细信息
|
||
* - isTokenExpired: Token 是否已过期
|
||
* - refreshedSession: 如果刷新了 Token,返回更新后的会话对象
|
||
*/
|
||
/**
|
||
* 生成前端JWT
|
||
* @param userInfo 用户信息
|
||
* @param expiresIn OAuth token过期时间(秒)
|
||
* @returns JWT字符串
|
||
*/
|
||
async function generateFrontendJWT(userInfo: UserInfo, savedUserData: SsoUser, userRole: UserRole, expiresIn: number): Promise<string> {
|
||
const jwtUserInfo: UserInfoForJWT = {
|
||
sub: userInfo.sub,
|
||
user_id: savedUserData.id!,
|
||
username: savedUserData.username,
|
||
nick_name: savedUserData.nick_name,
|
||
email: savedUserData.email,
|
||
phone_number: savedUserData.phone_number,
|
||
ou_id: savedUserData.ou_id,
|
||
ou_name: savedUserData.ou_name,
|
||
is_leader: savedUserData.is_leader,
|
||
user_role: userRole
|
||
};
|
||
|
||
return JWTUtils.generateJWT(jwtUserInfo, expiresIn);
|
||
}
|
||
|
||
/**
|
||
* 创建包含JWT的用户信息对象
|
||
* @param userInfo OAuth用户信息
|
||
* @param savedUserData 数据库中保存的用户数据
|
||
* @param userRole 用户角色
|
||
* @param frontendJWT 前端JWT
|
||
* @returns 完整的用户信息对象
|
||
*/
|
||
function createUserInfoWithJWT(userInfo: UserInfo, savedUserData: SsoUser, userRole: UserRole, frontendJWT: string) {
|
||
return {
|
||
// 保持与callback.tsx中enhancedUserInfo相同的数据结构
|
||
sub: userInfo.sub,
|
||
username: savedUserData.username,
|
||
nick_name: savedUserData.nick_name,
|
||
phone_number: savedUserData.phone_number,
|
||
email: savedUserData.email,
|
||
ou_id: savedUserData.ou_id,
|
||
ou_name: savedUserData.ou_name,
|
||
status: savedUserData.status,
|
||
is_leader: savedUserData.is_leader,
|
||
// 增强字段,与OAuth登录保持一致
|
||
user_id: savedUserData.id,
|
||
user_role: userRole,
|
||
frontend_jwt: frontendJWT
|
||
};
|
||
}
|
||
|
||
export async function getUserSession(request: Request) {
|
||
const session = await getSession(request);
|
||
const isAuthenticated = session.get("isAuthenticated") === true;
|
||
const userRole = session.get("userRole") as UserRole;
|
||
let accessToken = session.get("accessToken");
|
||
const refreshToken = session.get("refreshToken");
|
||
let tokenIssuedAt = session.get("tokenIssuedAt");
|
||
let tokenExpiresIn = session.get("tokenExpiresIn");
|
||
const userInfo = session.get("userInfo");
|
||
let frontendJWT = session.get("frontendJWT");
|
||
|
||
let isTokenExpired = false;
|
||
let refreshedSession = null;
|
||
let shouldRegenerateJWT = false;
|
||
|
||
// 🔑 admin 用户不需要刷新 OAuth token,只需要维护 JWT
|
||
const isAdmin = userRole === 'admin';
|
||
|
||
// 如果有token信息,检查是否需要刷新(admin用户跳过OAuth token刷新)
|
||
if (!isAdmin && accessToken && refreshToken && tokenIssuedAt && tokenExpiresIn) {
|
||
try {
|
||
const tokenInfo = {
|
||
accessToken,
|
||
refreshToken,
|
||
tokenIssuedAt,
|
||
tokenExpiresIn
|
||
};
|
||
|
||
// 检查并自动刷新token
|
||
const refreshResult = await tokenManager.checkAndRefreshToken(tokenInfo);
|
||
|
||
if (refreshResult.success && refreshResult.newTokenInfo) {
|
||
const newToken = refreshResult.newTokenInfo;
|
||
|
||
// 如果token被刷新了,更新session
|
||
if (newToken.accessToken !== accessToken) {
|
||
console.log("Token已刷新,更新session");
|
||
|
||
session.set("accessToken", newToken.accessToken);
|
||
session.set("refreshToken", newToken.refreshToken);
|
||
session.set("tokenIssuedAt", newToken.tokenIssuedAt);
|
||
session.set("tokenExpiresIn", newToken.tokenExpiresIn);
|
||
|
||
// 更新本地变量
|
||
accessToken = newToken.accessToken;
|
||
tokenIssuedAt = newToken.tokenIssuedAt;
|
||
tokenExpiresIn = newToken.tokenExpiresIn;
|
||
|
||
// 标记需要重新生成JWT
|
||
shouldRegenerateJWT = true;
|
||
|
||
refreshedSession = session;
|
||
}
|
||
|
||
isTokenExpired = false;
|
||
} else {
|
||
console.error("Token刷新失败:", refreshResult.error);
|
||
isTokenExpired = true;
|
||
}
|
||
} catch (error) {
|
||
console.error("Token验证过程中出错:", error);
|
||
isTokenExpired = true;
|
||
}
|
||
} else if (isAdmin) {
|
||
// admin 用户:不检查 OAuth token 过期,始终保持登录状态
|
||
// console.log("admin 用户登录,跳过 OAuth token 刷新");
|
||
isTokenExpired = false;
|
||
|
||
// admin 用户需要有一个合理的 tokenExpiresIn 用于 JWT 生成
|
||
// 如果没有设置,使用一个默认值(如2小时)
|
||
if (!tokenExpiresIn) {
|
||
tokenExpiresIn = 7200; // 2小时
|
||
session.set("tokenExpiresIn", tokenExpiresIn);
|
||
refreshedSession = session;
|
||
}
|
||
}
|
||
|
||
// 检查前端JWT状态
|
||
if (isAuthenticated && !isTokenExpired && userInfo) {
|
||
let needsJWTRefresh = false;
|
||
|
||
// 检查是否有前端JWT
|
||
if (!frontendJWT) {
|
||
needsJWTRefresh = true;
|
||
console.log("缺少前端JWT,需要生成");
|
||
} else {
|
||
// 检查JWT是否即将过期
|
||
if (JWTUtils.isJWTExpiringSoon(frontendJWT)) {
|
||
needsJWTRefresh = true;
|
||
console.log("前端JWT即将过期,需要重新生成");
|
||
}
|
||
}
|
||
|
||
// 如果OAuth token被刷新了,也需要重新生成JWT
|
||
if (shouldRegenerateJWT) {
|
||
needsJWTRefresh = true;
|
||
console.log("OAuth token已刷新,需要重新生成JWT");
|
||
}
|
||
|
||
// 重新生成JWT
|
||
if (needsJWTRefresh && tokenExpiresIn) {
|
||
try {
|
||
// 从userInfo中获取用户数据
|
||
if (userInfo.user_id && userInfo.sub) {
|
||
const mockSavedUserData: SsoUser = {
|
||
id: userInfo.user_id,
|
||
sub: userInfo.sub,
|
||
username: userInfo.username || userInfo.sub,
|
||
nick_name: userInfo.nick_name || "未知用户",
|
||
phone_number: userInfo.phone_number,
|
||
email: userInfo.email,
|
||
ou_id: userInfo.ou_id || "default",
|
||
ou_name: userInfo.ou_name || "未知部门",
|
||
status: 0,
|
||
is_leader: userInfo.is_leader || false
|
||
};
|
||
|
||
const newJWT = await generateFrontendJWT(userInfo, mockSavedUserData, userRole, tokenExpiresIn);
|
||
|
||
// 打印JWT重新生成信息
|
||
console.log("=== Token刷新时重新生成JWT ===");
|
||
// console.log("原始userInfo:", userInfo);
|
||
// console.log("重构的用户数据:", mockSavedUserData);
|
||
// console.log("用户角色:", userRole);
|
||
// console.log("新生成的JWT:", newJWT);
|
||
// console.log("JWT过期时间:", JWTUtils.getJWTExpiration(newJWT));
|
||
|
||
// 更新session中的JWT
|
||
if (!refreshedSession) {
|
||
refreshedSession = session;
|
||
}
|
||
refreshedSession.set("frontendJWT", newJWT);
|
||
|
||
// 更新userInfo以包含新的JWT
|
||
const updatedUserInfo = createUserInfoWithJWT(userInfo, mockSavedUserData, userRole, newJWT);
|
||
refreshedSession.set("userInfo", updatedUserInfo);
|
||
|
||
console.log("更新后的userInfo:", updatedUserInfo);
|
||
console.log("=== JWT重新生成完成 ===");
|
||
|
||
frontendJWT = newJWT;
|
||
}
|
||
} catch (error) {
|
||
console.error("生成前端JWT失败:", error);
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
isAuthenticated: isAuthenticated && !isTokenExpired,
|
||
userRole,
|
||
accessToken,
|
||
refreshToken,
|
||
userInfo,
|
||
isTokenExpired,
|
||
refreshedSession, // 如果刷新了token,返回更新后的session
|
||
frontendJWT // 返回前端JWT
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 创建用户登录会话(完整版)
|
||
*
|
||
* 在用户成功登录后调用此函数来创建完整的会话并设置 Cookie。
|
||
* 这个函数支持完整的OAuth2.0登录流程,包括token管理和用户信息存储。
|
||
*
|
||
* 处理流程:
|
||
* 1. 创建新的会话对象
|
||
* 2. 设置认证状态、用户角色、token信息
|
||
* 3. 保存用户信息和前端JWT
|
||
* 4. 生成签名的 Cookie
|
||
* 5. 返回重定向响应并设置 Cookie
|
||
*
|
||
* @param params - 会话创建参数
|
||
* @returns HTTP 302 重定向响应,包含设置 Cookie 的头部
|
||
*/
|
||
export async function createUserSession(params: {
|
||
isAuthenticated: boolean;
|
||
userRole: string;
|
||
redirectTo: string;
|
||
accessToken?: string;
|
||
refreshToken?: string;
|
||
tokenExpiresIn?: number;
|
||
userInfo?: UserInfo;
|
||
frontendJWT?: string;
|
||
}) {
|
||
const session = await sessionStorage.getSession();
|
||
|
||
// 基础认证信息
|
||
session.set("isAuthenticated", params.isAuthenticated);
|
||
session.set("userRole", params.userRole);
|
||
|
||
// OAuth token信息
|
||
if (params.accessToken) {
|
||
session.set("accessToken", params.accessToken);
|
||
session.set("tokenIssuedAt", Date.now());
|
||
}
|
||
if (params.refreshToken) {
|
||
session.set("refreshToken", params.refreshToken);
|
||
}
|
||
if (params.tokenExpiresIn) {
|
||
session.set("tokenExpiresIn", params.tokenExpiresIn);
|
||
}
|
||
|
||
// 用户信息和JWT
|
||
if (params.userInfo) {
|
||
session.set("userInfo", params.userInfo);
|
||
}
|
||
if (params.frontendJWT) {
|
||
session.set("frontendJWT", params.frontendJWT);
|
||
}
|
||
|
||
const cookie = await sessionStorage.commitSession(session);
|
||
// console.log("创建完整会话 - 设置Cookie:", !!cookie);
|
||
// console.log("创建完整会话 - 用户角色:", params.userRole);
|
||
// console.log("创建完整会话 - 重定向到:", params.redirectTo);
|
||
|
||
return new Response(null, {
|
||
status: 302, // HTTP 重定向状态码
|
||
headers: {
|
||
Location: params.redirectTo, // 重定向目标 URL
|
||
"Set-Cookie": cookie, // 设置会话 Cookie
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 创建用户登录会话(简化版)
|
||
*
|
||
* 在用户成功登录后调用此函数来创建会话并设置 Cookie。
|
||
* 这个函数通常在以下场景中使用:
|
||
* - 临时管理员登录
|
||
* - 测试用户登录
|
||
* - 其他简单认证方式成功后
|
||
*
|
||
* 处理流程:
|
||
* 1. 创建新的会话对象
|
||
* 2. 设置认证状态和用户角色
|
||
* 3. 生成签名的 Cookie
|
||
* 4. 返回重定向响应并设置 Cookie
|
||
*
|
||
* @param isAuthenticated - 是否已认证,通常为 true
|
||
* @param userRole - 用户角色,决定用户的权限级别
|
||
* @param redirectTo - 登录成功后重定向的 URL,默认为首页
|
||
* @returns HTTP 302 重定向响应,包含设置 Cookie 的头部
|
||
*/
|
||
export async function createSimpleUserSession(isAuthenticated: boolean, userRole: UserRole, redirectTo: string) {
|
||
const session = await sessionStorage.getSession();
|
||
session.set("isAuthenticated", isAuthenticated);
|
||
session.set("userRole", userRole);
|
||
|
||
const cookie = await sessionStorage.commitSession(session);
|
||
console.log("创建简化会话 - 设置Cookie:", !!cookie);
|
||
console.log("创建简化会话 - 用户角色:", userRole);
|
||
console.log("创建简化会话 - 重定向到:", redirectTo);
|
||
|
||
return new Response(null, {
|
||
status: 302, // HTTP 重定向状态码
|
||
headers: {
|
||
Location: redirectTo, // 重定向目标 URL
|
||
"Set-Cookie": cookie, // 设置会话 Cookie
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 销毁会话(用户登出)
|
||
*
|
||
* 当用户主动登出或会话失效时调用此函数。
|
||
*
|
||
* 处理流程:
|
||
* 1. 获取当前用户的会话和访问令牌
|
||
* 2. 调用 IDaaS 单点登出接口
|
||
* 3. 销毁本地会话数据(清除所有存储的信息)
|
||
* 4. 清除客户端的会话 Cookie
|
||
* 5. 重定向到登录页面
|
||
*
|
||
* 注意事项:
|
||
* - 这个函数会同时处理本地会话和 IDaaS 的单点登出
|
||
* - 即使 IDaaS 登出失败,也会清除本地会话
|
||
* - 销毁会话后,用户需要重新登录才能访问受保护的页面
|
||
*
|
||
* @param request - Remix Request 对象,用于获取当前会话
|
||
* @returns HTTP 302 重定向响应,清除 Cookie 并跳转到登录页
|
||
*/
|
||
export async function logout(request: Request) {
|
||
const session = await getSession(request);
|
||
|
||
// 获取访问令牌和应用ID,用于调用IDaaS单点登出
|
||
const accessToken = session.get("accessToken");
|
||
const appId = OAUTH_CONFIG.appId || 'idaasoauth2';
|
||
|
||
// 如果存在访问令牌,调用IDaaS单点登出
|
||
if (accessToken && appId) {
|
||
try {
|
||
await callIDaaSLogout(accessToken, appId);
|
||
console.log("IDaaS单点登出成功");
|
||
} catch (error) {
|
||
console.error("IDaaS单点登出失败:", error);
|
||
// 即使IDaaS登出失败,也继续清除本地会话
|
||
}
|
||
}
|
||
|
||
return new Response(null, {
|
||
status: 302, // HTTP 重定向状态码
|
||
headers: {
|
||
Location: "/login", // 重定向到登录页面
|
||
"Set-Cookie": await sessionStorage.destroySession(session), // 清除会话 Cookie
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 调用IDaaS单点登出接口
|
||
*
|
||
* @param accessToken - 用户的访问令牌
|
||
* @param appId - 应用ID
|
||
* @returns Promise<void>
|
||
*/
|
||
async function callIDaaSLogout(accessToken: string, appId: string): Promise<void> {
|
||
const serverUrl = OAUTH_CONFIG.serverUrl || 'http://10.79.112.85';
|
||
const redirectUri = OAUTH_CONFIG.redirectUri || 'http://10.79.97.17/';
|
||
const logoutUrl = `${serverUrl}/public/sp/slo/${appId}`;
|
||
|
||
const formData = new URLSearchParams();
|
||
formData.append('access_token', accessToken);
|
||
formData.append('redirect_url', encodeURIComponent(redirectUri));
|
||
|
||
try {
|
||
const response = await fetch(logoutUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
},
|
||
body: formData.toString(),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`IDaaS登出失败: ${response.status} ${response.statusText}`);
|
||
}
|
||
|
||
console.log("IDaaS单点登出请求成功");
|
||
} catch (error) {
|
||
console.error("调用IDaaS登出接口失败:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 保存用户信息到数据库
|
||
*
|
||
* 此函数实现以下逻辑:
|
||
* 1. 内部生成临时 JWT(user_id 为 'login',仅用于数据库操作)
|
||
* 2. 根据 userInfo.sub 查询 sso_users 表中是否已存在该用户
|
||
* 3. 如果存在,则更新用户信息(如果用户已有 area 值则不更新)
|
||
* 4. 如果不存在,则插入新的用户记录
|
||
* 5. 返回保存的用户数据和临时 JWT
|
||
*
|
||
* @param userInfo - 从 IDaaS 获取的用户信息
|
||
* @param userRole - 用户角色
|
||
* @param tokenExpiresIn - Token过期时间(秒)
|
||
* @param area - 用户所属地区,根据端口号确定
|
||
* @returns Promise<{success: boolean, data?: SsoUser, tempToken?: string, error?: string}>
|
||
*/
|
||
export async function saveUserInfo(
|
||
userInfo: UserInfo,
|
||
userRole: UserRole,
|
||
tokenExpiresIn: number,
|
||
area?: string
|
||
): Promise<{success: boolean, data?: SsoUser, tempToken?: string, error?: string}> {
|
||
try {
|
||
console.log("开始保存用户信息", userInfo);
|
||
|
||
// 验证必要字段
|
||
if (!userInfo.sub) {
|
||
return { success: false, error: "用户唯一标识 sub 不能为空" };
|
||
}
|
||
|
||
// 🔒 安全:在服务端生成临时 JWT,user_id 使用占位符 'login'
|
||
// 这样客户端无法看到真实的 user_id
|
||
const tempUserInfo: UserInfoForJWT = {
|
||
sub: userInfo.sub,
|
||
user_id: 'login', // 使用占位符,避免在客户端暴露真实ID
|
||
username: 'login',
|
||
nick_name: userInfo.nick_name || userInfo.nickname || userInfo.name || "未知用户",
|
||
email: userInfo.email,
|
||
phone_number: userInfo.phone_number,
|
||
ou_id: userInfo.ou_id || "default",
|
||
ou_name: userInfo.ou_name || "未知部门",
|
||
is_leader: userInfo.is_leader || false,
|
||
user_role: userRole
|
||
};
|
||
|
||
const tempToken = JWTUtils.generateJWT(tempUserInfo, tokenExpiresIn);
|
||
|
||
// 1. 根据 sub 查询是否已存在该用户
|
||
const existingUserResult = await postgrestGet<SsoUser[]>("sso_users", {
|
||
filter: {
|
||
"sub": `eq.${userInfo.sub}`,
|
||
"deleted_at": "is.null" // 只查询未删除的记录
|
||
},
|
||
token: tempToken
|
||
});
|
||
|
||
if (existingUserResult.error) {
|
||
console.error("查询用户失败:", existingUserResult.error);
|
||
return { success: false, error: `查询用户失败: ${existingUserResult.error}` };
|
||
}
|
||
|
||
const existingUsers = existingUserResult.data || [];
|
||
const existingUser = existingUsers.length > 0 ? existingUsers[0] : null;
|
||
|
||
// 准备要保存的用户数据
|
||
// 注意:OAuth返回的字段是nickname,而不是nick_name
|
||
const userData: Partial<SsoUser> = {
|
||
sub: userInfo.sub,
|
||
username: userInfo.username || userInfo.name || userInfo.sub,
|
||
nick_name: userInfo.nick_name || userInfo.nickname || userInfo.name || "未知用户",
|
||
phone_number: userInfo.phone_number || undefined,
|
||
email: userInfo.email || undefined,
|
||
ou_id: userInfo.ou_id || "default",
|
||
ou_name: userInfo.ou_name || "未知部门",
|
||
status: userInfo.status !== undefined ? userInfo.status : 0,
|
||
is_leader: userInfo.is_leader || false,
|
||
};
|
||
|
||
if (existingUser) {
|
||
// 2. 用户已存在,执行更新操作
|
||
console.log("用户已存在,执行更新操作", existingUser.id);
|
||
|
||
// 只有在现有用户没有 area 或 area 为空时,才更新 area
|
||
if (area && !existingUser.area) {
|
||
userData.area = area;
|
||
console.log("用户原本无地区信息,更新地区为:", area);
|
||
} else if (existingUser.area) {
|
||
console.log("用户已有地区信息:", existingUser.area, "不更新");
|
||
}
|
||
|
||
const updateResult = await postgrestPut<SsoUser[], Partial<SsoUser>>(
|
||
"sso_users",
|
||
userData,
|
||
{ id: existingUser.id! },
|
||
tempToken
|
||
);
|
||
|
||
if (updateResult.error) {
|
||
console.error("更新用户失败:", updateResult.error);
|
||
return { success: false, error: `更新用户失败: ${updateResult.error}` };
|
||
}
|
||
|
||
console.log("用户信息更新成功");
|
||
return {
|
||
success: true,
|
||
data: Array.isArray(updateResult.data) ? updateResult.data[0] : updateResult.data as unknown as SsoUser,
|
||
tempToken // 返回临时 JWT
|
||
};
|
||
} else {
|
||
// 3. 用户不存在,执行插入操作,设置地区信息
|
||
console.log("用户不存在,执行插入操作");
|
||
|
||
// 新用户直接设置 area
|
||
if (area) {
|
||
userData.area = area;
|
||
console.log("新用户,设置地区为:", area);
|
||
}
|
||
|
||
const insertResult = await postgrestPost<SsoUser[], SsoUser>("sso_users", userData as SsoUser, tempToken);
|
||
|
||
if (insertResult.error) {
|
||
console.error("插入用户失败:", insertResult.error);
|
||
return { success: false, error: `插入用户失败: ${insertResult.error}` };
|
||
}
|
||
|
||
console.log("用户信息插入成功");
|
||
|
||
// 4. 给这个用户默认添加一个角色,角色为common
|
||
const userData_with_id = Array.isArray(insertResult.data) ? insertResult.data[0] : insertResult.data as unknown as SsoUser;
|
||
if (userData_with_id?.id) {
|
||
await addDefaultRole(userData_with_id.id, 2, tempToken);
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
data: userData_with_id,
|
||
tempToken // 返回临时 JWT
|
||
};
|
||
}
|
||
} catch (error) {
|
||
console.error("保存用户信息时发生错误:", error);
|
||
return {
|
||
success: false,
|
||
error: `保存用户信息失败: ${error instanceof Error ? error.message : String(error)}`
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 为用户添加默认角色
|
||
*
|
||
* @param userId - 用户ID
|
||
* @param roleId - 角色ID,默认为2(common角色)
|
||
* @param token - JWT令牌,用于调用postgrest服务
|
||
* @returns 添加结果
|
||
*/
|
||
export async function addDefaultRole(userId: string, roleId: number = 2, token?: string) {
|
||
try {
|
||
console.log(`为用户 ${userId} 添加默认角色 ${roleId}`);
|
||
|
||
// 检查用户是否已经有此角色
|
||
const existingRoleResult = await postgrestGet<Array<{id: number, user_id: string, role_id: number}>>("user_role", {
|
||
filter: {
|
||
user_id: `eq.${userId}`,
|
||
role_id: `eq.${roleId}`
|
||
},
|
||
token
|
||
});
|
||
|
||
if (existingRoleResult.error) {
|
||
console.error("查询用户角色失败:", existingRoleResult.error);
|
||
return { success: false, error: `查询用户角色失败: ${existingRoleResult.error}` };
|
||
}
|
||
|
||
const existingRoles = existingRoleResult.data || [];
|
||
if (existingRoles.length > 0) {
|
||
console.log("用户已经拥有此角色,跳过添加");
|
||
return { success: true, data: existingRoles[0] };
|
||
}
|
||
|
||
// 添加角色
|
||
const addRoleResult = await postgrestPost<Array<{id: number, user_id: string, role_id: number}>, {user_id: string, role_id: number}>("user_role", {
|
||
user_id: userId,
|
||
role_id: roleId
|
||
}, token);
|
||
|
||
if (addRoleResult.error) {
|
||
console.error("添加用户角色失败:", addRoleResult.error);
|
||
return { success: false, error: `添加用户角色失败: ${addRoleResult.error}` };
|
||
}
|
||
|
||
console.log("用户角色添加成功");
|
||
return {
|
||
success: true,
|
||
data: Array.isArray(addRoleResult.data) ? addRoleResult.data[0] : addRoleResult.data
|
||
};
|
||
} catch (error) {
|
||
console.error("添加用户角色时发生错误:", error);
|
||
return {
|
||
success: false,
|
||
error: `添加用户角色失败: ${error instanceof Error ? error.message : String(error)}`
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通过用户sub获取用户信息
|
||
*
|
||
* @param sub - 用户的唯一标识
|
||
* @returns 用户信息
|
||
*/
|
||
export async function getUserBySub(sub: string) {
|
||
try {
|
||
// console.log(`查询用户: ${sub}`);
|
||
|
||
const userResult = await postgrestGet<SsoUser[]>("sso_users", {
|
||
filter: {
|
||
sub: `eq.${sub}`
|
||
}
|
||
});
|
||
|
||
if (userResult.error) {
|
||
console.error("查询用户失败:", userResult.error);
|
||
return { success: false, error: `查询用户失败: ${userResult.error}` };
|
||
}
|
||
|
||
const users = userResult.data || [];
|
||
const user = users.length > 0 ? users[0] : null;
|
||
|
||
if (!user) {
|
||
return { success: false, error: "用户不存在" };
|
||
}
|
||
|
||
return { success: true, data: user };
|
||
} catch (error) {
|
||
console.error("查询用户时发生错误:", error);
|
||
return {
|
||
success: false,
|
||
error: `查询用户失败: ${error instanceof Error ? error.message : String(error)}`
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 账号密码登录接口
|
||
*
|
||
* @param username - 用户名
|
||
* @param password - 密码
|
||
* @param redirectTo - 登录成功后重定向的URL
|
||
* @returns HTTP重定向响应或错误响应
|
||
*/
|
||
export async function simpleRootLogin(
|
||
username: string,
|
||
password: string,
|
||
redirectTo: string
|
||
) {
|
||
try {
|
||
// 输入验证
|
||
if (!username?.trim() || !password?.trim()) {
|
||
return new Response(JSON.stringify({
|
||
success: false,
|
||
error: "用户名和密码不能为空"
|
||
}), {
|
||
status: 400,
|
||
headers: { "Content-Type": "application/json" }
|
||
});
|
||
}
|
||
|
||
// 调用登录接口
|
||
const loginResponse = await fetch(`${API_BASE_URL}/password_login`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
sub: username.trim(),
|
||
password: password.trim()
|
||
})
|
||
});
|
||
|
||
const loginResult = await loginResponse.json();
|
||
console.log('登录接口返回', loginResult);
|
||
|
||
// 检查重试次数
|
||
const retryCount = loginResult.retryCount || loginResult.retry_count || 0;
|
||
console.log('登录重试次数:', retryCount);
|
||
|
||
if (loginResult.code === 0 && loginResult.data) {
|
||
// 登录成功,构建用户信息
|
||
const userData = loginResult.data;
|
||
// console.log('管理员登录userData', userData);
|
||
const userRole = userData.role; // 默认角色
|
||
|
||
// 生成模拟的OAuth token信息
|
||
const mockTokenExpiresIn = 7200; // 2小时
|
||
const mockAccessToken = `mock_access_token_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
const mockRefreshToken = `mock_refresh_token_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
|
||
// 生成前端JWT
|
||
const jwtUserInfo: UserInfoForJWT = {
|
||
sub: userData.sub,
|
||
user_id: userData.user_id,
|
||
username: userData.username,
|
||
nick_name: userData.nick_name,
|
||
email: userData.email,
|
||
phone_number: userData.phone_number,
|
||
ou_id: userData.ou_id,
|
||
ou_name: userData.ou_name,
|
||
is_leader: userData.is_leader,
|
||
user_role: userRole
|
||
};
|
||
|
||
const frontendJWT = JWTUtils.generateJWT(jwtUserInfo, mockTokenExpiresIn);
|
||
|
||
// 构建增强的用户信息对象
|
||
const enhancedUserInfo = {
|
||
...userData,
|
||
user_id: userData.user_id,
|
||
user_role: userRole,
|
||
frontend_jwt: frontendJWT
|
||
};
|
||
|
||
// 使用统一的session创建函数
|
||
return createUserSession({
|
||
isAuthenticated: true,
|
||
userRole: userRole,
|
||
redirectTo,
|
||
accessToken: mockAccessToken,
|
||
refreshToken: mockRefreshToken,
|
||
tokenExpiresIn: mockTokenExpiresIn,
|
||
userInfo: enhancedUserInfo,
|
||
frontendJWT
|
||
});
|
||
} else {
|
||
// 登录失败,检查账户是否被锁定
|
||
let errorMsg = loginResult.msg || "登录失败,请检查用户名和密码";
|
||
let isLocked = false;
|
||
|
||
// 检查是否因重试次数过多被锁定
|
||
if (retryCount >= 5) {
|
||
errorMsg = "账户已被锁定,密码错误次数过多,请联系管理员";
|
||
isLocked = true;
|
||
} else if (retryCount > 0) {
|
||
// 显示剩余尝试次数
|
||
const remainingAttempts = 5 - retryCount;
|
||
errorMsg = `${loginResult.msg || "用户名或密码错误"},还有 ${remainingAttempts} 次尝试机会`;
|
||
}
|
||
|
||
return new Response(JSON.stringify({
|
||
success: false,
|
||
error: errorMsg,
|
||
retryCount: retryCount,
|
||
isLocked: isLocked,
|
||
remainingAttempts: isLocked ? 0 : (5 - retryCount)
|
||
}), {
|
||
status: isLocked ? 403 : 401, // 403 表示禁止访问(账户被锁)
|
||
headers: { "Content-Type": "application/json" }
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("登录请求失败:", error);
|
||
return new Response(JSON.stringify({
|
||
success: false,
|
||
error: "登录请求失败,请稍后重试"
|
||
}), {
|
||
status: 500,
|
||
headers: { "Content-Type": "application/json" }
|
||
});
|
||
}
|
||
} |