2021/02/28追記
この記事の内容はとても古いです。用いているライブラリも最終更新が数年前のレベルなので、これからOAuth2プロバイダーを作成される場合は、以下のライブラリをご検討ください。
https://github.com/panva/node-oidc-provider
趣味のプロダクトでOAuth2プロバイダー(OAuth2サーバー)を作る必要性に迫られて、3日ぐらいうんうん唸りながら作り上げたのですが、意外と日本語によるNode.jsでのOAuth2プロバイダー作成の知見がネット上に転がっていないので、ここで記しておくことにします。
#はじめに
OAuth2はTwitterやFacebookなどのSNSサービスで良く用いられている、サードパーティアプリケーションをAPIに接続するための認可を取るサービスです。今回作るのは、WebアプリからTwitter等のサービスに接続するためのOAuth2 Clientではなく、サービス側のプロバイダーと呼ばれる部分です。
##環境
- Node.js v8.9.1
- express.js v4.15.3
- MySQL
- Nuxt.js v1.0.0-rc3
Nuxt.jsはクライアントサイドに用います。今回の開発ではnuxt-community/express-template
というNuxt.js公式のExpressと合体したテンプレートを用いていますが、分けて立ち上げても特に問題はないと思いますし、何ならクライアントにNuxt.jsを使う必要は全くありません。
#事前準備
OAuth2プロバイダーは、大まかには以下のようなフローで動作します。処理フローによって複数の認証方式に分かれますが、今回は「Authorization Code Grant」フローで作成します。詳細は以下が詳しいです。
OAuth 2.0 全フローの図解と動画 - https://qiita.com/TakahikoKawasaki/items/200951e5b5929f840a1f
- クライアント開発者がログインする
- ログインした開発者が新たなクライアントIDの生成を認可サーバーに要求する
- 要求が正当であれば、認可サーバーは生成したclient_idとclient_secretを開発者に返却する
- クライアントはclient_idを認可エンドポイントに通知する
- 認可エンドポイントは要求が正当であることを確認し、APIに接続することを許可するかエンドユーザーに問う
- 許可された場合、認可エンドポイントは短命の認可コードを発行しクライアントに通知する
- クライアントは認可コードをトークンエンドポイントに通知する
- トークンエンドポイントは要求が正当であればアクセストークンとリフレッシュトークンを返す
- クライアントはアクセストークンをheaderに含めれば、セキュリティがかかった情報にアクセスできる
- アクセストークンの有効期限が切れる前に、リフレッシュトークンをトークンエンドポイントに通知すると、新たなアクセストークンが発行される
おおざっぱに書くとこんな感じだと思います。
##必要なものをインストール
###sequelize
MySQLを使っているのでnpm i -S sequelize mysql2
及びnpm i -D sequelize-cli
でsequelizeをインストールします。
データベースサーバー設定およびsequelize-cliの使い方については割愛します。
例えばusersテーブルを作成する場合、ターミナルを作業フォルダ内(今回のテンプレートの場合、/server)で開き、以下のように実行します。
$ sequelize model:create --name user --attributes name:string,email:string,password:string
$ sequelize db:migrate
これでデータベースにユーザーテーブルが作成されます。
もちろんMySQL Workbench等でテーブルを作成しても構いませんが、自動的にデータベースにアクセスするためのモデルが一緒に作成されるので、こちらのほうが手間が省けるかと思います。
###express-oauth-server
続いて、先述のフローのうち4~10の処理を請け負ってくれるexpress-oauth-server
をインストールします。
node-oauth2-server
のexpress向けラッパーです。
$ npm i -S express-oauth-server
###nanoid
クライアントID文字列の生成に使います。
$ npm i -S nanoid
###@nuxt.js/axios
クライアントサイドからサーバーサイドへのアクセスに使います。
Nuxt本体は事前にcliで環境構築しているものとします。
$ npm i -S @nuxtjs/axios
##ユーザー認証機構の作成
まずはサーバーサイドに開発者を登録するためのユーザー認証機構が必要となります。
これはGoogleやTwitterやFacebook等のOAuthを用いるのがセキュリティ的にもユーザーの利便性でも確実だとは思いますが、ここではめんどくさいので従来のuser_id、password登録による認証を用います。
詳細な作成方法についてはすでに語りつくされていると思いますので、ここでは割愛します。
新規登録とログインとログアウトができればいいと思います。
#認証フローの実装
いよいよフローの実装に入っていきます。
##データテーブルにテーブルを作成
認証フローで用いるテーブルを作成します。
$ sequelize model:create --name oauth_client --attributes client_name:string,client_id:string,client_secret:string,redirect_uri:string,user_id:integer
$ sequelize model:create --name oauth_authorization_code --attributes code:string,scope:string,expires_at:date,redirect_uri:string,user_id:integer
$ sequelize model:create --name oauth_token --attributes access_token:string,expires_at:string,scope:string,client_id:string,user_id:integer
$ sequelize model:create --name oauth_refresh_token --attributes refresh_token:string,expires_at:string,scope:string,client_id:string,user_id:integer
$ sequelize db:migrate
##クライアントID発行と通知
ログイン状態でなければアクセスできないルートでクライアントIDを発行します。
今回は面倒なのでログインチェックはしていません。
ここでは、localhost:3000/api/oauth/clientにclient_name
、redirect_uri
、user_id
をPOSTすることで発行されるようにします。
redirect_uri
は、エンドユーザーがAPI接続を許可した際に、認可コードを通知するためのリダイレクトに使うURLです。OAuth2認証における必須パラメータですので、確実に登録されるようにしておきます。
import app from 'express'
const db = require(__dirname + '/../models')
import nanoid from 'nanoid'
const router = app.Router()
// クライアント取得
router.get('/oauth/client/:id', function (req, res, next) {
db.oauth_clients.findAll({
where: {user_id: req.params.id}
}).then(result => {
res.json(JSON.stringify(result))
})
})
// クライアント登録
router.post('/oauth/client', function (req, res, next) {
db.oauth_clients.create({
client_name: req.body.client_name,
client_id: nanoid(),
client_secret: nanoid(),
redirect_uri: req.body.redirect_uri,
user_id: req.body.user_id
}).then(result => {
console.log(result.get({plain: true}))
res.json('ok')
}).catch(err => {
console.log(err)
res.status(400).json('DataBase Error')
})
})
// クライアント削除
router.delete('/oauth/client/:id', function (req, res, next) {
db.oauth_clients.destroy({
where: {id: req.params.id}
}).then(result => {
res.json('Delete Success')
})
})
export default router
返却値にclient_id
とclient_secret
を含め、ログインしたユーザー(開発者)に提示します。
##express-oauth-serverの設定
フローの4~10を包括的に行ってくれるexpress-oauth-server
ですが、初期設定にそれなりにコツがいります。
###初期設定
事前にbody-parser
を読み込ませておきます。
import bodyParser from 'body-parser'
// 中略
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
ルーティングファイルにexpress-oauth-server
をインポートします。
import app from 'express'
// ここから追加
import OAuthServer from 'express-oauth-server'
app.oauth = new OAuthServer({
model: require(__dirname + '/../services/oauth'),
})
// ここまで追加
const db = require(__dirname + '/../models')
import nanoid from 'nanoid'
const router = app.Router()
// 以下略
###modelを作成
express-oauth-server
がデータベースと接続して情報を扱えるように、model: require(__dirname + '/../services/oauth'),
で定義されているモデルを作成します。
データベースと接続するので、自らのデータベース環境に合わせて関数を定義していく必要があります。
const db = require(__dirname + '/../models')
// アクセストークンの取得
module.exports.getAccessToken = function(accessToken) {
return db.oauth_token.findOne({
where: {access_token: accessToken}
}).then(result => {
const token = function (input) {
return new Promise(resolve => {
resolve(input)
})
}
const client = function (input) {
return new Promise(resolve => {
db.oauth_clients.findOne({
where: {client_id: result.client_id}
}).then(result => {
resolve(result)
})
})
}
const user = function (input) {
return new Promise(resolve => {
db.user.findOne({
where: {id: result.user_id}
}).then(result => {
resolve(result)
})
})
}
return Promise.all([
token(result),
client(result),
user(result)
])
}).spread((token, client, user) => {
return {
accessToken: token.access_token,
accessTokenExpiresAt: token.expires_at,
scope: token.scope,
client: {id: client.client_id},
user: {id: user.id}
}
})
}
// 認可コードの取得
module.exports.getAuthorizationCode = function(authorizationCode) {
return db.oauth_authorization_code.findOne({
where: {code: authorizationCode}
}).then(result => {
const authorization_code = function (input) {
return new Promise(resolve => {
resolve(input)
})
}
const client = function (input) {
return new Promise(resolve => {
db.oauth_clients.findOne({
where: {client_id: result.client_id}
}).then(result => {
resolve(result)
})
})
}
const user = function (input) {
return new Promise(resolve => {
db.user.findOne({
where: {id: result.user_id}
}).then(result => {
resolve(result)
})
})
}
return Promise.all([
authorization_code(result),
client(result),
user(result)
])
}).spread((authorization_code, client, user) => {
return {
code: authorization_code.code,
expiresAt: authorization_code.expires_at,
redirectUri: authorization_code.redirect_uri,
scope: authorization_code.scope,
client: {id: client.client_id},
user: {id: user.id}
}
})
}
// クライアントの取得
module.exports.getClient = function(clientId, clientSecret) {
let params = {client_id: clientId};
if (clientSecret) {
params.client_secret = clientSecret;
}
console.log(params)
return db.oauth_clients.findOne({
where: params
}).then(result => {
if(!result) {
return null
}
const client = {
id: result.client_id,
redirectUris: [result.redirect_uri],
grants: ['authorization_code', 'refresh_token'] // <--- データベースにgrantsとして登録しておくと吉?
}
return client
})
}
// リフレッシュトークンの取得
module.exports.getRefreshToken = function(refreshToken) {
return db.oauth_refresh_token.findOne({
where: {refresh_token: refreshToken}
}).then(result => {
const token = function (input) {
return new Promise(resolve => {
resolve(input)
})
}
const client = function (input) {
return new Promise(resolve => {
db.oauth_clients.findOne({
where: {client_id: result.client_id}
}).then(result => {
resolve(result)
})
})
}
const user = function (input) {
return new Promise(resolve => {
db.user.findOne({
where: {id: result.user_id}
}).then(result => {
resolve(result)
})
})
}
return Promise.all([
token(result),
client(result),
user(result)
])
}).spread((token, client, user) => {
return {
refreshToken: token.refresh_token,
refreshTokenExpiresAt: token.expires_at,
client: {id: client.client_id},
user: {id: user.id}
}
})
}
// 認可コードの取り消し
module.exports.revokeAuthorizationCode = function(authorizationCode) {
return db.oauth_authorization_code.destroy({
where: {code: authorizationCode.code}
}).then(result => {
return !!result
})
}
// トークンの取り消し
module.exports.revokeToken = function(token) {
return db.oauth_refresh_token.destroy({
where: {refresh_token: token.refreshToken}
}).then(result => {
return !!result
})
}
// 認可コードの生成
module.exports.saveAuthorizationCode = function(code, client, user) {
let authCode = {
code: code.authorizationCode,
expires_at: code.expiresAt,
redirect_uri: code.redirectUri,
scope: code.scope,
client_id: client.id,
user_id: user.id
}
return db.oauth_authorization_code.create(authCode)
.then(authorizationCode => {
return {
authorizationCode: authorizationCode.code,
expiresAt: authorizationCode.expires_at,
redirectUri: authorizationCode.redirect_uri,
scope: authorizationCode.scope,
client: {id: authorizationCode.client_id},
user: {id: authorizationCode.user_id}
}
})
}
// アクセストークンの生成 こっちのPromise.all()の書き方のほうがすっきりしてるのかな
module.exports.saveToken = function(token, client, user) {
let fns = [
new Promise(resolve => {
db.oauth_token.create({
access_token: token.accessToken,
expires_at: token.accessTokenExpiresAt,
scope: token.scope,
client_id: client.id,
user_id: user.id,
}).then(result => {
resolve(result)
})
}),
new Promise(resolve => {
db.oauth_refresh_token.create({
refresh_token: token.refreshToken,
expires_at: token.refreshTokenExpiresAt,
scope: token.scope,
client_id: client.id,
user_id: user.id,
}).then(result => {
resolve(result)
})
})
]
return Promise.all(fns)
.then(([accessToken, refreshToken]) => {
return {
accessToken: accessToken.access_token,
accessTokenExpiresAt: accessToken.expires_at,
refreshToken: refreshToken.refresh_token,
refreshTokenExpiresAt: refreshToken.expires_at,
scope: accessToken.scope,
client: {id: accessToken.client_id},
user: {id: accessToken.user_id}
}
})
}
// scopeのベリファイ
module.exports.verifyScope = function(token, scope) {
if(!token.scope) {
return false
}
let requestedScopes = scope.split(' ')
let authorizedScopes = token.split(' ')
return requestedScopes.every(s => authorizedScopes.indexOf(s) >= 0)
}
##エンドポイントの作成
モデルが作成できましたので、エンドポイントを作成していきます。
// 現在ログイン中のユーザー情報をハンドリングする
function loadCurrentUser(req) {
// const User = {id: req.session.passport.user}
const User = {id: 1}
return User
}
// 認可エンドポイント GET
// クライアント情報を送信し、エンドユーザー許可画面にクエリ付きでリダイレクトする
router.get('/oauth/authorize', function (req, res, next) {
/***********************************
認可エンドポイント パラメータ一覧
?response_type=code 必須
&client_id={クライアントID} 必須
&redirect_uri={リダイレクトURI} 必須
&scope={スコープ} 必須
&state={任意文字列} CSRF防止のため推奨
************************************/
const authorize_key = nanoid.apiKey()
const URI =
'/users/authorize'
+ '?response_type=code'
+ '&client_id=' + req.query.client_id
+ '&redirect_uri=' + req.query.redirect_uri
+ '&scope=' + req.query.scope
+ '&state=' + req.query.state
res.redirect(301, URI)
})
// 認可エンドポイント POST
// 上記で許可されたクエリがPOST送信され、自動で認可コードが発行される
// 認可コードはredirect_uriで指定されたURLにクエリ付きで通知
router.post('/oauth/authorize', app.oauth.authorize({
authenticateHandler: {
handle: loadCurrentUser
}
}), function (req, res, next) {
// new OAuthServer時にoptions: {continueMiddleware: true}が呼ばれていなければ読み込まれません
})
// トークンエンドポイント POST
// grant_typeがauthorization_codeの場合、
// client_id, client_secret, redirect_uri, 上記で通知された認可コードを送信する
// アクセストークンとリフレッシュトークンが返却される
// grant_typeがrefresh_tokenの場合、
// client_id, client_secret, refresh_tokenを送信する
// 新たなアクセストークンとリフレッシュトークンが返却される
router.post('/oauth/token', app.oauth.token(), function (req, res, next) {
/***********************************
トークンエンドポイント パラメータ一覧
?grant_type=authorization_code or refresh_token 必須 clientテーブルにgrantカラムとして登録しておくっぽい
&client_id={クライアントID} 必須
&client_secret={クライアントシークレット} 必須
&redirect_uri={リダイレクトURI} authorization_codeの場合必須
&code={認可コード} 必須 authorization_codeの場合必須
&refresh_token={リフレッシュトークン} refresh_tokenの場合必須
************************************/
res.json('token') // <--- new OAuthServer時にoptions: {continueMiddleware: true}が呼ばれていなければ読み込まれません
})
// セキュリティで保護されたルート
// headerにAuthorization: Bearer {アクセストークン}を設定することでアクセスできる
router.get('/oauth/secret', app.oauth.authenticate(), function (req, res, next) {
res.json('secret')
})
##クライアント側の実装
長くなってしまったので詳細は省きますが、おおむね以下のような手順です。
Nuxt側のコードはまだお見せできるレベルにないので、出来たら追記します。
###authorization_code認証(初回認証によるトークン発行)
- /api/oauth/clientに
client_name
、redirect_uri
、user_id
をPOST送信 - クライアントの環境変数など安全な場所に
client_id
、client_secret
、redirect_uri
を仕込む - /api/oauth/authorizeに
response_type=code
、client_id
、redirect_uri
、scope
(リードライトの権限設定みたいなもの)、state
(クライアント側でsession情報をハッシュ化したものなど)をGET送信 - 自動的にクライアントサイドの許可画面にクエリ付きでリダイレクト
- ログイン状態であれば許可画面を表示。していない状態であればログインフォームを一緒に表示(使用者ユーザーでログインして使用者ユーザーの情報をAPIから取得といったことが可能…だと思う)
- 許可された場合はinput type="hidden"などでクエリから取り出したパラメータを/api/oauth/authorizeにPOST送信
-
redirect_uri
へ返値から認可コードcode
とCSRF防止用のstate
がクエリ付きで返却 - GET送信時に作成した
state
と返値のstate
を比較する(CSRF防止) - 比較がtrueであれば、
grant_type=authorization_code
及び環境変数に保存しておいた各パラメータと認可コードcode
を/api/oauth/tokenにPOST送信(7からここまでを自動で行う場合ここはAjaxか) - アクセストークン
access_token
とリフレッシュトークンrefresh_token
が返値となるので、これをローカルストレージかどこかに保存しておく - headerで
Authorization: Bearer {access_token}
とすると、サーバー側でapp.oauth.authenticate()
が指定されたルートにアクセスできる
###refresh_token認証(アクセストークンの更新)
-
grant_type=refresh_token
と環境変数に保存しておいたパラメータのうちclient_id
、client_secret
、そして取得したrefresh_token
を/api/oauth/tokenにPOST送信 - 新しい
access_token
とrefresh_token
が返ってくるので保存 - アクセストークンの期限切れが来ると、
Invaild token: access token has expired
というエラーが返ってくるので、1~2を行いアクセストークンを更新する。以上を繰り返す
#あとがき
最後までご覧いただきありがとうございます。お見苦しくて長いコードで大変失礼しました。
それにしてもOAuth2プロバイダーに関する日本語の知見がほとんど無いのには驚きました。それどころかexpress-oauth-server
や内包されるnode-oauth2-server
が最近まで放置されていたという状態です。
動くまでには大変苦労させられましたが、出来てみると公式ドキュメントにほぼ則った感じになりました。というわけで英語が読める方はたぶんこの記事必要ないと思います。
そんな感じの記事ですが、お役に立ったら幸いです。
#参考資料