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 install
とnpm 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
プロダクション環境にデプロイする時には...
暗号キーは.gitignore
でバージョニングされないよう指定されているし、外部へ公開されるべきでなく、プロダクション環境ではプロダクション環境用の暗号キーが必要になるでしょう。
その場合は以下のコマンドをタイプすることで暗号キーの生成のみを行うことができます。
php artisan passport:keys
User
、AuthServiceProvider
、config/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;
}
そしてAuthServiceProvider
のboot
メソッドに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を設定してください。
クライアントの名前は後で晒されますので、あんまり変な名前を付けるとサービス利用者に笑われちゃいます。
クライアントが作成されるとClient ID
とClient Secret
が出ますが、Publicクライアントの場合Client Secret
が空欄になっています。
Client ID
は後々使うので控えておきましょう!
忘れちゃった場合はもう一度Passportクライアントを作り直すか、phpmyadminなどのツールでデータベースに格納されている値を見ることで確認ができます。
CORSの設定
Laravelサーバとクライアント開発機内だったとしても、ポートが異なる場合はオリジンをまたぐことになるのでCORSを設定する必要があります。
LaravelのCORSについてはあまり深く理解できていないので、説明はこのリンク先にお任せします...
CORS を許可する - Larapet
Laravelのconfig/cors.php
のpaths
の値を変えます。
デフォルトでは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>
state
とcode_verifier
を作り、後程ページ内で読める場所に保管します。
これを使う機会は認可後のリダイレクトを受けた時だけなのでsessionStorageが最適かと思います。
state
はランダムな文字列を指定します。Passportの例が40文字だったのでそれに倣って40文字の適当な文字列を入れました。
code_verifier
はRFC 7636仕様で決められているのだそう。43文字から128文字の文字、数字、それと4種の記号を含んだランダムな文字列である必要があるそうです。
が、Passportの例ではStr::random(128)
を使っていたので、少々いい加減ですがjsで似たような感じにしました。
code_challenge
はcode_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へ保存しておいたstate
とcode_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