Table Of Contents

骑驴找蚂蚁

全干工程师

symfony

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方法只有supportstrue才会执行, 它是登录验证的主方法。假设小程序端发送的参数是获取手机号的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每次都是获取新的(这样会导致老的失效),还有未处理手机区号等等。 以上代码不适合在生产环境中运行,可以用来在开发环境的验证逻辑使用。

参考文档

  1. https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html
  2. https://symfony.com/doc/current/security/custom_authenticator.html
  3. https://symfony.com/doc/current/security.html#authenticating-users

留言