使用Python模拟IDP实现用户SSO接入腾讯云与阿里云

感谢先行者,参考:blog.51cto.com/quietguoguo...

开发语言:Python

云端SSO方式:用户SSO

起初,是为了腾讯云开启用户SSO来接入企业SSO,但是腾讯云的文档,真是一言难尽,于是就去找阿里云的文档做参考,阿里云的文档就好一些,不过,想成功接入,还是不够。于是在网上找到【使用python构建伪IdP实现SAML接入阿里云访问控制SSO】这篇文章,最终成功实现了腾讯云接入内部SSO的功能。

本文会提供阿里云与腾讯云的接入方式,总体区别不大,只有稍微有些不同,遇到的时候会说。

第一步:生成IDP密钥对

密钥对主要作用使用公钥生成 idp 元数据文档 提供给 SP 做信任,然后就是使用公私钥对 IDP 响应的断言部分XML做签名。

脚本参考 generate_key.sh,使用原作者所提供的,并未做更改。

bash 复制代码
#!/bin/bash
#
# Usage: mk_keys.sh [prefix]
#
# Makes two files: private_key.pem and certificate.pem, optionally prefixed
# with the first positional argument.
#
# Thanks to http://robinelvin.wordpress.com/2009/09/04/saml-with-django/

if [[ $# -ge 1 ]] ; then
 prefix="${1}-"
else
 prefix=""
fi
private_key="${prefix}private-key.pem"
certificate="${prefix}certificate.pem"

echo "** This utility will create the OpenSSL key and certificate for the keys app."
type -P openssl &>/dev/null || {
    echo "** This utility requires openssl but it's not installed.  Aborting." >&2;
    exit 1;
}

echo "** Starting OpenSSL Interaction ------------------------------------"
openssl genrsa > "${private_key}"
openssl req -new -x509 -key "${private_key}" -out "${certificate}" -days 3650
echo "** Finished OpenSSL Interaction ------------------------------------"

echo "** These keys were created:"
ls -l -- "${private_key}" "${certificate}"
echo "** Finished."

第二步:生成IDP元数据文档

主要提供数据:idp entity id, 公钥;如下示例中:

EntityID:http://127.0.0.1:8000/sso/saml/login

公钥(太长已简化):MIIDazCCAlOgAwIBAgIUGDDqjlsgN

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="http://127.0.0.1:8000/sso/saml/login">
    <IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
        <KeyDescriptor use="signing">
            <ds:KeyInfo>
                <ds:X509Data>
                    <ds:X509Certificate>MIIDazCCAlOgAwIBAgIUGDDqjlsgN</ds:X509Certificate>
                </ds:X509Data>
            </ds:KeyInfo>
        </KeyDescriptor>
        <KeyDescriptor use="encryption">
            <ds:KeyInfo>
                <ds:X509Data>
                    <ds:X509Certificate>MIIDazCCAlOgAwIBAgIUGDD</ds:X509Certificate>
                </ds:X509Data>
            </ds:KeyInfo>
        </KeyDescriptor>
        <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
        <SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://127.0.0.1:8000/sso/saml/login"/>
        <SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://127.0.0.1:8000/sso/saml/login"/>
    </IDPSSODescriptor>
    <Organization>
        <OrganizationName xml:lang="en">Mouse Tech</OrganizationName>
        <OrganizationDisplayName xml:lang="en">Mouse Tech</OrganizationDisplayName>
        <OrganizationURL xml:lang="en">http://127.0.0.1:8000</OrganizationURL>
    </Organization>
</EntityDescriptor>

将内容保存为.xml后缀文件,上传到腾讯云或者阿里云即可。

第三步:用Flask实现IDP响应

下面会分别给出阿里云与腾讯云的代码示例,其中不同的地方:

  1. 跳转到IDP登录的时候,腾讯云用的POST请求,阿里云采用的是GET请求且阿里云的请求需要进行解压缩。
  2. IDP登录后的跳转腾讯云页面与方式:如您需要指定跳转腾讯云的其他页面,可使用 cloud.tencent.com/login/saml?... 形式指定,其中 xxxx 为需要指定的地址,需要做 urlencode。
  3. IDP登录后的跳转阿里云页面与方式:如果您的IdP支持设置RelayState参数,您可以将其配置成SSO登录成功后希望跳转到的页面URL。如果不进行配置,SSO登录成功后,将会跳转到阿里云控制台首页。

用到的第三方库: lxml:xml解析 signxml:xml签名

实现企业SSO的部分,本文不做涉及,请自行实现。

阿里云接口部分
python 复制代码
import uuid
import base64
import datetime
import zlib
import xml.etree.ElementTree
from lxml import etree
from signxml import XMLSigner
from flask import Flask, request, render_template


app = Flask(__name__)


SP_LOCATION = 'https://signin.aliyun.com/saml/SSO'
# 注意:此处为阿里云云实际SP Entity ID,具体可以参见阿里云的元数据
SP_ENTITY_ID = 'https://signin.aliyun.com/xxx/saml/SSO'
IDP_ENTITY_ID = 'http://127.0.0.1:8000/sso/saml/login'


def get_random_id() -> str:
    """
    生成随机id 作为response id ,以_开头
    """
    # It is very important that these random IDs NOT start with a number.
    random_id = '_' + uuid.uuid4().hex
    return random_id


def utcnow(delay=0) -> str:
    """
    生成yyyy-mm-ddThh:mm:ssZ 2020-03-17T10:58:07.048Z 三位毫秒
    :param delay:
    :return:
    """
    if delay == 0:
        return datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
    else:
        delay_seconds = datetime.timedelta(seconds=delay)
        now = datetime.datetime.utcnow() + delay_seconds
        return now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]+"Z"


def xml_assertion_template(in_response_to, assertion_id, username):
    """
    断言部分:签名的时候,是对断言部分进行签名
    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="placeholder"></ds:Signature>
    使用 signxml 签名的时候,Id="placeholder" 会在该位置替换插入签名后的xml
    :param in_response_to:
    :param assertion_id:
    :param username:
    :return:
    """
    xml_str = '<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="' + assertion_id + '" IssueInstant="' + utcnow() + '" Version="2.0">'
    xml_str += '<saml2:Issuer>' + IDP_ENTITY_ID + '</saml2:Issuer>'
    xml_str += '<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="placeholder"></ds:Signature>'
    xml_str += '<saml2:Subject>'
    xml_str += '<saml2:NameID>' + username + '</saml2:NameID>'
    xml_str += '<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">'
    xml_str += '<saml2:SubjectConfirmationData InResponseTo="' + in_response_to + '" NotOnOrAfter="' + utcnow(10) + '" Recipient="' + SP_LOCATION + '"/>'
    xml_str += '</saml2:SubjectConfirmation>'
    xml_str += '</saml2:Subject>'
    xml_str += '<saml2:Conditions NotBefore="' + utcnow() + '" NotOnOrAfter="' + utcnow(10) + '">'
    xml_str += '<saml2:AudienceRestriction>'
    xml_str += '<saml2:Audience>' + SP_ENTITY_ID + '</saml2:Audience>'
    xml_str += '</saml2:AudienceRestriction>'
    xml_str += '</saml2:Conditions>'
    xml_str += '<saml2:AuthnStatement AuthnInstant="' + utcnow() + '" SessionIndex="' + assertion_id + '">'
    xml_str += '<saml2:AuthnContext>'
    xml_str += '<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml2:AuthnContextClassRef>'
    xml_str += '</saml2:AuthnContext>'
    xml_str += '</saml2:AuthnStatement>'
    xml_str += '</saml2:Assertion>'

    return xml_str


def xml_response_template(in_response_to, response_id, signed_xml):
    xml_str = '<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="' + SP_LOCATION + '" ID="' + response_id + '" InResponseTo="' + in_response_to + \
          '" IssueInstant="' + utcnow() + '" Version="2.0">'
    xml_str += '<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">' + IDP_ENTITY_ID + '</saml2:Issuer>'
    xml_str += '<saml2p:Status>'
    xml_str += '<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>'
    xml_str += '</saml2p:Status>'
    xml_str += signed_xml
    xml_str += '</saml2p:Response>'

    return xml_str


def sign_response_xml(xml_str):
    """
    签名XML
    本处传入的签名XML是IDP响应的XML断言部分
    """
    data_to_sign = xml_str
    with open("./static/certificate.pem", "r") as f:
        cert = f.read()
    with open("./static/private-key.pem", "r") as f:
        p_key = f.read()

    root = etree.fromstring(data_to_sign)
    signed_root = XMLSigner(
        c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"
    ).sign(root, key=p_key, cert=cert)
    signed_root_str = etree.tostring(signed_root)

    return signed_root_str.decode("utf-8")


@app.route('/sso/saml/login', methods=["GET"])
def sso_saml_login():
    # 解析获取请求ID,该请求ID需要在返回的时候提供,保证请求与响应对应
    saml_request_data = request.args.get('SAMLRequest')
    saml_request_data = base64.b64decode(saml_request_data)
    idp_metadata = zlib.decompress(saml_request_data, -zlib.MAX_WBITS)
    xml_root = xml.etree.ElementTree.fromstring(idp_metadata.decode('utf-8'))
    in_response_to_id = xml_root.attrib.get("ID")
    
    # username 改为阿里云实际账号
    username = "mytest@xxx.onaliyun.com"
    response_id = get_random_id()
    assertion_id = get_random_id()

    assertion_xml = xml_assertion_template(
        in_response_to=in_response_to_id,
        assertion_id=assertion_id,
        username=username
    )
    assertion_signed_xml = sign_response_xml(assertion_xml)

    resp_xml = xml_response_template(
        in_response_to=in_response_to_id,
        response_id=response_id,
        signed_xml=assertion_signed_xml
    )
    resp_b64 = base64.b64encode(resp_xml.encode("utf-8"))

    return render_template(
        "response_saml_aliyun.html",
        saml_response=resp_b64.decode("utf-8"),
        relay_state="https%3A%2F%2Fram.console.aliyun.com%2Foverview"
    )


if __name__ == '__main__':
    app.run(port=8000, debug=True)
腾讯云接口部分
python 复制代码
import uuid
import base64
import datetime
import zlib
import xml.etree.ElementTree
from lxml import etree
from signxml import XMLSigner
from flask import Flask, request, render_template


app = Flask(__name__)


SP_LOCATION = 'https://cloud.tencent.com/saml/sso'
# 注意:此处为腾讯云实际SP Entity ID,具体可以参见腾讯云的元数据
SP_ENTITY_ID = 'https://cloud.tencent.com/xxx/saml/sso'
IDP_ENTITY_ID = 'http://127.0.0.1:8000/sso/saml/login'


def get_random_id() -> str:
    """
    生成随机id 作为response id ,以_开头
    """
    # It is very important that these random IDs NOT start with a number.
    random_id = '_' + uuid.uuid4().hex
    return random_id


def utcnow(delay=0) -> str:
    """
    生成yyyy-mm-ddThh:mm:ssZ 2020-03-17T10:58:07.048Z 三位毫秒
    :param delay:
    :return:
    """
    if delay == 0:
        return datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
    else:
        delay_seconds = datetime.timedelta(seconds=delay)
        now = datetime.datetime.utcnow() + delay_seconds
        return now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]+"Z"


def xml_assertion_template(in_response_to, assertion_id, username):
    """
    断言部分:签名的时候,是对断言部分进行签名
    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="placeholder"></ds:Signature>
    使用 signxml 签名的时候,Id="placeholder" 会在该位置替换插入签名后的xml
    :param in_response_to:
    :param assertion_id:
    :param username:
    :return:
    """
    xml_str = '<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="' + assertion_id + '" IssueInstant="' + utcnow() + '" Version="2.0">'
    xml_str += '<saml2:Issuer>' + IDP_ENTITY_ID + '</saml2:Issuer>'
    xml_str += '<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="placeholder"></ds:Signature>'
    xml_str += '<saml2:Subject>'
    xml_str += '<saml2:NameID>' + username + '</saml2:NameID>'
    xml_str += '<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">'
    xml_str += '<saml2:SubjectConfirmationData InResponseTo="' + in_response_to + '" NotOnOrAfter="' + utcnow(10) + '" Recipient="' + SP_LOCATION + '"/>'
    xml_str += '</saml2:SubjectConfirmation>'
    xml_str += '</saml2:Subject>'
    xml_str += '<saml2:Conditions NotBefore="' + utcnow() + '" NotOnOrAfter="' + utcnow(10) + '">'
    xml_str += '<saml2:AudienceRestriction>'
    xml_str += '<saml2:Audience>' + SP_ENTITY_ID + '</saml2:Audience>'
    xml_str += '</saml2:AudienceRestriction>'
    xml_str += '</saml2:Conditions>'
    xml_str += '<saml2:AuthnStatement AuthnInstant="' + utcnow() + '" SessionIndex="' + assertion_id + '">'
    xml_str += '<saml2:AuthnContext>'
    xml_str += '<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml2:AuthnContextClassRef>'
    xml_str += '</saml2:AuthnContext>'
    xml_str += '</saml2:AuthnStatement>'
    xml_str += '</saml2:Assertion>'

    return xml_str


def xml_response_template(in_response_to, response_id, signed_xml):
    xml_str = '<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="' + SP_LOCATION + '" ID="' + response_id + '" InResponseTo="' + in_response_to + \
          '" IssueInstant="' + utcnow() + '" Version="2.0">'
    xml_str += '<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">' + IDP_ENTITY_ID + '</saml2:Issuer>'
    xml_str += '<saml2p:Status>'
    xml_str += '<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>'
    xml_str += '</saml2p:Status>'
    xml_str += signed_xml
    xml_str += '</saml2p:Response>'

    return xml_str


def sign_response_xml(xml_str):
    """
    签名XML
    本处传入的签名XML是IDP响应的XML断言部分
    """
    data_to_sign = xml_str
    with open("./static/certificate.pem", "r") as f:
        cert = f.read()
    with open("./static/private-key.pem", "r") as f:
        p_key = f.read()

    root = etree.fromstring(data_to_sign)
    signed_root = XMLSigner(
        c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"
    ).sign(root, key=p_key, cert=cert)
    signed_root_str = etree.tostring(signed_root)

    return signed_root_str.decode("utf-8")


@app.route('/sso/saml/login', methods=["POST"])
def sso_saml_login():
    # 解析获取请求ID,该请求ID需要在返回的时候提供,保证请求与响应对应
    saml_request_data = request.form.get('SAMLRequest')
    saml_request_data = base64.b64decode(saml_request_data)
    idp_metadata = saml_request_data
    xml_root = xml.etree.ElementTree.fromstring(idp_metadata.decode('utf-8'))
    in_response_to_id = xml_root.attrib.get("ID")

    username = "mytest"
    response_id = get_random_id()
    assertion_id = get_random_id()

    assertion_xml = xml_assertion_template(
        in_response_to=in_response_to_id,
        assertion_id=assertion_id,
        username=username
    )
    assertion_signed_xml = sign_response_xml(assertion_xml)

    resp_xml = xml_response_template(
        in_response_to=in_response_to_id,
        response_id=response_id,
        signed_xml=assertion_signed_xml
    )
    resp_b64 = base64.b64encode(resp_xml.encode("utf-8"))

    return render_template(
        "response_saml_tencent.html",
        saml_response=resp_b64.decode("utf-8"),
        s_url="https%3A%2F%2Fconsole.cloud.tencent.com%2F"
    )


if __name__ == '__main__':
    app.run(port=8000, debug=True)
用于响应跳转的html模板

主要区别:登录后跳转到云端指定页面的方式不一样。

  1. IDP登录后的跳转腾讯云页面与方式:如您需要指定跳转腾讯云的其他页面,可使用 cloud.tencent.com/login/saml?... 形式指定,其中 xxxx 为需要指定的地址,需要做 urlencode。
  2. IDP登录后的跳转阿里云页面与方式:如果您的IdP支持设置RelayState参数,您可以将其配置成SSO登录成功后希望跳转到的页面URL。如果不进行配置,SSO登录成功后,将会跳转到阿里云控制台首页。
阿里云
xml 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
</head>
<body>
<p>登录跳转中,请稍后</p>
 <form id="saml" method="post" action="https://signin.aliyun.com/saml/SSO">
  <input type="hidden" name="SAMLResponse" value="{{ saml_response }}" />
  <input type="hidden" name="RelayState" value="{{ relay_state }}" />
 </form>
</body>
</html>

<script>
var frm = document.getElementById('saml');
frm.submit();
</script>
腾讯云
xml 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
</head>
<body>
<p>登录跳转中,请稍后</p>
 <form id="saml" method="post" action="https://cloud.tencent.com/saml/sso?s_url={{ s_url }}">
  <input type="hidden" name="SAMLResponse" value="{{ saml_response }}" />
 </form>
</body>
</html>

<script>
var frm = document.getElementById('saml');
frm.submit();
</script>

如果有需要,就试试吧。

相关推荐
极客代码2 分钟前
【Python TensorFlow】入门到精通
开发语言·人工智能·python·深度学习·tensorflow
义小深4 分钟前
TensorFlow|咖啡豆识别
人工智能·python·tensorflow
疯一样的码农8 分钟前
Python 正则表达式(RegEx)
开发语言·python·正则表达式
代码之光_19809 分钟前
保障性住房管理:SpringBoot技术优势分析
java·spring boot·后端
ajsbxi15 分钟前
苍穹外卖学习记录
java·笔记·后端·学习·nginx·spring·servlet
颜淡慕潇1 小时前
【K8S问题系列 |1 】Kubernetes 中 NodePort 类型的 Service 无法访问【已解决】
后端·云原生·容器·kubernetes·问题解决
进击的六角龙1 小时前
Python中处理Excel的基本概念(如工作簿、工作表等)
开发语言·python·excel
一只爱好编程的程序猿2 小时前
Java后台生成指定路径下创建指定名称的文件
java·python·数据下载
Aniay_ivy2 小时前
深入探索 Java 8 Stream 流:高效操作与应用场景
java·开发语言·python
gonghw4032 小时前
DearPyGui学习
python·gui