公司使用的阿里云作为公有云,每次员工入职或离职时同时需要维护两套账号(一套内部账号,一套阿里云RAM账号),为了让用户能够使用内部账号能访问阿里云,所以决定对接阿里云的SSO
https://help.aliyun.com/document_detail/93684.html
我相信不少人跟我一样,在做需求前首先去研究他的文档。结果看了一遍下来却不知所云,如果你也有这样的问题,那么接下来我带你一步一步的解析。
由于阿里云的SSO采用的是SAML2.0协议,所以第一步你需要了解SAML是什么!
SAML链接:
https://help.sap.com/doc/saphelp_me150/15.0.3VERSIONFORSAPME/zh-CN/17/6d45fc91e84ef1bf0152f2b947dc35/content.htm?no_cache=true
阿里云关于SSO的名词解释太多,由于篇幅原因,这里我只介绍IDP和SP这两个比较重要的概念
身份提供商:
说白了就是对接阿里云SSO的第三方提供的一个身份认证的服务。这个服务你可以使用云厂商的IDP(花钱买),也可以自建企业本地IDP(必须支持SAML2.0协议),而我选择后者(省钱才是王道)
服务提供商:
概念阿里云已经解释的比较详细了(然而用户可能还是一脸蒙蔽),在我们这个场景中SP其实指的就是阿里云,如果你要对接华为的SSO的话,这个SP其实指的就是华为云(这么解释的话你应该就好理解了吧)
说明:
阿里云SSO有「用户SSO」和「角色SSO」两种对接方式,我们选择「用户SSO」进行对接
在接下来的对接工作中,我想你应该已经知道,我们只需要基于SAML2.0来实现自己的IDP即可。由于公司内部有基于OAUTH2实现的SSO,所以我要做的就是在这个SSO服务中嵌入IDP
,当然你也可以单独拉个服务出来实现IDP
说明:
import (
"crypto"
"crypto/x509"
"encoding/pem"
"encoding/xml"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"sync"
"github.com/crewjam/saml"
"github.com/crewjam/saml/samlidp"
"github.com/gotomicro/ego-component/egorm"
oauth2dto "github.com/gotomicro/ego-component/eoauth2/storage/dto"
"github.com/gotomicro/ego/core/econf"
"go.uber.org/zap"
)
var (
// 保证saml实例为单例
once sync.Once
samlServerInstance *samlServer
)
type samlServer struct {
// serviceProviders 存储华为云,或者阿里云的SP实例
serviceProviders map[string]*saml.EntityDescriptor
lock sync.Mutex
// 自建IDP 实例
idp *saml.IdentityProvider
store samlidp.Store
// sp 名称列表
serviceProviderNames []string
}
// GetSamlServerInstance 获取SamlServer 实例(单例:懒汉)
func GetSamlServerInstance() *samlServer {
// lazy init
once.Do(func() {
// appHost 你的域名
// econf.GetString("domain"): 从配置文件中获取
appHost, err := url.Parse(econf.GetString("domain"))
if err != nil {
panic("get appHost fail:" + err.Error())
}
metadataURL := *appHost
ssoUrl := *appHost
logoutUrl := *appHost
// 获取IDP元数据信息路由
metadataURL.Path = metadataURL.Path + "/sso/third/idpMetadata"
// IDP认证路由
ssoUrl.Path = ssoUrl.Path + "/sso/third/saml"
// IDP退出路由
logoutUrl.Path = "/sso/logout"
samlServerInstance = &samlServer{
serviceProviders: map[string]*saml.EntityDescriptor{},
idp: &saml.IdentityProvider{
Key: rsaPrivateKey()(), // IDP 提供的 rsa 私钥
Certificate: x509Cert()(), // IDP 提供的 x509 证书
MetadataURL: metadataURL, // 获取IDP元数据信息路由
SSOURL: ssoUrl, // IDP认证路由(登录)
LogoutURL: logoutUrl, // IDP退出路由
},
serviceProviderNames: []string{"aliyun"},
}
// 实例化SP实例并存储
samlServerInstance.storeServiceProvider()
// 初始化SP
err = samlServerInstance.initializeServiceProviders()
if err != nil {
panic("initializeServiceProviders fail:" + err.Error())
}
samlServerInstance.idp.ServiceProviderProvider = samlServerInstance
})
return samlServerInstance
}
// IDPMetadata 生成基于 saml 2.0 的idp xml
// 后续会将该IDP xml 上传至阿里云中。使阿里云信任该IDP
func (s *samlServer) IDPMetadata() ([]byte, error) {
buf, err := xml.MarshalIndent(s.idp.Metadata(), "", " ")
if err != nil {
invoker.Logger.Error("IDPMetadata-MarshalIndent", zap.Error(err))
return nil, err
}
return buf, nil
}
// storeServiceProvider 根据SP提供的元数据文件,实例化SP实例并存储至缓存
// 阿里云作为SP,会提供元数据信息文件,来让你的IDP对阿里云作为SP进行信任
// https://ram.console.aliyun.com/providers
func (s *samlServer) storeServiceProvider() {
store := &samlidp.MemoryStore{}
for _, samlName := range s.serviceProviderNames {
metadata := saml.EntityDescriptor{}
// 读取从阿里云下载下来的元数据文件(建议线上环境,将该文件保存至k8s的Secret中)
confKey := fmt.Sprintf("saml.%s_metadata_file", samlName)
contentByte, err := util.ReadFile(econf.GetString(confKey))
if err != nil {
invoker.Logger.Error("storeServiceProvider-ReadFile", zap.Error(err), zap.Any("invalid samlName", samlName))
continue
}
err = xml.Unmarshal(contentByte, &metadata)
if err != nil {
invoker.Logger.Error("storeServiceProvider-Unmarshal", zap.Error(err), zap.Any("invalid metadata", string(contentByte)))
continue
}
spKey := fmt.Sprintf("/services/%s", samlName)
err = store.Put(spKey, samlidp.Service{
Name: samlName,
Metadata: metadata,
})
if err != nil {
invoker.Logger.Error("storeServiceProvider-storePut", zap.Error(err))
continue
}
}
// 将SP实例存储至内存中
s.store = store
}
// initializeServiceProviders: 初始化 sp
func (s *samlServer) initializeServiceProviders() error {
serviceNames, err := s.store.List("/services/")
if err != nil {
return err
}
for _, serviceName := range serviceNames {
service := samlidp.Service{}
if err := s.store.Get(fmt.Sprintf("/services/%s", serviceName), &service); err != nil {
return err
}
s.serviceProviders[service.Metadata.EntityID] = &service.Metadata
}
return nil
}
// rsaPrivateKey ras 私钥
func rsaPrivateKey() func() crypto.PrivateKey {
return func() crypto.PrivateKey {
// 该私钥上线时,你可以存储在k8s的Sceret中,本地调试的话,就直接读本地文件
contentBytes, err := util.ReadFile(econf.GetString("saml.keyFile"))
if err != nil {
panic("parse saml.keyFile fail:" + err.Error())
}
b, _ := pem.Decode(contentBytes)
if b == nil {
panic("Decode saml.keyFile fail")
}
k, err := x509.ParsePKCS8PrivateKey(b.Bytes)
if err != nil {
panic("ParsePKCS8PrivateKey saml.keyFile fail:" + err.Error())
}
return k
}
}
// x509Cert x509证书
func x509Cert() func() *x509.Certificate {
return func() *x509.Certificate {
// 该证书上线时,你可以存储在k8s的Sceret中,本地调试的话,就直接读本地文件
contentBytes, err := util.ReadFile(econf.GetString("saml.crtFile"))
if err != nil {
panic("parse saml.crtFile fail:" + err.Error())
}
b, _ := pem.Decode(contentBytes)
if b == nil {
panic("Decode saml.crtFile fail:" + err.Error())
}
c, err := x509.ParseCertificate(b.Bytes)
if err != nil {
panic("ParseCertificate saml.crtFile fail:" + err.Error())
}
return c
}
}
此处的目的是建立阿里云对你的IDP的信任
到这里可能有人会问了,企业IDP元数据我在哪获取呢?
上述初始化代码中有这样一个方法
func (s *samlServer) IDPMetadata() ([]byte, error)
你可以调用该方法来获取IDP元数据文件(再包一层HTTP来调用该方法:注意鉴权)
你获取的IDP元数据文件可能是这样的:
然后将该文件上传至阿里云中,链接:https://ram.console.aliyun.com/providers
注意:以上操作时,请不要开启SSO,不然会影响现有用户的登录(因为此时还没有对接完成,只是建立了互信,IDP认证的接口还没开发)
说明: 当你访问阿里云时(开启阿里云SSO),阿里云会回调IDP的认证接口,所以接下来的时间我们会去实现该接口
// HTTP Route 相关代码省略
// HandlerSamlSSO 处理saml登录
func (s *samlServer) HandlerSamlSSO(c *oacore.Context) {
var (
request = c.Request
writer = c.Writer
)
redirectLogin := func() {
c.Redirect(302, genRedirectUrl(request))
}
// 生成IDP 的 request:更多细节,可以翻看源码
req, err := saml.NewIdpAuthnRequest(s.idp, request)
if err != nil {
invoker.Logger.Error("HandlerSSO-NewIdpAuthnRequest", zap.Error(err))
c.JSONE(-1, "获取请求数据失败:"+err.Error(), nil)
return
}
// 校验 IDP request
if err := req.Validate(); err != nil {
invoker.Logger.Error("HandlerSSO-Validate", zap.Error(err))
c.JSONE(-1, "校验请求数据失败:"+err.Error(), nil)
return
}
// 校验用户是否登录
userByToken, err := c.GetCookieUser()
if err != nil {
invoker.Logger.Warn("HandlerSSO-GetUserByParentToken", zap.Error(err))
// 没有登录的话,跳转至内部的SSO登录页面
redirectLogin()
return
}
// 构建跳转至SP的断言信息
samlSession, err := buildSession(userByToken)
if err != nil {
invoker.Logger.Error("HandlerSSO-buildSession", zap.Error(err))
c.JSONE(-1, "获取断言信息失败:"+err.Error(), nil)
return
}
assertionMaker := s.idp.AssertionMaker
if assertionMaker == nil {
assertionMaker = saml.DefaultAssertionMaker{}
}
if err := assertionMaker.MakeAssertion(req, samlSession); err != nil {
invoker.Logger.Error("HandlerSSO-MakeAssertion", zap.Error(err))
c.JSONE(-1, "设置断言失败:"+err.Error(), nil)
return
}
/*
翻看此处的源码:其实做了两件事情
1.将断言信息写入表单
2.提交表单(表单URL指向的是阿里云SSO)
tmpl := template.Must(template.New("saml-post-form").Parse(`` +
`` +
`` +
`` +
``))
*/
if err := req.WriteResponse(writer); err != nil {
invoker.Logger.Error("HandlerSSO-WriteResponse", zap.Error(err))
c.JSONE(-1, "write断言失败:"+err.Error(), nil)
return
}
}
// genRedirectUrl: 生成内部SSO系统的认证地址(跳转至内部系统的登录页面)
func genRedirectUrl(request *http.Request) string {
var (
// oauth2 clientID: 配置文件中获取
clientID = econf.GetString("saml.clientID")
// 阿里云跳转时携带的 SAMLRequest
samlRequest = url.QueryEscape(request.URL.Query().Get("SAMLRequest"))
// 阿里云跳转时携带的 SAMLRequest
relayState = url.QueryEscape(request.URL.Query().Get("RelayState"))
)
redirectUrl := fmt.Sprintf("/sso/login?SAMLRequest=%s&RelayState=%s&redirect_uri=%s&client_id=%s&response_type=code",
samlRequest, relayState, econf.GetString("domain")+"/sso/third/saml", clientID, source)
return redirectUrl
}
// buildSession 构造saml2.0断言所需字段
func buildSession(user *oauth2dto.User) (*saml.Session, error) {
var (
sourceType uint8
)
// 校验是否给该员工开启了阿里云账号(我们有后台去维护员工的阿里云账号)
userThirdOpen, err := mysql.UserThirdOpenX(invoker.Db, egorm.Conds{
"uid": user.Uid,
})
if err != nil {
invoker.Logger.Error("buildSession-UserThirdOpenX", zap.Error(err))
return nil, fmt.Errorf("获取用户第三放应用信息失败:%s", err.Error())
}
if userThirdOpen.ID <= 0 {
return nil, errors.New("请联系管理员同步第三方账号")
}
// 通用断言部分
// NameID: 为员工的阿里云RAM账号
nameId := strings.TrimSpace(userThirdOpen.NameID)
session := &saml.Session{
NameID: nameId,
UserName: user.Username,
UserEmail: user.Email,
}
return session, nil
}
说明:到此开发和配置已经完成,接下来我们需要开启阿里云的SSO,然后验证整个流程
阿里云会携带saml相关请求参数重定向至企业内部的IDP登录页,认证成功后,会调用 func (s *samlServer) HandlerSamlSSO(c *oacore.Context)函数处理并跳转至阿里云
以上就是关于企业内部SSO对接阿里云基于SAML2.0协议SSO的流程,如果解决你的问题,烦请点个赞喽!