2024年1月25日木曜日

OpenID for Verifiable Credentialsの正式セキュリティ分析が完了

こんにちは、富士榮です。

先日のOpenID Summitや前日のOpenID Foundation Workshopでも触れられましたが、OpenID for Verifiable Credentialsの正式セキュリティ分析(Formal Security Analysis)が完了した、というニュースが1月18日にリリースされています。


このセキュリティ分析はドイツのシュトゥットガルト大学によって開発されたモデルを用いていて、これまでもOpenID ConnectやFAPIなどのプロトコルやプロファイルとしての安全性を検証するための用いられてきています。

特にFAPIが金融グレードのセキュリティを求めるプロファイルに対応するのと同様に、OpenID for Verifiable CredentialsはEUにおけるeIDAS2.0の枠組みの中で採用されるなど、国が発行するデジタル資格証明を安全にやり取りする上で非常に重要なプロトコルとなります。
そのため、このようなセキュリティ分析とお墨付きは相互運用性に加えて非常に重要な要素となりますね。

こちらが実際の分析結果のレポートへのリンクです。

100ページ以上ある詳細な分析なので、全部に目を通すのは難易度が高いですが、ざっくりいうと、以下の4つのシナリオを定義して詳細な分析をしていくという形態をとっています。
  • Proof of Presentation Authentication
  • Proof of Issuance Authentication
  • Proof of Presentation Session Integrity
  • Proof of Issuance Session Integrity
非常にシンプルで相手方をどのように認証するのか、という話およびSessionの完全性がどのように担保されるのか、という話をVCの発行(Issuance)と提示(Presentation)の両面で分析をかけていくという方法のようです。この辺りは通常のOpenID ConnectにおけるOpenID Provider、Relying Party、User-Agentでもstateやnonce、client_id/secretなどを使って同じことをやっているわけです。

分析結果を少し紹介します。
今回はIssuanceに関する指摘です。3点指摘がされています。

1. Cross-deviceシナリオにおけるIssuance時のカスタムURLスキーム
  • これはOpenID for Verifiable Credentialsに関わらず、ですがQRコードをスマートフォンに読み込ませるなど、PCブラウザと別のデバイスと組み合わせるシナリオにはつきもののカスタムURLスキーム(OID4VCIではopenid-credential-offer://)を使う場合に絶対に発生する課題です。
  • QRコードなどでこのカスタムURLスキーム起動されると、当該のスキームが登録されたネイティブアプリケーションが起動するわけですが、複数のWalletアプリケーションが同一の端末にインストールされている場合は、どのWalletが起動するのかの制御をIssuer側は制御できません。(少なくともiOSの場合は最後にインストールしたアプリ、Androidの場合はユーザが選択したアプリが選ばれる)
  • そうなると例えば悪意のあるアプリが当該カスタムURLスキームを使うと意図しないアプリ事業者へVerifiable Credentialsが渡されてしまうことが想定されます。
なお、このリスクに対応するためにQRコードを読み取る際にWallet側でPINコードを入力させるなど対策を行うわけですが、悪意のあるWalletだったとしても利用者はPINを入力してしまうことも想定されるため、根本的な解決は難しそうです。この辺りはUIの工夫なども必要な領域のようですね。

2. 認可コードフローにおける認証攻撃
  • この攻撃は悪意のあるWalletアプリケーションが認可コードフローを用いる場合に発生します。
  • よくある話としてアプリケーションを起動する際にユーザ認証を求め、多くの場合ユーザはなんの疑いもなくログインをしてしまいます。
  • こうなると悪意のあるアプリケーションに認可コードが渡るので(これは先のカスタムURLスキームの場合も含め)、悪意のあるアプリケーションがアクセストークンを取得、結果としてVerifiable Credentialsの発行をされてしまいます。
この攻撃は悩ましいところですね。アプリ内ブラウザでの認証と外部ブラウザ呼び出し〜カスタムURLスキームでの戻しの区別とかユーザにはつかないと思いますし。。

3. 事前認可コードフローにおけるセッションの完全性攻撃
  • 攻撃者が事前に認証し、クレデンシャルオファーを入手しておきます。
  • この攻撃者のIDを持つクレデンシャルをフィッシングサイトなどにQRコードとして埋め込んでおき、攻撃対象者の読み込ませることで、攻撃対象者のWalletに攻撃者のIDを持つクレデンシャルを発行することができてしまいます。
  • このクレデンシャル自体は正しく署名されたものなので、このクレデンシャルを用いてアクセスコントロールをしているサイトへ攻撃対象が気づかずに情報をアップロードするなどすると情報漏洩に繋がってしまいます。
この辺りもなかなか気づきにくいのでしょうが、他のフィッシング対策と合わせて防御していくしかないんじゃないかな、と思います。

とはいえ、引き続き仕様のアップデートは続いていますし、このフィードバックを受けて仕様のブラッシュアップも進んでいるので今後どうなっていくのかは引き続き要注目です。

今日はこのくらいにしておきます。
機会があればVerifiable Presentation側の分析結果も紹介します。



2024年1月24日水曜日

OpenID Providerを作る)認可エンドポイントでクライアントの登録状態を検証する

こんにちは、富士榮です。

前回はトークンエンドポイントでクライアント認証を実装したので、今回は認可エンドポイントに戻り、クライアントの登録状態および指定されたredirect_uriが正しいかどうかの検証処理を入れてみます。


その前にこれまでのおさらいです。


さて始めていきましょう。
今回は検証ということでエラー処理に関する仕様を中心に確認していく必要があります。
ポイントになるのは、この辺りです。

これらの章から分かることは、以下の2点です。
  • redirect_uriが不正ならOpenID Providerが直接エラーを返し、それ以外の場合はredirect_uriへコールバックすること(関連する記載:Redirection URI が無効でない限り, Authorization Server は適切なエラーと state パラメータで Authorization Request にて指定された Redirection URI に Client を返す.)
  • エラーレスポンスは各フローのredirect_uriへのコールバック方法に従って返却すること(要はコードフローなら&、インプリシット・ハイブリッドなら#で返せ、ということです)

また、redirect_uriのマッチングルールは認証リクエストに関する仕様に以下のように定められていますので、この辺りも確認ポイントです。
この URI は, Client が OpenID Provider に対して事前に登録済みの Redirection URI のいずれかと完全一致しなければならない (MUST). マッチングルールは [RFC3986] (Simple String Comparison) の Section 6.2.1 に従うこと.

要するに完全一致が必要ってことですね。ワイルドカードが使いたい、という要望はよく聞かれますがいわゆるオープンリダイレクトになってしまう可能性があることから仕様上は完全一致が要求されます。 


それらを踏まえ実装してみます。

まずはエラーの返却方法がresponse_typeによって異なることからクライアント検証を行う前にresponse_typeを判別しておく必要があります。これは前回のスコープのところで実装したロジックをそのまま使います。

// エラーの返却方法にも関連するためresponse_typeの判別は最初にやっておく
// response_typeの取得
const response_types = req.query.response_type.split(" ");

次にこれもトークンエンドポイントのところで実装したクライアント情報の取得ロジックを流用して登録状態を判別します。

// - client_idの登録状態の確認
// クライアント情報の読み取り
const clients = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../database/clients.json")));
// クライアント登録状態の確認
const client = clients.find(i => i.client_id === req.query.client_id);
if(typeof client === "undefined"){
// クライアントが未登録
res.redirect(req.query.redirect_uri + errors.errorOnAuthZ(response_types, "invalid_request", "unknown_client_id", req.query.state));
}else{

エラーハンドリングは共通処理となるのでこんな感じで外部化しておきます。

exports.errorOnAuthZ = function(response_types, error_code, error_description, state) {
// implicitもしくはHybridを判定するフラグ(フラグメントでレスポンスを返すかどうかの判定)
let mode;
if(response_types.includes("token") || response_types.includes("id_token")){
// ImplicitもしくはHybridフロー
mode = "#";
}else{
// codeフロー
mode = "&";
}
return(mode + "error=" + error_code + "&error_description=" + error_description + "&state=" + state);
}

次はredirect_uriのマッチングです。ここも登録状態の確認とほぼほぼ類似のロジックですが、未登録のredirect_uriの場合はOpenID Providerから直接エラーを返却するようにしています。

// redirect_uriがclient設定に合致していることの確認
if(!client.redirect_uris.includes(req.query.redirect_uri)){
// redirect_uri未登録
// redirect_uriが不正なのでOPから直接エラーを返却する
res.status = 400;
res.json({
error: "invalid_request",
error_description: "unknown_redirect_uri",
state: req.query.state
})
}else{


と、こんなところです。

ここまでの実装もこちらにプッシュしてありますので参考にしてください。


2024年1月23日火曜日

OpenID Providerを作る)トークンエンドポイントにクライアント認証を実装する

こんにちは、富士榮です。

そろそろトークンエンドポイントに手を入れていきましょう。今回はクライアントの登録状態の確認と認証を行ってみます。このあたりから登録情報を持つ必要が出てきます。(といっても まだデータベースを使うまでもないのでjsonファイルを使っていきます)

その前にこれまでのおさらいです。


クライアントに関する情報として何を管理すべきか

今回の実装においてはクライアント認証さえできればいいので最低限client_idとclient_secretがあれば問題ありませんが、本来はclient_idと紐付けて管理されるべき情報としてredirect_uriや同意画面を出そうと思うとクライアント名やクライアントに関する説明やロゴ画像、利用規約などの情報も必要になりますし、登録状態の管理も行おうと思うと登録日や更新日、有効・無効などのステータス、管理者の連絡先などの情報も必要になってくると思います。

といってもそこまで必要になるのはもう少し先の話なので、まずは最低限+αということで名称、ID、シークレット、redirect_uriをファイルに保存しておきます。なお、当然複数のクライアントを登録できるようにするためJSON配列でデータを保存しておきます。
/database/clients.json
[
{
"client_id": "123",
"client_secret": "secret",
"redirect_uris": [
"https://jwt.ms",
"http://localhost:3000/cb"
]
},
{
"client_id": "456",
"client_secret": "secret",
"redirect_uris": [
"https://rp.example.jp/cb",
"https://rp.example.com/cb"
]
}
]

redirect_uriも複数登録できる必要があるので配列にしています。(今回は利用しませんが)

クライアント認証方式を決定する

仕様の9章にclient_authenticationの定義があります。
  • client_secret_basic
    • いわゆるBASIC認証を行う
  • client_secret_post
    • リクエストのBodyにclient_idとclient_secretを入れて送信する
  • client_secret_jwt
    • client_assertionパラメータにJWTを入れて送信する。署名アルゴリズムはHMAC
  • private_key_jwt
    • client_secret_jwtとの違いは署名に秘密鍵を利用すること
  • none
    • クライアント認証を行わない
ベーシックな実装では基本的にclient_secret_basicとclient_secret_postくらいを実装しておけば問題ないので、まずはこの2つを実装しておきます。

クライアント認証を実装する

対象はタイトルの通りトークンエンドポイントなので、当該のエンドポイントにexpress-basic-authを使っても良かったのですが、client_secret_postもサポートするためにミドルウェアを使わずに実装します。といってもAuthorizationヘッダに"BASIC client_id(base64エンコード):client_secret(base64エンコード)”という形で値が渡ってきているだけなので、パースしてデコードするだけです。
今回は簡易実装なのでAuthorizationヘッダがあればclient_secret_basic、そうでなければclient_secret_postとして判別しています。
// - クライアントの認証
let client_id, client_secret;
if(typeof req.headers.authorization === "undefined"){
// client_secret_post
client_id = req.body.client_id;
client_secret = req.body.client_secret;
}else{
// client_secret_basic
const b64auth = req.headers.authorization.split(" ")[1];
[ client_id, client_secret ] = Buffer.from(b64auth, "base64").toString().split(":");
}

ちなみにclient_secret_postの場合はそのままclient_idとclient_secretがボディに入ってくるだけなので値を取得しています。

次は認証処理です。といっても先のjsonファイル内のclient_id/client_secretとマッチしているかどうかを確認するだけです。
// クライアント情報の読み取り
const clients = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../database/clients.json")));
// クライアント登録状態の確認
const client = clients.find(i => i.client_id === client_id);
if(typeof client === "undefined"){
// クライアントが未登録
res.statusCode = 400;
res.json({
errorMessage: "client not found"
});
}else{
// クライアント登録確認、シークレットの検証
if(client.client_secret !== client_secret){
// クライアント認証エラー
res.statusCode = 400;
res.json({
errorMessage: "client authentication was failed"
});
}else{
// クライアント認証成功

シンプルです。
ファイルを読み込んで、client_idで情報を検索、登録されているclient_secretの値を比較するだけです。

ということでこれでトークンエンドポイントのクライアント認証が実装できました。

client_secret_basicの認証エラーです。

client_secret_postの認証エラー(未登録)です。


今回はこんなところです。






2024年1月22日月曜日

OpenID Providerを作る)定義済み属性の値として何を返却すべきか

こんにちは、富士榮です。

前回はscopeの定義と返却すべき属性について整理をしました。

ちなみに仕様の5章に定義されているStandard ClaimsはIANAのレジストリに登録されているのでOpenID ProviderとRelying Partyの間で個別にフォーマットや値のすり合わせをしなくても良いように標準化されています。登録済みのClaimについては18章に記載されています。

ですので、OpenID Providerを作る時は、基本的にこのStandard Claimをサポートするようにし不足している属性はRelying Partyと個別に調整を行った上で定義する必要があります。

今回はStandard Claimsとして実際にどのような値を返すべきか確認していきましょう。


その前にこれまでのおさらいです。


では、早速確認していきます。
昨日のStandard Claims一覧を再掲しておきます。
scope属性形式備考
profile
namestring
family_namestring
given_namestring
middle_namestring
nick_namestring
preffered_usernamestring
profilestringURL
picturestringURL
websitestringURL
genderstringfemale/maleを利用
birthdatestringISO 8601:2004形式(YYYY-MM-DD)
zoneinfostringIANAタイムゾーン形式(JapanやAmerica/Los_Angelesなど)
localestringBCP47言語タグ表現(ja_JP、en_USなど)
updated_atnumberunixtime
email
emailstring
email_verifiedboolean検証済みならtrue
address
formattedstring
JSON
street_addressstring
localitystring
regionstring
postal_codestring
countrystring
phone
phone_numberstringE.164形式
phone_number_verifiedboolean検証済みならtrue

nameなどのstring形式の属性についてはあまり迷うことはないと思うので、迷いそうなところだけピックアップしておきたいと思います。
  • profile
    • この属性にはURLを返却することが想定されています。仕様をみると「この Web ページに掲載されるコンテンツはこの End-User に関するものであるべきである (SHOULD).」とあるので、もしOpenID Providerがマイページやプロフィールページを持っているならそのURLなどを使うと良いと思います。
  • picture
    • この属性も同じくURLですが、このような注意点が定義されています。
      • この URL は画像ファイル (PNG, JPEG, GIF 画像ファイル等) を参照すること (MUST). またこの画像は End-User が撮影した任意の写真ではなく, End-User に言及する際の表示に適切なプロフィール画像とするべきである (SHOULD).
    • 利用者自身でアップロードできたる編集するものじゃなさそうですが、OpenID Providerの種類(コンシューマ向けなのかエンタープライズ向けなのか)でどのような写真を使うのかは変わってきそうです
  • website
    • この属性もURLです。こちらも以下の注意点が記載されています。
      • この Web ページは End-User 自身や End-User が所属する組織が発信する情報を含むべきである (SHOULD).
    • こちらもシナリオ次第ですね
  • email
    • 想像通りだと思いますがメールアドレスのSyntax(RFC 5322)に従う必要があります
  • gender
    • string形式ですが使用上はmale/femaleが定義されています。しかしながら最近のID基盤ではそれ以外の値を定義することもあります。仕様にはその他の値を設定することも許容しています
      • 定義済の値に適切なものがない場合, その他の値を利用してもよい (MAY).
  • birthdate
    • こちらもstring形式ですがISO 8601:2004に準拠すべきです。仕様には以下の記載があります。
      • ISO 8601:2004 YYYY-MM-DD 形式で表現される. 生年を 0000 とすることで生年を省略することもできる (MAY). 生年のみを提示する場合は YYYY 形式としても良い. 利用するプラットフォームの日付関連の関数の実装によって, 生年のみを提供した場合の月日の扱いは様々であるため, 実装者はこの点を考慮にいれて日付を処理すべきである.
  • zoneinfo
    • こちらもstring形式です。ユーザのタイムゾーンを表す属性ですので、IANAのタイムゾーンレジストリに登録されているコードを使うことが想定されています。
    • 例えば日本ならJapan、北米/ロサンゼルスならAmerica/Los_Angelesです
  • locale
    • こちらもstring形式ですが、結構鬼門だと思っています。仕様には以下の記載があります
      • BCP47言語タグ表現. これは通常 ISO 639-1 Alpha-2 言語コードを小文字表記, ISO 3166-1 Alpha-2国コードを大文字表記し, ダッシュでつなげたものである. (en-US, fr-CA 等) 実装によってはダッシュの代わりにアンダースコアを区切り文字に用いる場合もあるため, 互換性の観点からは注意すること. (en_US 等) 
    • いわゆるISOのAlpha-2言語コード(jaとかen)とAlpha-2区にコード(jpとかus)を大文字表記したものをダッシュ(ハイフン)もしくはアンダースコアで繋げたものという緩めの仕様なので、ja-JPもしくはja_JPという形で値が表現されます。この辺りはRelying Partyに対してどのような表現になるのか事前に伝達しておく必要があります
  • phone_number
    • こちらもstring形式ですが、locale同様に少し揺れる要素があります。原則はE.164形式を推奨するようですが、内線番号の有無や区切り文字やカッコの有無などは考慮が必要です
      • この Claim のフォーマットとしては E.164を推奨する (RECOMMENDED). (+1 (425) 555-1212, +56 (2) 687 2400 等) 電話番号が拡張を含む場合, その拡張は RFC 3966 拡張シンタックスで表記することを推奨する (RECOMMENDED). (+1 (604) 555-1234;ext=5678 等)
    • E.164の仕様だけ見ていると+[国番号]に市外局番の最初の0をとったものと局番をつなげたものということですが、実装を見ているとハイフンの有無やカッコの有無が揺れているようにも見えます。この辺りも事前にRelying Partyに伝達しておいた方が良さそうです
  • updated_at
    • こちらはnumberとして定義されていますので要注意です。UTC 1970年1月1日の0:00:00からの経過秒数、いわゆるunix timeをセットする必要があります

今日はこのくらいにしておきたいと思います。




2024年1月21日日曜日

OpenID Providerを作る)scopeの定義と返却する属性

こんにちは、富士榮です。

積み残した実装を徐々に進めていきましょう。

その前にこれまでのおさらいです。


今回はscopeです。
OpenID Connect coreの仕様の該当箇所ではscopeと対応する属性について、このように定義されています。(簡略化しています)
scope属性形式備考
profile
namestring
family_namestring
given_namestring
middle_namestring
nick_namestring
preffered_usernamestring
profilestringURL
picturestringURL
websitestringURL
genderstringfemale/maleを利用
birthdatestringISO 8601:2004形式(YYYY-MM-DD)
zoneinfostringIANAタイムゾーン形式(JapanやAmerica/Los_Angelesなど)
localestringBCP47言語タグ表現(ja_JP、en_USなど)
updated_atnumberunixtime
email
emailstring
email_verifiedboolean検証済みならtrue
address
formattedstring
JSON
street_addressstring
localitystring
regionstring
postal_codestring
countrystring
phone
phone_numberstringE.164形式
phone_number_verifiedboolean検証済みならtrue

なお、上記とは別にOpenID ProviderをOAuth2.0の認可サーバと兼ねる場合でOpenID Connectプロトコルを使う場合は「openid」scopeの指定が必須となります。


例によってユーザ情報はハードコードしていますが、こんな感じの処理になるはずです。
/utils/user.js
exports.getUserIdentity = function(scopes) {
// ユーザデータの定義
let userIdentity = {
local_identifier: "test",
// profile scope
name: "taro test",
given_name: "taro",
family_name: "test",
middle_name: "",
nickname: "",
preferred_username: "test@example.jp",
profile: "https://twitter.com/phr_eidentity",
picture: "https://1.gravatar.com/avatar/25eee85430bd0bbdcb9cff75655afa43cc9f69bc8730aec852d8538179646ef1",
website: "hhtps://idmlab.eidentity.jp",
gender: "male",
birthdate: "1900-01-01",
zoneinfo: "Japan",
locale: "jp_JP",
updated_at: 1704034800,
// email scope
email: "test@example.jp",
email_verified: true,
// address scope
address: {
formatted: "Kokyogaien, Chiyoda-ku, Tokyo 1000002 JAPAN",
street_address: "Kokyogaien",
locality: "Chiyoda-ku",
region: "Tokyo",
postal_code: "1000002",
country: "JP"
},
// phone scope
phone_number: "+81-3-1234-5678",
phone_number_verified: true
}
// スコープによって返却する属性の絞り込み
if(!scopes.includes("profile")){
delete userIdentity.name;
delete userIdentity.given_name;
delete userIdentity.family_name;
delete userIdentity.middle_name;
delete userIdentity.nickname;
delete userIdentity.preferred_username;
delete userIdentity.profile;
delete userIdentity.picture;
delete userIdentity.website;
delete userIdentity.gender;
delete userIdentity.birthdate;
delete userIdentity.zoneinfo;
delete userIdentity.locale;
delete userIdentity.updated_at
};
if(!scopes.includes("email")){
delete userIdentity.email;
delete userIdentity.email_verified;
};
if(!scopes.includes("address")){
delete userIdentity.address;
};
if(!scopes.includes("phone")){
delete userIdentity.phone_number;
delete userIdentity.phone_number_verified;
};
return userIdentity;
}

scopeによってユーザ情報から必要な値のみを返すようにしています。

また、前回のPairwise識別子の実装と合わせてid_tokenのペイロードを作る処理は上記をコールする形で以下のように実装しています。
/oauth2/oauth2.js
//
// scope関連の処理
//
// scopeの判断
const scopes = req.query.scope.split(" ");
// 本来はscopeにopenidが入っていない場合はエラーとする(仕様上はopenidが含まれない場合の動作は未定義)
// scopeに応じたユーザの情報を取得する
let payload = userIdentity.getUserIdentity(scopes);
// Pairwise識別子の生成
const PPID = utils.createPPID(payload.local_identifier, req.query.redirect_uri);
// ローカル識別子の削除
delete payload.local_identifier;
// PPIDをsubとして設定
payload.sub = PPID;

これで例えば、openid、address、phoneを指定するとこんな感じでid_tokenが帰ってきます。
http://localhost:3000/oauth2/authorize?scope=openid%20address%20phone&response_type=id_token&client_id=111&redirect_uri=https://jwt.ms&state=hoge


必要な属性だけがid_tokenに含まれていることがわかります。

また、今回対応するscopeと属性を増やしたのでメタデータ(/discovery.js)も更新しておきましょう。
const scopes = ["openid", "profile", "email", "address", "phone"];

const claims_openid = ["sub", "iss", "aud", "exp", "iat", "nonce", "c_hash", "at_hash"];
const claims_profile = ["name", "family_name", "given_name", "middle_name", "nick_name", "preffered_username", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale", "updated_at"];
const claims_email = ["email", "email_verified"];
const claims_address = ["address"];
const claims_phone = ["phone_number", "phone_number_verified"];
const claims = claims_openid.concat(claims_profile, claims_email, claims_address, claims_phone);

今回のUpdateを含むコードはこちらにあげてありますので参考にしてください。


2024年1月20日土曜日

非機能系を中心にログイン時の処理について考えてみる

こんにちは、富士榮です。

OpenID Providerを作っていくとメインの機能ではないけど必要な属性情報を扱う必要性に気がついてきます。

例えば、最終ログイン日付とか利用しているOSやブラウザの種別などの環境要素なども代表的なものの一つだと思います。

これらの情報をどうやってユーザデータベースに保存するか、そしてこれらの情報をどのタイミングで読み書きするのか、がID基盤の性能やセキュリティ、スケーラビリティなどのいわゆる非機能系の設計を行う上では重要な要素になりそうです。今回はそれらの属性の取り扱いを含むログイン処理の実装について考えてみたいと思います。


非機能系で利用できそうな属性とは?

ちょっとこの辺を考えてみましょう。

例えばこんな区分の属性が考えられるかもしれません。

属性区分属性の例用途
環境情報
アクセス元IPアドレス地域によるアクセス制限、前回と異なる環境からのアクセスの検知(リスク要素)
利用ブラウザ前回と異なる環境からのアクセスの検知(リスク要素)、トラブル時の原因解析
利用OS前回と異なる環境からのアクセスの検知(リスク要素)、トラブル時の原因解析
アカウント情報
作成日時捨てアカウントの検知、トラブル時の原因解析
パスワード更新日時トラブル時の原因解析、アタック時の対応、移行・切り替え時の対応
ロック、削除状態トラブル時の原因解析
利用した認証手段前回と異なる環境からのアクセスの検知(リスク要素)、トラブル時の原因解析
最終ログイン日付トラブル時の原因解析、アタック時の対応、移行・切り替え時の対応


通常のメールアドレスや名前などのユーザ情報とは異なりますが、ユーザを認証する際のなりすましリスクの判定やトラブル時の原因解析などを考えるとこれらの情報は重要になる場面が容易に想定できます。(もちろんこれらの情報以外にも利用できる属性はたくさんあると思います)


これらの属性情報の扱いを含めログイン処理はどのように実装するべきか?

では、単純に上記の情報をアクセスの都度ユーザデータベースに保存していけば良いのか?というとこれはこれで考えるポイントがありそうです。

一般に認証(ログイン)処理は出来うる限りシンプルにしていくことが望ましいと考えられます。これは可用性や性能の観点から複雑な処理を入れるとログイン処理が遅くなったり同時に多数のユーザがアクセスする場合にサーバーリソースを多く消費してしまうことが考えられるためです。

ちなみにこれはMicrosoft Azure AD B2Cの例ですが、ユーザデータベースから属性情報を取得するオペレーションとユーザデータベースへ属性を書き込むオペレーションでは処理にかかる時間が倍以上異なる(当然書き込みの方が倍以上遅い)ということが(あくまで個人の経験として)わかっています。これは他の処理系を使っても同様の傾向にあるはずです。

また、加えてログイン時の条件や認証結果によってログイン処理にかかる時間があまりに異なると内部処理の推測をされてしまうなど攻撃者によって大きなヒントを与えてしまうことにもなりえます。(例えば、削除済みユーザ、ロック済みユーザとパスワードを単に間違えたユーザで認証試行に対するレスポンス時間が極端に異なると、ブルートフォースアタックをされた時にユーザが存在する可能性がわかってしまう、などのリスクに繋がります)

そう考えると、少なくともログイン処理では、入力された識別子をキーにデータベースを検索したあと、

  1. 以下の処理の時間を同じくらいの時間がかかるようにウェイトをかける
    • エントリが存在しなかった場合
    • エントリが存在するが
      • 提供されたクレデンシャルで認証ができなかった場合
      • ロックアウトなどにより認証をブロックする場
  2. ログイン成功の場合は環境属性を非同期で書き込む(キューインフチェーンパターンなどの利用)

など実装上の工夫をするべきだと言えると思います。

スクラッチでOpenID Providerを作って商用で利用することはそれほどないとは思いますが、この辺りのことも考えて実装されたID基盤を使うことでセキュアでスケーラビリティの高いシステムを構築することができるようになると思います。