Symfony Security小程序登录授权实践
默认Symfony Security
的用户验证器是不支持微信小程序这种登录方式的,我们需要使用自定义的用户验证器。下面将围绕小程序的登录流程在Security
中实现一个自定义的用户验证器。
验证器类
如果你使用了MakeBundle
, 你可以使用以下命令来快速创建。
$ bin/console make:security:custom
What is the class name of the authenticator (e.g. CustomAuthenticator):
> MiniAuthenticator
updated: config/packages/security.yaml
created: src/Security/MiniAuthenticator.php
Success!
// src/Security/MiniAuthenticator.php
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class MiniAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
// 检测怎样的请求才走该验证器
// return $request->headers->has('X-AUTH-TOKEN');
}
public function authenticate(Request $request): Passport
{
// 该方法是验证的主要方法.
// $apiToken = $request->headers->get('X-AUTH-TOKEN');
// if (null === $apiToken) {
// The token header was empty, authentication fails with HTTP Status
// Code 401 "Unauthorized"
// throw new CustomUserMessageAuthenticationException('No API token provided');
// }
// implement your own logic to get the user identifier from `$apiToken`
// e.g. by looking up a user in the database using its API key
// $userIdentifier = /** ... */;
// return new SelfValidatingPassport(new UserBadge($userIdentifier));
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// on success, let the request continue
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$data = [
// you may want to customize or obfuscate the message first
'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
// or to translate this message
// $this->translator->trans($exception->getMessageKey(), $exception->getMessageData())
];
return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
}
// public function start(Request $request, AuthenticationException $authException = null): Response
// {
// /**
// * If you would like this class to control what happens when an anonymous user accesses a
// * protected page (e.g. redirect to /login), uncomment this method and make this class
// * implement Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface.
// *
// * For more details, see https://symfony.com/doc/current/security/experimental_authenticators.html#configuring-the-authentication-entry-point
// */
// }
}
将security.yaml
转化成security.php
文件。
# config/packages/security.php
use App\Entity\User;
use App\Security\AccessDeniedHandler;
use App\Security\AuthenticationEntryPoint;
use App\Security\MiniProgramAuthenticator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Http\Authenticator\JsonLoginAuthenticator;
use Symfony\Config\SecurityConfig;
return static function (SecurityConfig $config, ContainerBuilder $builder) {
$firewallConfig = $config->firewall('main');
$firewallConfig
->pattern('.*')
->lazy(true)
->customAuthenticators([
MiniAuthenticator::class,
])
;
};
用户提供商
小程序登录之前,我们需要配置好用户提供商,需要为小程序的用户建立用户表。还是使用命令来生成实体类:
$ php bin/console make:user
The name of the security user class (e.g. User) [User]:
> MiniUser
Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
> yes
Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
> mobile
Will this app need to hash/check user passwords? Choose No if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).
Does this app need to hash/check user passwords? (yes/no) [yes]:
> yes
created: src/Entity/MiniUser.php
created: src/Repository/MiniUserRepository.php
updated: src/Entity/MiniUser.php
updated: config/packages/security.yaml
Success!
Next Steps:
- Review your new App\Entity\MiniUser class.
- Use make:entity to add more fields to your MiniUser entity and then run make:migration.
- Create a way to authenticate! See https://symfony.com/doc/current/security.html
这里我们的唯一标识字段名为mobile
, 小程序使用手机号来验证用户。
生成之后不要忘记使用bin/console make:migration && bin/console doctrine:migrations:migrate
来构建实体表。
修改security.php
文件,将用户提供商加入配置文件中。
// config/packages/security.php
// ...
return static function (SecurityConfig $config, ContainerBuilder $builder) {
$config->provider('app_user_provider')
->entity()
->class(MiniUser::class)
->property('mobile')
;
$firewallConfig = $config->firewall('main');
$firewallConfig
->pattern('.*')
->lazy(true)
->provider('app_user_provider')
->customAuthenticators([
MiniAuthenticator::class,
])
;
};
修改MiniAuthenticator.php
文件,将上面生成的MiniUserRepository
类注入到验证器类中。
class MiniAuthenticator extends AbstractAuthenticator
{
private MiniUserRepository $repository;
/**
* 使用构造注入
* @param MiniUserRepository $repository
*/
public function __construct(MiniUserRepository $repository)
{
$this->repository = $repository;
}
// ...
}
验证器逻辑处理
一切准备就绪后,我们就要完成验证器中的验证逻辑。首先需要完成supports
方法,之后是authenticate
方法。
supports
方法表示什么样请求才会经过此验证器处理。
// ...
public function supports(Request $request): bool
{
// 如果URL是`/v1/mini/authorization`就是微信小程序发出来的登录请求验证.
return $request->getPathInfo() === "/v1/mini/authorization";
}
// ...
authenticate
方法只有supports
为true
才会执行, 它是登录验证的主方法。假设小程序端发送的参数是获取手机号的code
码文档
现在authenticate
方法的逻辑流程是检测code
参数是否存在,根据code
参数调用获取手机号接口,获取手机号成功,检测表中是否存在此手机号用户(不存在就新增一条用户记录), 之后返回Passport
类。
// src/Security/MiniAuthenticator.php
class MiniAuthenticator extends AbstractAuthenticator
{
private MiniUserRepository $repository;
private HttpClientInterface $httpClient;
/**
* @param MiniUserRepository $repository
* @param HttpClientInterface $httpClient
*/
public function __construct(MiniUserRepository $repository, HttpClientInterface $httpClient)
{
$this->repository = $repository;
$this->httpClient = $httpClient;
}
public function supports(Request $request): bool
{
// 如果URL是`/v1/mini/authorization`就是微信小程序发出来的登录请求验证.
return $request->getPathInfo() === "/v1/mini/authorization";
}
/**
* @param Request $request
* @return Passport
* @throws \Throwable
*/
public function authenticate(Request $request): Passport
{
// TODO: Implement authenticate() method.
$credentials = $request->toArray();
if (empty($credentials['code'])) {
throw new AuthenticationException('授权参数解析失败...', Response::HTTP_NOT_ACCEPTABLE);
}
$miniAppId = "xxx";
$miniAppSecret = "xxx";
// 获取手机号之前需要先获取accessToken
$response = $this->httpClient->request('GET', 'https://api.weixin.qq.com/cgi-bin/token', [
'query' => [
'appid' => $miniAppId,
'secret' => $miniAppSecret,
'grant_type' => 'client_credential',
],
])->toArray();
if (!empty($response['errmsg'])) {
throw new BadRequestHttpException($response['errmsg']);
}
if (empty($response['access_token'])) {
throw new BadRequestHttpException("token获取失败");
}
$token = $response['access_token'];
$response = $this->httpClient->request('POST', 'https://api.weixin.qq.com/wxa/business/getuserphonenumber', [
'query' => [
'access_token' => $token,
'code' => $credentials['code'],
]
])->toArray();
if (empty($response['phone_info'])) {
throw new BadRequestHttpException("手机号获取失败");
}
$phoneInfo = $response['phone_info'];
$user = $this->repository->findOneBy([
'mobile' => $phoneInfo['purePhoneNumber'],
]);
if ($user == null) {
$user = new MiniUser();
$user->setMobile($phoneInfo['purePhoneNumber']);
$user->setRoles([]);
// 请使用passwordHasher接口生成密码
$this->repository->upgradePassword($user, 'xxxxxx');
}
return new SelfValidatingPassport(new UserBadge($user->getMobile(), function (string $mobile) use ($user) {
return $user;
}));
}
// ...
}
上面就是整个验证的逻辑代码,当然里面还有很多细节未处理。比如accessToken
每次都是获取新的(这样会导致老的失效),还有未处理手机区号等等。 以上代码不适合在生产环境中运行,可以用来在开发环境的验证逻辑使用。