fix(security): 文件列表 userId 隔离 + CAS 退出清 SSO

研读模块 /gpt/file/list 之前没强制按 userId 过滤,理论上枚举
knowledgeBaseId 能拿到他人文件元信息(虽然 download 那一步有 userId 校验
所以下不下来,但文件名/大小/上传时间会泄露)。

- FileController.listFile 强制注入 userId = getSysUserId()
- UploadFileMapper.xml BaseSelect 加 <if userId != null> 过滤分支

CAS 退出登录之前只清了本地 JWT,没调 CAS server logout,导致:
- 后端 Redis 里的 token 还在
- CAS server 的 SSO cookie 还在 → 再点"统一身份登录"立即静默登入
- 其他接 CAS 的系统也还能继续访问

新增 POST /app/api/logout:
- 删 Redis 里 LOGIN_TOKEN_KEY:{userId}:{sessionId}
- SecurityContextHolder.clearContext()
- 返回 casLogoutUrl(${serverLogout}?service=前端 login 页)

前端 Operates.vue quit() 改 async:先调 logout 拿 casLogoutUrl,
removeToken 后 window.location.href 跳过去,让 CAS 清 SSO cookie 再回 /login。

CasProperties 加回 serverLogout 字段(之前清理时删了,本次需要)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 20:34:48 +08:00
parent b83c540018
commit a548425923
7 changed files with 79 additions and 2 deletions

View File

@@ -18,6 +18,8 @@ public class CasProperties {
private String serverHost;
/** CAS 服务器登录入口,通常 ${serverHost}/login */
private String serverLogin;
/** CAS 服务器登出入口,通常 ${serverHost}/logout */
private String serverLogout;
/** 本应用的 CAS 回调路径servlet path通常 /cas/login */
private String appLogin;
}

View File

@@ -6,6 +6,8 @@ import com.inspur.llm.chat.base.constant.SysConfigConstants;
import com.inspur.llm.chat.base.security.JwtTokenUtils;
import com.inspur.llm.chat.base.security.Oauth2Token;
import com.inspur.llm.chat.base.security.UserDetail;
import com.inspur.llm.chat.base.security.cas.CasProperties;
import com.inspur.llm.chat.base.security.cas.CasUrlBuilder;
import com.inspur.llm.chat.base.util.RedisUtils;
import com.inspur.llm.chat.base.validator.ValidatorUtil;
import com.inspur.llm.chat.gpt.enums.ResponseEnum;
@@ -27,6 +29,12 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -47,6 +55,8 @@ public class LoginController {
private IUserService userService;
@Autowired
private RedisUtils redisUtils;
@Autowired
private CasProperties casProperties;
/**
* 登录
@@ -101,6 +111,46 @@ public class LoginController {
return ResponseInfo.success(token);
}
/**
* 退出登录:删 Redis 里的 token + 返回 CAS server logout URL若用户用 CAS 登录的)
*
* 前端拿到 casLogoutUrl 后 window.location.href 跳过去CAS 服务器会清掉 SSO cookie
* 并跳回 service 参数指定的页面(我们的 /login。这样下次再点"统一身份登录"会重新跑
* CAS 认证流,不会因为 SSO cookie 还在而直接静默登入。
*/
@PostMapping("/api/logout")
public ResponseInfo<Map<String, String>> logout(HttpServletRequest request) {
Map<String, String> data = new HashMap<>();
try {
UserDetail userDetail = JwtTokenUtils.getLoginUser();
if (userDetail != null) {
// 删 Redis 里这个 session 的 token
String key = RedisConstants.LOGIN_TOKEN_KEY
+ userDetail.getId() + StringPoolConstant.COLON + userDetail.getSessionId();
redisUtils.del(key);
}
} catch (Exception e) {
// Redis 不可用不影响登出流程
e.printStackTrace();
}
SecurityContextHolder.clearContext();
// 拼 CAS server logout URL
// service 参数:让 CAS 退出后回跳到我们的前端 login 页(动态用 X-Forwarded-Host
String backToFront;
try {
backToFront = URLEncoder.encode(
CasUrlBuilder.buildServiceUrl(request, "/metalinfo/#/login"),
StandardCharsets.UTF_8.name());
} catch (Exception e) {
backToFront = "";
}
String casLogoutUrl = casProperties.getServerLogout()
+ (backToFront.isEmpty() ? "" : "?service=" + backToFront);
data.put("casLogoutUrl", casLogoutUrl);
return ResponseInfo.success(data);
}
/**
* 添加登录日志
*

View File

@@ -152,6 +152,8 @@ public class FileController extends BaseController {
*/
@GetMapping("/list")
public ResponseInfo<List<UploadFileVO>> listFile(@RequestParam Map map) {
// 强制按当前登录用户隔离,防止枚举别人的 knowledgeBaseId/folderId 拿到他人文件元信息
map.put("userId", getSysUserId());
return fileService.listFile(new Query(map));
}

View File

@@ -97,6 +97,7 @@ security:
cas:
server-host: http://192.168.203.20:8180
server-login: ${security.cas.server-host}/login
server-logout: ${security.cas.server-host}/logout
app-login: /cas/login

View File

@@ -44,6 +44,7 @@
<if test="q.folderId != null and q.folderId > -1"> and t.folder_id = #{q.folderId}</if>
<if test="q.embeddingId != null and q.embeddingId != ''"> and t.embedding_id = #{q.embeddingId}</if>
<if test="q.knowledgeBaseId != null and q.knowledgeBaseId != ''"> and t.knowledge_base_id = #{q.knowledgeBaseId}</if>
<if test="q.userId != null"> and t.user_id = #{q.userId}</if>
<if test="q.type != null"> and t.type = #{q.type}</if>
<if test="q.startDate != null and q.startDate != ''"> and date_format(t.create_time,'%Y-%m-%d') &gt;= #{q.startDate} </if>
<if test="q.endDate != null and q.endDate != ''"> and date_format(t.create_time,'%Y-%m-%d') &lt;= #{q.endDate} </if>

View File

@@ -16,6 +16,13 @@ export function fetchVerify<T>(data: object) {
})
}
// 退出登录:清后端 Redis 里的 token返回 CAS server logout URL前端跳过去清 SSO cookie
export function fetchLogout<T>() {
return post<T>({
url: '/app/api/logout',
})
}
// 获取用户信息
export function fetchSession<T>() {
return get<T>({

View File

@@ -57,6 +57,7 @@ import { useAuthStore } from "@/store";
import { reactive, watch, computed } from "vue";
import { useRouter, useRoute } from "vue-router";
import { User, Setting, SwitchButton, TopRight } from "@element-plus/icons-vue";
import { fetchLogout } from "@/api";
const authStore = useAuthStore();
const isAdmin = computed(() => authStore.session?.admind === true);
@@ -112,9 +113,22 @@ const openExtLink = (url: string) => {
window.open(url, '_blank');
};
const quit = () => {
const quit = async () => {
// 调后端 logout清 Redis token + 拿 CAS 服务器登出 URL
// 拿到后跳过去清 CAS SSO cookie否则下次"统一身份登录"会因为 cookie 还在直接静默登入
let casLogoutUrl: string | null = null;
try {
const res: any = await fetchLogout();
casLogoutUrl = res?.data?.casLogoutUrl || null;
} catch (e) {
console.warn("logout API 调用失败,本地清理后跳登录页", e);
}
authStore.removeToken();
router.push("/login");
if (casLogoutUrl) {
window.location.href = casLogoutUrl;
} else {
router.push("/login");
}
};
const goProfile = () => {