liuq пре 1 месец
родитељ
комит
31aaa5bc56

+ 38 - 2
backend/app/api/v1/endpoints/oidc.py

@@ -1,4 +1,4 @@
-from typing import Any, List
+from typing import Any, List, Optional
 import logging
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy.orm import Session
@@ -14,10 +14,14 @@ router = APIRouter()
 logger = logging.getLogger(__name__)
 
 @router.get("/login-request", summary="获取登录请求信息 (OIDC)")
-def get_login_request(challenge: str):
+def get_login_request(
+    challenge: str,
+    current_user: Optional[User] = Depends(deps.get_current_active_user_optional)
+):
     """
     从 Hydra 获取登录请求信息。
     前端调用此接口以检查是否应跳过登录(如果响应中 skip=true)。
+    如果用户在统一认证平台已有会话,也会自动接受。
     """
     try:
         req = hydra_service.get_login_request(challenge)
@@ -25,6 +29,12 @@ def get_login_request(challenge: str):
             logger.info(f"Skipping login for challenge {challenge}, subject: {req.subject}")
             # If Hydra says skip, we just accept it immediately
             return hydra_service.accept_login_request(challenge, subject=req.subject)
+            
+        # 如果不是 skip,但用户在平台已登录,则自动接受
+        if current_user:
+            logger.info(f"Auto-accepting login for challenge {challenge}, using platform session, subject: {current_user.id}")
+            return hydra_service.accept_login_request(challenge, subject=str(current_user.id))
+            
         return req
     except Exception as e:
         logger.exception(f"Failed to get login request for challenge: {challenge}")
@@ -171,3 +181,29 @@ def reject_consent(
     except Exception as e:
         logger.exception(f"Failed to reject consent request for challenge: {challenge}")
         raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get("/logout-request", summary="获取登出请求信息 (OIDC)")
+def get_logout_request(challenge: str):
+    """
+    从 Hydra 获取登出请求信息。
+    """
+    try:
+        return hydra_service.get_logout_request(challenge)
+    except Exception as e:
+        logger.exception(f"Failed to get logout request for challenge: {challenge}")
+        raise HTTPException(status_code=400, detail=str(e))
+
+@router.post("/logout/accept", summary="接受登出请求 (OIDC)")
+def accept_logout(
+    challenge: str
+):
+    """
+    接受登出请求,返回 redirect_to。
+    """
+    try:
+        logger.info(f"Accepting logout request for challenge: {challenge}")
+        return hydra_service.accept_logout_request(challenge)
+    except Exception as e:
+        logger.exception(f"Failed to accept logout request for challenge: {challenge}")
+        raise HTTPException(status_code=500, detail=str(e))

+ 27 - 0
backend/app/services/hydra_service.py

@@ -151,4 +151,31 @@ class HydraService:
             logger.error(f"拒绝同意请求失败 (challenge: {challenge}): {e}")
             raise
 
+    def get_logout_request(self, challenge: str):
+        try:
+            resp = requests.get(
+                f"{self.admin_base}/admin/oauth2/auth/requests/logout",
+                params={"logout_challenge": challenge},
+                timeout=5
+            )
+            resp.raise_for_status()
+            return resp.json()
+        except Exception as e:
+            logger.error(f"获取登出请求失败 (challenge: {challenge}): {e}")
+            raise
+
+    def accept_logout_request(self, challenge: str):
+        try:
+            resp = requests.put(
+                f"{self.admin_base}/admin/oauth2/auth/requests/logout/accept",
+                params={"logout_challenge": challenge},
+                timeout=5
+            )
+            resp.raise_for_status()
+            logger.info(f"接受登出请求 (challenge: {challenge})")
+            return resp.json()
+        except Exception as e:
+            logger.error(f"接受登出请求失败 (challenge: {challenge}): {e}")
+            raise
+
 hydra_service = HydraService()

+ 2 - 2
docker-compose.wsl.yml

@@ -177,8 +177,8 @@ services:
       - DSN=postgres://hydra:secret@postgresd:5432/hydra?sslmode=disable&max_conns=20&max_idle_conns=4
       - URLS_SELF_ISSUER=https://api.hnyunzhu.com/hydra
       - URLS_CONSENT=https://api.hnyunzhu.com/consent
-      - URLS_LOGIN=https://api.hnyunzhu.com/login
-      - URLS_LOGOUT=https://api.hnyunzhu.com/login
+      - URLS_LOGIN=https://api.hnyunzhu.com/auto-login
+      - URLS_LOGOUT=https://api.hnyunzhu.com/auto-logout
       - SECRETS_SYSTEM=youReallyNeedToChangeThis
       - OIDC_SUBJECT_IDENTIFIERS_SUPPORTED_TYPES=public,pairwise
       - OIDC_SUBJECT_IDENTIFIERS_PAIRWISE_SALT=youReallyNeedToChangeThis

+ 2 - 2
docker-compose.yml

@@ -177,8 +177,8 @@ services:
       - DSN=postgres://hydra:secret@postgresd:5432/hydra?sslmode=disable&max_conns=20&max_idle_conns=4
       - URLS_SELF_ISSUER=https://api.hnyunzhu.com/hydra
       - URLS_CONSENT=https://api.hnyunzhu.com/consent
-      - URLS_LOGIN=https://api.hnyunzhu.com/login
-      - URLS_LOGOUT=https://api.hnyunzhu.com/login
+      - URLS_LOGIN=https://api.hnyunzhu.com/auto-login
+      - URLS_LOGOUT=https://api.hnyunzhu.com/auto-logout
       - SECRETS_SYSTEM=youReallyNeedToChangeThis
       - OIDC_SUBJECT_IDENTIFIERS_SUPPORTED_TYPES=public,pairwise
       - OIDC_SUBJECT_IDENTIFIERS_PAIRWISE_SALT=youReallyNeedToChangeThis

+ 8 - 0
frontend/src/api/oidc.ts

@@ -19,3 +19,11 @@ export const rejectLogin = (challenge: string, rejectData: { error: string; erro
 export const rejectConsent = (challenge: string, rejectData: { error: string; error_description?: string }) => {
   return api.post(`/oidc/consent/reject?challenge=${challenge}`, rejectData)
 }
+
+export const getLogoutRequest = (challenge: string) => {
+  return api.get(`/oidc/logout-request?challenge=${challenge}`)
+}
+
+export const acceptLogout = (challenge: string) => {
+  return api.post(`/oidc/logout/accept?challenge=${challenge}`)
+}

+ 11 - 1
frontend/src/router/index.ts

@@ -30,6 +30,16 @@ const routes: Array<RouteRecordRaw> = [
     name: 'Consent',
     component: () => import('../views/Consent.vue')
   },
+  {
+    path: '/auto-login',
+    name: 'AutoLogin',
+    component: () => import('../views/AutoLogin.vue')
+  },
+  {
+    path: '/auto-logout',
+    name: 'AutoLogout',
+    component: () => import('../views/AutoLogout.vue')
+  },
   {
     path: '/mobile/login',
     name: 'MobileLogin',
@@ -212,7 +222,7 @@ router.beforeEach((to, from, next) => {
   }
 
   // Public routes
-  const publicRoutes = ['/login', '/register', '/consent', '/reset-password', '/setup', '/mobile/login', '/mobile/reset-password']
+  const publicRoutes = ['/login', '/register', '/consent', '/reset-password', '/setup', '/mobile/login', '/mobile/reset-password', '/auto-login', '/auto-logout']
   if (publicRoutes.includes(to.path)) {
     next()
     return

+ 85 - 0
frontend/src/views/AutoLogin.vue

@@ -0,0 +1,85 @@
+<template>
+  <div class="auto-login-container">
+    <el-card class="auto-login-card">
+      <div v-if="error" class="error-state">
+        <el-icon class="icon-error"><CircleCloseFilled /></el-icon>
+        <h3>登录检测失败</h3>
+        <p>{{ error }}</p>
+        <el-button type="primary" @click="goToLogin" style="margin-top: 15px;">前往登录</el-button>
+      </div>
+      <div v-else class="loading-state">
+        <el-icon class="is-loading"><Loading /></el-icon>
+        <p>正在检查 SSO 登录状态...</p>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { getLoginRequest } from '../api/oidc'
+import { Loading, CircleCloseFilled } from '@element-plus/icons-vue'
+
+const route = useRoute()
+const router = useRouter()
+const error = ref('')
+
+onMounted(async () => {
+  const challenge = route.query.login_challenge as string
+  if (!challenge) {
+    error.value = "缺少 login_challenge 参数"
+    return
+  }
+
+  try {
+    const res = await getLoginRequest(challenge)
+    // 如果后台判断会话有效(skip=true),会直接返回 redirect_to
+    if (res.data?.redirect_to) {
+      window.location.href = res.data.redirect_to
+    } else {
+      // 否则说明没有平台登录态,重定向到真实的登录页面,并携带 challenge
+      router.replace({ path: '/login', query: { login_challenge: challenge } })
+    }
+  } catch (e: any) {
+    console.error(e)
+    error.value = e.response?.data?.detail || "获取 OIDC 请求失败"
+  }
+})
+
+const goToLogin = () => {
+  const challenge = route.query.login_challenge as string
+  if (challenge) {
+    router.replace({ path: '/login', query: { login_challenge: challenge } })
+  } else {
+    router.replace('/login')
+  }
+}
+</script>
+
+<style scoped>
+.auto-login-container {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 100vh;
+  background-color: #f0f2f5;
+}
+.auto-login-card {
+  width: 400px;
+  text-align: center;
+  padding: 30px 20px;
+}
+.icon-error {
+  font-size: 48px;
+  color: #f56c6c;
+}
+.is-loading {
+  font-size: 32px;
+  color: #409eff;
+}
+.loading-state p {
+  margin-top: 15px;
+  color: #606266;
+}
+</style>

+ 86 - 0
frontend/src/views/AutoLogout.vue

@@ -0,0 +1,86 @@
+<template>
+  <div class="auto-logout-container">
+    <el-card class="auto-logout-card">
+      <div v-if="error" class="error-state">
+        <el-icon class="icon-error"><CircleCloseFilled /></el-icon>
+        <h3>登出处理失败</h3>
+        <p>{{ error }}</p>
+        <el-button type="primary" @click="goToLogin" style="margin-top: 15px;">返回登录页</el-button>
+      </div>
+      <div v-else class="loading-state">
+        <el-icon class="is-loading"><Loading /></el-icon>
+        <p>正在安全退出...</p>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { acceptLogout } from '../api/oidc'
+import { useAuthStore } from '../store/auth'
+import { Loading, CircleCloseFilled } from '@element-plus/icons-vue'
+
+const route = useRoute()
+const router = useRouter()
+const authStore = useAuthStore()
+const error = ref('')
+
+onMounted(async () => {
+  const challenge = route.query.logout_challenge as string
+  if (!challenge) {
+    error.value = "缺少 logout_challenge 参数"
+    return
+  }
+
+  try {
+    const res = await acceptLogout(challenge)
+    
+    // 清除本地登录状态
+    localStorage.removeItem('token')
+    authStore.token = ''
+    authStore.user = null
+    
+    if (res.data?.redirect_to) {
+      window.location.href = res.data.redirect_to
+    } else {
+      router.replace('/login')
+    }
+  } catch (e: any) {
+    console.error(e)
+    error.value = e.response?.data?.detail || "处理登出请求失败"
+  }
+})
+
+const goToLogin = () => {
+  router.replace('/login')
+}
+</script>
+
+<style scoped>
+.auto-logout-container {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 100vh;
+  background-color: #f0f2f5;
+}
+.auto-logout-card {
+  width: 400px;
+  text-align: center;
+  padding: 30px 20px;
+}
+.icon-error {
+  font-size: 48px;
+  color: #f56c6c;
+}
+.is-loading {
+  font-size: 32px;
+  color: #409eff;
+}
+.loading-state p {
+  margin-top: 15px;
+  color: #606266;
+}
+</style>

+ 2 - 12
frontend/src/views/Login.vue

@@ -281,18 +281,8 @@ onMounted(async () => {
   
   if (challenge) {
     loginChallenge.value = challenge
-    try {
-      loading.value = true
-      const res = await getLoginRequest(challenge)
-      // If backend auto-accepted (skip=true), it returns redirect_to
-      if (res.data.redirect_to) {
-        window.location.href = res.data.redirect_to
-      }
-    } catch (e) {
-      console.error(e)
-    } finally {
-      loading.value = false
-    }
+    // Remove auto-accept skip logic from here since it's handled by AutoLogin.vue
+    checkLoading.value = false
   } else if (appid) {
     // 3. Handle App SSO
     try {