まだプログラマーですが何か?

プログラマーネタとアスリートネタ中心。たまに作成したウェブサービス関連の話も http://twitter.com/dotnsf

タグ:authentication

Github の認証をスマホアプリを使った2段階認証にするための手順を紹介します。対応したスマホアプリはいくつか存在しますが、このブログエントリでは IBM Security Verify アプリを使った設定手順を紹介します。

なお Github の2段階認証を有効にするには、ここで紹介する手順を実行する前に、Github の認証方法をベーシック認証ではなくパーソナルアクセストークン認証に切り替えておく必要があります。そちらの手順については以前のブログエントリを参照してください:
Github のベーシック認証が利用できなくなる前にパーソナルアクセストークンに対応する


【Github の2段階認証対応】
今回は IBM Security Verify アプリを使った Github の2段階認証対応手順を紹介します。まず iPhone や Android スマホでストアアプリを起動し、"IBM Security Verify" を検索して該当アプリをインストールします(無料です):
2021031307


そして起動します。下図は初回起動直後の画面です。この時点ではまだどのサービスとも連携できていないので、「アカウントの接続」ボタンが表示されます。このボタンをタップします:
IMG_3073


「コンピュータの QR コードをスキャンしてください」というメッセージが表示されます。この後 Github と連携するための QR コード読み取りを行います。スマホアプリ側はいったんこの状態にしたまま続けて Github 側の作業を行います:
IMG_3074


別の PC などで Github にログインし、"Settings" メニューから "Account security" を選択し、2段階認証を有効にするため、画面内の "Enable two-factor authentication" ボタンをタップします:
2021031301


次の画面で "Set up using an app" ボタンを選択します:
2021031302


すると以下のようなリカバリーコードが表示されます。リカバリーコードはアカウントにアクセスできなくなった場合に、再びアクセスできるようにするためのコードです。"Download" ボタンを押して内容を保存してから、"Next" ボタンをクリックして先に進みます:
2021031303


すると以下のように QR コードが表示されます。改めて先程起動したままだったスマホの IBM Security Verify アプリの画面から「QR コードのスキャン」を選択し、起動したカメラでこの QR コードを撮影します:
2021031304

するとアプリ側は「完了しました」と表示されます。"OK" をクリックします:
IMG_3075


Github との接続が完了した様子がわかります。この "Github" 部分をタップします:
IMG_3076


すると以下のような画面が表示されます。モザイクになっている箇所にはアクセスコードと呼ばれる6桁の数字が表示されており、約30秒ごとに異なる値に変更されます:
IMG_3077


この数字が変更になる前に Github 画面内の QR コード下部に6桁の数字を入力して、"Enable" ボタンをクリックします(作業途中で数字が切り替わってしまった場合、その6桁の数字は無効です。改めて有効な数字を入力して、切り替わる前に "Enable" ボタンを押してください):
2021031305


これで IBM Security Verify アプリを使った Github の2段階認証が有効になりました:
2021031306


うまく設定できているか、動作確認をしてみます。この状態で PC の Github からサインアウトし、再びサインインします:
2021031401


するとパスワード入力後に2段階認証のコード入力画面に切り替わります:
2021031402


スマホにインストールした IBM Security Verify アプリを起動して、先程と同様に Github アカウントを選択すると6桁の認証コードが表示されています:
2021031404


入力期限が切れる前にこの6桁の数字を正しく入力して "Verify" ボタンをクリックするとログインできます:
2021031403






 

サブジェクトが少しわかりにくいと思ったので最初にやりたいことを補足しておきます。

ウェブサービスを公開する際に Basic 認証と呼ばれる認証機能を有効にすることがあります。アクセス時にユーザーIDとパスワードが聞かれ、正しい組み合わせを入力しないと先に進めなくなる、というものです。会員制サービスや、正式公開前のサービスを限られた人だけで使いたい場合、グーグル等の検索エンジンクローラーに見つからない状態で運用したい場合などによく使われます:
thumb_basic


今回やりたかったのは、この Basic 認証を例えば以下の条件で実現するような Node.js アプリケーションを作ることです:
・パス /hello 以下にアクセスした際に Basic 認証が必要
・/hello にアクセスするには URL パラメータ id が必要(つまり GET /hello だけではエラーとなり、GET /hello?id=XXX というフォーマットでアクセスする必要がある)
/hello?id=XXX の時と /hello?id=YYY の時とでは Basic 認証のユーザーIDやパスワードが異なる


最後のが今回の肝となる条件です。パラメータ id の値ごとに Basic 認証のユーザーIDやパスワードが変わり(データベース等に格納されているものを id をキーに取り出して比較するイメージ)、これを Node.js + Express 環境でどのように実現するか、というのが挑戦内容です。


やりたいことが明確になったところで、改めて Node.js + Express 環境で Basic 認証をかける方法をググってみると、basic-auth-connect モジュールを使う方法がメジャーな方法の1つとして見つかります。これは簡単にいうと以下のような感じで Basic 認証をかけるものことができるものです:
var express = require( 'express' ),
    basicAuth = require( 'basic-auth-connect' ),
    app = express();

app.all( '/hello*', basicAuth( function( user, pass ){
  return ( 'username' === user && 'password' === pass );
}));

  :
  :

app.get( '/hello', function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );
  res.write( JSON.stringify( { status: true }, 2, null ) );
  res.end();
});

  :
  :

GET /hello リクエストに対しては単に { status: true } という JSON を返すだけの定義がされていますが、その前に Basic 認証を有効にする部分が記述されています。この例では(/hello に何らかの URL パラメータが付属する場合も含めた) /hello* というパスに GET リクエストが行われた場合に Basic 認証が必要になり、ユーザーID 'username' 、パスワード 'password' が入力された場合のみ true(認証成功)で実際の GET /hello の処理が行われ、それ以外の場合は false(認証失敗)という扱いとなって再度入力が求められたり、何度か間違えると認証エラー扱いとなる、というものです。とても便利で、よく使っています。


さて、今回は上述の条件で Basic 認証を有効にする必要があり、少し異なる処理が必要です。正しいユーザーIDとパスワードは URL パラメータ id によって変わるのですが、この URL パラメータは req オブジェクから取り出す必要があり、今の形のままでは(認証判断時に req オブジェクトが取得できないので)取得が難しそうです。自分もこの basic-auth-connect モジュールを使う前提で実装を考えていたので詰まってしまいました。。

結論としては basic-auth-connect モジュールを使うことを諦め、自分で認証判断してエラー時にエラーコードを返す、という地味な処理に切り替えて実装できました:
var express = require( 'express' ),
    //basicAuth = require( 'basic-auth-connect' ),  //. basic-auth-connect は使わない
    app = express();

//. パラメータ id 毎に必要なユーザーIDとパスワード(本当はデータベース等から取得するイメージ)
var db = {
  "000" : { user: 'a', pass: 'x' },
  "001" : { user: 'b', pass: 'y' },
  "002" : { user: 'c', pass: 'z' }
};

/* URL パラメータ毎に認証情報を変えたい */
app.use( function( req, res, next ){
  //. hello* へのリクエスト時かどうかを判断
  var originalUrl = req.originalUrl;
  if( originalUrl.startsWith( '/hello' ) ){
    //. URL パラメータ ID を取り出す
    var id = req.query.id;
    //. 指定された ID のユーザー ID とパスワードが存在しているかどうかを調べる
    if( db[id] ){
      //. ヘッダから入力されたユーザーIDとパスワードを取り出す
      var b64auth = ( req.headers.authorization || '' ).split( ' ' )[1] || '';
      var [ user, pass ] = Buffer.from( b64auth, 'base64' ).toString().split( ':' );

      //. 入力内容が正しい場合のみ next() を返して本来の処理へ
      if( db[id].user == user && db[id].pass == pass ){
        return next();
      }else{
        //. 入力内容が間違っていた場合は認証エラー扱いとする
        res.set( 'WWW-Authenticate', 'Basic realm="MyApp"' );
        res.status(401).send( 'Authentication required.' );
      }
    }else{
      //. 指定された ID が存在していなかった場合も認証エラー扱いとする
      res.set( 'WWW-Authenticate', 'Basic realm="MyApp"' );
      res.status(401).send( 'Authentication required.' );
    }
  }else{
    return next();
  }
});

  :
  :

app.get( '/hello', function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );
  res.write( JSON.stringify( { status: true }, 2, null ) );
  res.end();
});

  :
  :


赤字部分が今回作成した処理です。req オブジェクトからリクエスト先のパスや Basic 認証で指定された情報を取り出して正しい情報かどうかを判断し、正しい場合は本来の処理へ、間違っていた場合は HTTP の認証エラー結果を返すような内容を記述しています。basic-auth-connect モジュールを使うとこのあたりの細かな記述をする必要がなかったのですが、自分で判断する場合はこのあたりも自分の責任範囲で用意する必要があります。

上述の例では URL パラメータ id は "000", "001", "002" のいずれかである必要があり、それぞれの場合の Basic 認証情報(ユーザーID : パスワード)はそれぞれ "a":"x", "b":"y", "c":"z" としています。この正しい組み合わせが指定された場合のみ GET /hello が実行されて結果が返される、という処理が実行されるようになります。


(参照)
https://stackoverflow.com/questions/23616371/basic-http-authentication-with-node-and-express-4


Node-RED を使うことで IoT データの収集や Web API の実装などが非常に簡単に実現できます。このブログでも何度か紹介していますし、公開されている外部モジュールを使って更にカスタム機能を追加することも可能です。

今回紹介するのは HTTP in ノードに認証機能を追加する node-red-contrib-httpauth ノードです。これを使うと Node-RED に標準装備されている HTTP in ノード(HTTP リクエストノード)に Basic 認証や Digest 認証を簡単に追加することができるようになります:
2018082900


実際に使う場合は、Node-RED の画面右上のメニューから「パレットの管理(Manage Pallette)」を選びます:
2018082901


設定ダイアログが表示されたら、"Palette" の "Install" タブで "httpauth" と検索します。すると node-red-contrib-httpauth ノードが見つかるので、"install" ボタンをクリックしてノードを追加します:
2018082902


インストールが成功すると以下のような表示になります。ここから実際にノードが使えるようになります:
2018082903


この時点でパレットにも "http auth" というノードが追加されていることが確認できます:
2018082904


実際に使う場合、http in ノードの直後に http auth ノードを配置します。この例では http in ノードの直後に http auth ノードを配置し、その後ろに(いつも使っているような)template ノードや function ノードを配置して、最後に http response ノードで HTTP リクエスト可能な API を作りました:
2018082905


template ノードの中身はシンプルにしています(認証が成功するとこの文字列が表示される、というテストです):
2018082906


そして http auth ノードに認証内容を設定します。この例では Basic 認証でレルム文字列は MyRealm 、そしてユーザー名 : user1 &パスワード : pass1 を設定しました。この状態でデプロイします:
2018082907


デプロイ後にウェブブラウザでこの API にアクセスすると、先程設定した http auth が機能し、指定した内容の認証が行われます。具体的にはユーザー名とパスワードを問い合わせるダイアログが表示され、先程指定した内容が入力されないと先へ進めません:
2018082908


上記で設定した内容(ユーザー名 : user1、パスワード : pass1)が正しく入力されると HTTP リクエストが正しく実行され、設定していた文字列が表示されます:
2018082909


この http auth ノードを使うことで、Node-RED で作成する API や Web ページに簡単に Basic 認証をかけることができそうです。


 

Node.js のアプリケーションで、以下のような処理を実装してみました:
 - 認証(ログイン)用の API には誰でもアクセスできる
 - 認証 API では ID とパスワードを与えて認証し、正しいユーザーにはトークンを発行する
 - 認証以外の主な API はこの発行されたトークンを使ってアクセスした時だけ実行を許可する
 - API 呼び出し時にトークンがなかったり、正しくなかった場合は実行せずにエラー


この仕組を実現するために JSON Web Tokens (以下 "JWT")を使いました:
https://jwt.io/

2017081400


JWT はオープンかつ Node.js ではスタンダードなトークンベースの認証ライブラリです。以下で紹介するサンプルでは Web フレームワークである Express や、POST データを扱う body-parser も合わせて使うので、まとめてインストールしておきます:
$ npm install express body-parser jsonwebtoken

これらのライブラリを使って以下のようなアプリケーションを作成します:
//. app.js

var express = require( 'express' );
var app = express();
var bodyParser = require( 'body-parser' );
var jwt = require( 'jsonwebtoken' );

//. アプリケーションサーバーの稼働ポート番号
var port = process.env.PORT || 8080

//. 任意のシークレット文字列を登録
app.set( 'superSecret', 'welovenodejs' );  //. 任意の文字列

app.use( bodyParser.urlencoded( { extended: false } ) );
app.use( bodyParser.json() );

//. ユーザー情報(本来は DB などに格納された情報を使う)
var users = [
  { name: 'user0', password: 'pass0', admin: true },
  { name: 'user1', password: 'pass1', admin: false },
  { name: 'user2', password: 'pass2', admin: false },
  { name: 'user3', password: 'pass3', admin: false }
];

//. ドキュメントルートへの GET は許可
app.get( '/', function( req, res ){
  res.send( 'Hello. The API is at http://localhost:' + port + '/api' );
});

//. API ROUTES
var apiRoutes = express.Router();

//. トークンなしでアクセスを許可する API を先に定義する

//. POST(http://localhost:8080/api/authenticate)
apiRoutes.post( '/authenticate', function( req, res ){
  for( var i = 0; i < users.length; i ++ ){
    if( users[i].name == req.body.name && users[i].password == req.body.password ){
      //. 認証したユーザーの情報を使ってトークンを生成
      var token= jwt.sign( users[i], app.get( 'superSecret' ), {
        expiresIn: '24h'
      });
      res.json( { success: true, message: 'Authentication successfully finished.', token: token } );
      return;
    }
  }

  res.json( { success: false, message: 'Authentication failed.' } );
  return;
});

//. ここより上で定義した API には認証フィルタはかけていない(そのまま使える) //. 認証フィルタ apiRoutes.use( function( req, res, next ){
//. ポスト本体、URLパラメータ、HTTPヘッダいずれかにトークンがセットされているか調べる var token = req.body.token || req.query.token || req.headers['x-access-token']; if( !token ){
//. トークンが設定されていなかった場合は無条件に 403 エラー return res.status(403).send( { success: false, message: 'No token provided.' } ); } //. 設定されていたトークンの値の正当性を確認 jwt.verify( token, app.get( 'superSecret' ), function( err, decoded ){ if( err ){ //. 正当な値ではなかった場合はエラーメッセージを返す return res.json( { success: false, message: 'Invalid token.' } ); } //. 正当な値が設定されていた場合は処理を続ける req.decoded = decoded; next(); }); }); //. 以下はトークンがないと使えない API //. GET(http://localhost:8080/api/) apiRoutes.get( '/', function( req, res ){ res.json( { message: 'Welcome to API routing.' } ); }); //. GET(http://localhost:8080/api/users) apiRoutes.get( '/users', function( req, res ){ res.json( users ); }); //. /api 以下に API をルーティング app.use( '/api', apiRoutes ); app.listen( port ); console.log( 'server started http://localhost:' + port + '/' );

肝になるのはルーティングに認証フィルタを定義している箇所です。ここよりも前(上)で定義した内容には認証フィルタは有効にならないので、認証なしで使える API となります(つまり GET / と POST /authenticate は認証していなくても使えます)。

この POST /api/authenticate API でポストデータ user, password を受取り、その値が変数 users の中で定義されているいずれかの組み合わせと一致していれば 24H 有効なトークンが発行されます(これを以下の API 実行時にパラメータ指定します)。

一方、ここよりも後(下)で定義する内容には認証フィルタが有効になり、GET /api と GET /api/users は上記方法で取得したトークンがパラメータに設定されていないと正しく処理されない API となります。


実際にこのアプリケーションを実行し($ node app)、curl コマンドで挙動を確認してみましょう。まずは問題なく実行できるはずの GET / を実行します:
$ curl -XGET 'http://localhost:8080/'
Hello. The API is at http://localhost:8080/api

問題なく実行できました。次は認証フィルタをかけた GET /api を実行してみます:
$ curl -XGET 'http://localhost:8080/api'
{"success":false,"message":"No token provided."}

API を実行した結果、「トークンがない」時のエラーメッセージが表示されました。ここも期待通りに動いています(試しませんが、ユーザー一覧を取得する GET /api/users も同様のエラーになります)。

では POST /api/authenticate で認証してトークンを取得してみますが最初はわざとパスワードを間違えてみます:
$ curl -XPOST -H 'Content-Type:application/json' 'http://localhost:8080/api/authenticate' -d '{"name":"user1","password":"pass0"}'
{"success":false,"message":"Authentication failed."}

先程同様にエラーになりましたが、エラーメッセージが「認証失敗」に変わりました。では改めて正しいパスワードを指定して実行してみます:
$ curl -XPOST -H 'Content-Type:application/json' 'http://localhost:8080/api/authenticate' -d '{"name":"user1","password":"pass1"}'
{"success":true,"message":"Authentication successfully finished.","token":"XXXXXX...XXXXXX"}

今度は認証が成功しました。レスポンスにも "token" が含まれています。最後にここで返ってきた token の値を指定して、先程アクセスできなかった GET /api を実行してみます:
$ curl -XGET 'http://localhost:8080/api?token=XXXXXX...XXXXXX'
{"message":"Welcome to API routing."}

今度は API が実行できました。同様にして GET /api/users も実行できるはずです:
$ curl -XGET 'http://localhost:8080/api/users?token=XXXXXX...XXXXXX'
[{"name":"user0","password":"pass0","admin":true},{"name":"user1","password":"pass1","admin":false},{"name":"user2","password":"pass2","admin":false},{"name":"user3","password":"pass3","admin":false}]


こんな感じで API の実行可否をトークンで制御できるようになりました。

今回の例ではソースコード内に静的に用意されたユーザー一覧を使って認証を行いましたが、実際の運用ではデータベース内に定義されたテーブル情報などを使うことになると思います。ただ JWT の基本的な考え方はこの1つのソースファイルだけで実現できているので、応用しやすいと思っています。

 

Tomcat やら Jetty やらといった Java アプリケーションコンテナ(Java アプリケーションサーバー)の種類に依存しない形でユーザー認証を実現するサンプル を作ってみました。実装にはフィルタを使います。また今回は認証の種類に Basic 認証を使っています。


まずは以下のような javax.servlet.Filter インターフェースの実装となるクラス: BasicAuthenticationFilter を作ります:
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.sun.xml.internal.messaging.saaj.packaging.mime.internet.MimeUtility;

public class BasicAuthenticationFilter implements Filter{
  //. レルム名
  private final String realmName = "myRealm";


  //. Filter の実装に必要なメソッド(何もしない)
  public void init( FilterConfig config ) throws ServletException{
  }
  public void destroy(){
  }

  //. フィルタリング処理の実装
  public void doFilter( ServletRequest req, ServletResponse res, FilterChain filterChain ) throws IOException, ServletException{
    ByteArrayInputStream bin = null;
    BufferedReader br = null;

    boolean isAuthorized = false; //. この値で認証の可否を判断する
    try{
      HttpServletRequest httpReq = ( HttpServletRequest )req;
      String basicAuthData = httpReq.getHeader( "authorization" );
      if( basicAuthData != null && basicAuthData.length() > 6 ){
        //. Basic認証から情報を取得
        String basicAuthBody = basicAuthData.substring( 6 ); //. 'Basic dG9tY2F0OnRvbWNhdA== ' 

        //. BASE64 デコード
        bin = new ByteArrayInputStream( basicAuthBody.getBytes() ); 
        br = new BufferedReader( new InputStreamReader( MimeUtility.decode( bin, "base64" ) ) );
        StringBuilder buf = new StringBuilder();
        String line = null;
        while ( ( line = br.readLine() )!=null ) {
          buf.append( line );
        }

        //. 入力された username と password を取り出す
        String[] loginInfo = buf.toString().split( ":" );
        String username = loginInfo[0];
        String password = loginInfo[1];
//.     System.out.println( "Basic " + username + ":" + password );

        //. 取り出した username と password で認証可否を判断する

        //. 実際にはここで LDAP やユーザー情報データベースと比較して判断することになる
        isAuthorized = true; //. 今回の例ではとりあえず何かが入力されていれば認証 OK とする
      }

      if( !isAuthorized ){
        //. (認証に何も指定されていなかった場合も含めて)認証 NG だった場合はブラウザに UnAuthorized エラー(401)を返す
        HttpServletResponse httpRes = ( HttpServletResponse )res;
        httpRes.setHeader( "WWW-Authenticate", "Basic realm=" + this.realmName );
        httpRes.setContentType( "text/html" );
        httpRes.sendError( HttpServletResponse.SC_UNAUTHORIZED ); //. 401

        //. 最初に認証なしでアクセスした場合はここを通るので、その結果ブラウザが認証ダイアログを出す、という流れ
      }else{
    	//. 認証 OK だった場合はそのまま処理を続ける
        filterChain.doFilter( req, res );
      }
    }catch( Exception e ){
      throw new ServletException( e );
    }finally{
      //. ストリームのクローズ
      try{
        if( bin!=null ) bin.close();
        if( br !=null ) br.close();
      }catch( Exception e ){
      }
    }
  }
}



また、web.xml の 内に以下の <filter> と <filter-mapping> の記述(青字部分)を追加します。この例では全ての URL (/*) に対して認証をかけるよう指定して います:
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://java.sun.com/xml/ns/javaee" 

xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee 

http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" id="WebApp_ID" version="3.0">
  <display-name>BasicAuth</display-name>
  <welcome-file-list>
    <welcome-file>index.html</welcome-file>
  </welcome-file-list>

  :
  :

  <!-- Filter Configuration -->
  <filter>
    <filter-name>basicAuthFilter</filter-name>
    <filter-class>me.juge.basicauth.BasicAuthenticationFilter</filter-class>
  </filter>

  <!-- Filter Mapping -->
  <filter-mapping>
    <filter-name>basicAuthFilter</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>

</web-app>



後は確認用の index.html として適当な内容のものを用意します:
<html>
<head>
<title>Hello</title>
</head>
<body>
<h1>ハローワールド!</h1>
</body>
</html>
↑超適当!


こうして作成した Java アプリケーションを動かして、ウェブブラウザからコンテキストルート(/) にアクセスすると、(初回は認証情報を付けずにアクセスする ので)作成したフィルタから 401 が返され、結果以下のような認証ダイアログが表示されるはずです:
2017031101


ここに適当な内容の文字列を入力して再度アクセスすると、(今度は何かが入っていたことになるので)上記サンプルでは認証 OK という判断になり、用意した index.html が表示される、という流れが実現できます:
2017031102



今回のサンプルはこちらに公開します:
https://github.com/dotnsf/BasicAuth












 

このページのトップヘ