跳至主要内容

1 篇文章 含有標籤「sso」

檢視所有標籤

如何在 Azure Entra ID 中設定 SSO

· 閱讀時間約 6 分鐘

在現代應用程式中,單一登入(Single Sign-On, SSO)是一個非常重要的功能,它可以讓使用者在多個應用程式之間無縫切換,而不需要重複登入。Azure Entra ID 是 Azure 的一個身份驗證服務,可以幫助開發者實現 SSO 功能。本文將介紹如何在 Azure Entra ID 中設定 SSO,並且整合到自己開發的應用程式中。

什麼是 Azure Entra ID

Azure Entra ID 是 Azure 的一個身份驗證服務,提供了一個安全、可擴展的身份驗證解決方案,可以幫助開發者實現 SSO 功能。Azure Entra ID 支持多種身份驗證方式,包括密碼、多因素身份驗證、社交登入等。

必要準備

  • Azure 訂閱帳戶

建立 Enterprise 應用程式

  1. 登入 Azure 入口網站,選擇 Microsoft Entra ID,進入 Enterprise applications,點擊 Add application。

設定 Enterprise 應用程式

  1. 點擊 Create your own application。
  2. 輸入應用程式名稱
  3. 選擇 Integrate any other application you don't find in the gallery (Non-gallery),後點擊 Create。

加入可以登入的使用者和群組

  1. 進入建立好的 Enterprise 應用程式,點擊 Users and groups。
  2. 點擊 Add user,選擇可以登入的使用者或群組後儲存。

設定 SSO 模式

  1. 進入 Enterprise 應用程式,點擊 Single sign-on。
  2. 選擇 SAML。

設定 SAML 基本設定

  1. 輸入 Identifier (Entity ID)。建議可以使用 https://{tenant-name}.onmicrosoft.com/{app-name}
  2. 輸入 Reply URL 作為登入後的返回網址後點擊 Save。

設定 SAML 可取得的使用者屬性

  1. 輸入 claim name
  2. 選擇 claim 的來源,這裡選 Attribute。
  3. 選擇 claim 的值

SAML Metadata

做到這裡幾乎就完成了,畫面上會顯示 SAML Metadata,這是我們後續要用到的資訊。

  1. SP Identifier (Entity ID)
  2. IdP Identifier (Entity ID)
  3. IdP Login URL
  4. IdP Logout URL
  5. X.509 Certificate

整合到應用程式

這裡以 Python FastAPI 示範如何整合 Azure Entra ID 的 SSO 功能。

  1. 安裝 fastapipython3-saml
pip install "fastapi[standard]" python3-saml
  1. 程式碼如下:
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse

from onelogin.saml2.auth import OneLogin_Saml2_Auth

app = FastAPI()

# SAML Settings (Replace with your own settings)
SAML_PROVIDERS = {
"microsoft": {
"sp_entity_id": "<SP entity ID>",
"idp_entity_id": "<IdP entity ID>",
"idp_login_url": "<IdP login URL>",
"idp_logout_url": "<IdP logout URL>",
"idp_cert_filepath": "<IdP certificate filepath>"
}
}

SAML_EMAIL_CLAIM = "email"
SAML_USERNAME_CLAIM = "username"

# SAML Helper Functions
def init_saml_auth_settings(request:Request, provider:str, provider_setting: Dict):
sp_entity_id = provider_setting.get("sp_entity_id", None)
idp_entity_id = provider_setting.get("idp_entity_id", None)
idp_login_url = provider_setting.get("idp_login_url", None)
idp_logout_url = provider_setting.get("idp_logout_url", None)
idp_cert_filepath = provider_setting.get("idp_cert_filepath", None)

if not sp_entity_id:
error_text = f"SAML {provider} SP entity ID not found"
log.warning(error_text)
raise HTTPException(status_code=500, detail=error_text)

if not idp_entity_id:
error_text = f"SAML {provider} IdP entity ID not found"
log.warning(error_text)
raise HTTPException(status_code=500, detail=error_text)

if not idp_login_url:
error_text = f"SAML {provider} IdP login URL not found"
log.warning(error_text)
raise HTTPException(status_code=500, detail=error_text)

if not idp_logout_url:
error_text = f"SAML {provider} IdP logout URL not found"
log.warning(error_text)
raise HTTPException(status_code=500, detail=error_text)

if not idp_cert_filepath:
error_text = f"SAML {provider} IdP certificate filename not found"
log.warning(error_text)
raise HTTPException(status_code=500, detail=error_text)

if not os.path.exists(idp_cert_filepath):
log.warning(f"SAML certificate not found: {idp_cert_filepath}")
raise HTTPException(status_code=500, detail="SAML certificate not found.")


# Load the SAML certificate, and remove the header and footer lines and newlines to get the raw certificate string
with open(idp_cert_filepath, 'rb') as f:
saml_cert = f.read().decode('utf-8').replace('-----BEGIN CERTIFICATE-----', '').replace('-----END CERTIFICATE-----','').replace('\n','').strip()

pototcal = "https" if request.url.scheme == "https" else "http"
host = request.headers.get("host", "localhost")

origin_host = f"{pototcal}://{host}"
sp_acs_url = f"{origin_host}/saml/{provider}/acs"
sp_sls_url = f"{origin_host}/saml/{provider}/logout"

SAML_SETTINGS = {
"strict": True,
"debug": True,
"sp": {
"entityId": sp_entity_id,
"assertionConsumerService": {
"url": sp_acs_url,
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
},
"singleLogoutService": {
"url": sp_sls_url,
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
},
"NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
},
"idp": {
"entityId": idp_entity_id,
"singleSignOnService": {
"url": idp_login_url,
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
},
"singleLogoutService": {
"url": idp_logout_url,
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
},
"x509cert": saml_cert
}
}

return SAML_SETTINGS


def init_saml_auth(request_data: Dict, request: Request, provider:str, provider_setting: Dict):
"""
Initialize the SAML authentication object
"""
settings = init_saml_auth_settings(request, provider, provider_setting)
auth = OneLogin_Saml2_Auth(request_data, settings)
return auth

def get_saml_provider_setting(provider: str):
"""
Get the SAML provider setting
"""
if len(SAML_PROVIDERS.items()) == 0:
raise HTTPException(404)

provider_setting = SAML_PROVIDERS.get(provider, None)
if not provider_setting:
raise HTTPException(status_code=500, detail="Provider setting not found")
return provider_setting


# SAML Routes

@app.get("/saml/{provider}/login")
async def saml_login(provider: str, request: Request):
provider_setting = get_saml_provider_setting(provider)
req = {
'https': 'on' if request.url.scheme == 'https' else 'off',
'http_host': request.headers.get('host', ''),
'script_name': request.url.path,
'get_data': dict(request.query_params),
'post_data': await request.form()
}

auth = init_saml_auth(req, request, provider, provider_setting)
sso_url = auth.login()
return RedirectResponse(url=sso_url)

@app.post("/saml/{provider}/acs")
async def acs(provider: str, request: Request, response: Response):
provider_setting = get_saml_provider_setting(provider)
req = {
'https': 'on' if request.url.scheme == 'https' else 'off',
'http_host': request.headers.get('host', ''),
'script_name': request.url.path,
'get_data': dict(request.query_params),
'post_data': await request.form()
}

auth = init_saml_auth(req, request, provider, provider_setting)
auth.process_response()
errors = auth.get_errors()
error_reason = auth.get_last_error_reason()

if errors:
log.error(f"Errors: {errors}, Reason: {error_reason}")
raise HTTPException(status_code=401, detail=f"SAML Authentication Failed: {error_reason}")

if not auth.is_authenticated():
raise HTTPException(status_code=401, detail="Not authenticated")

attributes = auth.get_attributes()
name_id = auth.get_nameid()

email = attributes.get(SAML_EMAIL_CLAIM, [None])[0]
username = attributes.get(SAML_USERNAME_CLAIM, ["User"])[0]

if not email:
log.warning(f"SAML login callback failed, email is missing: {attributes}")d in attributes")

return {"email": email, "username": username, "name_id": name_id}

@app.get("/saml/{provider}/logout")
async def logout(provider: str, request: Request):
provider_setting = get_saml_provider_setting(provider)
req = {
'https': 'on' if request.url.scheme == 'https' else 'off',
'http_host': request.headers.get('host', ''),
'script_name': request.url.path,
'get_data': dict(request.query_params),
'post_data': await request.form()
}

auth = init_saml_auth(req, request, provider, provider_setting)
return RedirectResponse(url=auth.logout())

@app.get("/saml/{provider}/metadata")
async def metadata(provider: str, request: Request):
provider_setting = get_saml_provider_setting(provider)
saml_setting = init_saml_auth_settings(request, provider, provider_setting)
settings = OneLogin_Saml2_Settings(settings=saml_setting)
metadata = settings.get_sp_metadata()
return Response(content=metadata, media_type="text/xml")

參考資料