IBM Cloud から提供されているサービスの1つである AppID は、オンラインサインアップやパスワード忘れなどにも対応したログイン機能全般を簡単にアプリに組み込める形で提供されているサービスです。

自分も何度か使ったことがあります。Node.js 向けに提供されている SDK を使うと、サービスの属性値や認証後のコールバック URL をいくつか設定するだけで、あらかじめ用意された UI を使った認証機能を独自アプリケーション内に組み込んだり、認証後のユーザー情報を簡単に取得することができます。多くのアプリケーションではユーザー情報やユーザー認証が必要になるのですが、いちいちそのアプリケーション向けに開発する手間が省けるため、とても有用に活用しています。


ただ、唯一の難点だったのが「あらかじめ用意された UI」でした。カスタマイズできる要素が非常に少なく(ロゴ画像を1つ指定できるくらい)、アプリケーションのUIを頑張って作っても「ログイン時だけ他のページと違うテイストのUI」になってしまうことが気になっていました。

このUI画面もカスタマイズできるらしい、という噂は聞いていたのですが、軽く調査した限りでは具体的な情報がなくわからず、つまりある程度自分でトライ&エラーしながら調べる必要がありそうな内容だったのですが、ちゃんと調べる時間が取れずに放っておいてしまいました。

この度、自分の興味が再度高まったこともあって、改めてこの App ID UI のカスタマイズについて調べてみました。どうやら実現できそうな目処が付いたので、本ブログエントリの形でその方法を紹介したいと思います。なお以下は Node.js + Express というフレームワーク環境を前提としていますが、SDK の対応言語であればおそらく他の言語やフレームワークでも大丈夫だと思います。


【AppID の準備】
何はともあれ AppID サービスインスタンスを用意して、アプリケーション開発用の属性情報を取得する必要があります。まずは開発前の AppID 側の準備作業を紹介します。

IBM Cloud にログインし、「リソースの作成」から "App ID" を検索して、見つけたらクリックします:
2021030304


ロケーション(東京とか)とプラン(ライトであれば2段階認証が使えないなどの制約がありますが無料です)を選択して「作成」します:
2021030305


少し待つとインスタンスが作成され、App ID が利用可能な状態になります:
2021030306


作成したこの App ID を外部から利用するための属性情報を取得します。「サービス資格情報」タブを選択し、「新規資格情報」ボタンをクリックして、資格情報を1つ作成します。作成後に資格名(「サービス資格情報-1」などになっているはずです)をクリックすると、資格情報が JSON フォーマットで表示されます:
2021030303


資格情報は概ねこのような内容になっています(***** 部は伏せ字)。このうち、以下5つの値は後で利用するのでメモしておくか、コピペできるようにしておきます:
{
  "apikey": "********************",
  "appidServiceEndpoint": "********************",
  "clientId": "********************",
  "discoveryEndpoint": "********************",
  "iam_apikey_description": "Auto-generated for key ********************",
  "iam_apikey_name": "サービス資格情報-1",
  "iam_role_crn": "crn:v1:bluemix:public:iam::::serviceRole:Manager",
  "iam_serviceid_crn": "crn:v1:bluemix:public:iam-identity::a/********************::serviceid:ServiceId-********************",
  "managementUrl": "https://jp-tok.appid.cloud.ibm.com/management/v4/********************",
  "oauthServerUrl": "https://jp-tok.appid.cloud.ibm.com/oauth/v4/********************",
  "profilesUrl": "https://jp-tok.appid.cloud.ibm.com",
  "secret": "********************",
  "tenantId": "********************"
}


後で使う値確認方法
regionprofilesUrl などのURL文字列の .appid.cloud.ibm.com の前に付いている部分(上の例だと jp-tok)
tenantIdtenantId キーの値
apiKeyapikey キーの値
secretsecret キーの値
clientIdclientId キーの値


次に AppID 自体を利用可能な状態にしておきます。まずログイン時に利用するにはユーザーが登録されている必要があるので、あらかじめユーザーを登録しておきます。

※ AppID 自体がオンラインサインアップの機能を持っているのでオンラインサインアップでユーザー登録することも可能ですが、その説明は今回のブログの目的ではないのと、ここで説明する内容を完結にしたい目的もあってここでは紹介しません。今回はオンラインサインアップや外部 SNS アカウントを用いたログイン、パスワード忘れ時の対応等、ログインに無関係な機能を OFF にして説明します。もちろんこれらの機能もカスタム UI で同様に提供することは可能ですが、今回の説明では対象外とします。

AppID のダッシュボード画面から「クラウド・ディレクトリー」-「ユーザー」を選択し、「ユーザーの作成」ボタンをクリックします:
2021030303


名前(名と姓、表示用)、Eメール(ログインIDとなります)、パスワードを入力して「保存」します。なおオンラインサインアップでアカウントを作成した場合はメール認証が必要ですが、この直接作成する方法でユーザーを作成した場合は保存後すぐに有効なアカウントとして(メール認証なしに)登録されます:
2021030304


ユーザーが登録されました。(今回の検証では1つあれば充分ですが)必要に応じて同じ手順でユーザーを追加してください:
2021030305


最後に App ID 自体の挙動を構成します。今回はオンラインサインアップやパスワード忘れ時の対応などをすべて無効にして、登録済みユーザーのログイン機能だけを提供するように構成します。

最初に認証時のリダイレクト URL を指定します。実はこのリダイレクト URL の指定が必要なのはカスタマイズしない標準のログイン UI を使う場合のみで、独自 UI を使う際には指定の必要がないものですが、今回は比較のためカスタマイズしない場合でのログインも行うのでここでリダイレクト URL も設定しておきます。

ダッシュボード画面から「認証の管理」を選択し、「認証設定」タブ内の「Web リダイレクト URL の追加」からリダイレクト先を追加します。今回のデモでは localhost で動かすことを想定したリダイレクト先が http://localhost:8080/appid/callback となるサンプルを利用するので、この URL を指定して追加します:
2021030306


次に不要な機能を無効にします。まずは SNS サインイン機能を無効にします。ダッシュボード画面で「認証の管理」から「ID プロバイダー」を選び、「クラウド・ディレクトリー」だけを有効にします(他をすべて無効にします):
2021030308


最後にダッシュボードの「設定」を選択し、「ユーザーがアプリケーションをサインアップできるようにする」、「ユーザーがアプリケーションからアカウントを設定できるようにする」を両方とも「いいえ」に設定します:
2021030307


これで App ID 側の準備はすべて完了しました。ではこの App ID を使ったログイン機能を試してみましょう。


【カスタマイズなしの場合】
本エントリは AppID の UI をカスタマイズする方法を紹介することが主目的ですが、比較の意味も含めて、まずはカスタマイズせずに標準UIを使ってログインする場合のアプリケーションサンプルを紹介します。

サンプルはこちらに用意しました。実際に試す場合は Node.js を導入したシステムにダウンロードして展開するか、git clone するなどしてソースコードを手元に用意してください:
https://github.com/dotnsf/appid_customui

2021030309


ソースコードの準備ができたら、展開後のディレクトリで一度 npm install を実行して、依存ライブラリをまとめて導入しておきます:
$ cd appid_customui
$ npm install

そしてテキストエディタで settings.js を開き、2~6行目までの5箇所に上述の資格情報から取得した値を入力します。8行目の exports.redirectUri の値は既定値以外のコールバック URL を設定した場合のみ、その値に変更してください。変更が完了したら保存します:
//. IBM App ID
exports.region = '(上述の region の値)';
exports.tenantId = '(上述の tenantId の値)';
exports.apiKey = '(上述の apikey の値)';
exports.secret = '(上述の secret の値)';
exports.clientId = '(上述の clientId の値)';

exports.redirectUri = 'http://localhost:8080/appid/callback';
exports.oauthServerUrl = 'https://' + exports.region + '.appid.cloud.ibm.com/oauth/v4/' + exports.tenantId;

コードの説明をする前に一度実際に動かして挙動を確認してみましょう。以下のコマンドでアプリケーションを起動します:
$ node app

App ID の初期化などで少し時間がかかりますが、10 秒ほどすると "server starting on 8080 ..." というメッセージが表示され、起動が完了します。このメッセージを確認したらウェブブラウザで以下のアドレスを開きます:
http://localhost:8080/

アプリケーションが正しく起動していれば、App ID が提供するログインページに転送され、以下のような標準のログイン画面が表示されます(ロゴを加える程度のカスタマイズは可能ですが、ちと味気ないですよね。。):
2021030303


ID とパスワードに、先程 App ID のユーザー登録を行った際のメールアドレス及びパスワードを指定して "Sign in" ボタンをクリックします:
2021030304


正しいメールアドレスとパスワードの組み合わせが入力されていればログインが成功します。ログインに成功すると改めて http://localhost:8080/ に転送され、ログインしたユーザーの名前とログアウトボタンだけが表示される画面に遷移します。これがログイン後のトップページとなります(この画面は、もうちょっと見た目がんばることができると思ってはいますが、、自分で自由にデザインできます):
2021030305


このサンプルではトップページ以外に遷移するページはありません。"Logout" ボタンをクリックするとログアウトして再度 http://localhost:8080/ へ遷移します。が、今度はログインしていないので再び標準ログイン画面のページに転送されます。ここまでが App ID 標準ログイン画面を使ったデモの挙動です:
2021030303


ここまでの(標準ログイン画面を使ったログインの)挙動をコードでどのように実現しているかを説明しておきます。まず node コマンドで起動した app.js の全容はこのようになっています:
//. app.js
var express = require( 'express' ),
    bodyParser = require( 'body-parser' ),
    ejs = require( 'ejs' ),
    passport = require( 'passport' ),
    session = require( 'express-session' ),
    WebAppStrategy = require( 'ibmcloud-appid' ).WebAppStrategy,
    app = express();

var settings = require( './settings' );

//. setup session
app.use( session({
  secret: 'appid_normalui',
  resave: false,
  saveUninitialized: false
}));

app.use( bodyParser.urlencoded( { extended: true } ) );
app.use( bodyParser.json() );
app.use( express.Router() );
app.use( express.static( __dirname + '/public' ) );

app.set( 'views', __dirname + '/views' );
app.set( 'view engine', 'ejs' );


//. setup passport
app.use( passport.initialize() );
app.use( passport.session() );
passport.use( new WebAppStrategy({
  tenantId: settings.tenantId,
  clientId: settings.clientId,
  secret: settings.secret,
  oauthServerUrl: settings.oauthServerUrl,
  redirectUri: settings.redirectUri
}));
passport.serializeUser( ( user, cb ) => cb( null, user ) );
passport.deserializeUser( ( user, cb ) => cb( null, user ) );


//. login UI(**カスタムログインページでは変更)
app.get( '/login', passport.authenticate( WebAppStrategy.STRATEGY_NAME, {
  successRedirect: '/',
  forceLogin: true,
}));

//. logout
app.get( '/appid/logout', function( req, res ){
  WebAppStrategy.logout( req );
  res.redirect( '/' );
});

//. callback(*カスタムログインページでは不要)
app.get( '/appid/callback', function( req, res, next ){
  next();
}, 
  passport.authenticate( WebAppStrategy.STRATEGY_NAME )
);

//. ログイン済みでないとトップページが見れないようにする
app.all( '/*', function( req, res, next ){
  if( !req.user || !req.user.sub ){
    //. ログイン済みでない場合は強制的にログインページへ
    res.redirect( '/login' );
  }else{
    next();
  }
});

//. トップページ
app.get( '/', function( req, res ){
  //. 正しくユーザー情報が取得できていれば、トップページでユーザー情報を表示する
  if( req.user ){
    res.render( 'index', { user: req.user } );
  }else{
    res.render( 'index', { user: null } );
  }
});


var port = process.env.PORT || 8080;
app.listen( port );
console.log( "server starting on " + port + " ..." );

情報量としてはあまり多くありませんが、ログイン後の画面となる views/index.ejs の内容も載せておきます。Logout ボタンをクリックすると GET /appid/logout が実行されるようになっていることと、その下にログインユーザーの名前(user.name)が表示されるテンプレートになっています:
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8"/>
<script type="text/javascript" src="//code.jquery.com/jquery-2.2.4.min.js"></script>
<link href="//maxcdn.bootstrapcdn.com/bootstrap/4.3.0/css/bootstrap.min.css" rel="stylesheet"/>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/4.3.0/js/bootstrap.min.js"></script>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="black"/>
<meta name="apple-mobile-web-app-title" content="App ID Custom UI"/>
<title>App ID Custom UI</title>
<script>
</script>
<style type="text/css">
</style> 
</head>
<body>

<div class="container">
  <div>
    <a class="btn btn-warning" href="/appid/logout">Logout</a>
  </div>

  <% if( user && user.name ){ %>
  <h2><%= user.name %></h2>
  <% }else{ %>
  <h2>(App ID)</h2>
  <% } %>
</div>

</body>
</html>


具体的な詳しい内容はこのコードを見ていただくとして、この中には以下の4つの HTTP リクエストに対するルーティング(と1つのルール)が含まれています:
#HTTP リクエスト挙動
1GET /loginApp ID標準ログイン画面の表示。ログインが成功すると GET / (#5)の処理へ転送されます。
2GET /appid/logoutログアウト処理。その後 GET / (#5) を実行しようとするが、この時点ではログインしていないので、#4 のルールによって GET /login (#1) へ転送されて標準ログイン画面を表示します。
3GET /appid/callback標準ログイン画面のコールバック関数。正しいログイン組み合わせの情報が送信された場合は next()(処理続行)となり、間違った組み合わせの場合は再度ログイン画面に戻ります。
4ALL /*(ここから下のルーティングすべてに適用するルール)ログインされていない場合は強制的に GET /login (#1) へリダイレクトして標準ログイン画面に移動します。
5GET /index.ejs を使ってトップ画面を表示する。#4 のルールがあるのでログイン後の場合のみ表示します。


先程確認した挙動をもう一度このリストを使って順に説明します。まずブラウザで http://localhost:8080/ にアクセスすると #5 のルーティングが処理される、、ように見えますが、その前に #4 が定義されています。最初はログインしていない状態で実行されるため、まずは GET /login( #1 の処理)へ転送され、App ID の標準ログイン画面が表示されます。

ログイン画面で正しい情報を入力して Sign in ボタンを押すと、ログイン済みになった上で #3 へ移動し、処理が続行(#1)され 、ログイン済みなので改めて #5 (http://localhost:8080/)へ転送されます。

最初と同じ #5 のルーティングが処理されますが、今回はログイン済みのため #4 のルールは適用されません。そのまま #5 の処理が実施され、ログイン後の画面が表示されることになります。この後のオペレーションはすべて「ログイン済み」な状態として行われることになります:
2021030404


ログイン後のトップ画面で logout ボタンを押すと #2 が実行され、ログイン情報が消えた状態になってから再度 http://localhost:8080/ へ転送され(つまり最初に戻り)、標準のログイン画面が表示されます。


ちょっと長くなりましたが、これが App ID の標準ログイン画面を使った時のログイン~ログアウトまでのルーティングとその遷移となります:



【UI をカスタマイズする場合】
標準ログイン画面を使った場合の状態遷移がわかった所で、改めてログイン画面の独自カスタマイズについて紹介します。こちらもまずは一度動かしてみることにしましょう。以下のコマンドでアプリケーションを起動します(先程は app.js を起動しましたが、こちらでは app_customlogin.js を起動します):
$ node app_customlogin

App ID の初期化などで少し時間がかかりますが、10 秒ほどすると "server starting on 8080 ..." というメッセージが表示され、起動が完了します。このメッセージを確認したらウェブブラウザで以下のアドレスを開きます:
http://localhost:8080/

アプリケーションが正しく起動していれば、先程のような App ID 標準ログイン画面とは異なる、以下のような独自のログイン画面が表示されます:
2021030303


App ID にユーザー登録した際の username(メールアドレス)と password(パスワード)の組み合わせを入力して "Login" ボタンをクリックします:
2021030304


正しい組み合わせが入力できていれば、ログイン後の画面(名前が表示されている画面)に遷移します。そして "Logout" ボタンをクリックします:
2021030305


するとログアウトが行われ、あらためて元のカスタマイズされたログイン画面に遷移します:
2021030303


カスタマイズされたログイン画面を使う場合であっても、App ID に登録されたユーザー情報を用いて標準ログイン画面の時と同様の挙動や状態遷移が行われることを確認できました。

ではこちらのコードを確認してみます。起動した app_customlogin.js の内容は以下のようになっています:
//. app_customlogin.js
var express = require( 'express' ),
    bodyParser = require( 'body-parser' ),
    ejs = require( 'ejs' ),
    passport = require( 'passport' ),
    session = require( 'express-session' ),
    WebAppStrategy = require( 'ibmcloud-appid' ).WebAppStrategy,
    app = express();

var settings = require( './settings' );

//. setup session
app.use( session({
  secret: 'appid_customui',
  resave: false,
  saveUninitialized: false
}));

app.use( bodyParser.urlencoded( { extended: true } ) );
app.use( bodyParser.json() );
app.use( express.Router() );
app.use( express.static( __dirname + '/public' ) );

app.set( 'views', __dirname + '/views' );
app.set( 'view engine', 'ejs' );


//. setup passport
app.use( passport.initialize() );
app.use( passport.session() );
passport.use( new WebAppStrategy({
  tenantId: settings.tenantId,
  clientId: settings.clientId,
  secret: settings.secret,
  oauthServerUrl: settings.oauthServerUrl,
  redirectUri: settings.redirectUri
}));
passport.serializeUser( ( user, cb ) => cb( null, user ) );
passport.deserializeUser( ( user, cb ) => cb( null, user ) );


//. login UI(**カスタムログインページで変更)
app.get( '/login', function( req, res ){
  res.render( 'login', {} );
});

//. logout
app.get( '/appid/logout', function( req, res ){
  WebAppStrategy.logout( req );
  res.redirect( '/login' );
});

//. login submit(***カスタムログインページで追加)
app.post( '/appid/login/submit', bodyParser.urlencoded({extended: false}), passport.authenticate(WebAppStrategy.STRATEGY_NAME, {
  successRedirect: '/',
  failureRedirect: '/login',
  failureFlash : true
}));

//. ログイン済みでないとトップページが見れないようにする
app.all( '/*', function( req, res, next ){
  if( !req.user || !req.user.sub ){
    //. ログイン済みでない場合は強制的にログインページへ
    res.redirect( '/login' );
  }else{
    next();
  }
});

//. トップページ
app.get( '/', function( req, res ){
  //. 正しくユーザー情報が取得できていれば、トップページでユーザー情報を表示する
  if( req.user ){
    res.render( 'index', { user: req.user } );
  }else{
    res.render( 'index', { user: null } );
  }
});


var port = process.env.PORT || 8080;
app.listen( port );
console.log( "server starting on " + port + " ..." );

↑わざと色を付けています。は app.js から変更した部分、は app.js にはなくて追加した部分です。また app.get( '/appid/callback', function( req, res, next ){ ... で始まるコールバック処理の部分は、カスタムログイン画面を使う場合は不要なので削除しています。実はほぼ変わっていなくて、削除した部分も含めて大きく3箇所しか変わっていないことがわかると思います。

加えて、カスタムログイン画面となる views/login.ejs の内容もここに記載しておきます。index.ejs 同様、Bootstrap を使っていますが、ごくシンプルな内容の HTML になっています。特徴として入力した username と password の値が上で新たに追加した POST /appid/login/submit にポストするように作られていることがわかります:
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8"/>
<script type="text/javascript" src="//code.jquery.com/jquery-2.2.4.min.js"></script>
<link href="//maxcdn.bootstrapcdn.com/bootstrap/4.3.0/css/bootstrap.min.css" rel="stylesheet"/>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/4.3.0/js/bootstrap.min.js"></script>
<link href="//stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="black"/>
<meta name="apple-mobile-web-app-title" content="App ID Custom UI"/>
<title>App ID Custom UI - Login</title>

<style type="text/css">
</style> 

<script>
</script>

</head>
<body>

<div class="container">
  <form method="POST" action="/appid/login/submit">
    <table class="table table-bordered">
      <tbody>
        <tr><th>username</th><td><input type="text" class="form-control" name="username" id="username" value=""/></td></tr>
        <tr><th>password</th><td><input type="password" class="form-control" name="password" id="password" value=""/></td></tr>
      </tbody>
    </table>
    <input type="submit" class="btn btn-primary" value="Login"/>
  </form>
</div>

</body>
</html>


このカスタムログイン画面を使うケースでも、HTTP リクエストとルーティングの関係を表にしてみました:
#HTTP リクエスト挙動
1GET /loginカスタムログイン画面の表示。ログインが成功すると GET / (#5)の処理へ転送されます。
2GET /appid/logoutログアウト処理。その後 GET / (#5) を実行しようとするが、この時点ではログインしていないので、#4 のルールによって GET /login (#1) へ転送されてカスタムログイン画面を表示します。
3POST /appid/login/submitカスタムログイン画面で username と password を入力して Login ボタンを押した時に実行される関数。正しいログイン組み合わせの情報が送信された場合は GET / (#5) が実行されてトップ画面へ、間違った組み合わせの場合は GET /login (#1) が実行され、再度ログイン画面に戻ります。
4ALL /*(ここから下のルーティングすべてに適用するルール)ログインされていない場合は強制的に GET /login (#1) へリダイレクトしてカスタムログイン画面に移動します。
5GET /index.ejs を使ってトップ画面を表示する。#4 のルールがあるのでログイン後の場合のみ表示します。


まずブラウザで http://localhost:8080/ にアクセスすると App ID の標準ログイン画面を使った時と同様に(最初はログインしていない状態で実行されるため)まずは GET /login( #1 の処理)へ転送されます。その結果、カスタムログイン画面が表示されます。

ログイン画面で正しい情報を入力して Sign in ボタンを押すと、#3 の POST /appid/login/submit が実行されます。情報が正しい場合、ログイン済みになった上で #5 へ移動します。今度はログイン済みなのでログイン後の画面が表示されることになります。この後のオペレーションはすべて「ログイン済み」な状態として行われることになります:


2021030404


というわけで、英語でもあまり情報の見つかられなかった App ID のカスタムログインページ作成方法についてまとめてみました。


なお、本ブログエントリと、ここで紹介したサンプルを作る上で参考にしたのはこの Github リポジトリです。使い方の説明が丁寧にかかれているわけではないのですが、カスタムログインページだけでなく、パスワード忘れ時の対応画面など、一通りの App ID カスタム画面を開発するためのサンプルが含まれている(と思います):
https://github.com/ibm-cloud-security/appid-serversdk-nodejs