56
58

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Keycloak by OpenStandiaAdvent Calendar 2017

Day 24

まだパスワードで消耗してるの? TouchIDとFIDO2.0でパスワードレス認証を実装してみた(Keycloak編)

Last updated at Posted at 2017-12-23

FIDO認証とは

FIDO(Fast IDentity Online)認証とは、FidoAllianceという生体認証などを利用した新しいオンライン認証技術の標準化を推進する団体が策定を進めているオンライン認証の規格になります。パスワードに代わる新しい認証技術として生体認証やジェスチャー認証を実現できます。パスワード入力が不要とあることはもちろん、認証に必要な情報をサーバに上げる必要がなく、ネットワーク上にも流れないため、高いセキュリティを確保できます。

現在は『FIDO1.0』として下記の標準プロトコルが策定されています。

  • UAFv1.0(Universal Authentication Framework)
    • 端末内に生体認証データやPINを登録し、Webサービスなどにその端末を登録すれば、利用者が端末で生体認証(または、PINの入力。もしくはその併用)を行う事でログインが可能になる方式。


* U2Fv1.2(Universal Second Factor) * 既存の認証方式を導入しているWebサービスなどが、二要素認証を導入する際に用いる方式。利用者は、従来通りのアカウントIDとパスワードによる認証後、U2Fに対応したUSB端末やNFC端末を使う事でログインが可能になる。

また、上記プロトコルの他にウェブブラウザおよびウェブプラットフォーム全体で強力な認証の標準化を図ることを目的として、UAF、U2Fを統合した『FIDO2.0』のWebAPI仕様をW3Cへ提案しており、新たなWeb認証の標準仕様として策定が進められています。
※執筆時点ではワーキングドラフト版の仕様が公開されておりました。

一方、FidoAllianceには参加していないが、FIDOと同じようなUXを実現したものとして、AppleのTouchIDがあります。

そこで、今回はパスワードレス認証をやってみるということで、上記TouchIDを使用したパスワードレス認証と、FIDO2.0についてKeycloakで実装してみました。

#iPhoneのTouchIDを利用してパスワードレス認証を実装してみる

動作イメージ

Keycloakへ認証を行う端末を登録し、登録した端末を使用して認証を行い、Keycloakへログインします。

端末登録の流れ

  1. ブラウザからKeycloakにアカウント情報(ユーザ名、パスワード)を入力
  2. Keycloakは端末登録のためのQRコードを表示
  3. iPhoneのAuthenticatorアプリよりQRコードを読み取る
  4. iPhoneからデバイス情報をKeycloakに送信
  5. KeycloakはiPhoneより受信したデバイス情報をデータベースに格納する

端末認証

  1. ブラウザからKeycloakにアカウント情報(ユーザ名のみ)を入力
  2. Keycloakは登録されているiPhoneにPush通知を送信
  3. iPhoneのAuthenticatorアプリで認証を行う
  4. iPhoneから認証結果をKeycloakに送信
  5. KeycloakはiPhoneより受信した認証結果によりログインする

image.png

用意するもの

  • iPhone5s以降(TouchIDが利用可能なiPhone)
  • Mac(iOSアプリ開発用)
  • AppleDevelopperの有償ライセンス(プッシュ通知設定用)

iOSアプリ(Authenticator)の作成

Keycloakの実装に合わせて、iOSアプリを作成しておく必要があります。
アプリの実装内容は以下の5つとなります。

  1. QRコードの読み取り
  2. TouchIDの使用
  3. デバイストークンの取得、送信
  4. 鍵の作成、送信
  5. 署名の作成、送信
    (6. AppleDevelopperにデバイスの登録) ※アプリを公開しない場合

Keycloakの実装

今回はiPhoneのTouchIDを使用した指紋認証を実装してみます。
初めに端末登録を行います。実装の流れは以下のようなものを想定しています。
1.認証が必要なページへアクセス→ログイン画面が表示される
2.端末登録ボタンを押下し、ユーザ・パスワードを入力してログインを選択→ユーザ情報登録用のQRコードが表示される
3.iPhoneのアプリでQRコードを読み取り、TouchIDを使用して認証を行う→ログインされる
image.png

また、認証の流れとしては以下のようなものを想定しています。

  1. 認証が必要なページへアクセス→ログイン画面が表示される
  2. ログイン画面よりユーザ名を入力し、ログインを選択→端末へプッシュ通知が送信され、TouchIDを使用した認証を要求される(アプリ)
  3. 端末のTouchIDを使用して認証を行う→ログインされる
    image.png

Keycloakの実装内容

画面

  • ユーザ入力画面
    ユーザ名のみを入力する画面です。
    「ログイン」ボタン押下時は端末認証画面へ、「端末登録」ボタン押下時はユーザパスワード入力画面へ遷移します。  


* 端末登録画面 ユーザ情報登録用のQRコードを表示し、端末での認証を待つ画面です。

* 端末認証画面 Push通知を送信し、端末での認証を待つ画面です。

処理

  • ユーザ入力画面
    「端末登録」ボタン押下時にユーザパスワード入力画面に遷移するように実装します。


* 端末登録画面 画面表示時にユーザ情報登録用のQRコードを作成しコンテキストに埋め込むように実装します(QRコードの内容は「サーバURI」「レルム名」「ユーザ名」としています)
// コンテキストへQRコードを埋め込む処理
try {
	RealmModel realm = context.getRealm();
	UserModel user = context.getUser();
	String displayName = realm.getDisplayName() != null && !realm.getDisplayName().isEmpty() ? realm.getDisplayName() : realm.getName();

	String accountName = URLEncoder.encode(user.getUsername(), "UTF-8");
	String issuerName = URLEncoder.encode(displayName, "UTF-8") .replaceAll("\\+", "%20");

	String keyUri = context.getHttpRequest().getUri().getBaseUri() + "?issuerName=" + issuerName
					+ "&accountName=" + accountName;

	int width = 160;
	nt height = 160;

	QRCodeWriter writer = new QRCodeWriter();
	final BitMatrix bitMatrix = writer.encode(keyUri, BarcodeFormat.QR_CODE, width, height);

	ByteArrayOutputStream bos = new ByteArrayOutputStream();
	MatrixToImageWriter.writeToStream(bitMatrix, "png", bos);

	bos.close();
	context.form().setAttribute("barcodeString", keyUri)
		  .setAttribute("barcode", Base64.encodeBytes(bos.toByteArray()));

} catch (Exception e) {
	throw new RuntimeException(e);
}
  • 端末認証画面
    ※事前にアプリ作成時に取得した、Appleの証明書を任意の場所に配置しておきます。
    iPhoneにプッシュ通知を送信するためには「java-apnsライブラリ」を使用します。java-apnsライブラリを使用するようにPOM.xmlにライブラリを追加します。
pom.xml
<dependency>
    <groupId>com.notnoop.apns</groupId> 
    <artifactId>apns</artifactId> <version>1.0.0.Beta6</version>
</dependency>

画面表示時にプッシュ通知を送信するように実装します。

public void authenticate(AuthenticationFlowContext context) {
	// ・・・<略>・・・
	ApnsService service = APNS.newService().withCert({apple証明書パス}, {証明書のパスワード}).withSandboxDestination().build();
	String payload = APNS.newPayload().alertTitle("バナーに表示するメッセージ")
		               .alertBody("メッセージのテキスト").build();
	service.push({プッシュ通知を送信するデバイストークン}, payload);
}

iPhoneからの受信処理 REST API

iPhoneからの応答を処理するためRESTを作成します。

  • QRコード読み取り応答
    端末より受信した「デバイストークン」「公開鍵」をKeycloakのデータベースへ保存します。
// PublicKeyより必要な項目を抜き取ります。
String publickeyString = requiredModel.getPublickey();
String[] split = publickeyString.split(",");

String exponent = split[5].replace("decimal:", "").replace("}", "").trim();
String modules = split[6].replace("modulus:", "").trim();

BigInteger bintExponent = new BigInteger(exponent);
BigInteger bintModules = new BigInteger(modules, 16);

// デバイストークンより強制的に不要な文字列を削除します
String deviceToken = requiredModel.getDeviceToken();
deviceToken = deviceToken.replace("Optional(\"", "");
deviceToken = deviceToken.replace("\")", "");
System.out.println("deviceToken = " + deviceToken);

// DeviceTokenとPublickeyを登録する
UserCredentialModel input = new UserCredentialModel();
input.setType("AUTHNR");
input.setValue(bintExponent.toString() + "," + bintModules.toString());
input.setDevice(deviceToken);

UserModel user = KeycloakModelUtils.findUserByNameOrEmail(session, session.getContext().getRealm(), name);
session.userCredentialManager().updateCredential(session.getContext().getRealm(), user, input);
  • TouchID認証済み応答
    端末より受信した「Signature」の検証を行います。
    ※今回はiPhone側で署名する文字列を固定で"OK"に設定してあります。
char nil = 0;
byte[] message = ("OK" + nil).getBytes(StandardCharsets.UTF_8);

// メッセージをハッシュ化
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] massageDigest = digest.digest(message);

// 検証
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(publicKey);
verifier.update(massageDigest);
result = verifier.verify(sign);

Keycloakの設定

作成したAuthenticatorをFlowを追加します。
管理コンソール > Authentication > Flows より対象のフローを選択し、作成したAuthenticatorを追加します。
image.png

動作確認

実際に動かしてみます。

端末登録

  1. Keycloakに接続し、端末登録をクリック
    image.png

  2. 登録画面が表示されるのでアカウント情報を入力しログイン
    image.png

  3. QRコードが表示されるので、アプリからQRコードを読み取る
    image.png

  4. ログインできました!
    image.png

これで端末の登録ができたので、登録した端末でログインしてみます。

端末登録済

  1. Keycloakに接続し、ユーザ名を入力しログイン
    image.png

  2. 認証待ちの画面が表示され、登録している端末にプッシュ通知が届くので認証を行う
    image.png
    iPhoneプッシュ通知受信画面
    image.png

  3. ログインできました!
    image.png

参考サイト

下記を参考にしました。

Microsoft Edgeを利用したFIDO2.0を実装してみる

動作イメージ

image.png

認証器登録の流れ

  1. ブラウザからKeycloakにアカウント情報(ユーザ名、パスワード)を入力
  2. Keycloakは端末登録のためWindowsHelloを呼び出す
  3. ユーザはWindowsHelloに対し認証を行う
  4. WindowsHelloよりキーペアを作成しKeycloakに送信
  5. KeycloakはWindowsHelloより受信したキー情報をデータベースに格納する

※2~4についてはMicrosoftより提供されているmsCredentialオブジェクト(makeCredential)で実現しています。

認証器登録後のログインの流れ

  1. Keycloakより端末認証を選択
  2. Keycloakは認証を行うために、WindowsHelloを呼び出す
  3. ユーザはWindowsHelloに対し認証を行う
  4. WindowsHelloから認証結果をKeycloakに送信
  5. KeycloakはWindowsHelloより受信した認証結果よりログインする

※2~4についてはMicrosoftより提供されているmsCredentialオブジェクト(getAssertion)で実現しています。

用意するもの

  • Windows10端末(生体認証が利用可能なもの)

Keycloakの実装

今回はMicrosoft Edgeを利用したFIDO2.0を実装してみます。
初めに端末登録を行います。流れとしては以下のようなものを想定しています。

  1. 認証が必要なページへアクセス→ログイン画面が表示される
  2. ユーザ・パスワードを入力しログインを選択→端末の生体認証が要求される
  3. 端末の生体認証デバイスで認証を行う→登録済みのアカウントでログインされる
    image.png
    また、端末認証の流れとしては以下のようなものを想定しています。
  4. 認証が必要なページへアクセス→ログイン画面が表示される
  5. ログイン画面より「端末認証」を選択→端末で生体認証を要求される
  6. 端末の生体認証デバイスで認証を行う→登録済みのアカウントでログインされる
    image.png

Keycloakの実装内容

画面

  • Polyfill(※)を以下に配置します。

[KEYCLOAK_HOME]/themes/base/login/resources/js/webauthn.js

※Microsoft Edgeに実装されているmsCredentialは、FIDO2.0のWebAuthの仕様とは大きく異なっています。この差異を吸収するために polyfillを使用して開発します。
⇒今回使用したpolyfillはこちらになります。
 現在はMicrosoftから公開されています。

  • ユーザパスワード入力画面
    デフォルトのユーザパスワード入力画面へ「端末認証」ボタンを追加します。
<input class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}" name="fido-login" id="kc-fido-login" type="submit" value="端末認証" onclick="this.form.elements['submit_type'].value = 'fido-login';" />
  • 認証器登録画面
    「端末登録」ボタン押下時にWebAuthn.makeCredential()の呼出を実装します。
//Script
<script language="javascript"> 
 function reg_click() {
    make(); 
 }
  function login_click() {
    document.getElementById('submit_type').value = 'login';
    document.forms['kc-totp-login-form'].submit();
  }
  function make() {
    var accountInfo = {
      id: '${userID}',
      rpDisplayName: '${rpName}', // Name of relying party
      userDisplayName: '${userID}' // Name of user account
    };
    var cryptoParameters = [
      {
        type: 'ScopedCred',  // also 'FIDO_2_0' is okay !
        algorithm: 'RSASSA-PKCS1-v1_5'
      }
    ];
    navigator.authentication.makeCredential(accountInfo, cryptoParameters)
    .then(function (result) {
      document.getElementById('credID').value = result.credential.id;
      document.getElementById('publicKey').value = result.publicKey;
      document.getElementById('submit_type').value = 'regist';
      document.forms['kc-totp-login-form'].submit();
    }).catch(function (err) {
        alert('err: ' + err.message);
    });
  }
</script>

// ボタン
<form id="kc-totp-login-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
  • 端末認証画面
    画面表示(onLoad)時にWebAuthn.getAssertion()の呼出を実装します。
//Script
<script language="javascript">
  window.onload = function() {
    sign();
  }
  function sign() {
    navigator.authentication.getAssertion('${challenge}')
    .then(function(result) {
      document.getElementById('credID').value = result.credential.id;
      document.getElementById('signature').value = result.signature;
      document.getElementById('authnrdata').value = result.authenticatorData;
      document.getElementById('clientdata').value = result.clientData; 
      document.getElementById('submit_type').value = 'fido-authn';
      document.forms['kc-totp-login-form'].submit();
    }).catch(function (err) {
      alert('err: ' + err.message); 
   });
  }
</script>

処理

  • 認証器登録画面(フォーム受信時)
    フォームより受信した「ユーザID」と「公開鍵」をKeycloakのデータベースへ保存します。
public void processAction(RequiredActionContext context) {

	// 「端末に登録する」ボタン押下
	if (submitType.equals("regist")) {
		String jsonPublicKey = formData.getFirst("publicKey");

		// JSONデータよりエスケープシーケンスを削除
		jsonPublicKey = jsonPublicKey.replaceAll("^\"", "");      // 行頭の["]を削除
		jsonPublicKey = jsonPublicKey.replaceAll("\"$", "");      // 行末の["]を削除
		jsonPublicKey = jsonPublicKey.replaceAll("\\\\r", "\r");  // 行中の[\\r]を置換
		jsonPublicKey = jsonPublicKey.replaceAll("\\\\n", "\n");  // 行中の[\\n]を置換
		jsonPublicKey = jsonPublicKey.replaceAll("\\\\t", "\t");  // 行中の[\\t]を置換
		jsonPublicKey = jsonPublicKey.replaceAll("\\\\\"", "\""); // 行中の[\"]を置換
 
		// 公開鍵チェック
		PublicKey publicKey;
		try {
			publicKey = JWKParser.create().parse(jsonPublicKey).toPublicKey();
		} catch (Exception e) {
			setInitForm(context);
			return;
		}

		RSAPublicKey rsaPublicKey = (RSAPublicKey) publicKey;

	        // 公開鍵を格納
		UserCredentialModel input = new UserCredentialModel();
		input.setType(FidoCredentialProvider.FIDO);
		input.setValue(rsaPublicKey.getModulus() + "," + rsaPublicKey.getPublicExponent());
		context.getSession().userCredentialManager().updateCredential(context.getRealm(), context.getUser(), input);

		context.success();
		return;
	}
}
  • 端末認証処理(フォーム表示前)
    KeycloakでChallengeを生成してフォームへ渡し、クライアントセッションへ設定します。
public void action(AuthenticationFlowContext context) {
	// ・・・<略>・・・
	//「端末認証」ボタン押下
	if (submitType.equals("fido-login")) {
		// challengeを生成してクライアントセッションへ設定
		String challengeData = getChallengeData();
		context.getClientSession().setNote("CHALLENGE_DATA", challengeData);
		Response challenge = context.form()
			.setAttribute("challenge", challengeData)
			.createForm("fido.ftl");
		context.challenge(challenge);
		return;
	}
	// ・・・<略>・・・
}
  • 端末認証処理(フォーム受信時)
    フォームより受信した「Signature」「ClientData」「AuthenticationData」とクライアントセッションに設定されているchallengeを用いて検証を行います。
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();

String signature_enc = formData.getFirst("signature");
String authnrdata_enc = formData.getFirst("authnrdata");
String clientdata_enc = formData.getFirst("clientdata");

PublicKey publicKey = restorePublicKey(context, user);

// クライアントセッションよりchallengeDataを取得
String challengeData = context.getClientSession().getNote("CHALLENGE_DATA");

// get signature
Charset charset = StandardCharsets.UTF_8;
byte[] sign = Base64.getUrlDecoder().decode(signature_enc.getBytes(charset));
byte[] authnrdata = Base64.getUrlDecoder().decode(authnrdata_enc.getBytes(charset));
byte[] clientdata = Base64.getUrlDecoder().decode(clientdata_enc.getBytes(charset));

// ブラウザから送付されたchallengeDataを検証する
if (verifyChallengeData(challengeData, clientdata,charset)) {
		
} else {
	System.out.println("★ChallengeData is NG!");
	return false;
}

boolean ret = false;
try {
	// ブラウザから送付された署名を検証する
	ret = verifySignature(publicKey, sign, authnrdata, clientdata);
} catch (InvalidKeyException e) {
	e.printStackTrace();
} catch (NoSuchAlgorithmException e) {
	e.printStackTrace();
} catch (SignatureException e) {
	e.printStackTrace();
}

Keycloakの設定

作成したAthenticatorをFlowに追加します。
管理コンソール > Authentication > Flows より対象のフローを選択し、作成したAuthenticatorを追加します。
image.png

動作確認

実際に動かしてみます。

端末登録

  1. Keycloakに接続し、アカウント情報を入力してログイン
    image.png

  2. 端末登録確認画面が表示されるので端末登録をクリック
    image.png

  3. Windows Helloの認証画面が表示されるので認証を行う
    image.png

  4. ログインできました!
    image.png

これで端末登録が出来たので、登録した端末情報でログインしてみます。

端末登録済

  1. アカウント情報は入力せずに、端末認証クリック
    image.png

  2. Windows Helloの認証画面が表示されるので認証を行う
    image.png

  3. 端末登録をしたユーザアカウントでログインできました!
    image.png

動くことが確認できました!

参考サイト

下記を参考にしました。

まとめ

TouchIDを利用した指紋認証は、Push通知を行うためのappleの設定もあり大変でした。
また、FIDO2.0は現在ドラフト仕様であること、Microsoft Edgeでのみ動作することなどから、今後の動向に注意する必要がありそうです。
パスワードレス認証はセキュリティやユーザの利便性の観点から普及が進んでいるので、今後の展望に期待したいと思います!

56
58
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
56
58

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?