[Symfony Security] 使用 Guard 實現 OAuth 登錄

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.local

GITHUB_CLIENT_ID=YOUR_CLIENT_ID
GITHUB_CLIENT_SECRET=YOUR_CLIENT_SECRET

創建一個類用於集中管理 Github API 訪問服務,我們使用 http-client 組件作爲 HTTP 客戶端:

// ./src/OAuth/Github.php

<?php

namespace 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.yaml

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

創建用戶對象 (user class),用戶對象必需繼承 UserInterface

// ./src/Entity/User.php

class 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.php

class 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.php

class 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.yarml

security:
    # ...

    # 定義用戶加載器
    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.yaml

security:
    # 用戶密碼加密方式,該參數決定了用戶密碼將由何種方式加密
    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]
        
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章