yupanzi

Authentik 集成钉钉 OAuth2 登录

· 4 分钟阅读

背景

Authentik 是一个开源的身份验证和授权服务,支持多种身份验证方式。钉钉提供了 OAuth2 授权机制,但其接口是非标准的,需要通过自定义转换服务来适配。

本文介绍如何使用 Authentik 的 OAuth2 提供程序集成钉钉登录,让用户可以使用钉钉账户登录应用。

参考文档:Authentik OAuth Sources

实现步骤

1. 创建钉钉应用

参考以下文档创建钉钉应用:

1.1 钉钉 OAuth 接口说明

官方文档:获取登录用户的访问凭证

OAuth2 流程(钉钉版本):

接口示例
钉钉 OAuth2 协议流程

1. 授权请求
GET https://login.dingtalk.com/oauth2/auth?
redirect_uri=https%3A%2F%2Fwww.example.com%2F&response_type=code&client_id=dingyourclientid&scope=openid&prompt=consent

2. 回调地址格式
https://www.example.com/?authCode=6b427e8bfab83e93bedd13f16a430702

3. 获取 Token
POST https://api.dingtalk.com/v1.0/oauth2/userAccessToken
Content-Type: application/json

{
  "clientId": "ding your id",
  "clientSecret": "your secret",
  "code": "6b427e8bfab83e93bedd13f16a430702",
  "grantType": "authorization_code"
}

响应:
{
  "expireIn": 7200,
  "accessToken": "a8f4e3215a703ce9a7164e91dbab53c0",
  "refreshToken": "b13e5a61b421342d95d86c9e64c275c6"
}

4. 获取用户信息
GET https://api.dingtalk.com/v1.0/contact/users/me
x-acs-dingtalk-access-token: a8f4e3215a703ce9a7164e91dbab53c0
Content-Type: application/json

响应:
{
  "nick": "AWIS ME",
  "unionId": "D578iS5hxxxx",
  "avatarUrl": "https://static-legacy.dingtalk.com/media/lADPGT5i9m5ZyXDNA4LNAtA_720.jpg",
  "openId": "WySPOpXqxE",
  "mobile": "1350xxxxxxxx",
  "stateCode": "86",
  "email": "[email protected]"
}

参考实现:

2. 配置 Authentik

参考:Twitch 集成文档

关键配置项:

配置项
身份验证类型OpenID Connect
Scopesopenid
Authorization URLhttps://login.dingtalk.com/oauth2/auth?prompt=consent
Token URL自定义转换服务 URL(见下文)
User Info URL自定义转换服务 URL(见下文)

注意:钉钉的 OAuth2 接口是非标准的(命名方法和参数格式有差异),需要自己实现转换服务。参考:知乎文章

3. 实现 OAuth2 转换服务

使用 AWS Serverless(Lambda)实现,将钉钉接口转换为标准 OAuth2 格式。

3.1 Token 接口

📄 /auth/dingtalk/token
import requests
import json
from base64 import b64decode
from urllib.parse import parse_qs

TOKEN_URL = 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken'

def parse_form_data_to_json(form_data):
    parsed_data = parse_qs(form_data)
    result = {k: v[0] for k, v in parsed_data.items()}
    return result

def main(event, context):
    print(f"event:\n{event}")
    s = event.get("body")
    if event.get("isBase64Encoded") and s:
        s = b64decode(s).decode("utf-8")
    body = parse_form_data_to_json(s)

    headers = {"Content-Type": "application/json"}
    response = requests.post(TOKEN_URL, json={
        'clientId': body.get('client_id'),
        'clientSecret': body.get('client_secret'),
        'code': body.get('code'),
        'grantType': body.get('grant_type'),
    }, headers=headers)
    response.raise_for_status()
    res = response.json()

    result = {
        # 'refresh_token': res.get('refreshToken'),
        'access_token': res.get('accessToken'),
        'expires_in': res.get('expiresIn'),
        'token_type': 'Bearer',
    }

    return {"statusCode": 200, "body": json.dumps(result)}

3.2 用户信息接口

📄 /auth/dingtalk/profile
import requests
import json

URL = 'https://api.dingtalk.com/v1.0/contact/users/me'

def main(event, context):
    print(f"event:\n{event}")
    access_token = event.get('headers', {}).get('authorization', '')
    access_token = access_token.replace('Bearer ', '')
    print(access_token)

    headers = {
        "Content-Type": "application/json",
        'x-acs-dingtalk-access-token': access_token,
    }
    response = requests.get(URL, headers=headers)
    response.raise_for_status()
    user_info = response.json()
    print(user_info)

    result = {
        # 'issuer': userInfoURL,
        # 'picture': user_info.get('avatarUrl'),
        'sub': user_info['openId'],  # 关键字段,必须有
        'nickname': user_info['nick'],
        'name': user_info['nick'],
        'email': user_info['email']
    }
    return {"statusCode": 200, "body": json.dumps(result)}

常见问题

错误:Could not determine id.

原因:返回的用户信息中缺少 sub 字段。

解决:确保转换服务返回包含 sub 字段的 JSON,sub 通常对应钉钉的 openId

相关源码:

错误日志示例:

{
  "auth_via": "unauthenticated",
  "domain_url": "example.com",
  "event": "Authentication Failure",
  "host": "example.com",
  "level": "warning",
  "logger": "authentik.sources.oauth.views.callback",
  "pid": 4721,
  "reason": "Could not determine id.",
  "request_id": "28a8d8818c63441da41051455c32d437",
  "schema_name": "public",
  "timestamp": "2024-04-09T10:30:48.464283"
}

总结

通过自定义转换服务,成功将钉钉的非标准 OAuth2 接口适配为 Authentik 可识别的标准格式,实现了钉钉登录集成。核心要点:

  1. 钉钉接口参数命名与标准 OAuth2 不同(如 clientId vs client_id
  2. 必须在用户信息中返回 sub 字段
  3. 使用 Serverless 服务作为中间层进行协议转换

相关文章