5
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Laravel PassportのPKCE OAuthをやる

Last updated at Posted at 2020-08-02

Laravel Passportを使ったPKCEを使った認可コード引き換えをするOAuthをやってみよう!
ノリで先行してやってしまって、あとから備忘録的に書いているので間違っている箇所があるかもしれません。

使ったもの

Laravel 7.22.2
Docker Desktop for Windows (on WSL2)

またフロントエンドにはNuxtをつかいました。
ユースケースとしてはLaravelをWeb APIサーバに、NuxtをWebクライアントに、という形ですが応用は効くかと思います。

Laravelの準備

インストールや環境構築は飛ばします。
認可・認証機能でLaravel Mixを使うのでnpmコマンドを使えるように仕込む必要があります。

認証機能を準備する

認証 7.x Laravelに従ってコマンドをポチポチしたりします。

ルート定義

composer require laravel\ui
php artisan ui vue --auth

2行目のコマンドをタイプすると「npm installnpm run devよろしく~」みたいなメッセージがでるのでやっておきましょう。

npm install
npm run dev

Passportの準備

Laravel PassportはLaravel公式パッケージのひとつでOAuth2.0を使った認可とAPIの認証がLaravelで手早く行えるようになるものです。

インストール

composer require laravel/passport

インストールするとマイグレーションされるテーブルが増えるのでphp artisan migrateします。
その後、認可に使われたりする暗号キーをつくるためにphp artisan passport:installをします。

php artisan migrate
php artisan passport:install
プロダクション環境にデプロイする時には...
開発環境でPassportを使いつつ開発をして、本番環境でデプロイした際、暗号キーの生成だけ必要なことがあると思います。

暗号キーは.gitignoreでバージョニングされないよう指定されているし、外部へ公開されるべきでなく、プロダクション環境ではプロダクション環境用の暗号キーが必要になるでしょう。

その場合は以下のコマンドをタイプすることで暗号キーの生成のみを行うことができます。

php artisan passport:keys

UserAuthServiceProviderconfig/auth.phpの書き換え

UserモデルでHasApiTokensトレイトを使うように追記してやります。

<?php

namespace App;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Passport\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;
}

そしてAuthServiceProviderbootメソッドにPassportの機能がルーティングされるように処理を追加します。

<?php

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Laravel\Passport\Passport;

class AuthServiceProvider extends ServiceProvider
{
    /**
     * アプリケーションのポリシーのマップ
     *
     * @var array
     */
    protected $policies = [
        'App\Model' => 'App\Policies\ModelPolicy',
    ];

    /**
     * 全認証/認可サービスの登録
     *
     * @return void
     */
    public function boot()
    {
        $this->registerPolicies();

        Passport::routes();
    }

最後にconfig/auth.phpを書き換えて、API認証にPassportが使われるように仕向けます。

'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver' => 'passport', // <- これ
        'provider' => 'users',
    ],
],

Passportクライアントをつくる

artisanでつくります。
この時--publicオプションを設定します。
Laravelはそもそも普通のWebアプリケーションを構築するフレームワークであるため、Passport自体もWebアプリケーション間で認可と認証をし合うように設計されているものです。

認可のやりとりにclient_secretを利用することになるのですが、Web APIを利用するクライアントにclient_secretを配信してしまってはマズいため、--publicオプションを付け、client_secretを発行しないようにします。
これにより、PKCE (Proof Key for Code Exchange by OAuth Public Clients) 従った方法でしかPassportによる認可を受けられなくしているわけです。

php artisan passport:client --public

このコマンドを実行すると対話形式でセットアップがすすみます。
どのユーザに関連づけるかクライアントの名前は何か、認証後のリダイレクト先はどこかと聞かれるので、適切な値を入れましょう。
認証後のリダイレクト先は後程作ります。クライアントの然るべきページのURIを設定してください。

クライアントの名前は後で晒されますので、あんまり変な名前を付けるとサービス利用者に笑われちゃいます。

image.png
※晒し例

クライアントが作成されるとClient IDClient Secretが出ますが、Publicクライアントの場合Client Secretが空欄になっています。
Client IDは後々使うので控えておきましょう!
忘れちゃった場合はもう一度Passportクライアントを作り直すか、phpmyadminなどのツールでデータベースに格納されている値を見ることで確認ができます。

CORSの設定

Laravelサーバとクライアント開発機内だったとしても、ポートが異なる場合はオリジンをまたぐことになるのでCORSを設定する必要があります。

LaravelのCORSについてはあまり深く理解できていないので、説明はこのリンク先にお任せします...
CORS を許可する - Larapet

Laravelのconfig/cors.phppathsの値を変えます。
デフォルトではapi/*のみが設定されており、APIルート全体でオリジンをまたぐ通信を許すようになっています。
これにoauth/tokenを追加してください。

クライアントでは認可サーバでのユーザ認証後、クライアントにリダイレクトされ、トークンとの引き換え用コードが渡されます。
この引き換えコードとトークンを引き換えるときにオリジンをまたぐリクエストを送信することになるので、このレスポンスを読むためにこの設定が必要です。


ここまで来たらLaravelの設定は終わりです。

クライアントの準備

Nuxtを使ったのでNuxtで説明していきます。

パスワード・メールアドレスの入力フォームはLaravel側のものを使います。
入力フォームのあるページへクライアントからリダイレクト、Laravelで認可を受けた後にリダイレクトでクライエントへ返される、という流れです。

ページをつくる

認可・認証をするのに最低限2つのページが必要です。
1つ目はページを認可サーバのフォームへ置き換えるもの、2つ目は認可後に認可コードとトークンの引き換えを行うものです。
例として前者をlogin.vue、後者をauthenticated.vueとしてpages/ディレクトリ配下に追加します。

login.vue

<template>

</template>

<script lang="ts">
import {Component, Vue} from "nuxt-property-decorator";

@Component
export default class login extends Vue {
  async mounted() {
    const state = createRandomString(40);
    const verifier = createRandomString(128);
    const challenge = await AuthCode.makeCodeChallenge(verifier);

    sessionStorage.setItem('state', state);
    sessionStorage.setItem('verifier', verifier);

    location.replace(`http://localhost:8080/oauth/authorize?${this.transformQueryString({
      'client_id': '2',
      'redirect_uri': 'http://localhost:3000/authenticated',
      'response_type': 'code',
      'scope': '',
      'state': state,
      'code_challenge': challenge,
      'code_challenge_method': 'S256'
    })}`);
  }

  transformQueryString(obj: any) {
    let str = '';
    for (const it in obj) {
      str += `${encodeURIComponent(it)}=${encodeURIComponent(obj[it])}&`;
    }

    return str.slice(0, -1);
  }

  hash(message: string){
    const encoder = new TextEncoder();
    const data = encoder.encode(message);
    return crypto.subtle.digest('SHA-256', data);
  }

  async makeCodeChallenge(verifier: string) {
    const encoded = Buffer.from(new Uint8Array(verifier).buffer).toString("base64"));
    return encoded
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '')
  }

  createRandomString(num: number){
    return [...Array(num)].map(() => Math.random().toString(36)[2]).join('');
  }
}
</script>

<style scoped>

</style>

statecode_verifierを作り、後程ページ内で読める場所に保管します。
これを使う機会は認可後のリダイレクトを受けた時だけなのでsessionStorageが最適かと思います。

stateはランダムな文字列を指定します。Passportの例が40文字だったのでそれに倣って40文字の適当な文字列を入れました。
code_verifierRFC 7636仕様で決められているのだそう。43文字から128文字の文字、数字、それと4種の記号を含んだランダムな文字列である必要があるそうです。
が、Passportの例ではStr::random(128)を使っていたので、少々いい加減ですがjsで似たような感じにしました。

code_challengecode_verifierのハッシュ値を作り、これをBase64エンコードしたものになります。

Passportの例はこんな感じです。

$encoded = base64_encode(hash('sha256', $code_verifier, true));
$codeChallenge = strtr(rtrim($encoded, '='), '+/', '-_');

認可後のコード引き換え時にcode_verifierをサーバ側でこの処理をしたものとクライアントが生成したcode_challengeが比較されるのでこれと同じ動きをするものをクライアント側で書かなくてはなりません。

jsだとこんな感じです。
SHA256ハッシュ値生成を文字列にせずArrayBufferを保つのがポイントです。

// ハッシュ値をつくる
const encoder = new TextEncoder();
const data = encoder.encode(message);
const hashed = await crypto.subtle.digest('SHA-256', data); // <- DOMが持つ関数ですが、Promiseが返ります

// base64エンコード
const encoded = btoa(String.fromCharCode(...new Uint8Array(await this.hash(verifier))));
const codeChallenge = encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');

これで下ごしらえは完了です。

location.replace()でページを置き換えます。
location.replace()を使うことで置き換え後のページはブラウザの履歴に残らず、このページで生成するいろいろな値が用意されない状態で認可サーバへ飛んでしまうことを防ぎます。

クエリパラメータとして以下のものが必要です。

{
  'client_id': '1',  // <- Passportクライアントを作ったときに表示されたもの
  'redirect_uri': 'http://localhost:3000/authenticated', // <- Passportクライアントを作ったときに指定したもの
  'response_type': 'code',
  'scope': '', // <- 空だよ Laravelの設定で認証ユーザの権限が表現できるみたい
  'state': '...', // <- 先ほど作ったランダムな文字列
  'code_challenge': '...', // <- 先ほど作ったハッシュ化とかエンコードとかしたやつ
  'code_challenge_method': 'S256'
}

client_idはPassportクライアント作成後に表示された数字を指定してあげます。
redirect_uriはPassportクライアントをコマンドで作ったときに入力したURIを入力します。
これが一致しないと認可されません。

リダイレクトして処理を続けることで認可に必要だけど漏れてしまったらマズい情報を漏らさずに認可ができる、というわけです。

authenticated.vue

<template>
  <div>
    引き換えコードをもらった
    <p v-if="!!token">
      {{ token }}
    </p>
  </div>
</template>

<script lang="ts">

import {Component, Vue} from "nuxt-property-decorator";
import axios from 'axios';

@Component
export default class authenticated extends Vue {
  token: string | null = null;

  mounted() {
    const state = sessionStorage.getItem('state');
    const verifier = sessionStorage.getItem('verifier');
    const code = this.$route.query.code;

    if (state != this.$route.query.state) {
      console.error('ステートが違う');
      return;
    }

    axios.post('http://localhost:8080/oauth/token', {
      grant_type: 'authorization_code',
      client_id: 2,
      redirect_uri: 'http://localhost:3000/authenticated',
      code_verifier: verifier,
      code: code
    }).then(({data}) => console.log(data))
  }
}
</script>

<style scoped>

</style>

login.vueでsessionStorageへ保存しておいたstatecode_verifierを取り出します。
そして、リダイレクトURIのクエリパラメータに認可コードが含まれてますのでcodeパラメータを参照し、取り出します。

次に、認可コード引き換えの規約としてクライアント側でstateを検査する必要があるとのことなので、クエリパラメータのstateとsessionStorageから取り出したstateが一致するか検証します。

そして、認可処理の大詰め、認可コードとトークンの引き換えを行います。
Laravelサーバのoauth/tokenへPOSTリクエストを送信します。

必要なパラメータは以下の通り。

{
  grant_type: 'authorization_code,
  client_id: 1, // <- Passportクライアントを作ったときに表示されたもの
  redirect_uri: 'http://localhost:3000/authenticated', // <- Passportクライアントを作ったときに指定したもの
  code_verifier: verifier, // <- sessionStorageから取り出したverifier
  code: code // <- クエリパラメータから取り出したcode
}

リクエストをサーバに送信するとCORSになるので、まずプリフライトがされ、航路が安全か確認されます。
そのあと、トークンのリクエスト本体が飛んで、トークンがレスポンスされます。

ここでCORSに引っかかったり、code_verifierがなんのといわれることがありますが、エラーレスポンスがしっかりしていて、原因の断定がしやすいかと思うので、頑張って解決しましょう!

うごかす

クライアントのlogin.vueがマッピングされてるところにアクセスしてみます。
そしたらLaravelのログインページへリダイレクトされたような感じになって、ログインするとOAuthらしさのある「アクセスしちゃう?する?」みたいな画面になって、「Authorize」をクリックするとクライアントに戻ってきて、トークンが獲得できると思います。


以上Laravel Passportを使ったPKCEを使った認可コード引き換えをするOAuthでした!!
APIの認可はこうすべきだったのか...!

見たもの

CORS を許可する - Larapet
PHP: hash - Manual
SubtleCrypto.digest() - Web API | MDN
JavaScriptでバイナリを扱いたい - Qiita
sessionStorageをつかってみる - Qiita

5
8
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?