仕事でOAuthについて理解する必要があり、その勉強の過程で
「OAuth徹底入門」を読みました。
その備忘録としてサンプル実行の過程を残しておきます。※問題があるようでしたら記事は削除します。
著者は、Justin Richerさん、Antonio Sansoさん。翻訳者は須田智之さん。
サンプルは以下の場所にあります。
https://github.com/oauthinaction/oauth-in-action-code/
基本として、Node.jsを利用します。私はv8.11.3の環境で実行しました。
#Chapter3 シンプルなOAuthクライアントの構築
##3-1 認可サーバーへのOAuthクライアントの登録
サンプルファイル
\oauth-in-action-code\exercises\ch-3-ex-1
まずはコマンドプロンプトを立ち上げて、対象のディレクトリに移動します。
cd C:\Users\任意のディレクトリ\oauth-in-action-code\exercises\ch-3-ex-1
必要なモジュールを読み込むためにnpm intallします。
npm install
これで、準備は完了です。
認可サーバーを立ち上げます。
node authorizationServer.js
実行させると OAuth Authorization Server is listening at http://127.0.0.1:9001 とと返ってきました。ブラウザを開き、http://127.0.0.1:9001 にアクセスします。
画面に表示された情報は、以下のものです
-
client_id: oauth-client-1
- この演習の認可サーバではすでにクライアントのclient_idに対して「oauth-client-1」を割り当てています。今度は、このclient_idの値をクライアントアプリケーションに伝える必要があります。
-
client_secret: oauth-client-secret-1
- このクライアントは、認可サーバーとのやり取りをする際に、クライアント自身を認証するために使うclient_secretと呼ばれる共有シークレットを持っています。認可サーバーのトークンエンドポイントにいくつかの方法で渡せるようになっています。
- client_secretは、ほぼすべての場合において認可サーバーによって割り振られるようになっています。
- client_secret: oauth-client-secret-1 という値は、あくまで例として示していますが最悪のシークレットです。最低限の乱雑さもなければ、この「OAuth徹底入門」やQiitaに書いた時点で、もはや秘密でもなんでもなくなってしまっています。
-
redirect_uri: http://localhost:9000/callback
- client_idやclient_secret以外の項目は、クライアントアプリケーションによって決定されるものであり、認可サーバーが割り当てるものではありません。
一方で、クライアントはどの認可サーバーに対して、どのようなやり取りをするのかを知っていなければなりません。
クライアントは、以下のエンドポイントの場所を知っている必要が有ります。ただし、それ以上のことは実際に知る必要はありません。
- authorization_endpoint: http://localhost:9001/authorize
- token_endpoint: http://localhost:9001/token
##3.2 認可コードによる付与方式を使ったトークンの取得
OAuthクライアントが認可サーバーからトークンを取得するためには、リソース所有者によって権限を何らかの方法で移譲されなければなりません。この章では**認可コードによる付与方式(Authorization Code Grant Type)**と呼ばれる方法を利用します。
###3-2-1 認可リクエストの送信
クライアントアプリケーションを立ち上げます。
コマンドプロンプトをもう一つ立ち上げて、先ほどと同じように、client.jsを実行してクライアントを立ち上げます。
cd C:\Users\任意のディレクトリ\oauth-in-action-code\exercises\ch-3-ex-1
そして
node client.js
OAuth Client is listening at http://127.0.0.1:9000 と返ってきました。
ブラウザを開き、http://127.0.0.1:9000 にアクセスします。
クライアントアプリケーションのホームページにはユーザーをauthorization_endpointであるhttp://localhost:9001/authorizeに送るボタン「Get OAuth Token」と、保護対象リソースを呼び出すボタン「Get Protected Resource」の2つのボタンがあります。
####Get OAuth Tokenボタン
client.jsを開いて、中身を確認していきます。
このページはget関数から呼び出されるようになっています。
が、、初期状態では何も記載されていません。ボタンを押しても何も起きず、以下の処理が走ります。
app.get('/authorize', function(req, res){
/*
* Send the user to the authorization server
*/
});
認可のプロセスを開始するには、ユーザーをサーバーの認可エンドポイントに転送し、そのURLには適切なクエリパラメータが全て含まれるようにしなければなりません。そこで以下の関数を準備します。(※すでにclient.js内に記載されています。)
この関数は、引数に以下のものを渡します
・base : 対象となるURL
・options : クエリパラメータとして追加しようとしているすべてのパラメータが含まれたオブジェクト
var buildUrl = function(base, options, hash) {
var newUrl = url.parse(base, true);
delete newUrl.search;
if (!newUrl.query) {
newUrl.query = {};
}
__.each(options, function(value, key, list) {
newUrl.query[key] = value;
});
if (hash) {
newUrl.hash = hash;
}
return url.format(newUrl);
};
この関数を利用して、URLを作る準備を行います。
まずは、client.js上部に存在している変数clientに対して client_idにoauth-client-1をセットします。
また、後々のためにclient_secretにもoauth-client-secret-1をセットします。
/*
* Add the client information in here
*/
var client = {
"client_id": "oauth-client-1",
"client_secret": "oauth-client-secret-1",
"redirect_uris": ["http://localhost:9000/callback"]
};
次に、authorizeのget内で、buildUrlを呼び出してURLを形成したのち、リダイレクト処理を入れます。
app.get('/authorize', function(req, res){
var authorizeUrl = buildUrl(authServer.authorizationEndpoint,{
response_type: "code",
client_id: client.client_id,
redirect_uri:client.redirect_uris[0]
});
res.redirect(authorizeUrl);
console.log("authorize!!!");
});
いろいろ関数を経ているので、混乱するかもしれませんが、やっていることは以下のようなURLを作成して、リダイレクトしているだけです。
http://localhost:9001/authorize?response_type=code&client_id=oauth-client-1&redirect_uri=http%3A%2F%2Flocalhost%3A9000%2Fcallback
失敗している場合は以下の画面が表示されています。
URLのクエリにパラメータが含まれているか再度確認しましょう
Approveボタンを押すと、クライアントのリダイレクトURLに遷移します。
ここでは、先ほどセットしたhttp://localhost:9000/callbackに飛んでいるはずです。
しかし、client.js内のget関数/callbackには、何のresも記述されていないので、何も表示されずに終わってしまいます。
###3-2-3 認可サーバーからのレスポンスの処理
認証ページでどちらかを選択すると、ユーザはクライアントアプリケーションに戻されます。(callback)
OAuthのプロセスにけるこの部分では、受け取ったパラメータの中のcodeパラメータから認可サーバーで生成された認可コードを読み取る必要があります。
このリクエストは、もともと認可サーバーからリダイレクトされたものであるため、認可コードはリクエストしたものに対してのレスポンスではなく、リクエストの中にあります。
先ほどのApproveボタンを押した後のURLを見てみると、URLの後ろにクエリパラメーターであるcodeが付与されています。
http://localhost:9000/callback?code=FcXP2kCW&state=
よって、クエリパラメータから値を取得するために以下の記述で変数codeに取り込みます。
var code = req.query.code;
そうすると、今度は、この認可コードを取り出して、HTTPメソッドのPOSTでその認可コードを直接トークンエンドポイントに送る必要が出てきます。
var form_data = qs.stringify({
grant_type:"authorization_code",
code:code,
redirect_uri:client.redirect_uris[0]
});
qs.stringify:第1引数にクエリ文字列を表すオブジェクトを指定します。
返り値としてクエリ文字列を表すオブジェクトが得られます。
ここでredirect_uriを含んでいる理由:OAuthの仕様では、リダイレクトURLが認可リクエストで指定されている場合、それと同じURIがトークンエンドポイントへのリクエストに含められなければならないようになっているから。これは、攻撃者が危険なリダイレクトURIを正当なクライアントに対して使うことを防ぎます。
いくつかのヘッダを送って、このリクエストがformエンコードによるリクエストであることをサーバーに伝える必要があり、そのときにBasic認証を使ってクライアントを認証する必要があります。
Basic認証を行うためにはユーザー名とパスワードを一つのコロン(:)で区切って連結し、それをBase64でエンコードした文字列にして、それをAuthorizationヘッダーに設定します。OAuth2.0はクライアントIDをユーザー名として、そしてクライアントシークレットをパスワードとして使うようになっていますが、まず最初にクライアントIDとクライアントシークレットがURLエンコードされていなければなりません。ここで以下の関数を利用します。
var headers = {
"Content-Type":"application/x-www-form-urlencoded",
"Authorization":"Basic " + encodeClientCredentials(client.client_id,client.client_secret)
};
ここで上記2つのform_dataとheadersをPOSTします。
var tokRes = request("POST",authServer.tokenEndpoint,{
body:form_data,
headers:headers
});
リクエストが成功すると、認可サーバーはアクセストークンの値などを持ったJSONオブジェクトを返します。
{ access_token: 'piGUY5hoV0JCHryLsZhsMapOROqjVl8P',
token_type: 'Bearer',
scope: '' }
クライアントアプリケーションはこれを読込み、JSONオブジェクトを解析してアクセストークンの値を取りだします。
var body = JSON.parse(tokRes.getBody());
access_token = body.access_token;
全体のコールバックとしては以下のように書きました。実際の「徹底入門」の本には付録Bとしてエラー処理を含めたソースコードが記載されています。
app.get('/callback', function(req, res){
/*
* Parse the response from the authorization server and get a token
*/
var code = req.query.code;
var form_data = qs.stringify({
grant_type:"authorization_code",
code:code,
redirect_uri:client.redirect_uris[0]
});
var headers = {
"Content-Type":"application/x-www-form-urlencoded",
"Authorization":"Basic " + encodeClientCredentials(client.client_id,client.client_secret)
};
var tokRes = request("POST",authServer.tokenEndpoint,{
body:form_data,
headers:headers
});
var body = JSON.parse(tokRes.getBody());
console.log(body);
access_token = body.access_token;
res.render('index', {access_token: access_token, scope: scope});
});
アクセストークンを取得して格納できるようになったので、ブラウザ上に表示されました。
ただし、実際のOAuthアプリケーションではこのようにアクセストークンを表示するようなことは、、$\huge{絶対にやってはいけないです。}$
なぜなら、アクセストークンはクライアントが守るべき秘密の値だからです。今回はあくまで教育目的で表示しましたが、本番で使うアプリケーションではこのような実装をしないようにしてください。
##3-2-3 stateパラメータを使ったサイトをまたいだ攻撃に対する保護の追加
現在の設定では、誰かがhttp://localhost:9000/callbackにアクセスすると、クライアントは受け取ったcodeの値を何も考えずにそのまま取り出し、その値を認可サーバーにPOSTで送信しようとします。
このままでは、攻撃者がクライアントを使って認可サーバーの有効な認可コードをだまし取る可能性があります。
クライアントはユーザーがリクエストすらしたことがないトークンを攻撃者によって取得されてしまう可能性があります。
そこで、OAuthの任にのパラメータであるstateを使っていきます。
このstateはランダムな値であり、今回のアプリケーションでは変数に格納するようにします。
そして古いアクセストークンが破棄されると、すぐにこの値を生成するようにします。
state = randomstring.generate();
※頭で、var state = null;しているので、変数宣言は不要
そしてこの値をアプリケーションで保存しておきます。
そうすることで、認可サーバーでredirect_uriが呼び出されて処理がクライアントに戻ってきたときも、この値を利用できるようになります。
state = randomstring.generate();
var authorizeUrl = buildUrl(authServer.authorizationEndpoint,{
response_type: "code",
client_id: client.client_id,
redirect_uri:client.redirect_uris[0],
state:state
});
これで、URLにクエリパラメータstateが付与されます。
app.get('/callback', function(req, res){
/*
* Parse the response from the authorization server and get a token
*/
if(req.query.state!=state){
res.render("error",{eroor:"Stateの値が違います"});
return;
}
・・・
ここでは、stateの値が違う場合に改ざんされたものとしてエラーページに送るようにしています。
##3-3 保護対象リソースへのトークンの使用
アクセストークンを取得できるようになり、次に何をすればよいか。
そのアクセストークンを使って何ができるのか。今回は保護対象リソースは有効なので、アクセストークンを受け取ると何らかの情報を返す仕組みがすでに用意されています。
クライアントの2つめのボタン「Get Protected Resource」の処理を仕込んでいきます。初期状態は例のごとく空っぽの状態です。
app.get('/fetch_resource', function(req, res) {
/*
* Use the access token to call the resource server
*/
});
まずはアクセストークンの有無を確認する条件文を追加します。ない場合はエラーページに飛ばします。
app.get('/fetch_resource', function(req, res) {
if(!access_token){
res.render("error",{error:"アクセストークンがありません"});
return;
}
});
この関数で行う処理は、保護対象リソースを呼び出し、返ってきたデータを対象のページに渡して、そのデータを画面に表示することです。
そのために、まず最初にリクエストをどこに送るのかについて知る必要が有ります。
今回の演習では、client.jsのprotectedResource変数にhttp://localhost:9002/resourceが定義されています。ここにPOSTでリクエストを行い、レスポンスとしてJSONが返ってくることを想定します。
しかし、このままではまだ機能しません。その理由は、保護対象リソースはこの呼び出しが許可されたものであることを想定しているのですが、このクライアントはOAuthのトークンを取得できるようになっているにも関わらず、まだそのトークンを使った処理を何もしていないからです。
ここでは、OAuthで定義されているAuthorizationBearerのヘッダーに値としてトークンを設定し、そのトークンを送るようにします。
app.get('/fetch_resource', function(req, res) {
if(!access_token){
res.render("error",{error:"アクセストークンがありません"});
return;
}
var headers = {
"Authorization":"Bearer " + access_token
};
var resource=request("POST",protectedResource,{headers:headers});
});
これでリクエストを保護対象リソースに送れるようになりました。
もしリクエストが成功すれば、JSON解析後にデータをページに渡します。失敗した場合はエラーページに渡します。
##3-4 アクセストークンのリフレッシュ
現在アクセストークンを使って保護対象リソースを読み込めるようになっていますが、もし途中でアクセストークンの有効期限が切れた場合はどうなるのでしょうか?再度ユーザーに認可を行わせるのは煩わしいです。
OAuth2.0では、ユーザーを巻き込むことなく新しいアクセストークンを取得するための方法として、リフレッシュトークンを提供しています。
これはとても重要で、OAuthは最初に権限移譲を行ったあとで、そのユーザーがいなくなった場合でも、アクセストークンが使われることがあるからです。
ここで新しい演習ソースコードを使います。
ch-3-ex-2フォルダでnpm install を実行します。
それぞれ、クライアントと認可サーバー、保護対象リソースを立ち上げます。
node client.js
node authorizationServer.js
node protectedResource.js
そしてクライアント側のhttp://127.0.0.1:9000/をチェックすると
すでにアクセストークンとリフレッシュトークンの値が入った状態にあります。
ただし、このアクセストークンは発行されてからの有効期限が過ぎた状態のものです。にもかかわらず、このクライアントは、このアクセストークンが現在無効になっていることを知らいないので、仮に保護対象リソースに対してアクセスしようとしても結果失敗してしまいます。
そこで、この演習では、クライアントがリフレッシュトークンを使って新しいアクセストークンを取得できるようにし、その新しいアクセストークンを使って再び保護対象リソースへの呼び出しを行うようにしていきます。
今回は先ほどの演習と異なり、再度認可することなくリフレッシュトークンを利用します。これはもともとこのクライアントに返されていたもので、アクセストークンを取得したときのJSONオブジェクトに含まれていたものです。
ここからは、client.jsのなかを見ながら、トークンのリフレッシュ処理を追加していきます。
app.get('/fetch_resource', function(req, res) {
console.log('Making request with access token %s', access_token);
var headers = {
'Authorization': 'Bearer ' + access_token,
'Content-Type': 'application/x-www-form-urlencoded'
};
var resource = request('POST', protectedResource,
{headers: headers}
);
if (resource.statusCode >= 200 && resource.statusCode < 300) {
var body = JSON.parse(resource.getBody());
res.render('data', {resource: body});
return;
} else {
/*
* Instead of always returning an error like we do here, refresh the access token if we have a refresh token
*/
console.log("resource status error code " + resource.statusCode);
res.render('error', {error: 'Unable to fetch resource. Status ' + resource.statusCode});
}
});
上記のコードに、以下のエラー処理を追加します。
if (resource.statusCode >= 200 && resource.statusCode < 300) {
var body = JSON.parse(resource.getBody());
res.render('data', {resource: body});
return;
} else {
access_token = null;
if(refresh_token){
refreshAccessToken(req,res);
return;
}else{
res.render('error',{error:resource.statusCode});
return;
}
}
保護対象リソースを取得しようとした際のエラー時に、現在のアクセストークンを無効にする処理をしています。
refreshAccessToken関数の中では、トークンエンドポイントのリクエストを作成しています。
アクセストークンをリフレッシュすることは認可付与としては特別なケースであり、grant_typeパラメータに値「refresh_token」を設定しています。この処理は、callback関数内(authorization_code)でトークンエンドポイントへのリクエストを行った処理とほぼ同じ処理です。
違う箇所は、form_dataの処理において、authorization_codeでなくrefresh_tokenとしているところ。パラメータとしてrefresh_tokenを設定しているところです。
var refreshAccessToken = function(req, res) {
var form_data = qs.stringify({
grant_type: 'refresh_token',
refresh_token: refresh_token
});
var headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + encodeClientCredentials(client.client_id, client.client_secret)
};
var tokRes = request('POST', authServer.tokenEndpoint,
{
body: form_data,
headers: headers
});
};
そして、リフレッシュトークンが有効の場合、認可サーバーは通常のはじめてのアクセストークン取得時と同様の挙動をします。
そのレスポンスとしてのJSONの中身(JSON.parse(tokRes.getBody()))は更新されたアクセストークンのみならず、リフレッシュトークンも含まれます。リフレッシュトークンを新しいものに更新する場合は、このタイミングで更新します。
{ access_token: 'y84W85QELRsTS6vcNFqpNHLHwF7l6UhC',
token_type: 'Bearer',
refresh_token: 'j2r3oj32r23rmasd98uhjrk2o3i' }
最終的に、再度リソースを取得するように指示を行います。
res.redirect('/fetch_resource');
var refreshAccessToken = function(req, res) {
var form_data = qs.stringify({
grant_type: 'refresh_token',
refresh_token: refresh_token
});
var headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + encodeClientCredentials(client.client_id, client.client_secret)
};
var tokRes = request('POST', authServer.tokenEndpoint,
{
body: form_data,
headers: headers
});
if (tokRes.statusCode >= 200 && tokRes.statusCode < 300) {
var body = JSON.parse(tokRes.getBody());
access_token=body.access_token;
if(body.refresh_token){
refresh_token=body.refresh_token;
}
res.redirect('/fetch_resource');
return;
} else {
}
};
問題なく動作している場合は、保護対象リソースにアクセスできます。
仮にリフレッシュトークンをうまく取得できない場合は、リフレッシュトークンとアクセストークンの両方を破棄し、エラー処理を行います。
if (tokRes.statusCode >= 200 && tokRes.statusCode < 300) {
var body = JSON.parse(tokRes.getBody());
access_token=body.access_token;
if(body.refresh_token){
refresh_token=body.refresh_token;
}
res.redirect('/fetch_resource');
return;
} else {
refresh_token = null;
res.render('error', { error: 'トークンを更新できません' }); return;
}
##3-5 まとめ
- 認可コードによる付与方式を使ってトークンを取得するにはわかりやすい手順で
- リフレッシュトークンの機能が使えるなら、ユーザーの手を煩わせることなく、新しいアクセストークンを取得できるようになる。
- OAuth 2.0 Bearerトークンはシンプル。HTTPコールにシンプルなHTTPヘッダーを追加することだけが要求される