Go 项目中 SSO 技术方案实现
一、方案概述
本方案基于OIDC(OpenID Connect)协议实现单点登录功能,采用"集中式IdP(身份提供者)+ 分布式SP(服务提供者)"架构,适用于中大型Go语言项目集群。通过该方案,用户只需一次登录即可访问所有信任的业务系统,实现"一处登录,多处使用"的效果。
二、架构设计
2.1 整体架构图
┌───────────┐ ┌──────────────┐ ┌──────────────┐
│ │ │ │ │ │
│ 用户浏览器 │◄────►│ IdP服务 │◄────►│ 数据库 │
│ │ │ (Go/Gin) │ │ (用户信息) │
└───────────┘ └──────────────┘ └──────────────┘
▲ ▲
│ │
▼ ▼
┌───────────┐ ┌──────────────┐
│ │ │ │
│ SP1服务 │◄────►│ Redis集群 │
│ (Go/Gin) │ │ (会话/令牌) │
└───────────┘ └──────────────┘
▲
│
▼
┌───────────┐
│ │
│ SP2服务 │
│ (Go/Gin) │
│ │
└───────────┘
2.2 核心组件
IdP(身份提供者):
- 统一认证中心,负责用户身份验证
- 颁发和管理令牌(ID Token、Access Token)
- 维护全局会话
SP(服务提供者):
- 各业务系统,如订单系统、用户系统等
- 依赖IdP进行身份认证
- 维护本地会话和权限控制
存储层:
- PostgreSQL:存储用户信息、SP配置
- Redis:存储全局会话、令牌黑名单、缓存
三、技术栈选型
组件 | 选型 | 用途 |
---|---|---|
Web框架 | Gin | 构建IdP和SP服务 |
OIDC协议 | coreos/go-oidc + golang/oauth2 | 实现OIDC认证流程 |
JWT处理 | golang-jwt/jwt | 生成和验证JWT令牌 |
会话管理 | gin-contrib/sessions | 管理SP本地会话 |
数据库 | PostgreSQL | 存储用户和配置信息 |
缓存 | Redis | 存储会话和令牌 |
密码加密 | golang.org/x/crypto/bcrypt | 用户密码加密存储 |
日志 | zap | 系统日志记录 |
配置管理 | viper | 配置文件管理 |
四、核心流程实现
4.1 首次登录流程
- 用户访问SP,SP检测到未登录,重定向到IdP
- IdP展示登录页面,用户输入凭据
- IdP验证凭据,创建全局会话
- IdP生成授权码,重定向回SP的回调地址
- SP使用授权码向IdP请求令牌
- IdP返回ID Token和Access Token
- SP验证ID Token,创建本地会话
- SP允许用户访问资源
4.2 跨系统登录流程
- 用户已登录IdP,访问另一个SP
- SP检测到未登录,重定向到IdP
- IdP检测到已有全局会话,无需重新登录
- IdP生成授权码,重定向回该SP的回调地址
- SP使用授权码获取并验证令牌,创建本地会话
- SP允许用户访问资源
4.3 统一登出流程
- 用户在任一SP发起登出请求
- SP清除本地会话,并重定向到IdP的登出端点
- IdP清除全局会话,生成登出令牌
- IdP向用户已登录的所有SP发送登出通知
- 各SP接收通知,清除本地会话
- 重定向到登出成功页面
五、代码实现示例
5.1 IdP核心实现
package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"log"
"net/http"
"os"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/redis"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v4"
"github.com/spf13/viper"
"go.uber.org/zap"
)
// 全局变量
var (
logger *zap.Logger
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
)
// 配置结构体
type Config struct {
ServerPort int
RedisAddr string
RedisPassword string
RedisDB int
JWTExpiryHours int
}
// 自定义JWT声明
type CustomClaims struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
// 初始化配置
func initConfig() Config {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
if err := viper.ReadInConfig(); err != nil {
log.Fatalf("读取配置文件失败: %v", err)
}
var config Config
if err := viper.Unmarshal(&config); err != nil {
log.Fatalf("解析配置失败: %v", err)
}
return config
}
// 生成RSA密钥对
func generateRSAKeys() error {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
privateKey = key
publicKey = &key.PublicKey
// 保存公钥到文件,供SP使用
publicKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return err
}
publicKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyBytes,
})
return os.WriteFile("public_key.pem", publicKeyPEM, 0644)
}
// 生成JWT令牌
func generateJWT(userID, username string, roles []string, expiryHours int) (string, error) {
expirationTime := time.Now().Add(time.Duration(expiryHours) * time.Hour)
claims := &CustomClaims{
UserID: userID,
Username: username,
Roles: roles,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "sso-idp",
Subject: userID,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(privateKey)
}
// 登录处理
func loginHandler(c *gin.Context) {
var loginReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
ClientID string `json:"client_id" binding:"required"`
}
if err := c.ShouldBindJSON(&loginReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error()})
return
}
// 实际应用中应从数据库验证用户
// 这里简化处理,假设验证通过
userID := "user123"
roles := []string{
"user", "editor"}
// 创建全局会话
session