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
 import logging
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
@@ -14,10 +14,14 @@ router = APIRouter()
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 @router.get("/login-request", summary="获取登录请求信息 (OIDC)")
 @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 获取登录请求信息。
     从 Hydra 获取登录请求信息。
     前端调用此接口以检查是否应跳过登录(如果响应中 skip=true)。
     前端调用此接口以检查是否应跳过登录(如果响应中 skip=true)。
+    如果用户在统一认证平台已有会话,也会自动接受。
     """
     """
     try:
     try:
         req = hydra_service.get_login_request(challenge)
         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}")
             logger.info(f"Skipping login for challenge {challenge}, subject: {req.subject}")
             # If Hydra says skip, we just accept it immediately
             # If Hydra says skip, we just accept it immediately
             return hydra_service.accept_login_request(challenge, subject=req.subject)
             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
         return req
     except Exception as e:
     except Exception as e:
         logger.exception(f"Failed to get login request for challenge: {challenge}")
         logger.exception(f"Failed to get login request for challenge: {challenge}")
@@ -171,3 +181,29 @@ def reject_consent(
     except Exception as e:
     except Exception as e:
         logger.exception(f"Failed to reject consent request for challenge: {challenge}")
         logger.exception(f"Failed to reject consent request for challenge: {challenge}")
         raise HTTPException(status_code=400, detail=str(e))
         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}")
             logger.error(f"拒绝同意请求失败 (challenge: {challenge}): {e}")
             raise
             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()
 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
       - 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_SELF_ISSUER=https://api.hnyunzhu.com/hydra
       - URLS_CONSENT=https://api.hnyunzhu.com/consent
       - 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
       - SECRETS_SYSTEM=youReallyNeedToChangeThis
       - OIDC_SUBJECT_IDENTIFIERS_SUPPORTED_TYPES=public,pairwise
       - OIDC_SUBJECT_IDENTIFIERS_SUPPORTED_TYPES=public,pairwise
       - OIDC_SUBJECT_IDENTIFIERS_PAIRWISE_SALT=youReallyNeedToChangeThis
       - 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
       - 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_SELF_ISSUER=https://api.hnyunzhu.com/hydra
       - URLS_CONSENT=https://api.hnyunzhu.com/consent
       - 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
       - SECRETS_SYSTEM=youReallyNeedToChangeThis
       - OIDC_SUBJECT_IDENTIFIERS_SUPPORTED_TYPES=public,pairwise
       - OIDC_SUBJECT_IDENTIFIERS_SUPPORTED_TYPES=public,pairwise
       - OIDC_SUBJECT_IDENTIFIERS_PAIRWISE_SALT=youReallyNeedToChangeThis
       - 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 }) => {
 export const rejectConsent = (challenge: string, rejectData: { error: string; error_description?: string }) => {
   return api.post(`/oidc/consent/reject?challenge=${challenge}`, rejectData)
   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',
     name: 'Consent',
     component: () => import('../views/Consent.vue')
     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',
     path: '/mobile/login',
     name: 'MobileLogin',
     name: 'MobileLogin',
@@ -212,7 +222,7 @@ router.beforeEach((to, from, next) => {
   }
   }
 
 
   // Public routes
   // 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)) {
   if (publicRoutes.includes(to.path)) {
     next()
     next()
     return
     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) {
   if (challenge) {
     loginChallenge.value = 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) {
   } else if (appid) {
     // 3. Handle App SSO
     // 3. Handle App SSO
     try {
     try {