Files
gangyan/chat_web_front/src/views/login/index.vue
liuguancen b83c540018 fix(cas): 接通 CAS 单点登录全链路 + 清理冗余配置
修复链上的 8 个真 bug:
1. UserDetail(UserVO,Set) 漏 setAuthorities → CAS token 构造抛 IllegalArgumentException
   修:CasUserDetailsService.buildGptUserDetail 手动补 empty authorities

2. frontEndUrl 写死内网 IP,localhost 隧道用户跳回时"无法访问此网站"
   修:CasUrlBuilder 用 X-Forwarded-Host / Host 动态拼 service URL

3. vite proxy 没配 /metalinfo/chat_web_backend,CAS 回跳 ticket 被前端路由吞
   修:加一条 proxy(rewrite 去 /metalinfo 前缀)+ X-Forwarded-Host 转发

4. ticket 校验 service URL 跟 entry point 不一致 → CAS server mismatch
   修:自定义 AuthenticationDetailsSource 用同一个动态 URL

5. sendRedirect URL 含 # 经容器编码成 %23,浏览器拿不到 hash → 404
   修:改用 query 参数(/metalinfo/?cas_token=xxx),前端 router beforeEach 拦截

6. CAS 登录后 HttpSession 残留,第二次访问 /cas/login 不触发 entryPoint → 落到
   DispatcherServlet → 找不到映射 → 404 Whitelabel
   修:SuccessHandler 完成后 invalidate session + clear SecurityContext

7. CAS 路径漏写 Redis token,JwtAuthenticationFilter 校验时 LOGIN_TOKEN_KEY 找不到
   → "token已失效" → 前端 axios interceptor 清 token 跳回 login
   修:SuccessHandler 同步写 redisUtils.set,与 LoginController.saveLoginLog 对齐

8. permission.ts 没拦 query 里的 cas_token,hash 路由下 location.search 取不到
   修:router beforeEach 优先消费 cas_token 再走 getSession

清理冗余:
- CasProperties 删 6 个未用字段(enabled/serverLogout/appLogout/appKey/
  appSecret/httpsFlag/frontEndUrl)
- application.yml 同步删,移除写死的 app-secret 等敏感字段
- 删外部 override 文件 chat_web_backend/config/application.yml
- casServiceProperties.setService 改占位符(实际不被读取,只满足
  ServiceProperties.afterPropertiesSet 的非空校验)
- 删 permission.ts 的 [CAS] [GUARD] debug log,保留 catch error 一条

新增:
- CasUrlBuilder 工具类:从请求动态解析 host/scheme,多个地方共用

UI:
- welcome 页面玻璃按钮 + 呼吸光晕/光感动画(用户自己调过,本次保留)
- App.vue:/welcome 路径不渲染 Operates 侧边栏

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:22:57 +08:00

357 lines
8.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="cassicLogin">
<!-- 背景CASSIC logo + 红金渐变 + 波纹粒子图片直接做底 -->
<div class="bgImage"></div>
<!-- 中央登录卡 -->
<div class="loginCard">
<h2 class="title">登录注册中心</h2>
<div class="titleBar"></div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
hide-required-asterisk
class="form"
@submit.prevent
>
<!-- 账号 -->
<el-form-item prop="tel">
<el-input
v-model.trim="form.tel"
:placeholder="loginMode === 'sms' ? '请输入手机号' : '请输入账号'"
size="large"
>
<template #prefix>
<span class="iconBox"><el-icon><User /></el-icon></span>
</template>
</el-input>
</el-form-item>
<!-- 密码 -->
<el-form-item v-if="loginMode === 'password'" prop="password">
<el-input
v-model.trim="form.password"
type="password"
placeholder="请输入密码"
size="large"
show-password
>
<template #prefix>
<span class="iconBox"><el-icon><Lock /></el-icon></span>
</template>
</el-input>
</el-form-item>
<!-- 验证码图形 / 短信 -->
<el-form-item prop="captcha" v-if="loginMode === 'sms' || showCaptcha">
<div class="captchaRow">
<el-input
v-model.trim="form.captcha"
:placeholder="loginMode === 'sms' ? '请输入短信验证码' : '请输入验证码'"
size="large"
>
<template #prefix>
<span class="iconBox"><el-icon><Stamp /></el-icon></span>
</template>
</el-input>
<button
type="button"
class="captchaBtn"
:disabled="smsCountdown > 0"
@click="handleCaptcha"
>
{{ smsCountdown > 0 ? `${smsCountdown}s` : (loginMode === 'sms' ? '获取验证码' : '看不清') }}
</button>
</div>
</el-form-item>
<!-- 切换模式 -->
<div class="switchModeRow">
<a class="modeLink" @click="toggleMode">
{{ loginMode === 'password' ? '短信验证登录' : '账号密码登录' }}
</a>
</div>
<!-- 主登录按钮 -->
<el-button
class="primaryBtn"
:loading="submitting"
@click="handleSubmit"
>
</el-button>
<!-- 统一身份登录 -->
<el-button class="casBtn" @click="goCasLogin">
统一身份登录CASSIC
</el-button>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { User, Lock, Stamp } from "@element-plus/icons-vue";
import { fetchVerify } from "@/api";
import { useAuthStore } from "@/store";
interface LoginForm {
tel: string;
password: string;
captcha: string;
loginType: number;
}
const form = reactive<LoginForm>({
tel: "",
password: "",
captcha: "",
loginType: 3,
});
const formRef = ref<FormInstance>();
const router = useRouter();
const authStore = useAuthStore();
const submitting = ref(false);
const loginMode = ref<"password" | "sms">("password"); // password = 账号密码登录sms = 短信验证登录
const showCaptcha = ref(false); // 账号密码模式下,可选图形验证码(暂未启用,留位)
const smsCountdown = ref(0);
const rules = reactive<FormRules<LoginForm>>({
tel: [{ required: true, message: "请输入账号 / 手机号", trigger: "blur" }],
password: [{ required: true, message: "请输入密码", trigger: "blur" }],
});
const toggleMode = () => {
loginMode.value = loginMode.value === "password" ? "sms" : "password";
form.captcha = "";
};
let countdownTimer: number | null = null;
const handleCaptcha = () => {
if (loginMode.value === "sms") {
if (!form.tel) {
ElMessage.warning("请先输入手机号");
return;
}
// 触发短信发送(后端接口待联调;先做倒计时占位)
smsCountdown.value = 60;
countdownTimer = window.setInterval(() => {
smsCountdown.value -= 1;
if (smsCountdown.value <= 0 && countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}, 1000);
ElMessage.info("短信验证码功能尚未接入后端,先用账号密码模式登录");
} else {
ElMessage.info("图形验证码暂未启用");
}
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
submitting.value = true;
try {
const res = await fetchVerify<{ token: string }>(form);
if (res.code === 200) {
authStore.setToken(res.data.token);
router.push("/welcome");
} else {
ElMessage.error(res.msg || "登录失败");
}
} finally {
submitting.value = false;
}
});
};
const goCasLogin = () => {
window.location.href = `/chat_web_backend/cas/login`;
};
// CAS 回调URL 含 cas_token → 存 token + 跳 welcome
// 注意 hash 路由下 token 在 hash 里(#/login?cas_token=xxx不在 location.search
onMounted(() => {
const hash = window.location.hash || "";
const qIdx = hash.indexOf("?");
const queryStr = qIdx >= 0 ? hash.substring(qIdx + 1) : window.location.search.replace(/^\?/, "");
const params = new URLSearchParams(queryStr);
const casToken = params.get("cas_token");
if (casToken) {
authStore.setToken(casToken);
// 清掉 URL 上的 cas_token 参数
window.history.replaceState(
{},
document.title,
window.location.pathname + (qIdx >= 0 ? hash.substring(0, qIdx) : window.location.hash)
);
router.push("/welcome");
}
});
</script>
<style scoped lang="scss">
.cassicLogin {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.bgImage {
position: absolute;
inset: 0;
background-image: url("../../assets/images/login/cassicLoginBg.jpg");
background-size: cover;
background-position: center;
z-index: 0;
}
.loginCard {
position: relative;
z-index: 1;
width: 460px;
padding: 40px 40px 36px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 12px 40px rgba(120, 0, 0, 0.18);
backdrop-filter: blur(8px);
}
.title {
margin: 0;
text-align: center;
font-size: 26px;
font-weight: 700;
color: #c10b08;
letter-spacing: 2px;
}
.titleBar {
width: 36px;
height: 3px;
background: #c10b08;
border-radius: 2px;
margin: 12px auto 28px;
}
.form {
:deep(.el-form-item) {
margin-bottom: 18px;
}
:deep(.el-input__wrapper) {
border-radius: 4px;
box-shadow: 0 0 0 1px #f3d2cf inset;
background: #fff;
padding: 4px 12px;
}
:deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px #c10b08 inset;
}
}
.iconBox {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 4px;
background: #c10b08;
color: #fff;
font-size: 14px;
font-style: normal;
margin-right: 4px;
i {
font-style: normal;
}
}
.captchaRow {
display: flex;
width: 100%;
gap: 10px;
align-items: stretch;
.el-input {
flex: 1;
}
}
.captchaBtn {
flex: 0 0 110px;
border: none;
border-radius: 4px;
background: #f3d2cf;
color: #c10b08;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.captchaBtn:hover:not(:disabled) {
background: #e8b9b6;
}
.captchaBtn:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.switchModeRow {
margin: 4px 0 18px;
}
.modeLink {
font-size: 13px;
color: #c10b08;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.primaryBtn {
width: 100%;
height: 48px;
border: none;
border-radius: 4px;
background: #c10b08;
color: #fff;
font-size: 18px;
letter-spacing: 8px;
cursor: pointer;
&:hover {
background: #a30907;
}
}
.casBtn {
width: 100%;
height: 44px;
margin-top: 12px;
margin-left: 0;
border-radius: 4px;
border: 1px solid #c10b08;
background: rgba(255, 255, 255, 0.6);
color: #c10b08;
font-size: 15px;
&:hover {
background: rgba(255, 255, 255, 0.85);
border-color: #a30907;
}
}
</style>