基于SAML 2.0对接阿里云的SSO(单点登录)

背景

公司使用的阿里云作为公有云,每次员工入职或离职时同时需要维护两套账号(一套内部账号,一套阿里云RAM账号),为了让用户能够使用内部账号能访问阿里云,所以决定对接阿里云的SSO
基于SAML 2.0对接阿里云的SSO(单点登录)_第1张图片

  • 主流程介绍
  1. 用户访问阿里云
  2. 阿里云调转至公司内部的SSO(单点登录)
  3. 公司内部SSO让用户进行登录
  4. 认证成功后跳转至阿里云

阿里云SSO

官网介绍

https://help.aliyun.com/document_detail/93684.html

文档解析

我相信不少人跟我一样,在做需求前首先去研究他的文档。结果看了一遍下来却不知所云,如果你也有这样的问题,那么接下来我带你一步一步的解析。

什么是SAML

由于阿里云的SSO采用的是SAML2.0协议,所以第一步你需要了解SAML是什么!
SAML链接:

https://help.sap.com/doc/saphelp_me150/15.0.3VERSIONFORSAPME/zh-CN/17/6d45fc91e84ef1bf0152f2b947dc35/content.htm?no_cache=true

IDP和SP

基于SAML 2.0对接阿里云的SSO(单点登录)_第2张图片
阿里云关于SSO的名词解释太多,由于篇幅原因,这里我只介绍IDP和SP这两个比较重要的概念

  • IDP

身份提供商:
说白了就是对接阿里云SSO的第三方提供的一个身份认证的服务。这个服务你可以使用云厂商的IDP(花钱买),也可以自建企业本地IDP(必须支持SAML2.0协议),而我选择后者(省钱才是王道)

  • SP

服务提供商:
概念阿里云已经解释的比较详细了(然而用户可能还是一脸蒙蔽),在我们这个场景中SP其实指的就是阿里云,如果你要对接华为的SSO的话,这个SP其实指的就是华为云(这么解释的话你应该就好理解了吧)

开始对接

说明:
阿里云SSO有「用户SSO」和「角色SSO」两种对接方式,我们选择「用户SSO」进行对接
基于SAML 2.0对接阿里云的SSO(单点登录)_第3张图片
在接下来的对接工作中,我想你应该已经知道,我们只需要基于SAML2.0来实现自己的IDP即可。由于公司内部有基于OAUTH2实现的SSO,所以我要做的就是在这个SSO服务中嵌入IDP
,当然你也可以单独拉个服务出来实现IDP

开始开发

说明:

  • 以下代码使用golang实现
  • 使用 github.com/crewjam/saml来实现自建IDP
  • 微服务框架: https://ego.gocn.vip/
  • 开发前,请先下载阿里云提供的元数据文件,后续代码中会用到

基于SAML 2.0对接阿里云的SSO(单点登录)_第4张图片

自建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的信任
到这里可能有人会问了,企业IDP元数据我在哪获取呢?

上述初始化代码中有这样一个方法

func (s *samlServer) IDPMetadata() ([]byte, error)

你可以调用该方法来获取IDP元数据文件(再包一层HTTP来调用该方法:注意鉴权)
你获取的IDP元数据文件可能是这样的:

基于SAML 2.0对接阿里云的SSO(单点登录)_第5张图片

然后将该文件上传至阿里云中,链接:https://ram.console.aliyun.com/providers
基于SAML 2.0对接阿里云的SSO(单点登录)_第6张图片
注意:以上操作时,请不要开启SSO,不然会影响现有用户的登录(因为此时还没有对接完成,只是建立了互信,IDP认证的接口还没开发)

开发IDP认证接口

说明: 当你访问阿里云时(开启阿里云SSO),阿里云会回调IDP的认证接口,所以接下来的时间我们会去实现该接口
基于SAML 2.0对接阿里云的SSO(单点登录)_第7张图片

// 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,然后验证整个流程

  • 开启SSO
    基于SAML 2.0对接阿里云的SSO(单点登录)_第8张图片

  • 使用RAM账号登录
    基于SAML 2.0对接阿里云的SSO(单点登录)_第9张图片

基于SAML 2.0对接阿里云的SSO(单点登录)_第10张图片
基于SAML 2.0对接阿里云的SSO(单点登录)_第11张图片
基于SAML 2.0对接阿里云的SSO(单点登录)_第12张图片

阿里云会携带saml相关请求参数重定向至企业内部的IDP登录页,认证成功后,会调用 func (s *samlServer) HandlerSamlSSO(c *oacore.Context)函数处理并跳转至阿里云

总结

以上就是关于企业内部SSO对接阿里云基于SAML2.0协议SSO的流程,如果解决你的问题,烦请点个赞喽!

你可能感兴趣的:(golang,sso,阿里云,云计算)