添加sso

This commit is contained in:
2026-08-03 15:57:40 +08:00 Unverified
parent fb812268d7
commit 6bee29a351
16 changed files with 917 additions and 5 deletions
+5 -1
View File
@@ -15,7 +15,11 @@ http.interceptors.request.use((config) => {
http.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && !error.config?.url?.endsWith('/auth/login')) {
const isAuthenticationRequest =
error.config?.url?.endsWith('/auth/login') ||
error.config?.url?.endsWith('/auth/sso/exchange') ||
error.config?.url?.endsWith('/auth/sso/bind')
if (error.response?.status === 401 && !isAuthenticationRequest) {
localStorage.removeItem('jiaowu_token')
localStorage.removeItem('jiaowu_user')
goLogin(location.pathname + location.search + location.hash)
+12
View File
@@ -13,6 +13,18 @@ const router = createRouter({
component: () => import('../views/LoginView.vue'),
meta: { public: true },
},
{
path: '/sso/callback',
name: 'sso-callback',
component: () => import('../views/SsoCallbackView.vue'),
meta: { public: true },
},
{
path: '/sso/bind',
name: 'sso-bind',
component: () => import('../views/SsoBindView.vue'),
meta: { public: true },
},
{
path: '/timetable',
name: 'public-timetable',
+29 -1
View File
@@ -27,6 +27,24 @@ export const useAuthStore = defineStore('auth', () => {
window.dispatchEvent(new Event('mingxu-auth-changed'))
}
async function exchangeSso(code: string) {
const { data } = await http.post('/auth/sso/exchange', { code })
token.value = data.token
user.value = data.user
localStorage.setItem('jiaowu_token', data.token)
localStorage.setItem('jiaowu_user', JSON.stringify(data.user))
window.dispatchEvent(new Event('mingxu-auth-changed'))
}
async function bindSso(code: string, userName: string, password: string) {
const { data } = await http.post('/auth/sso/bind', { code, userName, password })
token.value = data.token
user.value = data.user
localStorage.setItem('jiaowu_token', data.token)
localStorage.setItem('jiaowu_user', JSON.stringify(data.user))
window.dispatchEvent(new Event('mingxu-auth-changed'))
}
async function refresh() {
if (!token.value) return
const { data } = await http.get('/auth/me')
@@ -42,5 +60,15 @@ export const useAuthStore = defineStore('auth', () => {
window.dispatchEvent(new Event('mingxu-auth-changed'))
}
return { token, user, isLoggedIn, isSuperAdmin, login, refresh, logout }
return {
token,
user,
isLoggedIn,
isSuperAdmin,
login,
exchangeSso,
bindSso,
refresh,
logout,
}
})
+17
View File
@@ -1208,6 +1208,23 @@ button { cursor: pointer; }
.public-timetable-link { display: block; margin: 14px 0 18px; color: #176b87; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; }
.account-activation-link { display: block; margin: -8px 0 18px; color: #315b73; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; }
.login-submit { width: 100%; margin-top: 6px; height: 46px; }
.sso-login-submit { width: 100%; height: 46px; border-color: #176b87; color: #176b87; font-weight: 650; }
.login-divider { display: flex; align-items: center; gap: 12px; margin: 16px 0; color: #9aa4b2; font-size: 12px; }
.login-divider::before, .login-divider::after { content: ""; flex: 1; height: 1px; background: #e5e9ef; }
.sso-callback-page { min-height: 100vh; display: grid; place-content: center; justify-items: center; padding: 24px; background: #f4f7fa; color: #263445; text-align: center; }
.sso-callback-page h1 { margin: 18px 0 8px; font-size: 22px; }
.sso-callback-page p { margin: 0; color: var(--muted); }
.sso-binding-page { min-height: 100vh; display: grid; place-items: center; padding: 28px; background: radial-gradient(circle at top left, #e3f5f2, transparent 42%), #f4f7fa; }
.sso-binding-card { width: min(470px, 100%); padding: 38px; border: 1px solid #e3e8ee; border-radius: 18px; background: #fff; box-shadow: 0 20px 55px rgba(35, 57, 78, .12); }
.sso-binding-card .brand-mark { margin-bottom: 24px; }
.sso-binding-card h1 { margin: 8px 0 12px; font-family: "STZhongsong", "Songti SC", serif; font-size: 28px; }
.binding-description { margin: 0 0 26px; color: var(--muted); line-height: 1.75; }
.binding-description strong { color: #176b87; }
.sso-binding-card label { display: block; margin-bottom: 18px; }
.sso-binding-card label > span { display: block; margin-bottom: 8px; color: #525b6d; font-size: 12px; font-weight: 650; }
.binding-submit { width: 100%; height: 46px; }
.binding-security-note { margin: 18px 0 10px; color: #7b8794; font-size: 12px; line-height: 1.65; }
.sso-binding-card > a { color: #315b73; font-size: 13px; font-weight: 650; text-decoration: none; }
@media (max-width: 1100px) {
.metric-grid { grid-template-columns: repeat(2, 1fr); }
+44 -1
View File
@@ -1,13 +1,16 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
import http from '../api/http'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const ssoLoading = ref(false)
const sso = reactive({ enabled: false, displayName: '学校统一身份认证' })
const form = reactive({
userName: String(route.query.userName ?? ''),
password: '',
@@ -24,6 +27,36 @@ async function submit() {
loading.value = false
}
}
function startSso() {
ssoLoading.value = true
const apiBaseUrl = String(import.meta.env.VITE_API_BASE_URL ?? '/api').replace(/\/$/, '')
const redirect = String(route.query.redirect ?? '/dashboard')
window.location.assign(
`${apiBaseUrl}/auth/sso/login?returnUrl=${encodeURIComponent(redirect)}`,
)
}
const ssoErrors: Record<string, string> = {
authentication_failed: '统一身份认证未完成,请重新尝试。',
missing_subject: 'Keycloak 未返回用户唯一标识,请联系管理员检查客户端映射。',
account_disabled: '教务系统账号已停用或锁定,请联系管理员。',
account_link_failed: '统一身份账号绑定失败,请联系管理员。',
account_update_failed: '登录状态更新失败,请稍后重试。',
binding_expired: '账户绑定请求已失效,请重新使用统一身份认证登录。',
}
onMounted(async () => {
const ssoError = String(route.query.ssoError ?? '')
if (ssoError) ElMessage.error(ssoErrors[ssoError] ?? '统一身份认证失败,请重新尝试。')
try {
const { data } = await http.get('/auth/sso/settings')
sso.enabled = Boolean(data.enabled)
sso.displayName = String(data.displayName || sso.displayName)
} catch {
sso.enabled = false
}
})
</script>
<template>
@@ -82,6 +115,16 @@ async function submit() {
>
进入工作台
</el-button>
<div v-if="sso.enabled" class="login-divider"><span></span></div>
<el-button
v-if="sso.enabled"
class="sso-login-submit"
size="large"
:loading="ssoLoading"
@click="startSso"
>
使用{{ sso.displayName }}登录
</el-button>
<router-link class="public-timetable-link" to="/timetable">无需登录查询班级课表 </router-link>
<router-link class="account-activation-link" to="/activate">学生首次登录自助激活账号 </router-link>
</form>
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const checking = ref(true)
const binding = reactive({
providerDisplayName: '学校统一身份认证',
externalUserName: '',
})
const form = reactive({ userName: '', password: '' })
function safeRedirect(value: unknown) {
const path = String(value ?? '')
return path.startsWith('/') && !path.startsWith('//') ? path : '/dashboard'
}
async function submit() {
if (!form.userName.trim() || !form.password) {
ElMessage.warning('请填写现有教务系统账号和密码。')
return
}
loading.value = true
try {
await auth.bindSso(String(route.query.code ?? ''), form.userName, form.password)
ElMessage.success('账户绑定成功。')
await router.replace(safeRedirect(route.query.redirect))
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
onMounted(async () => {
const code = String(route.query.code ?? '')
if (!code) {
await router.replace({ name: 'login', query: { ssoError: 'authentication_failed' } })
return
}
try {
const { data } = await http.get('/auth/sso/binding', { params: { code } })
binding.providerDisplayName = String(data.providerDisplayName || binding.providerDisplayName)
binding.externalUserName = String(data.externalUserName || '')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
await router.replace({ name: 'login', query: { ssoError: 'binding_expired' } })
} finally {
checking.value = false
}
})
</script>
<template>
<main class="sso-binding-page">
<section v-loading="checking" class="sso-binding-card">
<div class="brand-mark" aria-hidden="true">
<span v-for="index in 9" :key="index" />
</div>
<span class="eyebrow">首次使用统一身份认证</span>
<h1>绑定现有教务系统账号</h1>
<p class="binding-description">
已通过{{ binding.providerDisplayName }}验证
<strong v-if="binding.externalUserName">{{ binding.externalUserName }}</strong>
请输入一次现有教务系统账号和密码今后即可直接使用单点登录
</p>
<form @submit.prevent="submit">
<label>
<span>教务系统账号</span>
<el-input v-model="form.userName" size="large" autocomplete="username" />
</label>
<label>
<span>教务系统密码</span>
<el-input
v-model="form.password"
size="large"
type="password"
show-password
autocomplete="current-password"
@keyup.enter="submit"
/>
</label>
<el-button
class="binding-submit"
type="primary"
size="large"
native-type="submit"
:loading="loading"
:disabled="checking"
>
确认绑定并登录
</el-button>
</form>
<p class="binding-security-note">
绑定只建立登录关联不会修改您的角色学院或人员档案
</p>
<router-link to="/login">取消并返回登录页</router-link>
</section>
</main>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
function safeRedirect(value: unknown) {
const path = String(value ?? '')
return path.startsWith('/') && !path.startsWith('//') ? path : '/dashboard'
}
onMounted(async () => {
const code = String(route.query.code ?? '')
if (!code) {
await router.replace({ name: 'login', query: { ssoError: 'authentication_failed' } })
return
}
try {
await auth.exchangeSso(code)
await router.replace(safeRedirect(route.query.redirect))
} catch (error) {
ElMessage.error(apiErrorMessage(error))
await router.replace({ name: 'login', query: { ssoError: 'authentication_failed' } })
}
})
</script>
<template>
<main class="sso-callback-page">
<el-icon class="is-loading" :size="34"><Loading /></el-icon>
<h1>正在完成统一身份认证</h1>
<p>请稍候不要关闭此页面</p>
</main>
</template>