You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

28 KiB

SSO 统一身份认证集成方案(前后端分离 CAS 模式)

一、方案概述

1.1 架构设计

采用前后端分离的 CAS 单点登录模式,流程如下:

用户访问 → 前端检测未登录 → 重定向到 SSO 服务端 → 用户认证 
        → SSO 返回票据 → 后端验证票据 → 生成本地 Token 
        → 返回前端 → 后续请求使用本地 Token

1.2 为什么需要生成本地 Token?

原因:

  1. 性能考虑:只需首次验证调用 SSO 服务器,后续请求本地验证
  2. 解耦考虑:SSO 短暂故障不影响已登录用户
  3. 兼容性:与现有 RuoYi 框架的 Token 机制无缝集成
  4. 灵活性:可独立管理本系统的会话和权限

SSO 票据 vs 本地 Token:

  • SSO 票据 = 护照(跨系统通用,用于首次认证)
  • 本地 Token = 身份证(系统内使用,用于会话维持)

二、前端代码实现

2.1 新增文件:src/api/sso.js

import request from '@/utils/request'

/**
 * 获取 SSO 登录 URL
 * @param {string} redirect - 重定向地址
 */
export function getSsoLoginUrl(redirect) {
  return request({
    url: '/sso/loginUrl',
    method: 'get',
    params: { redirect },
    headers: {
      isToken: false
    }
  })
}

/**
 * SSO 回调处理,验证票据并登录
 * @param {Object} data - 包含 ticket 等参数
 */
export function ssoCallback(data) {
  return request({
    url: '/sso/callback',
    method: 'post',
    data: data,
    headers: {
      isToken: false
    }
  })
}

/**
 * SSO 登出
 */
export function ssoLogout() {
  return request({
    url: '/sso/logout',
    method: 'post'
  })
}

2.2 新增文件:src/views/sso/login.vue

<!--
 * SSO 统一身份认证登录页面
-->
<template>
  <div class="sso-login-container">
    <div class="sso-login-box" v-loading="loading" element-loading-text="正在跳转统一身份认证平台...">
      <div class="loading-content">
        <i class="el-icon-loading loading-icon"></i>
        <p class="loading-text">正在跳转到统一身份认证平台,请稍候...</p>
        <p class="loading-hint">如果长时间没有跳转,请检查网络连接或尝试普通登录</p>
      </div>
    </div>
  </div>
</template>

<script>
import { getSsoLoginUrl } from '@/api/sso'

export default {
  name: 'SsoLogin',
  data() {
    return {
      loading: true,
      redirect: undefined
    }
  },
  created() {
    // 获取重定向地址
    this.redirect = this.$route.query && this.$route.query.redirect
    // 发起 SSO 登录
    this.handleSsoLogin()
  },
  methods: {
    async handleSsoLogin() {
      try {
        // 调用后端接口获取 SSO 登录 URL
        const res = await getSsoLoginUrl(this.redirect)
        
        if (res.code === 200 && res.data) {
          const loginUrl = res.data
          console.log('SSO 登录 URL:', loginUrl)
          
          // 重定向到 SSO 服务器登录页面
          window.location.href = loginUrl
        } else {
          throw new Error(res.msg || '获取 SSO 登录地址失败')
        }
      } catch (error) {
        console.error('SSO 登录异常:', error)
        this.$message({
          message: error.message || 'SSO 登录异常,请尝试普通登录',
          type: 'error',
          duration: 5000
        })
        
        // 延迟跳转到普通登录页
        setTimeout(() => {
          this.$router.push({ 
            path: '/login', 
            query: this.redirect ? { redirect: this.redirect } : {} 
          })
        }, 2000)
      } finally {
        this.loading = false
      }
    }
  }
}
</script>

<style lang="scss" scoped>
.sso-login-container {
  width: 100%;
  height: 100%;
  background: url('../assets/images/loginbg.jpg') no-repeat;
  background-size: 100% 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

.sso-login-box {
  background: url("../assets/images/loginbox.png") no-repeat center bottom;
  width: 1920px;
  height: 1080px;
  position: absolute;
  top: 0;
  right: 0;
  margin-left: -699px;
  z-index: 1;
  background-size: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

.loading-content {
  text-align: center;
  color: #fff;
  background: rgba(7, 34, 111, 0.9);
  padding: 40px 60px;
  border-radius: 15px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

.loading-icon {
  font-size: 80px;
  color: #409EFF;
  animation: rotating 2s linear infinite;
  display: block;
  margin-bottom: 20px;
}

.loading-text {
  font-size: 24px;
  font-weight: bold;
  margin-bottom: 15px;
  background: linear-gradient(to bottom, #ddfbff, #2fa9ff);
  -webkit-background-clip: text;
  color: transparent;
  display: block;
}

.loading-hint {
  font-size: 16px;
  color: #ccc;
  line-height: 1.6;
}

@keyframes rotating {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}
</style>

2.3 新增文件:src/views/sso/callback.vue

<!--
 * SSO 统一身份认证回调页面
 * 处理 SSO 返回的票据并完成登录
-->
<template>
  <div class="sso-callback-container">
    <div class="sso-callback-box" v-loading="loading" element-loading-text="正在完成登录验证...">
      <div class="loading-content">
        <i class="el-icon-check loading-icon" :class="{ 'icon-success': success }"></i>
        <p class="loading-text">{{ loadingText }}</p>
        <p class="loading-hint">{{ loadingHint }}</p>
      </div>
    </div>
  </div>
</template>

<script>
import { ssoCallback } from '@/api/sso'
import { setToken } from '@/utils/auth'

export default {
  name: 'SsoCallback',
  data() {
    return {
      loading: true,
      success: false,
      loadingText: '正在验证 SSO 票据...',
      loadingHint: '请稍候...'
    }
  },
  created() {
    // 处理回调
    this.handleCallback()
  },
  methods: {
    async handleCallback() {
      try {
        // 从 URL 参数获取 ticket
        const query = this.$route.query
        const ticket = query.ticket
        
        if (!ticket) {
          throw new Error('未找到 SSO 票据,请重新登录')
        }
        
        const redirect = query.redirect || '/questionModule/index'
        
        console.log('SSO 回调,ticket:', ticket)
        
        // 调用后端验证接口
        const res = await ssoCallback({ 
          ticket,
          redirect 
        })
        
        // 验证响应
        if (res.code === 200 && res.data && res.data.token) {
          // 保存 token
          setToken(res.data.token)
          
          console.log('SSO 登录成功,token 已保存')
          
          this.success = true
          this.loadingText = '登录成功!'
          this.loadingHint = '正在跳转到系统首页...'
          
          // 延迟跳转,让用户看到成功提示
          setTimeout(() => {
            this.$router.push(redirect).catch(() => {})
          }, 1000)
        } else {
          throw new Error(res.msg || 'SSO 验证失败')
        }
      } catch (error) {
        console.error('SSO 回调错误:', error)
        
        this.loadingText = '登录失败'
        this.loadingHint = error.message || '请稍后重试或联系管理员'
        
        this.$message({
          message: error.message || 'SSO 登录失败',
          type: 'error',
          duration: 5000
        })
        
        // 延迟跳转到登录页
        setTimeout(() => {
          this.$router.push('/login')
        }, 2000)
      } finally {
        this.loading = false
      }
    }
  }
}
</script>

<style lang="scss" scoped>
.sso-callback-container {
  width: 100%;
  height: 100%;
  background: url('../assets/images/loginbg.jpg') no-repeat;
  background-size: 100% 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

.sso-callback-box {
  background: url("../assets/images/loginbox.png") no-repeat center bottom;
  width: 1920px;
  height: 1080px;
  position: absolute;
  top: 0;
  right: 0;
  margin-left: -699px;
  z-index: 1;
  background-size: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

.loading-content {
  text-align: center;
  color: #fff;
  background: rgba(7, 34, 111, 0.9);
  padding: 40px 60px;
  border-radius: 15px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

.loading-icon {
  font-size: 80px;
  color: #409EFF;
  animation: rotating 2s linear infinite;
  display: block;
  margin-bottom: 20px;
  
  &.icon-success {
    color: #67C23A;
    animation: none;
  }
}

.loading-text {
  font-size: 24px;
  font-weight: bold;
  margin-bottom: 15px;
  background: linear-gradient(to bottom, #ddfbff, #2fa9ff);
  -webkit-background-clip: text;
  color: transparent;
  display: block;
}

.loading-hint {
  font-size: 16px;
  color: #ccc;
  line-height: 1.6;
}

@keyframes rotating {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}
</style>

2.4 修改文件:public/static/config.js

在文件末尾添加 SSO 配置:

const MyConfig = {
  BASE_API: 'http://localhost:8050/gd_dataserver',
  FILE_BASE_URL: 'http://localhost:8050/gd_dataserver',
  TEMPLATE_FILE_URL: 'http://localhost:9527/asset/template',
  MQTTconnection: {
    host: "bjsdqc.cn",
    port: "8083",
    endpoint: "/mqtt",
  },
  apk_url: "http://bjsdqc.cn:9527/asset/apk/gd_v1.7.2.apk",
  isFlat: false,
  
  // ========== SSO 统一身份认证配置 ==========
  SSO: {
    // 是否启用 SSO 登录(生产环境根据实际需求配置)
    enabled: true,
    // SSO 服务器基础 URL(需要根据实际 SSO 服务器地址修改)
    serverUrl: 'http://localhost:8080/sso',
    // 本地回调地址(前端自动获取当前域名)
    callbackUrl: window.location.origin + '/sso/callback'
  }
}

2.5 修改文件:src/views/login.vue

(1)导入配置文件

在 <script> 标签中添加:

import MyConfig from '@/../public/static/config'

(2)添加 data 属性

在 data() 函数的 return 对象中添加:

ssoEnabled: MyConfig.SSO?.enabled || false

(3)添加 SSO 登录按钮

在模板中的 .loginname 后面添加:

<!-- SSO 统一身份认证登录按钮 -->
<div v-if="ssoEnabled" class="sso-login-section">
  <el-button 
    type="primary" 
    icon="el-icon-lock" 
    @click="handleSsoLogin"
    round
    size="medium"
    style="width: 80%; margin-bottom: 20px; background: linear-gradient(to right, #409EFF, #67C23A); border: none;"
  >
    统一身份认证登录
  </el-button>
  <el-divider>或者使用账号密码登录</el-divider>
</div>

(4)添加 SSO 登录方法

在 methods 中添加:

/**
 * SSO 登录处理
 */
handleSsoLogin() {
  // 跳转到 SSO 登录页,携带重定向参数
  this.$router.push({
    path: '/sso/login',
    query: { redirect: this.redirect }
  })
},

(5)添加样式

在 <style> 末尾添加:

// SSO 登录区域样式
.sso-login-section {
  text-align: center;
  margin-bottom: 15px;
  
  ::v-deep .el-divider__text {
    color: #ccc;
    font-size: 14px;
  }
}

2.6 修改文件:src/router/index.js

在 constantRoutes 数组中,/register 路由后面添加:

{
  path: '/sso',
  hidden: true,
  children: [
    {
      path: 'login',
      component: () => import('@/views/sso/login'),
      name: 'SsoLogin',
      meta: { title: 'SSO 登录' }
    },
    {
      path: 'callback',
      component: () => import('@/views/sso/callback'),
      name: 'SsoCallback',
      hidden: true
    }
  ]
},

2.7 修改文件:src/permission.js

修改白名单数组:

const whiteList = ['/login', '/register', '/checkToken', '/sso/login', '/sso/callback']

三、后端代码实现

3.1 新增文件:SsoController.java

路径:chenshanyouku/ruoyi-admin/src/main/java/com/chenshan/common/web/controller/SsoController.java

package com.chenshan.common.web.controller;

import java.util.HashMap;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.chenshan.common.core.domain.R;
import com.chenshan.sso.service.SsoService;

/**
 * SSO 统一身份认证控制器
 */
@RestController
@RequestMapping("/sso")
public class SsoController {

    @Autowired
    private SsoService ssoService;

    /**
     * 获取 SSO 登录 URL
     */
    @GetMapping("/loginUrl")
    public R<String> getLoginUrl(@RequestParam(required = false) String redirect) {
        try {
            String loginUrl = ssoService.getLoginUrl(redirect);
            return R.ok(loginUrl);
        } catch (Exception e) {
            return R.fail("获取 SSO 登录地址失败:" + e.getMessage());
        }
    }

    /**
     * SSO 回调处理
     */
    @PostMapping("/callback")
    public R<Map<String, Object>> callback(@RequestParam String ticket, 
                                          @RequestParam(required = false) String redirect) {
        try {
            Map<String, Object> result = ssoService.validateTicketAndLogin(ticket, redirect);
            
            if (result != null && result.containsKey("token")) {
                return R.ok(result);
            } else {
                return R.fail("SSO 验证失败");
            }
        } catch (Exception e) {
            return R.fail("SSO 登录失败:" + e.getMessage());
        }
    }

    /**
     * SSO 登出
     */
    @PostMapping("/logout")
    public R<Void> logout() {
        try {
            ssoService.ssoLogout();
            return R.ok();
        } catch (Exception e) {
            return R.fail("SSO 登出失败:" + e.getMessage());
        }
    }
}

3.2 新增文件:SsoService.java

路径:chenshanyouku/ruoyi-admin/src/main/java/com/chenshan/sso/service/SsoService.java

package com.chenshan.sso.service;

import java.net.URLEncoder;
import java.util.HashMap;
import java.util.Map;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import com.chenshan.common.utils.http.HttpUtils;
import com.chenshan.common.utils.json.JSONUtils;
import com.chenshan.system.domain.SysUser;
import com.chenshan.system.service.ISysUserService;
import com.chenshan.token.service.TokenService;

/**
 * SSO 统一身份认证服务
 */
@Service
public class SsoService {

    @Value("${sso.server-url}")
    private String ssoServerUrl;

    @Value("${sso.app-id}")
    private String appId;

    @Value("${sso.app-secret}")
    private String appSecret;

    @Value("${sso.redirect-uri}")
    private String redirectUri;

    private final ISysUserService userService;
    private final TokenService tokenService;

    public SsoService(ISysUserService userService, TokenService tokenService) {
        this.userService = userService;
        this.tokenService = tokenService;
    }

    /**
     * 获取 SSO 登录 URL
     */
    public String getLoginUrl(String redirect) throws Exception {
        String targetUrl = StringUtils.hasText(redirect) 
            ? redirect 
            : redirectUri;
        
        Map<String, String> params = new HashMap<>();
        params.put("appId", appId);
        params.put("redirectUri", URLEncoder.encode(targetUrl, "UTF-8"));
        params.put("timestamp", String.valueOf(System.currentTimeMillis()));
        
        String sign = generateSign(params, appSecret);
        params.put("sign", sign);
        
        StringBuilder loginUrl = new StringBuilder(ssoServerUrl);
        loginUrl.append("/login?");
        for (Map.Entry<String, String> entry : params.entrySet()) {
            loginUrl.append(entry.getKey())
                   .append("=")
                   .append(entry.getValue())
                   .append("&");
        }
        
        return loginUrl.substring(0, loginUrl.length() - 1);
    }

    /**
     * 验证 SSO 票据并登录
     */
    public Map<String, Object> validateTicketAndLogin(String ticket, String redirect) throws Exception {
        // 1. 验证 SSO 票据
        SsoUser ssoUser = validateTicket(ticket);
        
        if (ssoUser == null || !StringUtils.hasText(ssoUser.getUserId())) {
            throw new Exception("SSO 票据无效或已过期");
        }
        
        // 2. 查找或创建用户
        SysUser localUser = findOrCreateUser(ssoUser);
        
        if (localUser == null) {
            throw new Exception("用户不存在且无法自动创建");
        }
        
        // 3. 生成本地 Token
        String token = tokenService.createToken(localUser.getUserId());
        
        // 4. 准备返回结果
        Map<String, Object> result = new HashMap<>();
        result.put("token", token);
        result.put("userInfo", localUser);
        
        return result;
    }

    /**
     * 验证 SSO 票据
     */
    private SsoUser validateTicket(String ticket) throws Exception {
        Map<String, String> params = new HashMap<>();
        params.put("ticket", ticket);
        params.put("appId", appId);
        params.put("appSecret", appSecret);
        params.put("timestamp", String.valueOf(System.currentTimeMillis()));
        
        String sign = generateSign(params, appSecret);
        params.put("sign", sign);
        
        String response = HttpUtils.sendPost(ssoServerUrl + "/validate", params);
        
        Map<String, Object> responseMap = JSONUtils.parseObject(response, Map.class);
        
        Integer code = (Integer) responseMap.get("code");
        if (code == null || code != 200) {
            throw new Exception("SSO 票据验证失败:" + responseMap.get("msg"));
        }
        
        Object data = responseMap.get("data");
        String userJson = JSONUtils.toJSONString(data);
        return JSONUtils.parseObject(userJson, SsoUser.class);
    }

    /**
     * 查找或创建用户
     */
    private SysUser findOrCreateUser(SsoUser ssoUser) {
        // 1. 通过 SSO 用户 ID 查找
        SysUser user = userService.selectUserBySsoId(ssoUser.getUserId());
        
        if (user != null) {
            updateUserFromSso(user, ssoUser);
            return user;
        }
        
        // 2. 通过用户名查找
        user = userService.selectUserByUserName(ssoUser.getUsername());
        
        if (user != null) {
            associateSsoId(user, ssoUser.getUserId());
            updateUserFromSso(user, ssoUser);
            return user;
        }
        
        // 3. 创建新用户
        return createUserFromSso(ssoUser);
    }

    /**
     * 从 SSO 用户创建本地用户
     */
    private SysUser createUserFromSso(SsoUser ssoUser) {
        SysUser user = new SysUser();
        user.setUserName(ssoUser.getUsername());
        user.setNickName(ssoUser.getRealName());
        user.setEmail(ssoUser.getEmail());
        user.setPhonenumber(ssoUser.getPhone());
        user.setSex(ssoUser.getGender());
        user.setStatus("0");
        user.setRemark("SSO 自动创建用户");
        
        userService.insertUser(user);
        return user;
    }

    /**
     * 更新用户信息
     */
    private void updateUserFromSso(SysUser user, SsoUser ssoUser) {
        user.setNickName(ssoUser.getRealName());
        user.setEmail(ssoUser.getEmail());
        user.setPhonenumber(ssoUser.getPhone());
        user.setSex(ssoUser.getGender());
        
        userService.updateUser(user);
    }

    /**
     * 关联 SSO ID
     */
    private void associateSsoId(SysUser user, String ssoId) {
        // 需要在 sys_user 表中添加 sso_id 字段
        // user.setSsoId(ssoId);
        // userService.updateUser(user);
    }

    /**
     * SSO 登出
     */
    public void ssoLogout() throws Exception {
        // 调用 SSO 服务器的登出接口
    }

    /**
     * 生成签名(需根据实际 SSO 服务器实现)
     */
    private String generateSign(Map<String, String> params, String secret) {
        // TODO: 根据实际 SSO 服务器的签名算法实现
        // 示例代码:
        /*
        List<String> keys = new ArrayList<>(params.keySet());
        Collections.sort(keys);
        
        StringBuilder sb = new StringBuilder();
        for (String key : keys) {
            sb.append(key).append("=").append(params.get(key)).append("&");
        }
        sb.append("secret=").append(secret);
        
        return DigestUtils.md5Hex(sb.toString());
        */
        
        return "SIGNATURE_PLACEHOLDER";
    }

    /**
     * SSO 用户信息类
     */
    public static class SsoUser {
        private String userId;
        private String username;
        private String realName;
        private String email;
        private String phone;
        private String gender;
        
        // Getters and Setters
        public String getUserId() { return userId; }
        public void setUserId(String userId) { this.userId = userId; }
        
        public String getUsername() { return username; }
        public void setUsername(String username) { this.username = username; }
        
        public String getRealName() { return realName; }
        public void setRealName(String realName) { this.realName = realName; }
        
        public String getEmail() { return email; }
        public void setEmail(String email) { this.email = email; }
        
        public String getPhone() { return phone; }
        public void setPhone(String phone) { this.phone = phone; }
        
        public String getGender() { return gender; }
        public void setGender(String gender) { this.gender = gender; }
    }
}

3.3 配置文件:application.yml

在后端项目的配置文件中添加:

# SSO 统一身份认证配置
sso:
  # SSO 服务器地址(⚠️ 必须修改为实际地址)
  server-url: http://localhost:8080/sso
  
  # SP 应用 ID(⚠️ 从 SSO 管理员获取)
  app-id: chenshan_oil_system
  
  # SP 应用密钥(⚠️ 从 SSO 管理员获取)
  app-secret: your-app-secret-here
  
  # 重定向 URL(⚠️ 修改为实际前端地址)
  redirect-uri: http://localhost:9527/sso/callback
  
  # 是否启用 SSO
  enabled: true
  
  # 票据有效期(秒)
  ticket-validity: 300
  
  # 签名算法(⚠️ 根据 SSO 服务器要求)
  sign-algorithm: MD5

3.4 数据库迁移脚本

-- ============================================
-- SSO 统一身份认证 - 数据库迁移脚本
-- ============================================

-- 1. 在 sys_user 表中添加 SSO ID 字段
ALTER TABLE sys_user 
ADD COLUMN sso_id VARCHAR(64) DEFAULT NULL COMMENT 'SSO 系统用户 ID' AFTER user_id;

-- 2. 为 sso_id 字段添加索引
CREATE INDEX idx_sso_id ON sys_user(sso_id);

-- 3. 创建 SSO 日志表(可选)
CREATE TABLE IF NOT EXISTS sys_sso_log (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
  `user_id` bigint(20) DEFAULT NULL COMMENT '用户 ID',
  `username` varchar(30) DEFAULT '' COMMENT '用户名',
  `sso_ticket` varchar(128) DEFAULT '' COMMENT 'SSO 票据',
  `login_time` datetime DEFAULT NULL COMMENT '登录时间',
  `ip_address` varchar(50) DEFAULT '' COMMENT '登录 IP',
  `status` char(1) DEFAULT '0' COMMENT '状态(0 成功 1 失败)',
  `message` varchar(500) DEFAULT '' COMMENT '消息',
  PRIMARY KEY (`id`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_login_time` (`login_time`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='SSO 登录日志表';

3.5 后端补充代码

在 ISysUserService.java 中添加:

/**
 * 通过 SSO ID 查询用户
 * @param ssoId SSO 用户 ID
 * @return 用户信息
 */
SysUser selectUserBySsoId(String ssoId);

在对应的 Mapper XML 中添加:

<select id="selectUserBySsoId" parameterType="String" resultMap="SysUserResult">
    select user_id, user_name, nick_name, user_type, email, phonenumber, sex, status
    from sys_user
    where sso_id = #{ssoId}
</select>

四、集成步骤清单

4.1 准备工作

从单点登录 Demo 获取以下信息:

  • SSO 服务器地址
  • App ID
  • App Secret
  • 签名算法说明
  • 接口文档

4.2 前端实施步骤

  1. ✅ 创建 src/api/sso.js
  2. ✅ 创建 src/views/sso/login.vue
  3. ✅ 创建 src/views/sso/callback.vue
  4. ✅ 修改 public/static/config.js(添加 SSO 配置)
  5. ✅ 修改 src/views/login.vue(添加 SSO 按钮)
  6. ✅ 修改 src/router/index.js(添加 SSO 路由)
  7. ✅ 修改 src/permission.js(添加 SSO 白名单)

4.3 后端实施步骤

  1. ✅ 创建 SsoController.java
  2. ✅ 创建 SsoService.java
  3. ✅ 在 application.yml 添加 SSO 配置
  4. ✅ 执行数据库迁移脚本
  5. ✅ 在 ISysUserService 添加 selectUserBySsoId() 方法
  6. ✅ 实现 generateSign() 方法(根据实际 SSO 服务器)

4.4 测试验证

# 启动后端
cd D:\gitworkspace\chenshan\chenshanyouku
# 运行启动命令

# 启动前端
cd D:\gitworkspace\chenshan-client\web
npm run dev

测试流程:

  1. 访问登录页,查看是否有"统一身份认证登录"按钮
  2. 点击按钮,检查是否跳转到 SSO 服务器
  3. 在 SSO 服务器登录
  4. 检查是否正确回调
  5. 检查是否成功获取 token
  6. 验证能否正常访问系统

五、关键配置说明

5.1 前端配置

配置项 位置 说明
SSO 服务器地址 public/static/config.js 必须与后端一致
enabled config.js 控制是否显示 SSO 按钮

5.2 后端配置

配置项 说明 注意事项
sso.server-url SSO 服务器地址 生产环境用 HTTPS
sso.app-id SP 应用 ID 从 SSO 管理员获取
sso.app-secret SP 应用密钥 妥善保管,不能提交 Git
sso.redirect-uri 回调地址 必须与 SSO 服务器配置一致
sso.sign-algorithm 签名算法 必须与 SSO 服务器一致

5.3 数据库配置

需要添加的字段:

  • sys_user.sso_id:存储 SSO 系统的用户 ID

六、常见问题及解决方案

6.1 跨域问题

现象:SSO 回调时出现跨域错误

解决:

// vue.config.js
module.exports = {
  devServer: {
    proxy: {
      '/sso': {
        target: process.env.VUE_APP_SSO_SERVER_URL,
        changeOrigin: true,
        secure: false
      }
    }
  }
}

6.2 签名验证失败

排查步骤:

  1. 检查签名算法是否与 SSO 服务器一致
  2. 检查参数排序是否正确
  3. 检查 app-secret 是否正确
  4. 检查时间戳是否在有效期内

6.3 票据验证失败

可能原因:

  1. 票据已过期
  2. 票据已被使用
  3. SSO 服务器时间不同步
  4. 网络问题导致验证请求失败

七、安全注意事项

  1. HTTPS 传输:生产环境必须使用 HTTPS
  2. 密钥保护:app-secret 不能提交到版本控制系统
  3. 日志脱敏:SSO 票据不能在日志中明文显示
  4. 会话管理:设置合理的 session 超时时间
  5. 防重放攻击:使用时间戳和 nonce 机制

八、部署上线

8.1 开发环境

sso:
  server-url: http://dev-sso.example.com/sso
  app-id: chenshan_dev
  app-secret: dev-secret-key
  redirect-uri: http://localhost:9527/sso/callback
  enabled: true

8.2 生产环境

sso:
  server-url: https://sso.example.com/sso
  app-id: chenshan_prod
  app-secret: ${SSO_APP_SECRET}  # 从环境变量读取
  redirect-uri: https://your-domain.com/sso/callback
  enabled: true

文档生成时间:2026-03-31
适用项目:陈山智慧油库系统
SSO 版本:SPForJava1.5-V3.8