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:
@@ -18,6 +18,8 @@ public class CasProperties {
|
|||||||
private String serverHost;
|
private String serverHost;
|
||||||
/** CAS 服务器登录入口,通常 ${serverHost}/login */
|
/** CAS 服务器登录入口,通常 ${serverHost}/login */
|
||||||
private String serverLogin;
|
private String serverLogin;
|
||||||
|
/** CAS 服务器登出入口,通常 ${serverHost}/logout */
|
||||||
|
private String serverLogout;
|
||||||
/** 本应用的 CAS 回调路径(servlet path),通常 /cas/login */
|
/** 本应用的 CAS 回调路径(servlet path),通常 /cas/login */
|
||||||
private String appLogin;
|
private String appLogin;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.JwtTokenUtils;
|
||||||
import com.inspur.llm.chat.base.security.Oauth2Token;
|
import com.inspur.llm.chat.base.security.Oauth2Token;
|
||||||
import com.inspur.llm.chat.base.security.UserDetail;
|
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.util.RedisUtils;
|
||||||
import com.inspur.llm.chat.base.validator.ValidatorUtil;
|
import com.inspur.llm.chat.base.validator.ValidatorUtil;
|
||||||
import com.inspur.llm.chat.gpt.enums.ResponseEnum;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -47,6 +55,8 @@ public class LoginController {
|
|||||||
private IUserService userService;
|
private IUserService userService;
|
||||||
@Autowired
|
@Autowired
|
||||||
private RedisUtils redisUtils;
|
private RedisUtils redisUtils;
|
||||||
|
@Autowired
|
||||||
|
private CasProperties casProperties;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录
|
* 登录
|
||||||
@@ -101,6 +111,46 @@ public class LoginController {
|
|||||||
return ResponseInfo.success(token);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加登录日志
|
* 添加登录日志
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ public class FileController extends BaseController {
|
|||||||
*/
|
*/
|
||||||
@GetMapping("/list")
|
@GetMapping("/list")
|
||||||
public ResponseInfo<List<UploadFileVO>> listFile(@RequestParam Map map) {
|
public ResponseInfo<List<UploadFileVO>> listFile(@RequestParam Map map) {
|
||||||
|
// 强制按当前登录用户隔离,防止枚举别人的 knowledgeBaseId/folderId 拿到他人文件元信息
|
||||||
|
map.put("userId", getSysUserId());
|
||||||
return fileService.listFile(new Query(map));
|
return fileService.listFile(new Query(map));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ security:
|
|||||||
cas:
|
cas:
|
||||||
server-host: http://192.168.203.20:8180
|
server-host: http://192.168.203.20:8180
|
||||||
server-login: ${security.cas.server-host}/login
|
server-login: ${security.cas.server-host}/login
|
||||||
|
server-logout: ${security.cas.server-host}/logout
|
||||||
app-login: /cas/login
|
app-login: /cas/login
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
<if test="q.folderId != null and q.folderId > -1"> and t.folder_id = #{q.folderId}</if>
|
<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.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.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.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') >= #{q.startDate} </if>
|
<if test="q.startDate != null and q.startDate != ''"> and date_format(t.create_time,'%Y-%m-%d') >= #{q.startDate} </if>
|
||||||
<if test="q.endDate != null and q.endDate != ''"> and date_format(t.create_time,'%Y-%m-%d') <= #{q.endDate} </if>
|
<if test="q.endDate != null and q.endDate != ''"> and date_format(t.create_time,'%Y-%m-%d') <= #{q.endDate} </if>
|
||||||
|
|||||||
@@ -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>() {
|
export function fetchSession<T>() {
|
||||||
return get<T>({
|
return get<T>({
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import { useAuthStore } from "@/store";
|
|||||||
import { reactive, watch, computed } from "vue";
|
import { reactive, watch, computed } from "vue";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { User, Setting, SwitchButton, TopRight } from "@element-plus/icons-vue";
|
import { User, Setting, SwitchButton, TopRight } from "@element-plus/icons-vue";
|
||||||
|
import { fetchLogout } from "@/api";
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const isAdmin = computed(() => authStore.session?.admind === true);
|
const isAdmin = computed(() => authStore.session?.admind === true);
|
||||||
@@ -112,9 +113,22 @@ const openExtLink = (url: string) => {
|
|||||||
window.open(url, '_blank');
|
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();
|
authStore.removeToken();
|
||||||
router.push("/login");
|
if (casLogoutUrl) {
|
||||||
|
window.location.href = casLogoutUrl;
|
||||||
|
} else {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const goProfile = () => {
|
const goProfile = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user