こんにちは、開発本部でインターンをしている田中 湧大です。
今回は認証機能をPR TIMES上で実装し、企業・事業主ユーザーとメディアユーザーでログインするときにJWTを発行するようにしたのでその紹介をします。
JWT(JSON Web Token)とは
JSON Web Tokenは以下のようなドットで結合された文字列です。${HEADER}.${PAYLOAD}.${SIGNATURE}で構成されており、それぞれbase64エンコードされています。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRhbmFrYSIsInVybCI6InBydGltZXMuanAifQ.
NVG3MT6djcCjv1Q39Q-QfdZafDA45YYzDiVeknO4KjM
JWTはログインシステム、認証、認可などの様々な用途で用いられます。
なぜJWTを発行する必要があったか
理由についてはいくつかありますが、まず従来のシステムでは認証プロバイダーという別ドメイン・別システムが認証サーバーとして提供されており、PR TIMESとSTORY、Webクリッピングは認証プロバイダーから認可を受けた後それぞれでセッションを発行しています。その影響でログイン状態が共有されずバグなどが発生している状況でした。
またセッションが共有されていないため、例えばPR TIMESのフロントエンドからWebクリッピングのAPIを直接呼び出すことができませんでした(Webクリッピングのセッションが発行されている保証がない)。
そこでPR TIMES上で認証機能を実装した上で、ログイン時にJWTとRefresh Tokenを発行・Cookie上に保存し、それ以外のサービスがCookie上のJWTの信頼性を検証することでこれらの課題の解決を図りました。
Cookie上に保存した理由としては以下の2点です。
- セキュリティ面
- フロントエンドの実装を変更する必要がない
セキュリティ面についてですが、localStorageではXSSの脆弱性があったときJWTを盗むことができてしまいます。
CookieであればHttpOnly属性をつけることで直接奪えなくなります。Refresh Tokenはこれに加え、SameSite属性にStrictとPath属性をつけてできる限りの対策をしています。
従ってJWT導入後の構成は以下のようになります。PR TIMESはJWT発行のための秘密鍵を持っており、他のサービスは対応する公開鍵を所持しています。
JWTを導入するための準備として行ったこと
認証プロバイダーのパスワードカラムをPR TIMESのDBにマイグレートする
企業ユーザーのパスワードはPR TIMESのPostgreSQLではなく認証プロバイダー側のMySQLに保存されていました。これをPR TIMESのPostgreSQLにマイグレートする作業を行いました。
詳しくは以下の記事をご覧ください。
JWTの仕様を定める
JWTを発行するにあたって署名に用いるアルゴリズム、ペイロードの形式を定義しました。
アルゴリズムはEdDSAを採用しました。
詳しくは以下の記事をご覧ください。
今回はJWTを所有しているユーザーが一意に定まれば良く、他の情報については不要なため以下のようなシンプルなペイロードとしました。
{
"sub": "${ユーザー種別}::${ID}",
"exp": 99999999
}
PR TIMESにJWTを発行する処理を実装する
ここではJWTを発行し、セッションとして参照するまでを紹介します。
既存システムでログインされたときにJWTを発行する
いきなりJWTでのセッション管理に切り替えると全クライアントが強制ログアウトしてしまうため、これは避けたいです。そのためまず既存システムでログインされたときにJWTを発行するように変更します。
具体的には認証プロバイダーでログイン後、prtimes.jp/auth/callback
にリダイレクトされたときにJWTと更新に利用するRefresh Tokenを発行します。
Refresh TokenからJWTを更新するAPIを実装する
JWTの有効期限は短い時間を設定することが推奨されています。
しかしセッションに用いるという側面から数分ごと再認証が発生してしまうとUXの観点からよくありません。かといって有効期限を長くすることはセキュリティ的に問題があります。
そこでJWTの有効期限はある程度短くしつつ、Refresh Tokenを用いて定期的にJWTを更新するAPIをクライアントから定期的に叩くようにしました。
このAPIではRefresh Token自体の更新も有効期限に応じて行っています。
上記の章でも書いた通り、Refresh TokenはCookieのPath属性を利用して必要なリクエストパス以外には極力送信しないようにしています。
またRefresh Tokenはセッションに保存しているのでCookie上のRefresh Tokenと比較することでJWTの更新可否を判断しています。
APIはリクエストを受けて、Cookie上のRefresh Tokenがセッションに保存されたRefresh Tokenと一致するかとセッション上のRefresh Tokenが有効期限内かどうかを見てJWTを再発行するか削除するかを行います。
クライアントからAPIを叩くJavaScriptの実装は非常にシンプルで主な処理は以下だけです。
const update_token = () => {
fetch('/auth/token/refresh', {method: 'POST'}).then();
}
// requestIdleCallbackはSafari非対応のため
if (typeof requestIdleCallback === 'undefined') {
setTimeout(update_token, 1000);
} else {
requestIdleCallback(update_token, {
timeout: 1000
});
}
// time interval: 10 minute
setInterval(update_token, 600000);
この処理によって極力メインの処理や描画をブロックしたくなかったため requestIdleCallback
を使ってブラウザがアイドル状態の時に実行されるようにしました。
ただしこの関数は実験的な機能のためSafariなど一部のブラウザに互換性がありません。
対応していないブラウザだった場合は setTimeout
を使うようにしています。
requestIdleCallback – Web API | MDN
上記のJavaScriptを各ページで読み込みます。HTMLパースの中断を防ぐためにdefer属性を付与しています。
<script type="text/javascript" src="update_prtimes_jwt.js?v=xxx" defer></script>
今後、ページが追加された場合でも上記タグを一行埋め込むだけで良いので非常にシンプルです。
Webクリッピング、STORYがJWTを参照するように変更する
各サービスは独自にセッションを発行せず、JWTがあればログインしていると見なします。
この変更によりUX上大きく変わったことは各サービスのページに遷移したとき再認証の必要がなくなったことだと思います。
従来のシステムではトップページから企業ユーザーにログインし、Webクリッピングのページに飛んだときログインページにリダイレクトされ再認証する必要がありました。
現在ではその必要もなくなりスムーズにページ遷移できています。
ログインページのURLを変更する
auth.prtimes.jp/login
からprtimes.jp/auth/login
にログインページを変更します。
auth.prtimes.jp/login
はLaravelのBladeでレンダリングされており、それをPR TIMES側で使用しているテンプレートエンジンのSmartyに移行します。
単一ページで機能としてもシンプルなものしかないのでそこまで苦労せずに移行することができました。
リリースにはフィーチャートグルを使ってデプロイすることでリリース前の本番環境でも確認できるようにしました。
これによって実装者が気が付かなかった問題があった場合でも他の社内メンバーが発見したり、リリース前に気がつける可能性が高くなります。
またWebクリッピング、STORYの変更前にリリースし事前に検証できたこともGoodポイントでした。
フィーチャートグルについて
認証プロバイダーで発行されていた独自IDをPR TIMES側で発行するようにする
企業ユーザーには company_user_id
の他に認証プロバイダーでユーザー作成時に発行され、管理されている auth_user_id
という2つのIDが存在します。auth_user_id
はWebクリッピングなどの他のサービスでも使われていることもあり、認証プロバイダーが廃止されても切り離せない要素でした。
そこでPR TIMES側で発行されるように切り替えます。具体的にはPostgreSQLで auth_user_id
に対してシーケンスオブジェクトを作成しました。初期値には余裕を持った値を設定します。
SELECT setval('m_company_user_auth_user_id_seq', (SELECT max(auth_user_id) FROM m_company_user) + XXXX);
ALTER TABLE m_company_user ALTER auth_user_id SET DEFAULT nextval('m_company_user_auth_user_id_seq');
ALTER SEQUENCE m_company_user_auth_user_id_seq OWNED BY m_company_user.auth_user_id;
シーケンスオブジェクトを作成したあとは認証プロバイダーから返ってきた auth_user_id
をインサートせず、PostgreSQLに発行を任せます。
これにより認証プロバイダーに依存しているデータは完全になくなり、廃止することができます。
まとめ
このタスクではJWTの設計から実装、移行まで幅広く携わらせて頂きました。
自分にとってこのタスクは非常に挑戦的なタスクでした。先輩エンジニアのサポートもありつつ今回のタスクをやり切ることができ、エンジニアとして成長できたのではないかと感じています。
これからも様々な課題に挑戦しつつ、PR TIMESを前に進めるような開発をしていきたいと思いました。