背景
公司使用的阿里云作為公有云,每次員工入職或離職時(shí)同時(shí)需要維護(hù)兩套賬號(hào)(一套內(nèi)部賬號(hào),一套阿里云RAM賬號(hào)),為了讓用戶能夠使用內(nèi)部賬號(hào)能訪問(wèn)阿里云,所以決定對(duì)接阿里云的SSO
- 主流程介紹
- 用戶訪問(wèn)阿里云
- 阿里云調(diào)轉(zhuǎn)至公司內(nèi)部的SSO(單點(diǎn)登錄)
- 公司內(nèi)部SSO讓用戶進(jìn)行登錄
- 認(rèn)證成功后跳轉(zhuǎn)至阿里云
阿里云SSO
官網(wǎng)介紹
https://help.aliyun.com/document_detail/93684.html
文檔解析
我相信不少人跟我一樣,在做需求前首先去研究他的文檔。結(jié)果看了一遍下來(lái)卻不知所云,如果你也有這樣的問(wèn)題,那么接下來(lái)我?guī)阋徊揭徊降慕馕觥?/p>
什么是SAML
由于阿里云的SSO采用的是SAML2.0協(xié)議,所以第一步你需要了解SAML是什么!
SAML鏈接:
https://help.sap.com/doc/saphelp_me150/15.0.3VERSIONFORSAPME/zh-CN/17/6d45fc91e84ef1bf0152f2b947dc35/content.htm?no_cache=true
IDP和SP
阿里云關(guān)于SSO的名詞解釋太多,由于篇幅原因,這里我只介紹IDP和SP這兩個(gè)比較重要的概念
- IDP
身份提供商:
說(shuō)白了就是對(duì)接阿里云SSO的第三方提供的一個(gè)身份認(rèn)證的服務(wù)。這個(gè)服務(wù)你可以使用云廠商的IDP(花錢(qián)買(mǎi)),也可以自建企業(yè)本地IDP(必須支持SAML2.0協(xié)議),而我選擇后者(省錢(qián)才是王道)
- SP
服務(wù)提供商:
概念阿里云已經(jīng)解釋的比較詳細(xì)了(然而用戶可能還是一臉蒙蔽),在我們這個(gè)場(chǎng)景中SP其實(shí)指的就是阿里云,如果你要對(duì)接華為的SSO的話,這個(gè)SP其實(shí)指的就是華為云(這么解釋的話你應(yīng)該就好理解了吧)
開(kāi)始對(duì)接
說(shuō)明:
阿里云SSO有「用戶SSO」和「角色SSO」兩種對(duì)接方式,我們選擇「用戶SSO」進(jìn)行對(duì)接
在接下來(lái)的對(duì)接工作中,我想你應(yīng)該已經(jīng)知道,我們只需要基于SAML2.0來(lái)實(shí)現(xiàn)自己的IDP即可。由于公司內(nèi)部有基于OAUTH2實(shí)現(xiàn)的SSO,所以我要做的就是在這個(gè)SSO服務(wù)中嵌入IDP
,當(dāng)然你也可以單獨(dú)拉個(gè)服務(wù)出來(lái)實(shí)現(xiàn)IDP
開(kāi)始開(kāi)發(fā)
說(shuō)明:
- 以下代碼使用golang實(shí)現(xiàn)
- 使用 github.com/crewjam/saml來(lái)實(shí)現(xiàn)自建IDP
- 微服務(wù)框架: https://ego.gocn.vip/
- 開(kāi)發(fā)前,請(qǐng)先下載阿里云提供的元數(shù)據(jù)文件,后續(xù)代碼中會(huì)用到
自建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實(shí)例為單例
once sync.Once
samlServerInstance *samlServer
)
type samlServer struct {
// serviceProviders 存儲(chǔ)華為云,或者阿里云的SP實(shí)例
serviceProviders map[string]*saml.EntityDescriptor
lock sync.Mutex
// 自建IDP 實(shí)例
idp *saml.IdentityProvider
store samlidp.Store
// sp 名稱(chēng)列表
serviceProviderNames []string
}
// GetSamlServerInstance 獲取SamlServer 實(shí)例(單例:懶漢)
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元數(shù)據(jù)信息路由
metadataURL.Path = metadataURL.Path + "/sso/third/idpMetadata"
// IDP認(rèn)證路由
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 證書(shū)
MetadataURL: metadataURL, // 獲取IDP元數(shù)據(jù)信息路由
SSOURL: ssoUrl, // IDP認(rèn)證路由(登錄)
LogoutURL: logoutUrl, // IDP退出路由
},
serviceProviderNames: []string{"aliyun"},
}
// 實(shí)例化SP實(shí)例并存儲(chǔ)
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
// 后續(xù)會(huì)將該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 根據(jù)SP提供的元數(shù)據(jù)文件,實(shí)例化SP實(shí)例并存儲(chǔ)至緩存
// 阿里云作為SP,會(huì)提供元數(shù)據(jù)信息文件,來(lái)讓你的IDP對(duì)阿里云作為SP進(jìn)行信任
// https://ram.console.aliyun.com/providers
func (s *samlServer) storeServiceProvider() {
store := &samlidp.MemoryStore{}
for _, samlName := range s.serviceProviderNames {
metadata := saml.EntityDescriptor{}
// 讀取從阿里云下載下來(lái)的元數(shù)據(jù)文件(建議線上環(huán)境,將該文件保存至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實(shí)例存儲(chǔ)至內(nèi)存中
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 {
// 該私鑰上線時(shí),你可以存儲(chǔ)在k8s的Sceret中,本地調(diào)試的話,就直接讀本地文件
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證書(shū)
func x509Cert() func() *x509.Certificate {
return func() *x509.Certificate {
// 該證書(shū)上線時(shí),你可以存儲(chǔ)在k8s的Sceret中,本地調(diào)試的話,就直接讀本地文件
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元數(shù)據(jù)文件至阿里云
此處的目的是建立阿里云對(duì)你的IDP的信任
到這里可能有人會(huì)問(wèn)了,企業(yè)IDP元數(shù)據(jù)我在哪獲取呢?
上述初始化代碼中有這樣一個(gè)方法
func (s *samlServer) IDPMetadata() ([]byte, error)
你可以調(diào)用該方法來(lái)獲取IDP元數(shù)據(jù)文件(再包一層HTTP來(lái)調(diào)用該方法:注意鑒權(quán))
你獲取的IDP元數(shù)據(jù)文件可能是這樣的:
然后將該文件上傳至阿里云中,鏈接:https://ram.console.aliyun.com/providers
注意:以上操作時(shí),請(qǐng)不要開(kāi)啟SSO,不然會(huì)影響現(xiàn)有用戶的登錄(因?yàn)榇藭r(shí)還沒(méi)有對(duì)接完成,只是建立了互信,IDP認(rèn)證的接口還沒(méi)開(kāi)發(fā))
開(kāi)發(fā)IDP認(rèn)證接口
說(shuō)明: 當(dāng)你訪問(wèn)阿里云時(shí)(開(kāi)啟阿里云SSO),阿里云會(huì)回調(diào)IDP的認(rèn)證接口,所以接下來(lái)的時(shí)間我們會(huì)去實(shí)現(xiàn)該接口
// HTTP Route 相關(guān)代碼省略
// 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:更多細(xì)節(jié),可以翻看源碼
req, err := saml.NewIdpAuthnRequest(s.idp, request)
if err != nil {
invoker.Logger.Error("HandlerSSO-NewIdpAuthnRequest", zap.Error(err))
c.JSONE(-1, "獲取請(qǐng)求數(shù)據(jù)失敗:"+err.Error(), nil)
return
}
// 校驗(yàn) IDP request
if err := req.Validate(); err != nil {
invoker.Logger.Error("HandlerSSO-Validate", zap.Error(err))
c.JSONE(-1, "校驗(yàn)請(qǐng)求數(shù)據(jù)失敗:"+err.Error(), nil)
return
}
// 校驗(yàn)用戶是否登錄
userByToken, err := c.GetCookieUser()
if err != nil {
invoker.Logger.Warn("HandlerSSO-GetUserByParentToken", zap.Error(err))
// 沒(méi)有登錄的話,跳轉(zhuǎn)至內(nèi)部的SSO登錄頁(yè)面
redirectLogin()
return
}
// 構(gòu)建跳轉(zhuǎn)至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, "設(shè)置斷言失敗:"+err.Error(), nil)
return
}
/*
翻看此處的源碼:其實(shí)做了兩件事情
1.將斷言信息寫(xiě)入表單
2.提交表單(表單URL指向的是阿里云SSO)
tmpl := template.Must(template.New("saml-post-form").Parse(`<html>` +
`<form method="post" action="{{.URL}}" id="SAMLResponseForm">` +
`<input type="hidden" name="SAMLResponse" value="{{.SAMLResponse}}" />` +
`<input type="hidden" name="RelayState" value="{{.RelayState}}" />` +
`<input id="SAMLSubmitButton" type="submit" value="Continue" />` +
`</form>` +
`<script>document.getElementById('SAMLSubmitButton').style.visibility='hidden';</script>` +
`<script>document.getElementById('SAMLResponseForm').submit();</script>` +
`</html>`))
*/
if err := req.WriteResponse(writer); err != nil {
invoker.Logger.Error("HandlerSSO-WriteResponse", zap.Error(err))
c.JSONE(-1, "write斷言失敗:"+err.Error(), nil)
return
}
}
// genRedirectUrl: 生成內(nèi)部SSO系統(tǒng)的認(rèn)證地址(跳轉(zhuǎn)至內(nèi)部系統(tǒng)的登錄頁(yè)面)
func genRedirectUrl(request *http.Request) string {
var (
// oauth2 clientID: 配置文件中獲取
clientID = econf.GetString("saml.clientID")
// 阿里云跳轉(zhuǎn)時(shí)攜帶的 SAMLRequest
samlRequest = url.QueryEscape(request.URL.Query().Get("SAMLRequest"))
// 阿里云跳轉(zhuǎn)時(shí)攜帶的 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 構(gòu)造saml2.0斷言所需字段
func buildSession(user *oauth2dto.User) (*saml.Session, error) {
var (
sourceType uint8
)
// 校驗(yàn)是否給該員工開(kāi)啟了阿里云賬號(hào)(我們有后臺(tái)去維護(hù)員工的阿里云賬號(hào))
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("獲取用戶第三放應(yīng)用信息失敗:%s", err.Error())
}
if userThirdOpen.ID <= 0 {
return nil, errors.New("請(qǐng)聯(lián)系管理員同步第三方賬號(hào)")
}
// 通用斷言部分
// NameID: 為員工的阿里云RAM賬號(hào)
nameId := strings.TrimSpace(userThirdOpen.NameID)
session := &saml.Session{
NameID: nameId,
UserName: user.Username,
UserEmail: user.Email,
}
return session, nil
}
驗(yàn)證
說(shuō)明:到此開(kāi)發(fā)和配置已經(jīng)完成,接下來(lái)我們需要開(kāi)啟阿里云的SSO,然后驗(yàn)證整個(gè)流程
-
開(kāi)啟SSO
-
使用RAM賬號(hào)登錄
阿里云會(huì)攜帶saml相關(guān)請(qǐng)求參數(shù)重定向至企業(yè)內(nèi)部的IDP登錄頁(yè),認(rèn)證成功后,會(huì)調(diào)用 func (s *samlServer) HandlerSamlSSO(c *oacore.Context)函數(shù)處理并跳轉(zhuǎn)至阿里云文章來(lái)源:http://www.zghlxwxcb.cn/news/detail-409447.html
總結(jié)
以上就是關(guān)于企業(yè)內(nèi)部SSO對(duì)接阿里云基于SAML2.0協(xié)議SSO的流程,如果解決你的問(wèn)題,煩請(qǐng)點(diǎn)個(gè)贊嘍!文章來(lái)源地址http://www.zghlxwxcb.cn/news/detail-409447.html
到了這里,關(guān)于基于SAML 2.0對(duì)接阿里云的SSO(單點(diǎn)登錄)的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!