源码讲解:如何优雅的让 Laravel 微信登录支持 UnionId 和 snsapi_base
Laravel 通过官方扩展包——Socialite 来做 OAuth 登录,官方支持 Facebook、 Twitter、 LinkedIn、 Google、GitHub 和 Bitbucket,其他登录渠道依靠开源项目——Socialite Providers,有人提交了微信登录和微信服务号登录,试了下,发现有问题:
protected function mapUserToObject(array $user)
{
return (new User())->setRaw($user)->map([
'id' => Arr::get($user, 'openid'),
'unionid' => Arr::get($user, 'unionid'),
- user.id 写死了 openid, 把 unionid 单做了一个字段,不符合
Laravel\Socialite\AbstractUser
格式,导致登录以后里还要额外处理。 - 不方便切换 snsapi_base(服务号免授权登录) 和 snsapi_userinfo
- 代码不规范:出现了注释掉的代码、英文错误(包名叫做 weixin-web,中英文混合,IDE 警告,而微信官方的英文名是 WeChat。由于这个包已被广泛使用,无法改名。)
所以我重写了两个:WeChatWeb(GitHub、文档、Packagist) 和 WeChatServiceAccount(GitHub、文档、Packagist)。
- fork Providers,用 Generators 一键生成代码,也就3个文件,记得还要修改
Providers/build/split.sh
- fork Documentation,写一份文档。
- 等待项目管理员操作:合并 PR,手动执行 split.sh,手动打 tag,手动提交 package 到 packagist.org……我等了1个月
生成的代码是按照 OAuth2 标准,而微信登录并不标准,所以需要 HACK。
如何自由切换 openId 和 unionId
“用 openId 还是 unionId ?”是开发者根据不同业务来决定的,所以不能写死任何一种,应该可配置。查看文档和代码:
<?php
namespace Laravel\Socialite\Two;
use Laravel\Socialite\Contracts\Provider as ProviderContract;
abstract class AbstractProvider implements ProviderContract
{
protected function getCodeFields($state = null)
{
$fields = [
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUrl,
'scope' => $this->formatScopes($this->getScopes(), $this->scopeSeparator),
'response_type' => 'code',
];
// ...
}
public function with(array $parameters)
{
$this->parameters = $parameters;
return $this;
}
}
可以发现 Socialite 只有 with 和 scopes 可配置,根据语义,unionId 属于一个权限,所以应该放在 scopes 里:
'github' => [
'client_id' => env('GITHUB_CLIENT_ID'),
'client_secret' => env('GITHUB_CLIENT_SECRET'),
'redirect' => 'http://your-callback-url',
'scopes' => ['read:user', 'public_repo'],
],
'wechat_web' => [
'client_id' => env('WECHAT_WEB_APP_ID'),
'client_secret' => env('WECHAT_WEB_APP_SECRET'),
'redirect' => env('WECHAT_WEB_CALLBACK_URL'),
'with' => ['foo' => 'bar', hd' => 'example.com'],
'scopes' => ['unionId'],
],
unionId 作为一个假权限,不能带在请求里,在代码里要 HACK 掉:
<?php
namespace SocialiteProviders\WeChatWeb;
use SocialiteProviders\Manager\OAuth2\AbstractProvider;
use SocialiteProviders\Manager\OAuth2\User;
class Provider extends AbstractProvider
{
protected function mapUserToObject(array $user)
{
return (new User())->setRaw($user)->map([
// HACK: use unionid as user id
'id' => in_array('unionid', $this->getScopes()) ? $user['unionid'] : $user['openid'],
// HACK: Tencent scope snsapi_base only return openid
'nickname' => isset($user['nickname']) ? $user['nickname'] : null,
'name' => null,
'email' => null,
'avatar' => isset($user['headimgurl']) ? $user['headimgurl'] : null,
]);
}
protected function formatScopes(array $scopes, $scopeSeparator)
{
// HACK: unionid is a faker scope for user id
if (in_array('unionid', $scopes)) {
unset($scopes[array_search('unionid', $scopes)]);
}
return implode($scopeSeparator, $scopes);
}
}
如何自由切换 snsapi_base 和 snsapi_userinfo
微信服务号登录支持两种授权:
- snsapi_base (不弹出授权页面,直接跳转,只能获取用户openid)
- snsapi_userinfo (弹出授权页面,可通过openid拿到昵称、性别、所在地。并且, 即使在未关注的情况下,只要用户授权,也能获取其信息 )
按照标准修改 scopes:
'wechat_web' => [
'client_id' => env('WECHAT_WEB_APP_ID'),
'client_secret' => env('WECHAT_WEB_APP_SECRET'),
'redirect' => env('WECHAT_WEB_CALLBACK_URL'),
'scopes' => ['snsapi_base'],
],`
但腾讯的接口不符合 OAuth2 标准,scope 为 snsapi_base 时,竟然在第2步“获取 token”时返回了用户信息(openid),而不是标准规定的下一步“获取用户信息”。所以需要 HACK:
<?php
namespace SocialiteProviders\WeChatServiceAccount;
use SocialiteProviders\Manager\OAuth2\AbstractProvider;
class Provider extends AbstractProvider
{
protected function getUserByToken($token)
{
// HACK: Tencent return id when grant token, and can not get user by this token
if (in_array('snsapi_base', $this->getScopes())) {
return ['openid' => $this->credentialsResponseBody['openid']];
}
$response = $this->getHttpClient()->get('https://api.weixin.qq.com/sns/userinfo', [
'query' => [
'access_token' => $token, // HACK: Tencent use token in Query String, not in Header Authorization
'openid' => $this->credentialsResponseBody['openid'],
'lang' => 'zh_CN',
],
]);
return json_decode($response->getBody(), true);
}
protected function formatScopes(array $scopes, $scopeSeparator)
{
// HACK: unionid is a faker scope for user id
if (in_array('unionid', $scopes)) {
unset($scopes[array_search('unionid', $scopes)]);
}
// HACK: use scopes() instead of setScopes()
// docs: https://laravel.com/docs/socialite#access-scopes
if (in_array('snsapi_base', $scopes)) {
unset($scopes[array_search('snsapi_userinfo', $scopes)]);
}
return implode($scopeSeparator, $scopes);
}
}
渠道包代码到此结束,然后 Laravel route 按照通用写法即可:
Route::get('login/{provider}', 'Auth\LoginController@redirectToProvider');
Route::get('login/{provider}/callback', 'Auth\LoginController@handleProviderCallback');
Socialite 只管调用 API 获取 token 和 用户资料,然后 Laravel 项目里的 LoginController 进行创建/更新用户到数据库,那如何判断 unionId 呢?比如先进行了微信网页登录,再用手机进行了服务号登录,这两个渠道需要识别为一个用户,所以需要额外处理。怎么处理呢?有2个办法:
- 判断渠道名称,对微信进行 HACK
- 新增一个配置,做成通用的
第2种才是优雅的,如下:
'wechat_web' => [
'client_id' => env('WECHAT_WEB_APP_ID'),
'client_secret' => env('WECHAT_WEB_APP_SECRET'),
'redirect' => env('WECHAT_WEB_CALLBACK_URL'),
'union_id_with' => 'wechat_service_account,wechat_mini_program',
],
'wechat_service_account' => [
'client_id' => env('WECHAT_SERVICE_ACCOUNT_APP_ID'),
'client_secret' => env('WECHAT_SERVICE_ACCOUNT_APP_SECRET'),
'redirect' => env('WECHAT_SERVICE_ACCOUNT_CALLBACK_URL'),
'union_id_with' => 'wechat_web,wechat_mini_program',
],
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Socialite;
class LoginController extends Controller
{
/**
* Convert provider slug to service name which is using in config/services.php
* @param string $providerSlug e.g., paypal-sandbox
* @return string e.g., paypal_sandbox
*/
public static function convertProviderSlugToServiceName($providerSlug)
{
return str_replace('-', '_', strtolower($providerSlug));
}
public function redirectToProvider($providerSlug)
{
$provider = self::convertProviderSlugToServiceName($providerSlug);
return Socialite::driver($provider)
->scopes(config("services.{$provider}.scopes"))
->redirect();
}
public function handleProviderCallback($providerSlug)
{
$provider = self::convertProviderSlugToServiceName($providerSlug);
$remote_user = Socialite::driver($provider)
->scopes(config("services.{$provider}.scopes"))
->user();
// if logged in, should link multiple auth providers to an account
$user_id = auth()->user()->id ?? null;
// if you have defined "union_id_with" in config, it will be load at here
// some providers use one union id, e.g., WeChat Web, WeChat Service Account
if (!empty($union_id_with_providers = config("services.{$provider}.union_id_with"))) {
$user_id = SocialAccount::whereIn('provider', array_diff($union_id_with_providers, [$provider]))
->where('provider_user_id', $remote_user->getId())
->whereNotNull('user_id')
->value('user_id');
}
$social_account = SocialAccount::firstOrNew([
'provider' => $provider,
'provider_user_id' => $remote_user->getId(),
], ['user_id' => $user_id]);
$name = $remote_user->getName() ?: $remote_user->getNickname();
if (!empty($social_account->user)) {
$user = $social_account->user;
} else {
$user_model = config('auth.providers.users.model');
$email = $remote_user->getEmail() ?: $provider. '.' . $remote_user->getId() . '@example.com'; // faker for email unique in db
$user = $user_model::where('email', $email)->first();
if (empty($user)) {
$user = $user_model::create([
'email' => $email,
'name' => $name ?: $provider . ' user',
]);
}
$social_account->user()->associate($user);
}
$social_account->nickname = $remote_user->getNickname();
// ...
$social_account->save();
$user->save();
auth()->login($user);
return redirect()->intended($this->redirectPath());
}
}
注意:
- 链接要符合 Domain/URL Slug 规则,即“全小写,连字符”,比如微信网页登录的链接是 “https://example.com/login/wechat-web”
config/services.php
里面的渠道名称要符合 PHP 数组规则,即“全小写,下划线”,比如微信网页登录是 “wechat_web”
所以上面代码里进行了转换。由于 route、controller、登录页是各个项目通用的,所以我把它们也做成了一个开源项目:sinkcup/laravel-make-auth-socialite。