7
4

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.

OAuth2.0のAuthorization Code Grantでstateが必要な理由を理解する 実際の実装もやってみた

Last updated at Posted at 2022-02-22

はじめに

ではAuthorization Code Flowを体感するためにNode.jsのExpressでデモ用のWebアプリを作成して、実際にアクセストークンを取得しAPI実行までをやったみた。

その際には、

※今回はあえてCSRF対策やPKCE対策に必要になるパラメータを省略している。CSRF対策やPKCE対策については今後記事を執筆予定。

と書いたように、推奨されているセキュリティ対策のための実装をしていなかったので、今回はまずCSRF対策のために何をすべきか?について、仮にそれをしなかった場合に何が起きるか?を理解しつつ、実際にCSRF対策の実装をやってみたいと思う。

※本記事中で筆者の理解に誤りがあればご指摘頂けると幸いです。

Authorization Code Flowにおけるstateとは?

stateについては、RFC6749の4.1.1. Authorization Request

RECOMMENDED. An opaque value used by the client to maintain state between the request and callback. The authorization server includes this value when redirecting the user-agent back to the client. The parameter SHOULD be used for reventing cross-site request forgery as described in Section 10.12.(推奨。リクエストとコールバックの間の状態を維持するためにクライアントが使用する不透明な値。認可サーバーは、ユーザーエージェントをクライアントにリダイレクトする際に、この値を含めます。このパラメータは、セクション10.12で述べられているように、クロスサイトリクエストフォージェリを防ぐために使用されるべきである[SHOULD])

と書かれており、クロスサイトリクエストフォージェリ対策に必要な事が分かる。

※RFC6749におけるクロスサイトリクエストフォージェリに関する記述は10.12. Cross-Site Request Forgeryにある。

Cross-Site Request Forgery(CSRF)とは?

ここではまず、CSRFとは何なのか?について理解をために詳細を見ていく事にする。

一般的な文脈での意味

一般的には、CSRFとはWebアプリの脆弱性を利用した攻撃手法の1つ。具体的には攻撃者のサイトからの本来拒否すべきリクエストを受信し処理してしまう事でユーザが意図していない処理が実行されてしまう事。

攻撃の流れとしては、攻撃者が用意したサイトにアクセスさせて、その攻撃者のサイトから本来アクセスしたかったサーバへ違うリクエストを送る事で行われる。

攻撃をされると、

  • 誘導されたユーザ
    ログイン後の利用者のみが利用可能なサービスを攻撃者に悪用される(不正送金、不正商品購入、各種設定の変更など)
  • 攻撃を受けるサーバ・Webアプリ
    Dos攻撃によるサーバダウン

といった被害が出てしまう。

※CSRF攻撃の具体例としては、例えばCookieにセッションIDが保存されているようなWebアプリで、そのCookieを使ってユーザになりすましてリクエストを送る事で、認証済みのユーザのみしか操作できないような操作を攻撃者が実行するようなもの(詳細は以下を参照)。

OAuth2.0の文脈での意味

OAuth2.0でいうCSRF攻撃では、標的となったユーザに対して、攻撃者のリソースへアクセスできるアクセストークンを渡す(攻撃者のリソースへのアクセスの認可を与える)事が攻撃の内容になる。最初はなんでこれで攻撃になるのか?と思ったが、以下で見ていくように、確かに攻撃者のリソースにアクセスする事は問題になる。

まず、攻撃されたときにどうなるのか?を整理する上での登場するものを整理する。登場するものとしては、

  • ストレージサービス
    クラウドストレージ(Google Driveのようなもの)
  • Webアプリケーション
    クラウドストレージのサービスと連携している文書・図表作成ができるアプリ(クラウドストレージと連携する便利機能のおかげでユーザは自分のクラウドストレージ上に随時ファイルを保存できるイメージ)
    連携する時には、OAuth2.0の認可フローでアクセストークンを払い受け、クラウドストレージにアクセスする事で連携するものとする

このような状況下で、適切にWebアプリケーションが利用されている時の状態としては以下のような感じ。ユーザはWebアプリにクラウドストレージにアクセスする権限(認可)を与えて、文書・図表作成アプリはその認可で得たアクセストークンでユーザに代わりクラウドストレージにファイルを保存するという流れになる。
image.png

続けてこの構図の中でCSRF攻撃をされるとどうなるか?を見てみる。以下の図の通り、ユーザAは自身のクラウドストレージにデータを保存しているつもりが、攻撃者のクラウドストレージにファイルが保存されてしまう(ユーザAが文書・図表作成アプリを操作している時に、文書・図表作成アプリ側使うアクセストークンが悪意のあるユーザのものになってしまている状態)。これがOAuth2.0でのCSRF攻撃を受けた時の問題。
image.png

攻撃の実際の手順としては、以下の図のような流れで行わる。
image.png

上記の図の中で攻撃が成功してしまう原因は、攻撃者が認可フローを進めてアクセストークンを発行するための一時codeを取得した処理と、実際にアクセストークンを発行してもらうためのリクエストを行っている処理とが同一処理(セッション)で行われていないが、それ文書・図表作成アプリが許してしまっている事だと分かる。
そのため、この攻撃を防ぐには、認可リクエストとトークンエンドポイントへのリクエスト(アクセストークンを発行してもらうためのリクエスト)が同一セッションである事が担保すればいいという事も分かる。

実際の実装としてどうなるか?は次の章で見ていく。

CSRF対策のためのstateをAuthorization Code Flowに組み込む

実装としてはシンプルで以下のようにstateを認可リクエストに追加するだけでいい。

src/server.js
// 省略
import { generators } from 'openid-client';
// 省略

app.get('/begin', async (req, res) => {
	const { session } = req;

	// 省略

	const state = generators.state(); // <- stateを生成
	session.state = state; // <- セッションにstateを保存

	const params = {
		// 省略
		state
	};

	res.redirect(
		`${openidConfig.authorization_endpoint}?${qs.stringify(params)}`
	);
});

app.get('/oauth2/callback', async (req, res) => {
	const {
		session,
		query: { code, state }
	} = req;

	if (state !== session.state) throw new Error('Invalid state.'); // <- 認可リクエスト時のstate=リダイレクト時のstateか?を検証

	try {
		// 省略
	} catch (error) {
		res.end(error.message);
	}
});

// 省略

ソースコード全体は以下。

上記のように実装すると、以下のように認可リクエスト時にstate(state=IGPU7PRpmozn91cRGccVY61s08rPMDywowH6xlXuGdw)がクエリパラメータに追加され、それがリダイレクト時のクエリパラメータにも設定されるようになった事が分かる。

認可リクエスト時のURL リダイレクト時のURL
https://accounts.google.com/o/oauth2/v2/auth?client_id=*****&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar&redirect_uri=https%3A%2F%2Fexample.com%3A8080%2Foauth2%2Fcallback&state=IGPU7PRpmozn91cRGccVY61s08rPMDywowH6xlXuGdw https://example.com:8080/oauth2/callback?state=IGPU7PRpmozn91cRGccVY61s08rPMDywowH6xlXuGdw&code=4%2F0AX4XfWiDSzq6Wqz6JraJ5Ts36l-TfM0BmtVkUiVZ3VJPNZSRVO1U67d8Flv62dGDruWTDg&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar

このstate値が一致している=同一セッション(認可フロー)であるという事が確認でき、CSRF攻撃を防ぐことができる。

※注意として、上記の実装ではopenid-clientを一部利用しているにも関わらず自身でdiscoveryエンドポイントにアクセスしたり、リダイレクト後の/oauth2/callbackでstateの検証をしたりしているが、openid-clientの公式に書かれているような実装方法もある。

その上で上記の実装について一部補足する。

const state = generators.state();

openid-clientという便利なライブラリがあるのでそれを利用してstateの値を生成している。

session.state = state;

ユーザによる認証・認可後のリダイレクト時に、認可リクエスト時のstateと同じstateの値か?を検証しなければならないので、セッションにstateの値を保存し、リダイレクト時にセッションから保存していたstateを取り出せるようにする。

セッション自体はCookieにセッションIDを持たせているので、そのセッションIDに基づきセッションストアから保存してあったセッションの内容(state)を取り出せる。

※Expressにおけるセッションの実現方法はNode.js Expressでのセッションの実装を参照。

まとめ

Authorization Code Grant(Flow)でstateが必要な理由について理解を深める事ができた。次はPKCE対策についてその対策が必要な理由と対策方法について理解を深めてみたいと思う。

おまけ ~express-sessionを利用したセッションを導入する~

前回(以下の記事)では、アクセストークンを発行後、AccessTokenというインスタンスにアクセストークンを保存する実装をしていた。今回これをセッションを利用した実装に変える事で、stateもセッションに保存できるように実装した。以下で実際にどのようにセッションを導入したのか?についてみていく。

元々の実装は以下。

src/lib/access-token
export default class AccessToken {
	constructor() {
		this.token = null;
	}

	setToken(token) {
		this.token = token;
	}

	getToken() {
		return this.token;
	}
}
src/server.js
// 省略
import AccessToken from './lib/access-token';
// 省略
app.locals.AccessToken = new AccessToken();
// 省略
app.get('/oauth2/callback', async (req, res) => {
	const { AccessToken } = req.app.locals;

	try {
		// 省略
		const { data } = await axios.post(openidConfig.token_endpoint, params);
		const camelCaseData = camelcaseKeys(data);
		AccessToken.setToken(camelCaseData.accessToken); // <- ここでtoken(アクセストークン)を保存
		// 省略
});

app.get('/calendarList', async (req, res) => {
	const { AccessToken } = req.app.locals;

	try {
		const { data } = await axios.get(
			'https://www.googleapis.com/calendar/v3/users/me/calendarList',
			{
				headers: {
					Authorization: `Bearer ${AccessToken.getToken()}` // <- ここでtoken(アクセストークン)を利用
				}
			}
		);
		// 省略
});
// 省略

変更後(express-sessionによるセッション導入後)の実装としては以下。

src/server.js
// 省略
import expressSession from 'express-session';
import connectRedis from 'connect-redis';
import Redis from 'ioredis';
// 省略
const redis = new Redis();
const RedisStore = connectRedis(expressSession);
const store = new RedisStore({ client: redis });

app.use(
	expressSession({
		...config.get('redis.session'),
		secret: process.env.COOKIE_SECRET,
		store
	})
);
// 省略
app.get('/oauth2/callback', async (req, res) => {
	const {
		session,
		query: { code }
	} = req;

	try {
		// 省略
		const { data } = await axios.post(openidConfig.token_endpoint, params);
		const camelCaseData = camelcaseKeys(data);

		const regenerate = (oldSession) => {
			return new Promise((resolve, reject) => {
				oldSession.regenerate((err) => { // <- ここでセッションを再度作成
					if (err) throw reject(err);
					const { session: newSession } = req;
					newSession.accessToken = camelCaseData.accessToken; // <- 再作成したセッションにアクセストークンを保存
					resolve(newSession);
				});
			});
		};
		await regenerate(session);
		// 省略
});

app.get('/calendarList', async (req, res) => {
	const { session } = req;

	try {
		const { data } = await axios.get(
			'https://www.googleapis.com/calendar/v3/users/me/calendarList',
			{
				headers: {
					Authorization: `Bearer ${session.accessToken}` // <- ここでセッションに保存済みのアクセストークンを取り出し利用
				}
			}
		);
		// 省略
});
// 省略
config/default.json
{
	// 省略
	"redis": {
		"session": {
			"name": "op.sid",
			"resave": false,
			"saveUninitialized": true,
			"cookie": {
				"sameSite": "lax"
			}
		}
	}
}

ソースコード全体は以下。

※補足として、セッションハイジャックを防止の観点から、認可フローを開始しアクセストークンを取得するまでのセッションと、アクセストークン取得後のAPI実行時のセッションは違うものにしている。具体的には以下の実装の部分がそれ(セッションハイジャックについては安全なウェブサイトの作り方 - 1.4 セッション管理の不備などを参照)。

const regenerate = (oldSession) => {
		return new Promise((resolve, reject) => {
			oldSession.regenerate((err) => { // <- ここでセッションを再度作成
				if (err) throw reject(err);
				const { session: newSession } = req;
				newSession.accessToken = camelCaseData.accessToken; // <- 再作成したセッションにアクセストークンを保存
				resolve(newSession);
			});
		});
};
7
4
0

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
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?