2021-10-13

【Sign in with Slack】Laravel で Slack の OpenID Connect (OAuth 2.0) による認証を行う

公式ドキュメント: https://api.slack.com/authentication/sign-in-with-slack

認証処理の流れ

  1. ユーザが Laravel アプリケーションのログイン画面で「Sign in with Slack」ボタンを押下し、Slack の Authorizaion Endpoint へリダイレクトする。このとき、リダイレクトする URL には redirect_uri, state, nonce など OAuth および OpenID Connect で必要となるパラメータを付与する。
  2. ユーザはリダイレクト先で Slack にログイン (認証) およびユーザ情報提供の許可 (認可) を行う。
  3. Slack での認証・認可が完了すると、1. で指定した redirect_uri (コールバックページ) に一時承認コードが付与された状態で遷移する。
  4. コールバックページ では state パラメータの検証を行い、OK であれば一時承認コードを利用して Slack へ id_token をリクエストする。
  5. id_token 受領後、nonce パラメータの検証を行い、OK であれば id_token に含まれる Email アドレスと一致する登録済のユーザを取得する。
  6. ユーザが取得できた場合、そのユーザとしてログインして、セッションを再生成した上でホーム画面 (/home) に遷移する。
+--------+                                   +--------+
|        |                                   |        |
|        |---------(1) AuthN Request-------->|        |
|        |                                   |        |
|        |  +--------+                       |        |
|        |  |        |                       |        |
|        |  |  End-  |<--(2) AuthN & AuthZ-->|        |
|        |  |  User  |                       |        |
|   RP   |  |        |                       |   OP   |
|        |  +--------+                       |        |
|        |                                   |        |
|        |<--------(3) AuthN Response--------|        |
|        |                                   |        |
|        |---------(4) UserInfo Request----->|        |
|        |                                   |        |
|        |<--------(5) UserInfo Response-----|        |
|        |                                   |        |
+--------+                                   +--------+

上図は OpenID Connect Core 1.0 - 1.3. Overview より引用

  • AuthN: AutheNtication / 認証
  • AuthZ: AuthoriZation / 認可
  • RP: Relying Party, ここでは Laravel アプリケーション
  • OP: OpenID Provider, ここでは Slack

Slack の OpenID Connect 利用にあたっての前提事項

クライアントアプリケーション (Laravel) には HTTPS かつ FQDN で接続可能である必要があります。

  • Slack の OAuth では Redirect URI (コールバックページ) のサーバ証明書検証は行われないため、自己署名証明書(オレオレ証明書)で構いません。
  • 以下のような URL は OK です。
    • https://example.com
    • https://dev.example.jp
  • 以下のような URL は NG です。
    • http://example.com -> HTTPS である必要があります
    • https://127.0.0.1 -> FQDN である必要があります
    • https://localhost -> FQDN である必要があります

なお、Laravel Sail での接続の HTTPS 化については 過去記事 もご参照ください。

本記事で例示する Laravel アプリケーションの前提事項

  • ホスト URL を https://example.com とします。
  • OpenID Connect はログイン時のユーザ認証にのみ利用し、セッション管理は Laravel デフォルトの認証システムで行います。
  • ユーザはサインインに利用する Slack ワークスペースと Laravel アプリケーション 双方に登録済であり、かつメールアドレスが同一であるものとします。
  • HTTP Client には Guzzle を利用します。(https://docs.guzzlephp.org/en/stable/index.html)
  • 利用バージョンは Laravel 8.0, Guzzle 7.0 です。

以下、手順です。

Slack App の作成と環境変数の設定

https://api.slack.com/apps から OAuth 用のアプリケーションを作成します。 create_slack_app 作成した App の Client IDClient Secret.env に記載しておきます。Client Secret は名前の通り秘匿情報ですので、決してソースコードに直書きをしないでください。

※画像の ID 等はセキュリティの関係上、加工しています

client_secret

.env

SLACK_CLIENT_ID="9999999999999.9999999999999"
SLACK_CLIENT_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

なお、本記事の Laravel アプリケーションでは config 配下の const.php で定数管理を行っているため、そちらにも追記します。

config/const.php

<?php
return [
    'SLACK_CLIENT_ID' => env('SLACK_CLIENT_ID'),
    'SLACK_CLIENT_SECRET' => env('SLACK_CLIENT_SECRET'),
    // config('const.SLACK_CLIENT_SECRET') で値を取得できます
];

Slack App の OAuth & Permissions 設定

Slack App の設定画面に戻り、「OAuth & Permissions > Redirect URLs」にコールバックページの URL (https://example.com/slack/callback) を追加します。 client_secret つづいて「OAuth & Permissions > Scopes > User Token Scopes」で OAuth のスコープを指定します。

Slack では OpenID 用のスコープとして openid, email, profile が指定可能で、今回はメールアドレスでユーザ認証を行うため、 openidemail の2つを指定します。 client_secret これで Slack 側の設定は完了です。続いて Laravel アプリケーションの実装を行っていきます。

エンドポイントの追加

リダイレクト用ルート、コールバック用ルートをそれぞれ追加します。

routes/web.php

<?php
Route::get('/slack/redirect', 'OAuthController@redirect');
Route::get('/slack/callback', 'OAuthController@callback');

Controller の作成・記述

上述した OAuthController を作成します。

php artisan make:controller OAuthController

app/Http/Controllers/OAuthController

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Auth;
use App\User;

class OAuthController extends Controller
{
    // 省略
}

以下、Controller の実装です。

コンストラクタ

OAuth で利用するパラメータをフィールドとして定義しておきます。

public function __construct()
{
    // ログイン用ルートであるため、guest ミドルウェアをかまします
    $this->middleware('guest');

    $this->client_id = config('const.SLACK_CLIENT_ID');
    $this->client_secret = config('const.SLACK_CLIENT_SECRET');
    $this->redirect_uri = 'https://example.com/slack/callback';
}
redirect メソッド

ログイン画面で「Sign in with Slack」ボタンを押下時、このエンドポイントに遷移させ、 Slack の Authorization Endpoint へリダイレクトを行います。セキュリティ上のポイントは次の2点です。

  • statecsrf_token()を利用すること
    • CSRF 対策として、リダイレクトとコールバックが同一のセッションであることを検証するために設定します。
  • nonce は UUID をセッションに格納しておくこと
    • リプレイアタック対策として、セッションと id_token を紐付けるために設定します。
    • Laravel ではセッションヘルパの put() メソッドを利用します。
public function redirect()
{
    $state = csrf_token();
    $nonce = uniqid();
    request()->session()->put('nonce', $nonce);

    $to = "https://slack.com/openid/connect/authorize" .
        "?response_type=code" .
        "&scope=openid,email" .
        "&state={$state}" .
        "&nonce={$nonce}" .
        "&client_id={$this->client_id}" .
        "&redirect_uri={$this->redirect_uri}";

    return redirect($to);
}
callback メソッド

Slack での認証・認可が完了すると、このエンドポイントに戻ってきます。

※解説の都合上、メソッドの切り分けは行っていません。

public function callback()
{
    // state の検証
    if (csrf_token() !== request('state')) {
        abort(401);
    }

    // id_token のリクエスト
    $client = new \GuzzleHttp\Client();
    $res = $client->request('POST', "https://slack.com/api/openid.connect.token", [
        'form_params' => [
            'client_id' => $this->client_id,
            'client_secret' => $this->client_secret,
            'code' => request('code'),
            'redirect_uri' => $this->redirect_uri
        ]
    ]);

    // レスポンスのステータスチェック
    $status = $res->getStatusCode();
    if ($status !== 200) {
        abort(401);
    }
    $contents = json_decode($res->getBody()->getContents());
    if (!$contents->ok) {
        abort(401);
    }

    // JWT の payload の取得
    $id_token = explode('.', $contents->id_token);
    $payload = json_decode(base64_decode($id_token[1]));

    // nonce の検証
    $session_nonce = request()->session()->pull('nonce');
    if ($session_nonce !== $payload->nonce) {
        abort(401);
    }

    // ユーザの取得
    $user = User::where('email', $payload->email)->firstOrFail();

    // ログイン処理
    Auth::login($user);
    request()->session()->regenerate();
    return redirect('/home');
}

以下、要点を解説します。

  • state の検証

    • 前述の通り、リダイレクトとコールバックのセッションが同一であることを確認するため、リクエストに付与された state パラメータとセッションの csrf_token() が同一であることを検証します。
  • id_token のリクエスト

    • パラメータの一時承認コード (code) を利用して、Slack に id_token をリクエストします。
  • JWT の payload の取得

    • Slack からは JWT 形式で id_token が返却されるため、そこから payload を抽出します。
    • ドットを区切り文字として分割し、2つ目の文字列 (= payload) を base64 デコードします。
  • nonce の検証

    • リダイレクト時にセッションに格納しておいた nonce と、JWT の payload に含まれる nonce が一致することを確認します。
    • このとき、リプレイアタックを防ぐ目的からセッションに格納された nonce は削除する必要があります。Laravel では セッションヘルパの pull() メソッドを利用することで、値の取得と削除を同時に行うことができます。
  • ユーザの取得

    • JWT の payload に含まれるメールアドレスを利用してユーザを取得します。
    • サンプルコードでは firstOrFail() を使用していますが、ユーザが取得できなかった場合には未登録である旨をユーザに知らせるなど、適宜ハンドリングを行ってください。
  • ログイン処理

    • 取得できたユーザとしてログインさせたのち、セッションを再生成します。

ログイン画面に「Sign in with Slack」ボタンを配置

最後にボタンを配置して完了です。

Slack 公式でボタンジェネレータも用意されています。※ボタンのリンク先を /slack/redirect に置き換えて配置してください。

https://api.slack.com/authentication/sign-in-with-slack#generator

<a href="/slack/redirect" style="align-items:center;color:#000;background-color:#fff;border:1px solid #ddd;border-radius:4px;display:inline-flex;font-family:Lato, sans-serif;font-size:16px;font-weight:600;height:48px;justify-content:center;text-decoration:none;width:256px"><svg xmlns="http://www.w3.org/2000/svg" style="height:20px;width:20px;margin-right:12px" viewBox="0 0 122.8 122.8"><path d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9zm6.5 0c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z" fill="#e01e5a" /><path d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2zm0 6.5c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z" fill="#36c5f0" /><path d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2zm-6.5 0c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z" fill="#2eb67d" /><path d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9zm0-6.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z" fill="#ecb22e" /></svg>Sign in with Slack</a>

最後に

Slack の OpenID Connect による認証の実装は以上となります。

OAuth や OpenID Connect はソーシャルログインが手軽に実装できる一方で、クライアント側の実装を適切に行わないと脆弱性が顕在化し、セキュリティインシデントの原因となります。OAuth および OpenID Connect の仕様を理解した上で、正しく実装することでセキュアなアプリケーション開発を行いましょう。(自戒を込めて。)

参考