修复链上的 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>
357 lines
8.5 KiB
Vue
357 lines
8.5 KiB
Vue
<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>
|