Symfony Security

无论进行何种身份验证,本质上都是通过用户凭证 (credentials) 寻找用户 (user class) 的过程,无论是传统的表单 (username/password) 登录还是 API 令牌 (token),凭证通常存储于请求对象 (header, body, query 等) 中,因此从请求对象中提取用户凭证并寻找用户的过程,即称之为认证 (Authentication) 。

Symfony Security 安全组件由 4 个子组件组合而成,它们之间相互独立,你可以选择性的安装。

组件描述
security-core提供用户密码加密到认证、授权等基本安全功能。
security-guard一个抽象的身份验证层,用于创建复杂的身份验证系统。
security-http将安全组件与 HTTP 协议相互集成,以处理安全方面的请求和响应。
security-csrf为跨域请求伪造 (CSRF) 提供验证和保护。

使用 Symfony Security 需要预先安装,由于 security-bundle 已经集成了所有的安全组件,因此只需要安装它即可:

$ composer require symfony/security-bundle

实现 Github 登录

要实现 Github 登录,首先需要在 Settings -> Developer settings -> OAuth Apps -> New OAuth App 创建一个应用,并设置认证回调地址(Authorization callback URL),创建成功之后会分配你一个 Client IDClient Secret,将此参数复制到项目 环境变量文件:

# .env.localGITHUB_CLIENT_ID=YOUR_CLIENT_IDGITHUB_CLIENT_SECRET=YOUR_CLIENT_SECRET

创建一个类用于集中管理 Github API 访问服务,我们使用 http-client 组件作为 HTTP 客户端:

// ./src/OAuth/Github.php<?phpnamespace App\OAuth;use Symfony\Contracts\HttpClient\HttpClientInterface;class Github{    const AUTHORIZE_URL = 'https://github.com/login/oauth/authorize';    const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';    const USER_URL = 'https://api.github.com/user';    private $client;    private $clientId;    private $clientSecret;    public function __construct(HttpClientInterface $client, string $clientId, string $clientSecret)    {        $this->client = $client;        $this->clientId = $clientId;        $this->clientSecret = $clientSecret;    }    public function getAuthorizeUrl(string $redirectUri, string $state = null): string    {        $query = [            'client_id' => $this->clientId,            'redirect_uri' => $redirectUri,            'state' => $state,        ];        return self::AUTHORIZE_URL.'?'.http_build_query($query);    }    public function getAccessToken(string $code): array    {        $options = [            'body' => [                'client_id' => $this->clientId,                'client_secret' => $this->clientSecret,                'code' => $code,            ],            'headers' => [                'Accept' => 'application/json',            ],        ];        $response = $this->client->request('POST', self::ACCESS_TOKEN_URL, $options);        $data = $response->toArray();        if (isset($data['error'])) {            throw new \RuntimeException(sprintf('%s (%s)', $data['error_description'], $data['error']));        }        return $data;    }    public function getUser(string $accessToken): array    {        $options = [            'headers' => [                'Authorization' => sprintf('token %s', $accessToken),            ],        ];        $response = $this->client->request('GET', self::USER_URL, $options);        $data = $response->toArray();        if (isset($data['error'])) {            throw new \RuntimeException(sprintf('%s (%s)', $data['error_description'], $data['error']));        }        return $data;    }}

配置环境变量到服务:

# ./config/services.yamlservices:    # ...    App\OAuth\Github:        $clientId: '%env(GITHUB_CLIENT_ID)%'        $clientSecret: '%env(GITHUB_CLIENT_SECRET)%'

创建用户对象 (user class),用户对象必需继承 UserInterface。

// ./src/Entity/User.phpclass User implements UserInterface{    // ...    /**     * @ORM\Column(type="string", length=255, unique=true)     */    private $username;    /**     * @ORM\Column(type="string", length=255)     */    private $nickname;    /**     * @ORM\Column(type="string", length=255)     */    private $avatar;    /**     * @ORM\Column(type="datetime", nullable=true)     */    private $updatedAt;    /**     * @ORM\Column(type="datetime_immutable")     */    private $createdAt;    // ...}

创建控制器,跳转到 Github 进行认证:

// ./src/Controller/SecurityController.phpclass SecurityController extends AbstractController{    /**     * @Route("/login/oauth/github", name="login_oauth_github")     *     * @param Request $request     * @param Github  $client     *     * @return Response     */    public function loginWithGithub(Request $request, Github $client)    {        // 随机 state 字符串,用于防止 CSRF 攻击        $state = bin2hex(random_bytes(8));        $session = $request->getSession();        $session->set(GithubAuthenticator::STATE, $state);        // 生成回调地址,该地址即为 Authorization callback URL,需在 Github 上填写        $callback = $this->generateUrl('login_oauth_github_callback', [], 0);        $redirect = $client->getAuthorizeUrl($callback, $state);        return $this->redirect($redirect);    }    /**     * @Route("/login/oauth/github/callback", name="login_oauth_github_callback")     *     * 只需要定义路由,这个路由什么都不用干,因为它将会被 Guard 拦截     */    public function loginWithGithubCallback()    {        // nothing todo...    }}

创建 Guard 拦截器实现认证过程:

// ./src/Security/GithubAuthenticator.phpclass GithubAuthenticator extends AbstractGuardAuthenticator{    use TargetPathTrait;    const STATE = '_github_oauth_state';    private $entityManager;    private $httpUtils;    private $github;    public function __construct(EntityManagerInterface $entityManager, HttpUtils $httpUtils, Github $github)    {        $this->entityManager = $entityManager;        $this->httpUtils = $httpUtils;        $this->github = $github;    }    /**     * 每一个请求都会进入该方法,需要在此过滤那些不相干的请求,返回 false 即可跳过     */    public function supports(Request $request)    {        // 过滤请求,只拦截回调地址即可,回调地址中 Github 回带上 code        return $this->httpUtils->checkRequestPath($request, 'login_oauth_github_callback')            && $request->query->has('code');    }    /**     * 如果匹配到 supports 则调用该方法,用于从请求中获取凭证,用于 getUser     */    public function getCredentials(Request $request)    {        // 验证 state,防止 CSRF 攻击        $state = $request->query->get('state');        if ($state !== $request->getSession()->get(self::STATE)) {            throw new CustomUserMessageAuthenticationException('Bad authentication state.');        }        return $request->query->get('code');    }    /**     * 从 getCredentials 获取到的凭证查找并返回用户,如果返回 NULL 或抛出异常则认证失败     * 如果返回了用户 (UserInterface),则进入到 checkCredentials     */    public function getUser($credentials, UserProviderInterface $userProvider)    {        // $credentials 即是 getCredentials 返回的数据        $token = $this->github->getAccessToken($credentials);        try {            $user = $this->github->getUser($token['access_token']);        } catch (\Throwable $th) {            // ...        }        try {            // 如果找到用户直接返回,进入下一步            $entity = $userProvider->loadUserByUsername($user['login']);        } catch (UsernameNotFoundException $e) {            // 如果第一次登录,则需要存进数据库            $entity = new User();            $entity->setUsername($user['login']);            $entity->setNickname($user['name']);            $entity->setAvatar($user['avatar_url']);            $entity->setCreatedAt(new \DateTimeImmutable());            $this->entityManager->persist($entity);            $this->entityManager->flush();        }        return $entity;    }    /**     * OAuth 认证不需要检查凭证正确与否     */    public function checkCredentials($credentials, UserInterface $user)    {        return true;    }    /**     * 任何一步认证失败将调用该方法     */    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)    {        throw new \RuntimeException($exception->getMessage());    }    /**     * 认证成功后将调用该方法,用于跳转至前一页面     */    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)    {        // getTargetPath 返回用户在认证前停留的页面地址,由 TargetPathTrait 提供        $targetPath = $this->getTargetPath($request->getSession(), $providerKey);        if (!$targetPath) {            $targetPath = $this->httpUtils->generateUri($request, 'app_index');        }        return $this->httpUtils->createRedirectResponse($request, $targetPath);    }    /**     * 如果在 secruity.yaml 中配置了access_control 段,当用户权限不足时进入到该方法,否则不执行     */    public function start(Request $request, AuthenticationException $authException = null)    {        return $this->httpUtils->createRedirectResponse($request, 'app_login');    }    /**     * 是否启用 “自动登录” 功能,OAuth 认证不支持该功能     */    public function supportsRememberMe()    {        return false;    }}

配置 Guard 拦截器:

# ./config/packages/security.yarmlsecurity:    # ...    # 定义用户加载器    providers:        entity_provider:            entity: { class: App\Entity\User, property: username }    # 定义防火墙规则    firewalls:        # ...        # 防火墙区域,可随意定义名称        my_area:            # 加载器使用 entity_provider            provider: entity_provider            # 拦截器使用 GithubAuthenticator            guard:                authenticators:                    - App\Security\GithubAuthenticator

现在,只需要在登录的地方放上链接至路由 login_oauth_github 即可使用 Github 登录:

<a href="{{ path('login_oauth_github') }}">Github 登录</a>

配置参数参考

以下是常见的安全配置参数说明。

# config/packages/security.yamlsecurity:    # 用户密码加密方式,该参数决定了用户密码将由何种方式加密    encoders:        # 由系统决定合适的加密方式        App\Entity\FooUser: auto        # 由 sodium 方式加密,可取范围:plaintext, pbkdf2, bcrypt, argon2i, native, sodium        App\Entity\BarUser: sodium    # 用户加载器,该参数决定用户从何处加载    providers:        # 硬编码用户加载器        my_memory_provider:            memory:                users:                    # 一个称为 foo 的用户,并拥有 ROLE_READER 角色                    foo: { password: foo_password, roles: ROLE_READER }                    # 又一个称为 bar 的用户,并拥有 ROLE_EDITOR 角色                    bar: { password: bar_password, roles: ROLE_EDITOR }        # 实体用户加载器(从数据库中加载)        my_entity_provider:            entity: { class: App\Entity\AcmeUser, property: username }        # 自定义用户加载器,必需实现 UserProviderInterface 接口        my_custom_provider:            id: App\Security\MyCustomProvider    # 访问区域,可配置多个访问区域,多个区域有先后之分比如 /api 和 /api/user 将先匹配到 /api    firewalls:        # 由 /api 开始的请求将匹配到 zone_a 区域,该区域由 my_entity_provider 提供用户        zone_a:            pattern: ^/api            provider: my_entity_provider        # 由 /admin 开始,并且 host 为 admin.com 的请求将匹配到 area_b 区域,该域由用 my_custom_provider 提供用户        zone_b:            pattern: ^/admin            host: admin.com            provider: my_custom_provider    # 访问控制,可配置多个访问区域,多个区域有先后之分    access_control:        # 由 /api 开始的请求必需包含 ROLE_API 或 ROLE_USER 角色        - { path: ^/api, roles: [ROLE_API, ROLE_USER] }        # 由 /admin 开始的请求必需包含 ROLE_ADMIN 角色        - { path: ^/admin, roles: ROLE_ADMIN }    # 用户角色等级    role_hierarchy:        # 拥有 ROLE_API 角色的用户将同时具备 ROLE_READER 和 ROLE_EDITOR 权限        ROLE_API: [ROLE_READER, ROLE_EDITOR]        # 拥有 ROLE_ADMIN 角色的用户将同时具备 ROLE_ADMIN_ARTICLE, ROLE_ADMIN_COMMENT 权限        ROLE_ADMIN: [ROLE_ADMIN_ARTICLE, ROLE_ADMIN_COMMENT]