mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
sources/oauth: add WeChat type (#18086)
* Add wechat. * Refactor comments and formatting in wechat.py Signed-off-by: Anduin Xue <anduin@aiursoft.com> * Fix lint. Signed-off-by: Anduin Xue <anduin@aiursoft.com> * Fix lint. * fix: Rename `WeChat` enum member to `Wechat` for consistency * docs: Add WeChat social login integration guide. * Docs updates * Revise WeChat integration instructions Updated instructions for creating a WeChat Website Application and added details about scopes and user attribute mappings. Signed-off-by: Anduin Xue <anduin@aiursoft.com> * Prettier * Update wechat.py Signed-off-by: Anduin Xue <anduin@aiursoft.com> --------- Signed-off-by: Anduin Xue <anduin@aiursoft.com> Co-authored-by: dewi-tik <dewi@goauthentik.io> Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
@@ -25,6 +25,7 @@ AUTHENTIK_SOURCES_OAUTH_TYPES = [
|
||||
"authentik.sources.oauth.types.slack",
|
||||
"authentik.sources.oauth.types.twitch",
|
||||
"authentik.sources.oauth.types.twitter",
|
||||
"authentik.sources.oauth.types.wechat",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -307,6 +307,15 @@ class RedditOAuthSource(CreatableType, OAuthSource):
|
||||
verbose_name_plural = _("Reddit OAuth Sources")
|
||||
|
||||
|
||||
class WeChatOAuthSource(CreatableType, OAuthSource):
|
||||
"""Social Login using WeChat."""
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
verbose_name = _("WeChat OAuth Source")
|
||||
verbose_name_plural = _("WeChat OAuth Sources")
|
||||
|
||||
|
||||
class OAuthSourcePropertyMapping(PropertyMapping):
|
||||
"""Map OAuth properties to User or Group object attributes"""
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""WeChat Type tests"""
|
||||
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.sources.oauth.types.wechat import WeChatType
|
||||
|
||||
WECHAT_USER = {
|
||||
"openid": "OPENID",
|
||||
"nickname": "NICKNAME",
|
||||
"sex": 1,
|
||||
"province": "PROVINCE",
|
||||
"city": "CITY",
|
||||
"country": "COUNTRY",
|
||||
"headimgurl": "https://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0",
|
||||
"privilege": ["PRIVILEGE1", "PRIVILEGE2"],
|
||||
"unionid": " o6_buyCrymLUUFYHxvDU6M2PHl22",
|
||||
}
|
||||
|
||||
|
||||
class TestTypeWeChat(TestCase):
|
||||
"""OAuth Source tests"""
|
||||
|
||||
def setUp(self):
|
||||
self.source = OAuthSource.objects.create(
|
||||
name="test",
|
||||
slug="test",
|
||||
provider_type="wechat",
|
||||
)
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_enroll_context(self):
|
||||
"""Test WeChat Enrollment context"""
|
||||
ak_context = WeChatType().get_base_user_properties(
|
||||
source=self.source, info=WECHAT_USER, client=None, token={}
|
||||
)
|
||||
self.assertEqual(ak_context["username"], WECHAT_USER["unionid"])
|
||||
self.assertIsNone(ak_context["email"])
|
||||
self.assertEqual(ak_context["name"], WECHAT_USER["nickname"])
|
||||
self.assertEqual(ak_context["attributes"]["openid"], WECHAT_USER["openid"])
|
||||
self.assertEqual(ak_context["attributes"]["unionid"], WECHAT_USER["unionid"])
|
||||
|
||||
def test_enroll_context_no_unionid(self):
|
||||
"""Test WeChat Enrollment context without unionid"""
|
||||
user = WECHAT_USER.copy()
|
||||
del user["unionid"]
|
||||
ak_context = WeChatType().get_base_user_properties(
|
||||
source=self.source, info=user, client=None, token={}
|
||||
)
|
||||
self.assertEqual(ak_context["username"], WECHAT_USER["openid"])
|
||||
self.assertIsNone(ak_context["email"])
|
||||
self.assertEqual(ak_context["name"], WECHAT_USER["nickname"])
|
||||
@@ -0,0 +1,162 @@
|
||||
"""WeChat (Weixin) OAuth Views"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from authentik.sources.oauth.clients.oauth2 import OAuth2Client
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.sources.oauth.types.registry import SourceType, registry
|
||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
||||
|
||||
|
||||
class WeChatOAuthRedirect(OAuthRedirect):
|
||||
"""WeChat OAuth2 Redirect"""
|
||||
|
||||
def get_additional_parameters(self, source: OAuthSource): # pragma: no cover
|
||||
# WeChat (Weixin) for Websites official documentation requires 'snsapi_login'
|
||||
# as the *only* scope for the QR code-based login flow.
|
||||
# Ref: https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html (Step 1) # noqa: E501
|
||||
return {
|
||||
"scope": ["snsapi_login"],
|
||||
}
|
||||
|
||||
|
||||
class WeChatOAuth2Client(OAuth2Client):
|
||||
"""
|
||||
WeChat OAuth2 Client
|
||||
|
||||
Handles the non-standard parts of the WeChat OAuth2 flow.
|
||||
"""
|
||||
|
||||
def get_access_token(self, redirect_uri: str, code: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get access token from WeChat.
|
||||
|
||||
WeChat uses a non-standard GET request for the token exchange,
|
||||
unlike the standard OAuth2 POST request. The AppID (client_id)
|
||||
and AppSecret (client_secret) are passed as URL query parameters.
|
||||
"""
|
||||
token_url = self.get_access_token_url()
|
||||
params = {
|
||||
"appid": self.get_client_id(),
|
||||
"secret": self.get_client_secret(),
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
|
||||
# Send the GET request using the base class's session handler
|
||||
response = self.do_request("get", token_url, params=params)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
self.logger.warning("Unable to fetch wechat token", exc=exc)
|
||||
raise exc
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Handle WeChat's specific error format (JSON with 'errcode' and 'errmsg')
|
||||
if "errcode" in data:
|
||||
self.logger.warning(
|
||||
"Unable to fetch wechat token",
|
||||
errcode=data.get("errcode"),
|
||||
errmsg=data.get("errmsg"),
|
||||
)
|
||||
raise RequestException(data.get("errmsg"))
|
||||
|
||||
return data
|
||||
|
||||
def get_profile_info(self, token: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Get Userinfo from WeChat.
|
||||
|
||||
This API call requires both the 'access_token' and the 'openid'
|
||||
(which was returned during the token exchange).
|
||||
"""
|
||||
profile_url = self.get_profile_url()
|
||||
params = {
|
||||
"access_token": token.get("access_token"),
|
||||
"openid": token.get("openid"),
|
||||
"lang": "en", # or 'zh_CN' (Simplified Chinese), 'zh_TW' (Traditional)
|
||||
}
|
||||
|
||||
response = self.do_request("get", profile_url, params=params)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
self.logger.warning("Unable to fetch wechat userinfo", exc=exc)
|
||||
raise exc
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Handle WeChat's specific error format
|
||||
if "errcode" in data:
|
||||
self.logger.warning(
|
||||
"Unable to fetch wechat userinfo",
|
||||
errcode=data.get("errcode"),
|
||||
errmsg=data.get("errmsg"),
|
||||
)
|
||||
raise RequestException(data.get("errmsg"))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class WeChatOAuth2Callback(OAuthCallback):
|
||||
"""WeChat OAuth2 Callback"""
|
||||
|
||||
# Specify our custom Client to handle the non-standard WeChat flow
|
||||
client_class = WeChatOAuth2Client
|
||||
|
||||
|
||||
@registry.register()
|
||||
class WeChatType(SourceType):
|
||||
"""WeChat Type definition"""
|
||||
|
||||
callback_view = WeChatOAuth2Callback
|
||||
redirect_view = WeChatOAuthRedirect
|
||||
verbose_name = "WeChat"
|
||||
name = "wechat"
|
||||
|
||||
# WeChat API URLs are fixed and not customizable
|
||||
urls_customizable = False
|
||||
|
||||
# URLs for the WeChat "Login for Websites" authorization flow
|
||||
authorization_url = "https://open.weixin.qq.com/connect/qrconnect"
|
||||
# nosec: B105 This is a public URL, not a hardcoded secret
|
||||
access_token_url = "https://api.weixin.qq.com/sns/oauth2/access_token" # nosec
|
||||
profile_url = "https://api.weixin.qq.com/sns/userinfo"
|
||||
|
||||
# Note: 'authorization_code_auth_method' is intentionally omitted.
|
||||
# The base OAuth2Client defaults to POST_BODY, but our custom
|
||||
# WeChatOAuth2Client overrides get_access_token() to use GET,
|
||||
# so this setting would be misleading.
|
||||
|
||||
def get_base_user_properties(self, info: dict[str, Any], **kwargs) -> dict[str, Any]:
|
||||
"""
|
||||
Map WeChat userinfo to authentik user properties.
|
||||
"""
|
||||
# The WeChat userinfo API (sns/userinfo) does *not* return an email address.
|
||||
# We explicitly set 'email' to None. Authentik will typically
|
||||
# prompt the user to provide one on their first login if it's required.
|
||||
|
||||
# 'unionid' is the preferred unique identifier as it's consistent
|
||||
# across multiple apps under the same WeChat Open Platform account.
|
||||
# 'openid' is the fallback, which is only unique to this specific AppID.
|
||||
return {
|
||||
"username": info.get("unionid", info.get("openid")),
|
||||
"email": None, # WeChat API does not provide Email
|
||||
"name": info.get("nickname"),
|
||||
"attributes": {
|
||||
# Save all other relevant info as user attributes
|
||||
"headimgurl": info.get("headimgurl"),
|
||||
"sex": info.get("sex"),
|
||||
"city": info.get("city"),
|
||||
"province": info.get("province"),
|
||||
"country": info.get("country"),
|
||||
"unionid": info.get("unionid"),
|
||||
"openid": info.get("openid"),
|
||||
},
|
||||
}
|
||||
@@ -11982,7 +11982,8 @@
|
||||
"reddit",
|
||||
"slack",
|
||||
"twitch",
|
||||
"twitter"
|
||||
"twitter",
|
||||
"wechat"
|
||||
],
|
||||
"title": "Provider type"
|
||||
},
|
||||
|
||||
@@ -50080,6 +50080,7 @@ components:
|
||||
- slack
|
||||
- twitch
|
||||
- twitter
|
||||
- wechat
|
||||
type: string
|
||||
ProxyMode:
|
||||
enum:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>WeChat</title><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -71,6 +71,8 @@ export function ProviderToLabel(provider?: ProviderTypeEnum): string {
|
||||
return "Twitter";
|
||||
case ProviderTypeEnum.Twitch:
|
||||
return "Twitch";
|
||||
case ProviderTypeEnum.Wechat:
|
||||
return "WeChat";
|
||||
case ProviderTypeEnum.UnknownDefaultOpenApi:
|
||||
return msg("Unknown provider type");
|
||||
}
|
||||
|
||||
@@ -615,6 +615,7 @@ const items = [
|
||||
"users-sources/sources/social-logins/telegram/index",
|
||||
"users-sources/sources/social-logins/twitch/index",
|
||||
"users-sources/sources/social-logins/twitter/index",
|
||||
"users-sources/sources/social-logins/wechat/index",
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: WeChat
|
||||
tags:
|
||||
- source
|
||||
- wechat
|
||||
---
|
||||
|
||||
Allows users to authenticate using their WeChat credentials by configuring WeChat as a federated identity provider via OAuth2.
|
||||
|
||||
## Preparation
|
||||
|
||||
The following placeholders are used in this guide:
|
||||
|
||||
- `authentik.company` is the FQDN of the authentik installation.
|
||||
|
||||
## WeChat configuration
|
||||
|
||||
To integrate WeChat with authentik you will need to register a "Website Application" (网站应用) on the [WeChat Open Platform](https://open.weixin.qq.com/).
|
||||
|
||||
1. Register for a developer account on the [WeChat Open Platform](https://open.weixin.qq.com/).
|
||||
2. Navigate to the **Management Center** (管理中心) > **Website Application** (网站应用) and click **Create Website Application** (创建网站应用).
|
||||
3. Submit the application for review.
|
||||
4. Once approved, you will obtain an **AppID** and **AppSecret**.
|
||||
5. In the WeChat application settings, configure the **Authorized Callback Domain** (授权回调域) to match your authentik domain (e.g. `authentik.company`).
|
||||
|
||||
:::info
|
||||
This integration uses the WeChat "Website Application" login flow (QR Code login). When users access the login page on a desktop device (Windows/Mac) with the WeChat client installed, they may see a "Fast Login" prompt.
|
||||
:::
|
||||
|
||||
## authentik configuration
|
||||
|
||||
To support the integration of WeChat with authentik, you need to create a WeChat OAuth source in authentik.
|
||||
|
||||
1. Log in to authentik as an administrator and open the authentik Admin interface.
|
||||
2. Navigate to **Directory** > **Federation and Social login**, click **Create**, and then configure the following settings:
|
||||
- **Select type**: select **WeChat OAuth Source** as the source type.
|
||||
- **Create OAuth Source**: provide a name, a slug (e.g. `wechat`), and set the following required configurations:
|
||||
- **Protocol settings**
|
||||
- **Consumer Key**: Enter the **AppID** from the WeChat Open Platform.
|
||||
- **Consumer Secret**: Enter the **AppSecret** from the WeChat Open Platform.
|
||||
- **Scopes**: define any further access scopes.
|
||||
3. Click **Finish**.
|
||||
|
||||
:::info Display new source on login screen
|
||||
For instructions on how to display the new source on the authentik login page, refer to the [Add sources to default login page documentation](../../index.md#add-sources-to-default-login-page).
|
||||
:::
|
||||
|
||||
:::info Embed new source in flow :ak-enterprise
|
||||
For instructions on embedding the new source within a flow, such as an authorization flow, refer to the [Source Stage documentation](../../../../../add-secure-apps/flows-stages/stages/source/).
|
||||
:::
|
||||
|
||||
## Source property mappings
|
||||
|
||||
Source property mappings allow you to modify or gather extra information from sources. See the [overview](../../property-mappings/index.md) for more information.
|
||||
|
||||
The following data is retrieved from WeChat and mapped to the user's attributes in authentik:
|
||||
|
||||
| WeChat Field | authentik Attribute | Description |
|
||||
| :---------------------- | :---------------------- | :------------------------------ |
|
||||
| `unionid` (or `openid`) | `username` | Used as the primary identifier. |
|
||||
| `nickname` | `name` | The user's display name. |
|
||||
| `headimgurl` | `attributes.headimgurl` | URL to the user's avatar. |
|
||||
| `sex` | `attributes.sex` | Gender (1=Male, 2=Female). |
|
||||
| `city` | `attributes.city` | User's city. |
|
||||
| `province` | `attributes.province` | User's province. |
|
||||
| `country` | `attributes.country` | User's country. |
|
||||
|
||||
### User Matching
|
||||
|
||||
WeChat users are identified by their `unionid` (if available) or `openid`.
|
||||
|
||||
- **UnionID**: Unique across multiple applications under the same developer account. authentik prioritizes this as the username.
|
||||
- **OpenID**: Unique to the specific application. Used as a fallback if `unionid` is not returned.
|
||||
|
||||
:::info
|
||||
WeChat does not provide the user's email address via the API.
|
||||
:::
|
||||
|
||||
## Resources
|
||||
|
||||
- [WeChat Open Platform](https://open.weixin.qq.com/)
|
||||
- [WeChat Login document](https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html)
|
||||
Reference in New Issue
Block a user