做统一认证登录
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import { convertMenusToRoutes } from "./dynamicRoutes";
|
||||
import { isSSOEnabled, redirectToAuthorize } from "@/utils/authClient";
|
||||
|
||||
// 静态子路由:需要在 Main 框架内显示的页面
|
||||
const staticMainChildren = [
|
||||
@@ -146,6 +147,13 @@ const staticRoutes = [
|
||||
component: () => import("@/views/login/forget.vue"),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
// 统一认证中心回跳页(接收 code 换取令牌,页面自身不展示业务内容)
|
||||
{
|
||||
path: "/auth/callback",
|
||||
name: "AuthCallback",
|
||||
component: () => import("@/views/auth/callback.vue"),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: "/home",
|
||||
name: "Home",
|
||||
@@ -304,7 +312,7 @@ function findFirstValidRoute(routes) {
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const token = localStorage.getItem("token");
|
||||
const publicPaths = ["/login", "/register", "/forget"];
|
||||
const publicPaths = ["/login", "/register", "/forget", "/auth/callback"];
|
||||
|
||||
if (publicPaths.includes(to.path)) {
|
||||
if (token) {
|
||||
@@ -319,6 +327,11 @@ router.beforeEach(async (to, from, next) => {
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
// 统一认证模式:跳认证中心登录(未开启时走原有本地登录页)
|
||||
if (isSSOEnabled()) {
|
||||
await redirectToAuthorize();
|
||||
return;
|
||||
}
|
||||
next({ path: "/login", query: { redirect: to.path } });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { getCurrentUser } from '@/api/login'
|
||||
import { isSSOEnabled, logoutSSO } from '@/utils/authClient'
|
||||
|
||||
// 用户信息类型
|
||||
const defaultUser = {
|
||||
@@ -93,6 +94,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
Object.assign(user, defaultUser)
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
// 统一认证模式:吊销令牌并跳认证中心完成单点登出(所有调用点自动生效)
|
||||
if (isSSOEnabled()) {
|
||||
logoutSSO()
|
||||
}
|
||||
}
|
||||
|
||||
// 检查认证状态
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 统一认证中心(UAC)客户端 —— OIDC Authorization Code + PKCE
|
||||
*
|
||||
* 接入方式:在 .env 中配置
|
||||
* VITE_AUTH_MODE=sso 开启统一认证(不配置则走原有 /backend/login)
|
||||
* VITE_AUTH_BASE=https://api.yunzer.cn/auth
|
||||
* VITE_AUTH_CLIENT_ID=yz-backend
|
||||
* VITE_AUTH_REDIRECT_URI=https://back.yunzer.cn/#/auth/callback
|
||||
*
|
||||
* 说明:token 仍存在 localStorage(沿用现有 utils/request.js 与 stores/auth.js),
|
||||
* 因此接入后业务代码无需改动即可带上 Authorization 头。
|
||||
*/
|
||||
|
||||
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE || "";
|
||||
const CLIENT_ID = import.meta.env.VITE_AUTH_CLIENT_ID || "yz-backend";
|
||||
const REDIRECT_URI =
|
||||
import.meta.env.VITE_AUTH_REDIRECT_URI || `${window.location.origin}/#/auth/callback`;
|
||||
const POST_LOGOUT_URI =
|
||||
import.meta.env.VITE_AUTH_POST_LOGOUT_URI || `${window.location.origin}/#/login`;
|
||||
|
||||
const ACCESS_KEY = "token"; // 与现有代码保持一致
|
||||
const REFRESH_KEY = "auth_refresh_token";
|
||||
const SID_KEY = "auth_sid";
|
||||
|
||||
/** 是否启用统一认证(默认关闭,保持原有登录方式) */
|
||||
export function isSSOEnabled() {
|
||||
return import.meta.env.VITE_AUTH_MODE === "sso" && !!AUTH_BASE;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PKCE
|
||||
|
||||
function randomString(len = 64) {
|
||||
const arr = new Uint8Array(len);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr, (b) => ("0" + b.toString(16)).slice(-2)).join("").slice(0, len);
|
||||
}
|
||||
|
||||
async function sha256(plain) {
|
||||
return crypto.subtle.digest("SHA-256", new TextEncoder().encode(plain));
|
||||
}
|
||||
|
||||
function base64url(buf) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function generatePKCE() {
|
||||
const verifier = randomString(64);
|
||||
const challenge = base64url(await sha256(verifier));
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 令牌存储
|
||||
|
||||
export function setTokens(data) {
|
||||
if (data?.access_token) localStorage.setItem(ACCESS_KEY, data.access_token);
|
||||
if (data?.refresh_token) localStorage.setItem(REFRESH_KEY, data.refresh_token);
|
||||
if (data?.sid) localStorage.setItem(SID_KEY, data.sid);
|
||||
}
|
||||
|
||||
export function getRefreshToken() {
|
||||
return localStorage.getItem(REFRESH_KEY) || "";
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
localStorage.removeItem(SID_KEY);
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return localStorage.getItem(ACCESS_KEY) || "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 主流程
|
||||
|
||||
/** 跳转认证中心登录(带 PKCE 与 state) */
|
||||
export async function redirectToAuthorize() {
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
sessionStorage.setItem("pkce_verifier", verifier);
|
||||
const state = randomString(24);
|
||||
sessionStorage.setItem("oidc_state", state);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_type: "code",
|
||||
scope: "openid profile tenant",
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
window.location.href = `${AUTH_BASE}/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** 处理认证中心回跳:用 code 换令牌 */
|
||||
export async function handleAuthorizeCallback(query = {}) {
|
||||
const code = query.code;
|
||||
const state = query.state;
|
||||
const savedState = sessionStorage.getItem("oidc_state");
|
||||
const verifier = sessionStorage.getItem("pkce_verifier");
|
||||
|
||||
if (!code) throw new Error("缺少授权码");
|
||||
if (savedState && state !== savedState) throw new Error("state 校验失败");
|
||||
if (!verifier) throw new Error("PKCE 校验信息丢失,请重新登录");
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
});
|
||||
|
||||
const res = await fetch(`${AUTH_BASE}/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.access_token) {
|
||||
throw new Error(data.error_description || data.error || "换取令牌失败");
|
||||
}
|
||||
|
||||
sessionStorage.removeItem("pkce_verifier");
|
||||
sessionStorage.removeItem("oidc_state");
|
||||
setTokens(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 静默刷新访问令牌 */
|
||||
export async function refreshAccessToken() {
|
||||
const refresh = getRefreshToken();
|
||||
if (!refresh) throw new Error("无刷新令牌");
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refresh,
|
||||
client_id: CLIENT_ID,
|
||||
});
|
||||
const res = await fetch(`${AUTH_BASE}/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.access_token) throw new Error("刷新令牌失败");
|
||||
setTokens(data);
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/** 单点登出:吊销令牌后回认证中心登出页 */
|
||||
export async function logoutSSO() {
|
||||
const token = getAccessToken();
|
||||
try {
|
||||
await fetch(`${AUTH_BASE}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ token, refresh_token: getRefreshToken() }),
|
||||
});
|
||||
} catch (e) {
|
||||
// 吊销失败也要继续登出,避免卡在本端
|
||||
}
|
||||
clearTokens();
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
post_logout_redirect_uri: POST_LOGOUT_URI,
|
||||
});
|
||||
window.location.href = `${AUTH_BASE}/logout?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** 拉取当前登录用户(认证中心视角) */
|
||||
export async function fetchUserInfo() {
|
||||
const res = await fetch(`${AUTH_BASE}/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${getAccessToken()}` },
|
||||
});
|
||||
if (!res.ok) throw new Error("获取用户信息失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 切换企业(一人多企业场景) */
|
||||
export async function switchTenant(tid) {
|
||||
const res = await fetch(`${AUTH_BASE}/switch-tenant`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${getAccessToken()}`,
|
||||
},
|
||||
body: JSON.stringify({ tid, client_id: CLIENT_ID }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.code !== 200) throw new Error(data.msg || "切换企业失败");
|
||||
if (data.data?.tokens) setTokens(data.data.tokens);
|
||||
return data.data;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { isSSOEnabled, refreshAccessToken, clearTokens } from '@/utils/authClient';
|
||||
|
||||
// 获取API基础URL,添加调试信息
|
||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL;
|
||||
@@ -40,13 +41,27 @@ service.interceptors.response.use(
|
||||
response => {
|
||||
return response.data;
|
||||
},
|
||||
error => {
|
||||
async error => {
|
||||
if (error.response) {
|
||||
// 后端统一返回 {code, msg},优先透出业务提示(如唯一性冲突、无权操作),
|
||||
// 避免页面只显示 "Request failed with status code xxx"
|
||||
const bizMsg = error.response.data?.msg || error.response.data?.message;
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
// 统一认证模式:先用 refresh_token 静默换取新令牌并重放原请求,
|
||||
// 刷新失败(令牌过期/被吊销)才真正登出。
|
||||
if (isSSOEnabled()) {
|
||||
try {
|
||||
const newToken = await refreshAccessToken();
|
||||
error.config.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
return service.request(error.config);
|
||||
} catch (e) {
|
||||
clearTokens();
|
||||
localStorage.removeItem('userInfo');
|
||||
window.location.href = '#/login';
|
||||
return Promise.reject(new Error('token无效'));
|
||||
}
|
||||
}
|
||||
console.error('未授权,请重新登录');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userInfo');
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="auth-callback">
|
||||
<div class="box">
|
||||
<div v-if="error" class="error">
|
||||
<p class="title">登录失败</p>
|
||||
<p class="msg">{{ error }}</p>
|
||||
<button class="btn" @click="retry">重新登录</button>
|
||||
</div>
|
||||
<div v-else class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>正在完成登录,请稍候…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import { handleAuthorizeCallback, redirectToAuthorize } from "@/utils/authClient";
|
||||
|
||||
const error = ref("");
|
||||
|
||||
async function finish() {
|
||||
// hash 路由:参数位于 location.hash 之后,这里统一从 URL 中取出
|
||||
const raw = window.location.href;
|
||||
const queryStr = raw.includes("?") ? raw.slice(raw.indexOf("?") + 1) : "";
|
||||
const query = Object.fromEntries(new URLSearchParams(queryStr));
|
||||
|
||||
try {
|
||||
await handleAuthorizeCallback(query);
|
||||
// 登录成功:清掉 URL 上的 code/state,进入首页
|
||||
window.location.href = "#/home";
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
error.value = e?.message || "登录失败";
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
redirectToAuthorize();
|
||||
}
|
||||
|
||||
onMounted(finish);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-callback {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.box {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 40px 36px;
|
||||
width: 360px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 0 auto 16px;
|
||||
border: 3px solid #e4e7ee;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #cf1322;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.msg {
|
||||
font-size: 13px;
|
||||
color: #5a6072;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.btn {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -196,17 +196,17 @@ const performLoginRequest = async () => {
|
||||
|
||||
if (res && res.code === 200) {
|
||||
// 登录成功:处理"记住我"
|
||||
// 安全:只记住账号与租户名,绝不明文存储密码
|
||||
if (rememberMe.value) {
|
||||
localStorage.setItem("loginAccount", account.value);
|
||||
localStorage.setItem("loginTenantName", tenant_name.value);
|
||||
localStorage.setItem("loginPassword", password.value);
|
||||
localStorage.setItem("loginRememberMe", "true");
|
||||
} else {
|
||||
localStorage.removeItem("loginAccount");
|
||||
localStorage.removeItem("loginTenantName");
|
||||
localStorage.removeItem("loginPassword");
|
||||
localStorage.setItem("loginRememberMe", "false");
|
||||
}
|
||||
localStorage.removeItem("loginPassword");
|
||||
|
||||
authStore.setLoginInfo({ ...res.data, tenant_name: tenant_name.value });
|
||||
|
||||
@@ -327,17 +327,17 @@ const startGeetest4 = async () => {
|
||||
});
|
||||
|
||||
if (loginRes && loginRes.code === 200) {
|
||||
// 安全:只记住账号与租户名,绝不明文存储密码
|
||||
if (rememberMe.value) {
|
||||
localStorage.setItem("loginAccount", account.value);
|
||||
localStorage.setItem("loginTenantName", tenant_name.value);
|
||||
localStorage.setItem("loginPassword", password.value);
|
||||
localStorage.setItem("loginRememberMe", "true");
|
||||
} else {
|
||||
localStorage.removeItem("loginAccount");
|
||||
localStorage.removeItem("loginTenantName");
|
||||
localStorage.removeItem("loginPassword");
|
||||
localStorage.setItem("loginRememberMe", "false");
|
||||
}
|
||||
localStorage.removeItem("loginPassword");
|
||||
|
||||
authStore.setLoginInfo({ ...loginRes.data, tenant_name: tenant_name.value });
|
||||
const { useTabsStore } = await import("@/stores");
|
||||
@@ -471,7 +471,6 @@ onMounted(async () => {
|
||||
if (savedRemember === "true") {
|
||||
tenant_name.value = localStorage.getItem("loginTenantName") || "";
|
||||
account.value = localStorage.getItem("loginAccount") || "";
|
||||
password.value = localStorage.getItem("loginPassword") || "";
|
||||
rememberMe.value = true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user