Browse Source

对接统一登录平台的快捷登录

liuq 2 months ago
parent
commit
f039b25281

+ 196 - 1
backend/controllers/auth_controller.go

@@ -1,13 +1,71 @@
 package controllers
 
 import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io"
+	"sort"
+	"strings"
+	"time"
+
 	"ems-backend/models"
 	"ems-backend/utils"
 	"net/http"
 
 	"github.com/gin-gonic/gin"
+	"gorm.io/gorm"
+)
+
+const (
+	SSOAppID     = "app_e3d1afe340085e24"
+	SSOAppSecret = "xnbJjZghQzZ0gPkc7e6ngirrAZq0oXum"
+	SSOValidateURL = "https://api.hnyunzhu.com/api/v1/simple/validate"
 )
 
+type SSOUserStruct struct {
+	Username string
+	Name     string
+	Email    string
+}
+
+// FindOrCreateUserFromSSO handles user creation for SSO login
+func FindOrCreateUserFromSSO(ssoUser SSOUserStruct) (*models.User, error) {
+	var user models.User
+
+	// 1. Check if user exists by unique identifier
+	err := models.DB.Where("username = ?", ssoUser.Username).First(&user).Error
+
+	if err == nil {
+		// User exists
+		return &user, nil
+	}
+
+	if err == gorm.ErrRecordNotFound {
+		// 2. User not found, create a NEW user without permissions
+		newUser := models.User{
+			Username: ssoUser.Username,
+			Name:     ssoUser.Name,
+			Status:   "0", // Normal status
+		}
+
+		if ssoUser.Email != "" {
+			email := ssoUser.Email
+			newUser.Email = &email
+		}
+
+		if err := models.DB.Create(&newUser).Error; err != nil {
+			return nil, err
+		}
+
+		return &newUser, nil
+	}
+
+	return nil, err
+}
+
 type LoginRequest struct {
 	Username string `json:"username" binding:"required"`
 	Password string `json:"password" binding:"required"`
@@ -27,7 +85,7 @@ func Login(c *gin.Context) {
 
 	// 1. Database User Check
 	var user models.User
-	// 使用 MD5 或其他加密方式比较密码(这里为了演示先用明文,实际项目请使用 bcrypt)
+	// 使用 MD5 或其他加密方式比较密码
 	if err := models.DB.Where("username = ? AND password = ?", req.Username, req.Password).First(&user).Error; err != nil {
 		c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
 		return
@@ -104,3 +162,140 @@ func UpdateProfilePwd(c *gin.Context) {
 	c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
 }
 
+type SSOLoginRequest struct {
+	Ticket string `json:"ticket" binding:"required"`
+}
+
+type SSOResponse struct {
+	Valid       bool   `json:"valid"`
+	UserID      int    `json:"user_id"`
+	Mobile      string `json:"mobile"`
+	MappedKey   string `json:"mapped_key"`
+	MappedEmail string `json:"mapped_email"`
+}
+
+func generateSSOSignature(params map[string]interface{}, secret string) string {
+	// 1. Sort keys
+	keys := make([]string, 0, len(params))
+	for k := range params {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+
+	// 2. Concatenate
+	var sb strings.Builder
+	for _, k := range keys {
+		if k == "sign" {
+			continue
+		}
+		// Filter out nil/empty if needed, but docs say "filter empty values and sign"
+		// Assuming non-empty for simplicity or add check
+		val := params[k]
+		if val == nil {
+			continue
+		}
+		
+		strVal := fmt.Sprintf("%v", val)
+		if strVal == "" {
+			continue
+		}
+
+		if sb.Len() > 0 {
+			sb.WriteString("&")
+		}
+		sb.WriteString(k + "=" + strVal)
+	}
+	
+	// Debug log: String to sign
+	fmt.Printf("SSO Signing String: %s\n", sb.String())
+
+	// 3. HMAC-SHA256
+	h := hmac.New(sha256.New, []byte(secret))
+	h.Write([]byte(sb.String()))
+	return hex.EncodeToString(h.Sum(nil))
+}
+
+func SSOLogin(c *gin.Context) {
+	var req SSOLoginRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
+		return
+	}
+
+	// 1. Build validation request
+	timestamp := time.Now().Unix()
+	payload := map[string]interface{}{
+		"app_id":    SSOAppID,
+		"ticket":    req.Ticket,
+		"timestamp": timestamp,
+	}
+	
+	// Calculate signature
+	sign := generateSSOSignature(payload, SSOAppSecret)
+	payload["sign"] = sign
+
+	// 2. Call UAP Validate API
+	client := &http.Client{Timeout: 10 * time.Second}
+	jsonBody, _ := json.Marshal(payload)
+	
+	resp, err := client.Post(SSOValidateURL, "application/json", strings.NewReader(string(jsonBody)))
+	if err != nil {
+		c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to connect to SSO provider"})
+		return
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 200 {
+		bodyBytes, _ := io.ReadAll(resp.Body)
+		fmt.Printf("SSO Validation Failed: Status=%d, Body=%s\n", resp.StatusCode, string(bodyBytes))
+		c.JSON(resp.StatusCode, gin.H{"error": "SSO Validation Failed", "details": string(bodyBytes)})
+		return
+	}
+
+	// 3. Parse Response
+	var ssoResp SSOResponse
+	bodyBytes, _ := io.ReadAll(resp.Body)
+	if err := json.Unmarshal(bodyBytes, &ssoResp); err != nil {
+		fmt.Printf("SSO Parse Error: %v, Body=%s\n", err, string(bodyBytes))
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse SSO response"})
+		return
+	}
+
+	if !ssoResp.Valid {
+		fmt.Printf("SSO Invalid Ticket: %+v\n", ssoResp)
+		c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid SSO Ticket"})
+		return
+	}
+
+	// 4. Find or Create User
+	// Strict: Only use MappedKey
+	if ssoResp.MappedKey == "" {
+		c.JSON(http.StatusUnauthorized, gin.H{"error": "SSO Login Failed: No mapped user key provided"})
+		return
+	}
+
+	ssoUser := SSOUserStruct{
+		Username: ssoResp.MappedKey,
+		Name:     ssoResp.MappedKey,
+		Email:    ssoResp.MappedEmail,
+	}
+
+	user, err := FindOrCreateUserFromSSO(ssoUser)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process user: " + err.Error()})
+		return
+	}
+
+	// 5. Generate Token
+	token, err := utils.GenerateToken(*user)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
+		return
+	}
+
+	user.Password = ""
+	c.JSON(http.StatusOK, LoginResponse{
+		Token: token,
+		User:  *user,
+	})
+}

+ 1 - 0
backend/integration_status.txt

@@ -0,0 +1 @@
+Redirect SSO Integration Completed

+ 1 - 0
backend/routes/routes.go

@@ -10,6 +10,7 @@ import (
 func SetupRoutes(r *gin.Engine) {
 	// Public Routes
 	r.POST("/api/v1/login", controllers.Login)
+	r.POST("/api/v1/login/sso", controllers.SSOLogin)
 
 	api := r.Group("/api/v1")
 	api.Use(middleware.AuthRequired())

+ 21 - 6
frontend/src/router/index.ts

@@ -1,5 +1,7 @@
 import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
 import Login from '../views/Login.vue';
+import NoPermission from '../views/common/NoPermission.vue';
+import SSOCallback from '../views/SSOCallback.vue';
 import { useUserStore } from '../stores/user';
 import { usePermissionStore } from '../stores/permission';
 
@@ -9,6 +11,16 @@ const constantRoutes: Array<RouteRecordRaw> = [
     name: 'Login',
     component: Login
   },
+  {
+    path: '/callback',
+    name: 'SSOCallback',
+    component: SSOCallback
+  },
+  {
+    path: '/no-permission',
+    name: 'NoPermission',
+    component: NoPermission
+  },
   {
     path: '/',
     redirect: '/monitor/dashboard'
@@ -20,7 +32,7 @@ const router = createRouter({
   routes: constantRoutes
 });
 
-const whiteList = ['/login'];
+const whiteList = ['/login', '/no-permission', '/callback'];
 
 const getFirstAccessiblePath = (routes: RouteRecordRaw[]): string | null => {
   for (const route of routes) {
@@ -73,11 +85,14 @@ router.beforeEach(async (to, _from, next) => {
                      console.log('Access routes generated:', accessRoutes);
                      
                      if (accessRoutes.length === 0) {
-                        // 防止死循环:如果获取不到路由,说明权限配置有问题或者后端异常
-                        // 强制登出并跳转登录页,避免在 / 和 /login 之间无限循环
-                        console.warn('No access routes found, forcing logout to prevent infinite loop');
-                        userStore.logout();
-                        next(`/login`);
+                        // 防止死循环:如果获取不到路由,说明权限配置有问题或者用户无权限
+                        // 强制跳转到无权限页面
+                        console.warn('No access routes found, redirecting to NoPermission page');
+                        if (to.path !== '/no-permission') {
+                            next('/no-permission');
+                        } else {
+                            next();
+                        }
                         return;
                      }
 

+ 7 - 0
frontend/src/stores/user.ts

@@ -16,6 +16,13 @@ export const useUserStore = defineStore('user', {
       localStorage.setItem('token', res.token);
       localStorage.setItem('user', JSON.stringify(res.user));
     },
+    async ssoLogin(ticket: string) {
+        const res: any = await api.post('/login/sso', { ticket });
+        this.token = res.token;
+        this.user = res.user;
+        localStorage.setItem('token', res.token);
+        localStorage.setItem('user', JSON.stringify(res.user));
+    },
     logout() {
       this.token = '';
       this.user = {};

+ 54 - 0
frontend/src/views/SSOCallback.vue

@@ -0,0 +1,54 @@
+<template>
+  <div class="redirect-container">
+    <a-spin tip="正在验证身份..." size="large" />
+    <div class="message">正在跳转登录...</div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { useUserStore } from '../stores/user';
+import { Message } from '@arco-design/web-vue';
+
+const route = useRoute();
+const router = useRouter();
+const userStore = useUserStore();
+
+onMounted(async () => {
+  const ticket = route.query.ticket as string;
+  
+  if (!ticket) {
+    Message.error('登录失败:未收到票据');
+    router.push('/login');
+    return;
+  }
+
+  try {
+    await userStore.ssoLogin(ticket);
+    Message.success('登录成功');
+    router.push('/');
+  } catch (error) {
+    console.error('SSO Login Error:', error);
+    Message.error('登录验证失败,请重试');
+    router.push('/login');
+  }
+});
+</script>
+
+<style scoped>
+.redirect-container {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  height: 100vh;
+  background-color: var(--color-bg-1);
+}
+
+.message {
+  margin-top: 16px;
+  color: var(--color-text-2);
+  font-size: 16px;
+}
+</style>

+ 37 - 0
frontend/src/views/common/NoPermission.vue

@@ -0,0 +1,37 @@
+<template>
+  <div class="no-permission-container">
+    <a-result status="403" title="暂无权限">
+      <template #subtitle>
+        您的账号已创建成功,但暂未分配任何权限。
+        <br />
+        请联系管理员为您分配角色或权限。
+      </template>
+      <template #extra>
+        <a-button type="primary" @click="handleLogout">返回登录</a-button>
+      </template>
+    </a-result>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useUserStore } from '../../stores/user';
+import { useRouter } from 'vue-router';
+
+const userStore = useUserStore();
+const router = useRouter();
+
+const handleLogout = () => {
+  userStore.logout();
+  router.push('/login');
+};
+</script>
+
+<style scoped>
+.no-permission-container {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 100vh;
+  background-color: var(--color-bg-1);
+}
+</style>