背景

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

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

参考文档:Authentik OAuth Sources

实现步骤

1. 创建钉钉应用

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

1.1 钉钉 OAuth 接口说明

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

OAuth2 流程(钉钉版本):

接口示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
钉钉 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
Scopes openid
Authorization URL https://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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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

相关源码:

错误日志示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"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 服务作为中间层进行协议转换