From 311869f68976cc44ed738369bdd18dce84e95387 Mon Sep 17 00:00:00 2001 From: Yuichi Araki Date: Tue, 17 Nov 2020 17:19:46 +0900 Subject: [PATCH] Update to the latest server API Fixes #9 --- android/.idea/compiler.xml | 6 + android/.idea/gradle.xml | 1 + android/.idea/misc.xml | 2 +- android/app-start/build.gradle | 4 +- .../com/example/android/fido2/MainActivity.kt | 50 ------ .../example/android/fido2/api/ApiResult.kt | 31 ++++ .../com/example/android/fido2/api/AuthApi.kt | 162 ++++++++--------- .../fido2/repository/AuthRepository.kt | 83 +++++---- .../android/fido2/repository/SignInState.kt | 3 +- .../example/android/fido2/ui/LiveDataExt.kt | 6 +- .../android/fido2/ui/auth/AuthFragment.kt | 45 +++-- .../android/fido2/ui/auth/AuthViewModel.kt | 20 ++- .../android/fido2/ui/home/HomeFragment.kt | 40 +++-- .../android/fido2/ui/home/HomeViewModel.kt | 21 ++- .../fido2/ui/username/UsernameFragment.kt | 1 - android/app/build.gradle | 4 +- .../example/android/fido2/api/AuthApiTest.kt | 62 ------- .../com/example/android/fido2/MainActivity.kt | 50 ------ .../example/android/fido2/api/ApiResult.kt | 31 ++++ .../com/example/android/fido2/api/AuthApi.kt | 167 +++++++++--------- .../fido2/repository/AuthRepository.kt | 104 +++++------ .../android/fido2/repository/SignInState.kt | 3 +- .../example/android/fido2/ui/LiveDataExt.kt | 6 +- .../android/fido2/ui/auth/AuthFragment.kt | 51 ++++-- .../android/fido2/ui/auth/AuthViewModel.kt | 20 ++- .../android/fido2/ui/home/HomeFragment.kt | 46 +++-- .../android/fido2/ui/home/HomeViewModel.kt | 21 ++- .../fido2/ui/username/UsernameFragment.kt | 1 - android/build.gradle | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 4 +- 30 files changed, 508 insertions(+), 539 deletions(-) create mode 100644 android/.idea/compiler.xml create mode 100644 android/app-start/src/main/java/com/example/android/fido2/api/ApiResult.kt delete mode 100644 android/app/src/androidTest/java/com/example/android/fido2/api/AuthApiTest.kt create mode 100644 android/app/src/main/java/com/example/android/fido2/api/ApiResult.kt diff --git a/android/.idea/compiler.xml b/android/.idea/compiler.xml new file mode 100644 index 0000000..61a9130 --- /dev/null +++ b/android/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/gradle.xml b/android/.idea/gradle.xml index 710b999..f5dd14a 100644 --- a/android/.idea/gradle.xml +++ b/android/.idea/gradle.xml @@ -15,6 +15,7 @@ diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml index 7bfef59..d5d35ec 100644 --- a/android/.idea/misc.xml +++ b/android/.idea/misc.xml @@ -1,6 +1,6 @@ - + diff --git a/android/app-start/build.gradle b/android/app-start/build.gradle index 1f74dc9..3226ab7 100644 --- a/android/app-start/build.gradle +++ b/android/app-start/build.gradle @@ -75,7 +75,7 @@ dependencies { implementation 'androidx.fragment:fragment-ktx:1.2.5' implementation 'androidx.core:core-ktx:1.3.2' - implementation 'androidx.constraintlayout:constraintlayout:2.0.2' + implementation 'androidx.constraintlayout:constraintlayout:2.0.4' def lifecycle_version = '2.2.0' implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" @@ -89,7 +89,7 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:$okhttp_version" implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version" - testImplementation 'junit:junit:4.13' + testImplementation 'junit:junit:4.13.1' androidTestImplementation 'androidx.test:runner:1.3.0' androidTestImplementation 'androidx.test:rules:1.3.0' diff --git a/android/app-start/src/main/java/com/example/android/fido2/MainActivity.kt b/android/app-start/src/main/java/com/example/android/fido2/MainActivity.kt index 47b511b..d4c5c80 100644 --- a/android/app-start/src/main/java/com/example/android/fido2/MainActivity.kt +++ b/android/app-start/src/main/java/com/example/android/fido2/MainActivity.kt @@ -16,30 +16,20 @@ package com.example.android.fido2 -import android.content.Intent import android.os.Bundle -import android.util.Log import android.widget.Toast import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.app.commit -import androidx.lifecycle.observe import com.example.android.fido2.repository.SignInState import com.example.android.fido2.ui.auth.AuthFragment import com.example.android.fido2.ui.home.HomeFragment import com.example.android.fido2.ui.username.UsernameFragment import com.google.android.gms.fido.Fido -import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse class MainActivity : AppCompatActivity() { - companion object { - private const val TAG = "MainActivity" - const val REQUEST_FIDO2_REGISTER = 1 - const val REQUEST_FIDO2_SIGNIN = 2 - } - private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -67,46 +57,6 @@ class MainActivity : AppCompatActivity() { } } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - when (requestCode) { - REQUEST_FIDO2_REGISTER -> { - val errorExtra = data?.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA) - if (errorExtra != null) { - val error = AuthenticatorErrorResponse.deserializeFromBytes(errorExtra) - error.errorMessage?.let { errorMessage -> - Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show() - Log.e(TAG, errorMessage) - } - } else if (resultCode != RESULT_OK) { - Toast.makeText(this, R.string.cancelled, Toast.LENGTH_SHORT).show() - } else { - val fragment = supportFragmentManager.findFragmentById(R.id.container) - if (data != null && fragment is HomeFragment) { - fragment.handleRegister(data) - } - } - } - REQUEST_FIDO2_SIGNIN -> { - val errorExtra = data?.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA) - if (errorExtra != null) { - val error = AuthenticatorErrorResponse.deserializeFromBytes(errorExtra) - error.errorMessage?.let { errorMessage -> - Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show() - Log.e(TAG, errorMessage) - } - } else if (resultCode != RESULT_OK) { - Toast.makeText(this, R.string.cancelled, Toast.LENGTH_SHORT).show() - } else { - val fragment = supportFragmentManager.findFragmentById(R.id.container) - if (data != null && fragment is AuthFragment) { - fragment.handleSignin(data) - } - } - } - else -> super.onActivityResult(requestCode, resultCode, data) - } - } - override fun onResume() { super.onResume() viewModel.setFido2ApiClient(Fido.getFido2ApiClient(this)) diff --git a/android/app-start/src/main/java/com/example/android/fido2/api/ApiResult.kt b/android/app-start/src/main/java/com/example/android/fido2/api/ApiResult.kt new file mode 100644 index 0000000..a1fb8f6 --- /dev/null +++ b/android/app-start/src/main/java/com/example/android/fido2/api/ApiResult.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.fido2.api + +class ApiResult( + + /** + * The session ID to be used for the subsequent API calls. Might be null if the API call does + * not return a new cookie. + */ + val sessionId: String?, + + /** + * The result data. + */ + val data: T +) diff --git a/android/app-start/src/main/java/com/example/android/fido2/api/AuthApi.kt b/android/app-start/src/main/java/com/example/android/fido2/api/AuthApi.kt index 80ad229..b1171d6 100644 --- a/android/app-start/src/main/java/com/example/android/fido2/api/AuthApi.kt +++ b/android/app-start/src/main/java/com/example/android/fido2/api/AuthApi.kt @@ -19,6 +19,7 @@ package com.example.android.fido2.api import android.util.JsonReader import android.util.JsonToken import android.util.JsonWriter +import android.util.Log import com.example.android.fido2.BuildConfig import com.example.android.fido2.decodeBase64 import com.example.android.fido2.toBase64 @@ -52,6 +53,8 @@ class AuthApi { companion object { private const val BASE_URL = BuildConfig.API_BASE_URL private val JSON = "application/json".toMediaTypeOrNull() + private const val SessionIdKey = "connect.sid=" + private const val TAG = "AuthApi" } private val client = OkHttpClient.Builder() @@ -63,9 +66,9 @@ class AuthApi { /** * @param username The username to be used for sign-in. - * @return The username. + * @return The Session ID. */ - fun username(username: String): String { + fun username(username: String): ApiResult { val call = client.newCall( Request.Builder() .url("$BASE_URL/username") @@ -79,19 +82,19 @@ class AuthApi { throwResponseError(response, "Error calling /username") } - return parseUsername(findSetCookieInResponse(response, "username")) + return response.result { Unit } } /** - * @param username The username sent to the server with `username()`. + * @param sessionId The session ID received on `username()`. * @param password A password. - * @return token The sign-in token to be used for subsequent API calls. + * @return An [ApiResult]. */ - fun password(username: String, password: String): String { + fun password(sessionId: String, password: String): ApiResult { val call = client.newCall( Request.Builder() .url("$BASE_URL/password") - .addHeader("Cookie", "username=$username") + .addHeader("Cookie", formatCookie(sessionId)) .method("POST", jsonRequestBody { name("password").value(password) }) @@ -101,19 +104,18 @@ class AuthApi { if (!response.isSuccessful) { throwResponseError(response, "Error calling /password") } - val cookie = findSetCookieInResponse(response, "signed-in") - return "$cookie; username=$username" + return response.result { Unit } } /** - * @param token The sign-in token. + * @param sessionId The session ID. * @return A list of all the credentials registered on the server. */ - fun getKeys(token: String): List { + fun getKeys(sessionId: String): ApiResult> { val call = client.newCall( Request.Builder() .url("$BASE_URL/getKeys") - .addHeader("Cookie", token) + .addHeader("Cookie", formatCookie(sessionId)) .method("POST", jsonRequestBody {}) .build() ) @@ -121,21 +123,23 @@ class AuthApi { if (!response.isSuccessful) { throwResponseError(response, "Error calling /getKeys") } - val body = response.body ?: throw ApiException("Empty response from /getKeys") - return parseUserCredentials(body) + + return response.result { + parseUserCredentials(body ?: throw ApiException("Empty response from /getKeys")) + } } /** - * @param token The sign-in token. + * @param sessionId The session ID. * @return A pair. The `first` element is an [PublicKeyCredentialCreationOptions] that can be * used for a subsequent FIDO2 API call. The `second` element is a challenge string that should * be sent back to the server in [registerResponse]. */ - fun registerRequest(token: String): Pair { + fun registerRequest(sessionId: String): ApiResult { val call = client.newCall( Request.Builder() .url("$BASE_URL/registerRequest") - .addHeader("Cookie", token) + .addHeader("Cookie", formatCookie(sessionId)) .method("POST", jsonRequestBody { name("attestation").value("none") name("authenticatorSelection").objectValue { @@ -149,29 +153,31 @@ class AuthApi { if (!response.isSuccessful) { throwResponseError(response, "Error calling /registerRequest") } - val body = response.body ?: throw ApiException("Empty response from /registerRequest") - return parsePublicKeyCredentialCreationOptions(body) + + return response.result { + parsePublicKeyCredentialCreationOptions( + body ?: throw ApiException("Empty response from /registerRequest") + ) + } } /** - * @param token The sign-in token. - * @param challenge The challenge string returned by [registerRequest]. + * @param sessionId The session ID. * @param response The FIDO2 response object. * @return A list of all the credentials registered on the server, including the newly * registered one. */ fun registerResponse( - token: String, - challenge: String, + sessionId: String, response: AuthenticatorAttestationResponse - ): List { + ): ApiResult> { response.keyHandle.toBase64() val rawId = response.keyHandle.toBase64() val call = client.newCall( Request.Builder() .url("$BASE_URL/registerResponse") - .addHeader("Cookie", "$token; challenge=$challenge") + .addHeader("Cookie", formatCookie(sessionId)) .method("POST", jsonRequestBody { name("id").value(rawId) name("type").value(PublicKeyCredentialType.PUBLIC_KEY.toString()) @@ -191,19 +197,22 @@ class AuthApi { if (!apiResponse.isSuccessful) { throwResponseError(apiResponse, "Error calling /registerResponse") } - val body = apiResponse.body ?: throw ApiException("Empty response from /registerResponse") - return parseUserCredentials(body) + return apiResponse.result { + parseUserCredentials( + body ?: throw ApiException("Empty response from /registerResponse") + ) + } } /** - * @param token The sign-in token. + * @param sessionId The session ID. * @param credentialId The credential ID to be removed. */ - fun removeKey(token: String, credentialId: String) { + fun removeKey(sessionId: String, credentialId: String): ApiResult { val call = client.newCall( Request.Builder() .url("$BASE_URL/removeKey?credId=$credentialId") - .addHeader("Cookie", token) + .addHeader("Cookie", formatCookie(sessionId)) .method("POST", jsonRequestBody {}) .build() ) @@ -211,20 +220,20 @@ class AuthApi { if (!response.isSuccessful) { throwResponseError(response, "Error calling /removeKey") } - // Nothing useful in the response body; ignore. + return response.result { Unit } } /** - * @param username The username to be used for the sign-in. + * @param sessionId The session ID to be used for the sign-in. * @param credentialId The credential ID of this device. * @return A pair. The `first` element is a [PublicKeyCredentialRequestOptions] that can be used * for a subsequent FIDO2 API call. The `second` element is a challenge string that should * be sent back to the server in [signinResponse]. */ fun signinRequest( - username: String, + sessionId: String, credentialId: String? - ): Pair { + ): ApiResult { val call = client.newCall( Request.Builder() .url( @@ -235,7 +244,7 @@ class AuthApi { } } ) - .addHeader("Cookie", "username=$username") + .addHeader("Cookie", formatCookie(sessionId)) .method("POST", jsonRequestBody {}) .build() ) @@ -243,8 +252,11 @@ class AuthApi { if (!response.isSuccessful) { throwResponseError(response, "Error calling /signinRequest") } - val body = response.body ?: throw ApiException("Empty response from /signinRequest") - return parsePublicKeyCredentialRequestOptions(body) + return response.result { + parsePublicKeyCredentialRequestOptions( + body ?: throw ApiException("Empty response from /signinRequest") + ) + } } /** @@ -253,15 +265,14 @@ class AuthApi { * @param response The assertion response from FIDO2 API. */ fun signinResponse( - username: String, - challenge: String, + sessionId: String, response: AuthenticatorAssertionResponse - ): Pair, String> { + ): ApiResult> { val rawId = response.keyHandle.toBase64() val call = client.newCall( Request.Builder() .url("$BASE_URL/signinResponse") - .addHeader("Cookie", "username=$username; challenge=$challenge") + .addHeader("Cookie", formatCookie(sessionId)) .method("POST", jsonRequestBody { name("id").value(rawId) name("type").value(PublicKeyCredentialType.PUBLIC_KEY.toString()) @@ -287,25 +298,20 @@ class AuthApi { if (!apiResponse.isSuccessful) { throwResponseError(apiResponse, "Error calling /signingResponse") } - val body = apiResponse.body ?: throw ApiException("Empty response from /signinResponse") - val cookie = findSetCookieInResponse(apiResponse, "signed-in") - return parseUserCredentials(body) to "$cookie; username=$username" + return apiResponse.result { + parseUserCredentials(body ?: throw ApiException("Empty response from /signinResponse")) + } } private fun parsePublicKeyCredentialRequestOptions( body: ResponseBody - ): Pair { + ): PublicKeyCredentialRequestOptions { val builder = PublicKeyCredentialRequestOptions.Builder() - var challenge: String? = null JsonReader(body.byteStream().bufferedReader()).use { reader -> reader.beginObject() while (reader.hasNext()) { when (reader.nextName()) { - "challenge" -> { - val c = reader.nextString() - challenge = c - builder.setChallenge(c.decodeBase64()) - } + "challenge" -> builder.setChallenge(reader.nextString().decodeBase64()) "userVerification" -> reader.skipValue() "allowCredentials" -> builder.setAllowList(parseCredentialDescriptors(reader)) "rpId" -> builder.setRpId(reader.nextString()) @@ -315,27 +321,22 @@ class AuthApi { } reader.endObject() } - return builder.build() to challenge!! + return builder.build() } private fun parsePublicKeyCredentialCreationOptions( body: ResponseBody - ): Pair { + ): PublicKeyCredentialCreationOptions { val builder = PublicKeyCredentialCreationOptions.Builder() - var challenge: String? = null JsonReader(body.byteStream().bufferedReader()).use { reader -> reader.beginObject() while (reader.hasNext()) { when (reader.nextName()) { "user" -> builder.setUser(parseUser(reader)) - "challenge" -> { - val c = reader.nextString() - builder.setChallenge(c.decodeBase64()) - challenge = c - } + "challenge" -> builder.setChallenge(reader.nextString().decodeBase64()) "pubKeyCredParams" -> builder.setParameters(parseParameters(reader)) "timeout" -> builder.setTimeoutSeconds(reader.nextDouble()) - "attestation" -> reader.skipValue() // Unusedp + "attestation" -> reader.skipValue() // Unused "excludeCredentials" -> builder.setExcludeList( parseCredentialDescriptors(reader) ) @@ -347,7 +348,7 @@ class AuthApi { } reader.endObject() } - return builder.build() to challenge!! + return builder.build() } private fun parseRp(reader: JsonReader): PublicKeyCredentialRpEntity { @@ -463,15 +464,6 @@ class AuthApi { return output.toString().toRequestBody(JSON) } - private fun parseUsername(cookie: String): String { - val start = cookie.indexOf("username=") - val end = cookie.indexOf(";") - if (start < 0 || end < 0 || start + 9 >= end) { - throw RuntimeException("Cannot parse the cookie") - } - return cookie.substring(start + 9, end) - } - private fun parseUserCredentials(body: ResponseBody): List { fun readCredentials(reader: JsonReader): List { val credentials = mutableListOf() @@ -539,9 +531,10 @@ class AuthApi { reader.endObject() } } catch (e: Exception) { - throw ApiException("Cannot parse error: $errorString") + Log.e(TAG, "Cannot parse the error: $errorString", e) + // Don't throw; this method is called during throwing. } - return "" // Don't throw; this method is called during throwing. + return "" } private fun JsonWriter.objectValue(body: JsonWriter.() -> Unit) { @@ -550,15 +543,22 @@ class AuthApi { endObject() } - /* - * Looks for a set-cookie header with a particular name - */ - private fun findSetCookieInResponse(response: Response, cname: String): String { - for (header in response.headers("set-cookie")) { - if (header.startsWith("$cname=")) { - return header - } + private fun Response.result(data: Response.() -> T): ApiResult { + val cookie = headers("set-cookie").find { it.startsWith(SessionIdKey) } + return ApiResult(if (cookie != null) parseSessionId(cookie) else null, data()) + } + + private fun parseSessionId(cookie: String): String { + val start = cookie.indexOf(SessionIdKey) + if (start < 0) { + throw ApiException("Cannot find $SessionIdKey") } - throw ApiException("Cookie not found: $cname") + val semicolon = cookie.indexOf(";", start + SessionIdKey.length) + val end = if (semicolon < 0) cookie.length else semicolon + return cookie.substring(start + SessionIdKey.length, end) + } + + private fun formatCookie(sessionId: String): String { + return "$SessionIdKey$sessionId" } } diff --git a/android/app-start/src/main/java/com/example/android/fido2/repository/AuthRepository.kt b/android/app-start/src/main/java/com/example/android/fido2/repository/AuthRepository.kt index 80417d0..1e544e5 100644 --- a/android/app-start/src/main/java/com/example/android/fido2/repository/AuthRepository.kt +++ b/android/app-start/src/main/java/com/example/android/fido2/repository/AuthRepository.kt @@ -16,6 +16,7 @@ package com.example.android.fido2.repository +import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.SharedPreferences @@ -24,17 +25,15 @@ import androidx.annotation.WorkerThread import androidx.core.content.edit import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations +import androidx.lifecycle.map import com.example.android.fido2.api.ApiException import com.example.android.fido2.api.AuthApi import com.example.android.fido2.api.Credential import com.example.android.fido2.toBase64 import com.google.android.gms.fido.Fido import com.google.android.gms.fido.fido2.Fido2ApiClient -import com.google.android.gms.fido.fido2.Fido2PendingIntent import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse import com.google.android.gms.fido.fido2.api.common.AuthenticatorAttestationResponse -import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.Tasks import java.util.concurrent.Executor import java.util.concurrent.Executors @@ -54,7 +53,7 @@ class AuthRepository( // Keys for SharedPreferences private const val PREFS_NAME = "auth" private const val PREF_USERNAME = "username" - private const val PREF_TOKEN = "token" + private const val PREF_SESSION_ID = "session_id" private const val PREF_CREDENTIALS = "credentials" private const val PREF_LOCAL_CREDENTIAL_ID = "local_credential_id" @@ -79,12 +78,6 @@ class AuthRepository( private val signInStateListeners = mutableListOf<(SignInState) -> Unit>() - /** - * Stores a temporary challenge that needs to be memorized between request and response API - * calls for credential registration and sign-in. - */ - private var lastKnownChallenge: String? = null - private fun invokeSignInStateListeners(state: SignInState) { val listeners = signInStateListeners.toList() // Copy for (listener in listeners) { @@ -104,11 +97,11 @@ class AuthRepository( init { val username = prefs.getString(PREF_USERNAME, null) - val token = prefs.getString(PREF_TOKEN, null) + val sessionId = prefs.getString(PREF_SESSION_ID, null) value = when { username.isNullOrBlank() -> SignInState.SignedOut - token.isNullOrBlank() -> SignInState.SigningIn(username) - else -> SignInState.SignedIn(username, token) + sessionId.isNullOrBlank() -> SignInState.SigningIn(username) + else -> SignInState.SignedIn(username) } } @@ -132,7 +125,8 @@ class AuthRepository( try { val result = api.username(username) prefs.edit(commit = true) { - putString(PREF_USERNAME, result) + putString(PREF_USERNAME, username) + putString(PREF_SESSION_ID, result.sessionId!!) } invokeSignInStateListeners(SignInState.SigningIn(username)) } finally { @@ -152,22 +146,28 @@ class AuthRepository( executor.execute { processing.postValue(true) val username = prefs.getString(PREF_USERNAME, null)!! + val sessionId = prefs.getString(PREF_SESSION_ID, null)!! try { - val token = api.password(username, password) - prefs.edit(commit = true) { putString(PREF_TOKEN, token) } - invokeSignInStateListeners(SignInState.SignedIn(username, token)) + val result = api.password(sessionId, password) + prefs.edit(commit = true) { + result.sessionId?.let { + putString(PREF_SESSION_ID, it) + } + } + invokeSignInStateListeners(SignInState.SignedIn(username)) } catch (e: ApiException) { Log.e(TAG, "Invalid login credentials", e) // start login over again prefs.edit(commit = true) { remove(PREF_USERNAME) - remove(PREF_TOKEN) + remove(PREF_SESSION_ID) remove(PREF_CREDENTIALS) } invokeSignInStateListeners( - SignInState.SignInError(e.message ?: "Invalid login credentials" )) + SignInState.SignInError(e.message ?: "Invalid login credentials") + ) } finally { processing.postValue(false) } @@ -182,16 +182,18 @@ class AuthRepository( executor.execute { refreshCredentials() } - return Transformations.map(prefs.liveStringSet(PREF_CREDENTIALS, emptySet())) { set -> + return prefs.liveStringSet(PREF_CREDENTIALS, emptySet()).map { set -> parseCredentials(set) } } @WorkerThread private fun refreshCredentials() { - val token = prefs.getString(PREF_TOKEN, null)!! + val sessionId = prefs.getString(PREF_SESSION_ID, null)!! + val result = api.getKeys(sessionId) prefs.edit(commit = true) { - putStringSet(PREF_CREDENTIALS, api.getKeys(token).toStringSet()) + result.sessionId?.let { putString(PREF_SESSION_ID, it) } + putStringSet(PREF_CREDENTIALS, result.data.toStringSet()) } } @@ -210,13 +212,12 @@ class AuthRepository( } /** - * Clears the sign-in token. The sign-in state will proceed to [SignInState.SigningIn]. + * Clears the credentials. The sign-in state will proceed to [SignInState.SigningIn]. */ - fun clearToken() { + fun clearCredentials() { executor.execute { val username = prefs.getString(PREF_USERNAME, null)!! prefs.edit(commit = true) { - remove(PREF_TOKEN) remove(PREF_CREDENTIALS) } invokeSignInStateListeners(SignInState.SigningIn(username)) @@ -231,7 +232,7 @@ class AuthRepository( executor.execute { prefs.edit(commit = true) { remove(PREF_USERNAME) - remove(PREF_TOKEN) + remove(PREF_SESSION_ID) remove(PREF_CREDENTIALS) } invokeSignInStateListeners(SignInState.SignedOut) @@ -242,17 +243,17 @@ class AuthRepository( * Starts to register a new credential to the server. This should be called only when the * sign-in state is [SignInState.SignedIn]. */ - fun registerRequest(processing: MutableLiveData): LiveData { - val result = MutableLiveData() + fun registerRequest(processing: MutableLiveData): LiveData { + val result = MutableLiveData() executor.execute { fido2ApiClient?.let { client -> processing.postValue(true) try { - val token = prefs.getString(PREF_TOKEN, null)!! + val sessionId = prefs.getString(PREF_SESSION_ID, null)!! // TODO(1): Call the server API: /registerRequest - // - Use api.registerRequest to get a PublicKeyCredentialCreationOptions. - // - Save the challenge for later use in registerResponse. + // - Use api.registerRequest to get an ApiResult of + // PublicKeyCredentialCreationOptions. // - Call fido2ApiClient.getRegisterIntent and create an intent to generate a // new credential. // - Pass the intent back to the `result` LiveData so that the UI can open the @@ -276,8 +277,7 @@ class AuthRepository( executor.execute { processing.postValue(true) try { - val token = prefs.getString(PREF_TOKEN, null)!! - val challenge = lastKnownChallenge!! + val sessionId = prefs.getString(PREF_SESSION_ID, null)!! // TODO(3): Call the server API: /registerResponse // - Create an AuthenticatorAttestationResponse from the data intent generated by @@ -304,8 +304,8 @@ class AuthRepository( executor.execute { processing.postValue(true) try { - val token = prefs.getString(PREF_TOKEN, null)!! - api.removeKey(token, credentialId) + val sessionId = prefs.getString(PREF_SESSION_ID, null)!! + api.removeKey(sessionId, credentialId) refreshCredentials() } catch (e: ApiException) { Log.e(TAG, "Cannot call removeKey", e) @@ -319,18 +319,17 @@ class AuthRepository( * Starts to sign in with a FIDO2 credential. This should only be called when the sign-in state * is [SignInState.SigningIn]. */ - fun signinRequest(processing: MutableLiveData): LiveData { - val result = MutableLiveData() + fun signinRequest(processing: MutableLiveData): LiveData { + val result = MutableLiveData() executor.execute { fido2ApiClient?.let { client -> processing.postValue(true) try { - val username = prefs.getString(PREF_USERNAME, null)!! + val sessionId = prefs.getString(PREF_SESSION_ID, null)!! val credentialId = prefs.getString(PREF_LOCAL_CREDENTIAL_ID, null) // TODO(4): Call the server API: /signinRequest // - Use api.signinRequest to get a PublicKeyCredentialRequestOptions. - // - Save the challenge for later use in signinResponse. // - Call fido2ApiClient.getSignIntent and create an intent to assert the // credential. // - Pass the intent to the `result` LiveData so that the UI can open the @@ -353,7 +352,7 @@ class AuthRepository( processing.postValue(true) try { val username = prefs.getString(PREF_USERNAME, null)!! - val challenge = lastKnownChallenge!! + val sessionId = prefs.getString(PREF_SESSION_ID, null)!! // TODO(6): Call the server API: /signinResponse // - Create an AuthenticatorAssertionResponse from the data intent generated by @@ -361,13 +360,11 @@ class AuthRepository( // - Use api.signinResponse to send the response back to the server. // - Save the returned list of credentials into the SharedPreferences. The key is // PREF_CREDENTIALS. - // - Save the returned sign-in token into the SharedPreferences. The key is - // PREF_TOKEN. // - Also save the credential ID into the SharedPreferences. The key is // PREF_LOCAL_CREDENTIAL_ID. The ID can be obtained from the `keyHandle` field of // the AuthenticatorAssertionResponse object. // - Notify the UI that the sign-in has succeeded. This can be done by calling - // `invokeSignInStateListeners(SignInState.SignedIn(username, token))` + // `invokeSignInStateListeners(SignInState.SignedIn(username))` } catch (e: ApiException) { Log.e(TAG, "Cannot call registerResponse", e) diff --git a/android/app-start/src/main/java/com/example/android/fido2/repository/SignInState.kt b/android/app-start/src/main/java/com/example/android/fido2/repository/SignInState.kt index 5453eed..059e0a6 100644 --- a/android/app-start/src/main/java/com/example/android/fido2/repository/SignInState.kt +++ b/android/app-start/src/main/java/com/example/android/fido2/repository/SignInState.kt @@ -45,7 +45,6 @@ sealed class SignInState { * The user is signed in. */ data class SignedIn( - val username: String, - val token: String + val username: String ) : SignInState() } diff --git a/android/app-start/src/main/java/com/example/android/fido2/ui/LiveDataExt.kt b/android/app-start/src/main/java/com/example/android/fido2/ui/LiveDataExt.kt index 9525b8f..9407ac4 100644 --- a/android/app-start/src/main/java/com/example/android/fido2/ui/LiveDataExt.kt +++ b/android/app-start/src/main/java/com/example/android/fido2/ui/LiveDataExt.kt @@ -20,9 +20,9 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.Observer -fun LiveData.observeOnce(lifecycleOwner: LifecycleOwner, onChanged: (T) -> Unit) { - val observer = object : Observer { - override fun onChanged(t: T) { +fun LiveData.observeOnce(lifecycleOwner: LifecycleOwner, onChanged: (T) -> Unit) { + val observer = object : Observer { + override fun onChanged(t: T?) { if (t != null) { onChanged(t) removeObserver(this) diff --git a/android/app-start/src/main/java/com/example/android/fido2/ui/auth/AuthFragment.kt b/android/app-start/src/main/java/com/example/android/fido2/ui/auth/AuthFragment.kt index 4b5c674..e2afda2 100644 --- a/android/app-start/src/main/java/com/example/android/fido2/ui/auth/AuthFragment.kt +++ b/android/app-start/src/main/java/com/example/android/fido2/ui/auth/AuthFragment.kt @@ -16,24 +16,27 @@ package com.example.android.fido2.ui.auth +import android.app.Activity import android.content.Intent -import android.content.IntentSender import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.observe -import com.example.android.fido2.MainActivity +import com.example.android.fido2.R import com.example.android.fido2.databinding.AuthFragmentBinding import com.example.android.fido2.ui.observeOnce +import com.google.android.gms.fido.Fido +import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse class AuthFragment : Fragment() { companion object { private const val TAG = "AuthFragment" + const val REQUEST_FIDO2_SIGNIN = 2 } private val viewModel: AuthViewModel by viewModels() @@ -62,23 +65,33 @@ class AuthFragment : Fragment() { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) - viewModel.signinIntent.observeOnce(this) { intent -> - val a = activity - if (intent.hasPendingIntent() && a != null) { - try { + viewModel.signinRequest().observeOnce(this) { intent -> - // TODO(5): Open the fingerprint dialog. - // - Open the fingerprint dialog by launching the intent from FIDO2 API. + // TODO(5): Open the fingerprint dialog. + // - Open the fingerprint dialog by launching the intent from FIDO2 API. - } catch (e: IntentSender.SendIntentException) { - Log.e(TAG, "Error launching pending intent for signin request", e) - } - } } } - fun handleSignin(data: Intent) { - viewModel.signinResponse(data) - } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_FIDO2_SIGNIN) { + val errorExtra = data?.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA) + if (errorExtra != null) { + val error = AuthenticatorErrorResponse.deserializeFromBytes(errorExtra) + error.errorMessage?.let { errorMessage -> + Toast.makeText(requireContext(), errorMessage, Toast.LENGTH_LONG).show() + Log.e(TAG, errorMessage) + } + } else if (resultCode != Activity.RESULT_OK) { + Toast.makeText(requireContext(), R.string.cancelled, Toast.LENGTH_SHORT).show() + } else { + if (data != null) { + viewModel.signinResponse(data) + } + } + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } } diff --git a/android/app-start/src/main/java/com/example/android/fido2/ui/auth/AuthViewModel.kt b/android/app-start/src/main/java/com/example/android/fido2/ui/auth/AuthViewModel.kt index 302a636..d90416a 100644 --- a/android/app-start/src/main/java/com/example/android/fido2/ui/auth/AuthViewModel.kt +++ b/android/app-start/src/main/java/com/example/android/fido2/ui/auth/AuthViewModel.kt @@ -17,12 +17,13 @@ package com.example.android.fido2.ui.auth import android.app.Application +import android.app.PendingIntent import android.content.Intent import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations +import androidx.lifecycle.map import com.example.android.fido2.repository.AuthRepository import com.example.android.fido2.repository.SignInState @@ -48,16 +49,17 @@ class AuthViewModel(application: Application) : AndroidViewModel(application) { addSource(password) { update(_processing.value == true, it) } } - val signinIntent = repository.signinRequest(_processing) + fun signinRequest(): LiveData { + return repository.signinRequest(_processing) + } - val currentUsername: LiveData = - Transformations.map(repository.getSignInState()) { state -> - when (state) { - is SignInState.SigningIn -> state.username - is SignInState.SignedIn -> state.username - else -> "(user)" - } + val currentUsername: LiveData = repository.getSignInState().map { state -> + when (state) { + is SignInState.SigningIn -> state.username + is SignInState.SignedIn -> state.username + else -> "(user)" } + } fun auth() { repository.password(password.value ?: "", _processing) diff --git a/android/app-start/src/main/java/com/example/android/fido2/ui/home/HomeFragment.kt b/android/app-start/src/main/java/com/example/android/fido2/ui/home/HomeFragment.kt index 5f12ce0..21a7974 100644 --- a/android/app-start/src/main/java/com/example/android/fido2/ui/home/HomeFragment.kt +++ b/android/app-start/src/main/java/com/example/android/fido2/ui/home/HomeFragment.kt @@ -16,27 +16,29 @@ package com.example.android.fido2.ui.home +import android.app.Activity import android.content.Intent -import android.content.IntentSender import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.observe import androidx.recyclerview.widget.LinearLayoutManager -import com.example.android.fido2.MainActivity import com.example.android.fido2.R import com.example.android.fido2.databinding.HomeFragmentBinding import com.example.android.fido2.ui.observeOnce +import com.google.android.gms.fido.Fido +import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse class HomeFragment : Fragment(), DeleteConfirmationFragment.Listener { companion object { private const val TAG = "HomeFragment" private const val FRAGMENT_DELETE_CONFIRMATION = "delete_confirmation" + const val REQUEST_FIDO2_REGISTER = 1 } private val viewModel: HomeViewModel by viewModels() @@ -97,17 +99,10 @@ class HomeFragment : Fragment(), DeleteConfirmationFragment.Listener { // FAB binding.add.setOnClickListener { viewModel.registerRequest().observeOnce(requireActivity()) { intent -> - val a = activity - if (intent.hasPendingIntent() && a != null) { - try { - // TODO(2): Open the fingerprint dialog. - // - Open the fingerprint dialog by launching the intent from FIDO2 API. + // TODO(2): Open the fingerprint dialog. + // - Open the fingerprint dialog by launching the intent from FIDO2 API. - } catch (e: IntentSender.SendIntentException) { - Log.e(TAG, "Error launching pending intent for register request", e) - } - } } } } @@ -116,8 +111,25 @@ class HomeFragment : Fragment(), DeleteConfirmationFragment.Listener { viewModel.removeKey(credentialId) } - fun handleRegister(data: Intent) { - viewModel.registerResponse(data) + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_FIDO2_REGISTER) { + val errorExtra = data?.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA) + when { + errorExtra != null -> { + val error = AuthenticatorErrorResponse.deserializeFromBytes(errorExtra) + error.errorMessage?.let { errorMessage -> + Toast.makeText(requireContext(), errorMessage, Toast.LENGTH_LONG).show() + Log.e(TAG, errorMessage) + } + } + resultCode != Activity.RESULT_OK -> { + Toast.makeText(requireContext(), R.string.cancelled, Toast.LENGTH_SHORT).show() + } + data != null -> viewModel.registerResponse(data) + } + } else { + super.onActivityResult(requestCode, resultCode, data) + } } } diff --git a/android/app-start/src/main/java/com/example/android/fido2/ui/home/HomeViewModel.kt b/android/app-start/src/main/java/com/example/android/fido2/ui/home/HomeViewModel.kt index 55a6fc1..8b4ba49 100644 --- a/android/app-start/src/main/java/com/example/android/fido2/ui/home/HomeViewModel.kt +++ b/android/app-start/src/main/java/com/example/android/fido2/ui/home/HomeViewModel.kt @@ -17,14 +17,14 @@ package com.example.android.fido2.ui.home import android.app.Application +import android.app.PendingIntent import android.content.Intent import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations +import androidx.lifecycle.map import com.example.android.fido2.repository.AuthRepository import com.example.android.fido2.repository.SignInState -import com.google.android.gms.fido.fido2.Fido2PendingIntent class HomeViewModel(application: Application) : AndroidViewModel(application) { @@ -34,26 +34,25 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) { val processing: LiveData get() = _processing - val currentUsername: LiveData = - Transformations.map(repository.getSignInState()) { state -> - when (state) { - is SignInState.SigningIn -> state.username - is SignInState.SignedIn -> state.username - else -> "User" - } + val currentUsername: LiveData = repository.getSignInState().map { state -> + when (state) { + is SignInState.SigningIn -> state.username + is SignInState.SignedIn -> state.username + else -> "User" } + } val credentials = repository.getCredentials() fun reauth() { - repository.clearToken() + repository.clearCredentials() } fun signOut() { repository.signOut() } - fun registerRequest(): LiveData { + fun registerRequest(): LiveData { return repository.registerRequest(_processing) } diff --git a/android/app-start/src/main/java/com/example/android/fido2/ui/username/UsernameFragment.kt b/android/app-start/src/main/java/com/example/android/fido2/ui/username/UsernameFragment.kt index 4676794..aab4839 100644 --- a/android/app-start/src/main/java/com/example/android/fido2/ui/username/UsernameFragment.kt +++ b/android/app-start/src/main/java/com/example/android/fido2/ui/username/UsernameFragment.kt @@ -23,7 +23,6 @@ import android.view.ViewGroup import android.view.inputmethod.EditorInfo import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.observe import com.example.android.fido2.databinding.UsernameFragmentBinding class UsernameFragment : Fragment() { diff --git a/android/app/build.gradle b/android/app/build.gradle index 374abbc..57b45ee 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -75,7 +75,7 @@ dependencies { implementation 'androidx.fragment:fragment-ktx:1.2.5' implementation 'androidx.core:core-ktx:1.3.2' - implementation 'androidx.constraintlayout:constraintlayout:2.0.2' + implementation 'androidx.constraintlayout:constraintlayout:2.0.4' def lifecycle_version = '2.2.0' implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" @@ -88,7 +88,7 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:$okhttp_version" implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version" - testImplementation 'junit:junit:4.13' + testImplementation 'junit:junit:4.13.1' androidTestImplementation 'androidx.test:runner:1.3.0' androidTestImplementation 'androidx.test:rules:1.3.0' diff --git a/android/app/src/androidTest/java/com/example/android/fido2/api/AuthApiTest.kt b/android/app/src/androidTest/java/com/example/android/fido2/api/AuthApiTest.kt deleted file mode 100644 index cdc6cd4..0000000 --- a/android/app/src/androidTest/java/com/example/android/fido2/api/AuthApiTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2019 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.android.fido2.api - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.LargeTest -import com.google.common.truth.Truth.assertThat -import org.junit.Test -import org.junit.runner.RunWith - -@LargeTest -@RunWith(AndroidJUnit4::class) -class AuthApiTest { - - val api = AuthApi() - - @Test - fun username() { - val result = api.username("wa") - assertThat(result).isEqualTo("wa") - } - - @Test - fun password() { - val result = api.password("wa", "o") - assertThat(result).contains("signed-in=yes") - assertThat(result).contains("username=wa") - } - - @Test - fun getKeys() { - val token = api.password("wa", "o") - val credentials = api.getKeys(token) - assertThat(credentials).isEmpty() - assertThat(api.getKeys(api.password("agektmr", "ajfda"))).isNotEmpty() - } - - @Test - fun registerRequest() { - val token = api.password("wa", "o") - val (result, _) = api.registerRequest(token) - assertThat(result.user.displayName).isEqualTo("No name") - assertThat(result.user.name).isEqualTo("wa") - assertThat(result.excludeList).isEmpty() - assertThat(result.authenticatorSelection?.attachment.toString()).isEqualTo("platform") - } - -} diff --git a/android/app/src/main/java/com/example/android/fido2/MainActivity.kt b/android/app/src/main/java/com/example/android/fido2/MainActivity.kt index 47b511b..d4c5c80 100644 --- a/android/app/src/main/java/com/example/android/fido2/MainActivity.kt +++ b/android/app/src/main/java/com/example/android/fido2/MainActivity.kt @@ -16,30 +16,20 @@ package com.example.android.fido2 -import android.content.Intent import android.os.Bundle -import android.util.Log import android.widget.Toast import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.app.commit -import androidx.lifecycle.observe import com.example.android.fido2.repository.SignInState import com.example.android.fido2.ui.auth.AuthFragment import com.example.android.fido2.ui.home.HomeFragment import com.example.android.fido2.ui.username.UsernameFragment import com.google.android.gms.fido.Fido -import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse class MainActivity : AppCompatActivity() { - companion object { - private const val TAG = "MainActivity" - const val REQUEST_FIDO2_REGISTER = 1 - const val REQUEST_FIDO2_SIGNIN = 2 - } - private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -67,46 +57,6 @@ class MainActivity : AppCompatActivity() { } } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - when (requestCode) { - REQUEST_FIDO2_REGISTER -> { - val errorExtra = data?.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA) - if (errorExtra != null) { - val error = AuthenticatorErrorResponse.deserializeFromBytes(errorExtra) - error.errorMessage?.let { errorMessage -> - Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show() - Log.e(TAG, errorMessage) - } - } else if (resultCode != RESULT_OK) { - Toast.makeText(this, R.string.cancelled, Toast.LENGTH_SHORT).show() - } else { - val fragment = supportFragmentManager.findFragmentById(R.id.container) - if (data != null && fragment is HomeFragment) { - fragment.handleRegister(data) - } - } - } - REQUEST_FIDO2_SIGNIN -> { - val errorExtra = data?.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA) - if (errorExtra != null) { - val error = AuthenticatorErrorResponse.deserializeFromBytes(errorExtra) - error.errorMessage?.let { errorMessage -> - Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show() - Log.e(TAG, errorMessage) - } - } else if (resultCode != RESULT_OK) { - Toast.makeText(this, R.string.cancelled, Toast.LENGTH_SHORT).show() - } else { - val fragment = supportFragmentManager.findFragmentById(R.id.container) - if (data != null && fragment is AuthFragment) { - fragment.handleSignin(data) - } - } - } - else -> super.onActivityResult(requestCode, resultCode, data) - } - } - override fun onResume() { super.onResume() viewModel.setFido2ApiClient(Fido.getFido2ApiClient(this)) diff --git a/android/app/src/main/java/com/example/android/fido2/api/ApiResult.kt b/android/app/src/main/java/com/example/android/fido2/api/ApiResult.kt new file mode 100644 index 0000000..a1fb8f6 --- /dev/null +++ b/android/app/src/main/java/com/example/android/fido2/api/ApiResult.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.fido2.api + +class ApiResult( + + /** + * The session ID to be used for the subsequent API calls. Might be null if the API call does + * not return a new cookie. + */ + val sessionId: String?, + + /** + * The result data. + */ + val data: T +) diff --git a/android/app/src/main/java/com/example/android/fido2/api/AuthApi.kt b/android/app/src/main/java/com/example/android/fido2/api/AuthApi.kt index 23eab65..b1171d6 100644 --- a/android/app/src/main/java/com/example/android/fido2/api/AuthApi.kt +++ b/android/app/src/main/java/com/example/android/fido2/api/AuthApi.kt @@ -19,6 +19,7 @@ package com.example.android.fido2.api import android.util.JsonReader import android.util.JsonToken import android.util.JsonWriter +import android.util.Log import com.example.android.fido2.BuildConfig import com.example.android.fido2.decodeBase64 import com.example.android.fido2.toBase64 @@ -52,6 +53,8 @@ class AuthApi { companion object { private const val BASE_URL = BuildConfig.API_BASE_URL private val JSON = "application/json".toMediaTypeOrNull() + private const val SessionIdKey = "connect.sid=" + private const val TAG = "AuthApi" } private val client = OkHttpClient.Builder() @@ -63,9 +66,9 @@ class AuthApi { /** * @param username The username to be used for sign-in. - * @return The username. + * @return The Session ID. */ - fun username(username: String): String { + fun username(username: String): ApiResult { val call = client.newCall( Request.Builder() .url("$BASE_URL/username") @@ -79,19 +82,19 @@ class AuthApi { throwResponseError(response, "Error calling /username") } - return parseUsername(findSetCookieInResponse(response, "username")) + return response.result { Unit } } /** - * @param username The username sent to the server with `username()`. + * @param sessionId The session ID received on `username()`. * @param password A password. - * @return token The sign-in token to be used for subsequent API calls. + * @return An [ApiResult]. */ - fun password(username: String, password: String): String { + fun password(sessionId: String, password: String): ApiResult { val call = client.newCall( Request.Builder() .url("$BASE_URL/password") - .addHeader("Cookie", "username=$username") + .addHeader("Cookie", formatCookie(sessionId)) .method("POST", jsonRequestBody { name("password").value(password) }) @@ -101,19 +104,18 @@ class AuthApi { if (!response.isSuccessful) { throwResponseError(response, "Error calling /password") } - val cookie = findSetCookieInResponse(response, "signed-in") - return "$cookie; username=$username" + return response.result { Unit } } /** - * @param token The sign-in token. + * @param sessionId The session ID. * @return A list of all the credentials registered on the server. */ - fun getKeys(token: String): List { + fun getKeys(sessionId: String): ApiResult> { val call = client.newCall( Request.Builder() .url("$BASE_URL/getKeys") - .addHeader("Cookie", token) + .addHeader("Cookie", formatCookie(sessionId)) .method("POST", jsonRequestBody {}) .build() ) @@ -121,21 +123,23 @@ class AuthApi { if (!response.isSuccessful) { throwResponseError(response, "Error calling /getKeys") } - val body = response.body ?: throw ApiException("Empty response from /getKeys") - return parseUserCredentials(body) + + return response.result { + parseUserCredentials(body ?: throw ApiException("Empty response from /getKeys")) + } } /** - * @param token The sign-in token. + * @param sessionId The session ID. * @return A pair. The `first` element is an [PublicKeyCredentialCreationOptions] that can be * used for a subsequent FIDO2 API call. The `second` element is a challenge string that should * be sent back to the server in [registerResponse]. */ - fun registerRequest(token: String): Pair { + fun registerRequest(sessionId: String): ApiResult { val call = client.newCall( Request.Builder() .url("$BASE_URL/registerRequest") - .addHeader("Cookie", token) + .addHeader("Cookie", formatCookie(sessionId)) .method("POST", jsonRequestBody { name("attestation").value("none") name("authenticatorSelection").objectValue { @@ -149,29 +153,31 @@ class AuthApi { if (!response.isSuccessful) { throwResponseError(response, "Error calling /registerRequest") } - val body = response.body ?: throw ApiException("Empty response from /registerRequest") - return parsePublicKeyCredentialCreationOptions(body) + + return response.result { + parsePublicKeyCredentialCreationOptions( + body ?: throw ApiException("Empty response from /registerRequest") + ) + } } /** - * @param token The sign-in token. - * @param challenge The challenge string returned by [registerRequest]. + * @param sessionId The session ID. * @param response The FIDO2 response object. * @return A list of all the credentials registered on the server, including the newly * registered one. */ fun registerResponse( - token: String, - challenge: String, + sessionId: String, response: AuthenticatorAttestationResponse - ): List { + ): ApiResult> { response.keyHandle.toBase64() val rawId = response.keyHandle.toBase64() val call = client.newCall( Request.Builder() .url("$BASE_URL/registerResponse") - .addHeader("Cookie", "$token; challenge=$challenge") + .addHeader("Cookie", formatCookie(sessionId)) .method("POST", jsonRequestBody { name("id").value(rawId) name("type").value(PublicKeyCredentialType.PUBLIC_KEY.toString()) @@ -191,19 +197,22 @@ class AuthApi { if (!apiResponse.isSuccessful) { throwResponseError(apiResponse, "Error calling /registerResponse") } - val body = apiResponse.body ?: throw ApiException("Empty response from /registerResponse") - return parseUserCredentials(body) + return apiResponse.result { + parseUserCredentials( + body ?: throw ApiException("Empty response from /registerResponse") + ) + } } /** - * @param token The sign-in token. + * @param sessionId The session ID. * @param credentialId The credential ID to be removed. */ - fun removeKey(token: String, credentialId: String) { + fun removeKey(sessionId: String, credentialId: String): ApiResult { val call = client.newCall( Request.Builder() .url("$BASE_URL/removeKey?credId=$credentialId") - .addHeader("Cookie", token) + .addHeader("Cookie", formatCookie(sessionId)) .method("POST", jsonRequestBody {}) .build() ) @@ -211,20 +220,20 @@ class AuthApi { if (!response.isSuccessful) { throwResponseError(response, "Error calling /removeKey") } - // Nothing useful in the response body; ignore. + return response.result { Unit } } /** - * @param username The username to be used for the sign-in. + * @param sessionId The session ID to be used for the sign-in. * @param credentialId The credential ID of this device. * @return A pair. The `first` element is a [PublicKeyCredentialRequestOptions] that can be used * for a subsequent FIDO2 API call. The `second` element is a challenge string that should * be sent back to the server in [signinResponse]. */ fun signinRequest( - username: String, + sessionId: String, credentialId: String? - ): Pair { + ): ApiResult { val call = client.newCall( Request.Builder() .url( @@ -235,7 +244,7 @@ class AuthApi { } } ) - .addHeader("Cookie", "username=$username") + .addHeader("Cookie", formatCookie(sessionId)) .method("POST", jsonRequestBody {}) .build() ) @@ -243,8 +252,11 @@ class AuthApi { if (!response.isSuccessful) { throwResponseError(response, "Error calling /signinRequest") } - val body = response.body ?: throw ApiException("Empty response from /signinRequest") - return parsePublicKeyCredentialRequestOptions(body) + return response.result { + parsePublicKeyCredentialRequestOptions( + body ?: throw ApiException("Empty response from /signinRequest") + ) + } } /** @@ -253,15 +265,14 @@ class AuthApi { * @param response The assertion response from FIDO2 API. */ fun signinResponse( - username: String, - challenge: String, + sessionId: String, response: AuthenticatorAssertionResponse - ): Pair, String> { + ): ApiResult> { val rawId = response.keyHandle.toBase64() val call = client.newCall( Request.Builder() .url("$BASE_URL/signinResponse") - .addHeader("Cookie", "username=$username; challenge=$challenge") + .addHeader("Cookie", formatCookie(sessionId)) .method("POST", jsonRequestBody { name("id").value(rawId) name("type").value(PublicKeyCredentialType.PUBLIC_KEY.toString()) @@ -287,58 +298,45 @@ class AuthApi { if (!apiResponse.isSuccessful) { throwResponseError(apiResponse, "Error calling /signingResponse") } - val body = apiResponse.body ?: throw ApiException("Empty response from /signinResponse") - val cookie = findSetCookieInResponse(apiResponse, "signed-in") - return parseUserCredentials(body) to "$cookie; username=$username" + return apiResponse.result { + parseUserCredentials(body ?: throw ApiException("Empty response from /signinResponse")) + } } private fun parsePublicKeyCredentialRequestOptions( body: ResponseBody - ): Pair { + ): PublicKeyCredentialRequestOptions { val builder = PublicKeyCredentialRequestOptions.Builder() - var challenge: String? = null JsonReader(body.byteStream().bufferedReader()).use { reader -> reader.beginObject() while (reader.hasNext()) { when (reader.nextName()) { - "challenge" -> { - val c = reader.nextString() - challenge = c - builder.setChallenge(c.decodeBase64()) - } + "challenge" -> builder.setChallenge(reader.nextString().decodeBase64()) "userVerification" -> reader.skipValue() "allowCredentials" -> builder.setAllowList(parseCredentialDescriptors(reader)) "rpId" -> builder.setRpId(reader.nextString()) - "timeout" -> { - val timeout = reader.nextDouble() - builder.setTimeoutSeconds(timeout) - } + "timeout" -> builder.setTimeoutSeconds(reader.nextDouble()) else -> reader.skipValue() } } reader.endObject() } - return builder.build() to challenge!! + return builder.build() } private fun parsePublicKeyCredentialCreationOptions( body: ResponseBody - ): Pair { + ): PublicKeyCredentialCreationOptions { val builder = PublicKeyCredentialCreationOptions.Builder() - var challenge: String? = null JsonReader(body.byteStream().bufferedReader()).use { reader -> reader.beginObject() while (reader.hasNext()) { when (reader.nextName()) { "user" -> builder.setUser(parseUser(reader)) - "challenge" -> { - val c = reader.nextString() - builder.setChallenge(c.decodeBase64()) - challenge = c - } + "challenge" -> builder.setChallenge(reader.nextString().decodeBase64()) "pubKeyCredParams" -> builder.setParameters(parseParameters(reader)) "timeout" -> builder.setTimeoutSeconds(reader.nextDouble()) - "attestation" -> reader.skipValue() // Unusedp + "attestation" -> reader.skipValue() // Unused "excludeCredentials" -> builder.setExcludeList( parseCredentialDescriptors(reader) ) @@ -350,7 +348,7 @@ class AuthApi { } reader.endObject() } - return builder.build() to challenge!! + return builder.build() } private fun parseRp(reader: JsonReader): PublicKeyCredentialRpEntity { @@ -466,15 +464,6 @@ class AuthApi { return output.toString().toRequestBody(JSON) } - private fun parseUsername(cookie: String): String { - val start = cookie.indexOf("username=") - val end = cookie.indexOf(";") - if (start < 0 || end < 0 || start + 9 >= end) { - throw RuntimeException("Cannot parse the cookie") - } - return cookie.substring(start + 9, end) - } - private fun parseUserCredentials(body: ResponseBody): List { fun readCredentials(reader: JsonReader): List { val credentials = mutableListOf() @@ -542,9 +531,10 @@ class AuthApi { reader.endObject() } } catch (e: Exception) { - throw ApiException("Cannot parse error: $errorString") + Log.e(TAG, "Cannot parse the error: $errorString", e) + // Don't throw; this method is called during throwing. } - return "" // Don't throw; this method is called during throwing. + return "" } private fun JsonWriter.objectValue(body: JsonWriter.() -> Unit) { @@ -553,15 +543,22 @@ class AuthApi { endObject() } - /* - * Looks for a set-cookie header with a particular name - */ - private fun findSetCookieInResponse(response: Response, cname: String): String { - for (header in response.headers("set-cookie")) { - if (header.startsWith("$cname=")) { - return header - } + private fun Response.result(data: Response.() -> T): ApiResult { + val cookie = headers("set-cookie").find { it.startsWith(SessionIdKey) } + return ApiResult(if (cookie != null) parseSessionId(cookie) else null, data()) + } + + private fun parseSessionId(cookie: String): String { + val start = cookie.indexOf(SessionIdKey) + if (start < 0) { + throw ApiException("Cannot find $SessionIdKey") } - throw ApiException("Cookie not found: $cname") + val semicolon = cookie.indexOf(";", start + SessionIdKey.length) + val end = if (semicolon < 0) cookie.length else semicolon + return cookie.substring(start + SessionIdKey.length, end) + } + + private fun formatCookie(sessionId: String): String { + return "$SessionIdKey$sessionId" } } diff --git a/android/app/src/main/java/com/example/android/fido2/repository/AuthRepository.kt b/android/app/src/main/java/com/example/android/fido2/repository/AuthRepository.kt index 2d9e768..02694f1 100644 --- a/android/app/src/main/java/com/example/android/fido2/repository/AuthRepository.kt +++ b/android/app/src/main/java/com/example/android/fido2/repository/AuthRepository.kt @@ -16,6 +16,7 @@ package com.example.android.fido2.repository +import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.SharedPreferences @@ -24,17 +25,15 @@ import androidx.annotation.WorkerThread import androidx.core.content.edit import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations +import androidx.lifecycle.map import com.example.android.fido2.api.ApiException import com.example.android.fido2.api.AuthApi import com.example.android.fido2.api.Credential import com.example.android.fido2.toBase64 import com.google.android.gms.fido.Fido import com.google.android.gms.fido.fido2.Fido2ApiClient -import com.google.android.gms.fido.fido2.Fido2PendingIntent import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse import com.google.android.gms.fido.fido2.api.common.AuthenticatorAttestationResponse -import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.Tasks import java.util.concurrent.Executor import java.util.concurrent.Executors @@ -54,7 +53,7 @@ class AuthRepository( // Keys for SharedPreferences private const val PREFS_NAME = "auth" private const val PREF_USERNAME = "username" - private const val PREF_TOKEN = "token" + private const val PREF_SESSION_ID = "session_id" private const val PREF_CREDENTIALS = "credentials" private const val PREF_LOCAL_CREDENTIAL_ID = "local_credential_id" @@ -79,12 +78,6 @@ class AuthRepository( private val signInStateListeners = mutableListOf<(SignInState) -> Unit>() - /** - * Stores a temporary challenge that needs to be memorized between request and response API - * calls for credential registration and sign-in. - */ - private var lastKnownChallenge: String? = null - private fun invokeSignInStateListeners(state: SignInState) { val listeners = signInStateListeners.toList() // Copy for (listener in listeners) { @@ -104,11 +97,11 @@ class AuthRepository( init { val username = prefs.getString(PREF_USERNAME, null) - val token = prefs.getString(PREF_TOKEN, null) + val sessionId = prefs.getString(PREF_SESSION_ID, null) value = when { username.isNullOrBlank() -> SignInState.SignedOut - token.isNullOrBlank() -> SignInState.SigningIn(username) - else -> SignInState.SignedIn(username, token) + sessionId.isNullOrBlank() -> SignInState.SigningIn(username) + else -> SignInState.SignedIn(username) } } @@ -132,7 +125,8 @@ class AuthRepository( try { val result = api.username(username) prefs.edit(commit = true) { - putString(PREF_USERNAME, result) + putString(PREF_USERNAME, username) + putString(PREF_SESSION_ID, result.sessionId!!) } invokeSignInStateListeners(SignInState.SigningIn(username)) } finally { @@ -152,22 +146,28 @@ class AuthRepository( executor.execute { processing.postValue(true) val username = prefs.getString(PREF_USERNAME, null)!! + val sessionId = prefs.getString(PREF_SESSION_ID, null)!! try { - val token = api.password(username, password) - prefs.edit(commit = true) { putString(PREF_TOKEN, token) } - invokeSignInStateListeners(SignInState.SignedIn(username, token)) + val result = api.password(sessionId, password) + prefs.edit(commit = true) { + result.sessionId?.let { + putString(PREF_SESSION_ID, it) + } + } + invokeSignInStateListeners(SignInState.SignedIn(username)) } catch (e: ApiException) { Log.e(TAG, "Invalid login credentials", e) // start login over again prefs.edit(commit = true) { remove(PREF_USERNAME) - remove(PREF_TOKEN) + remove(PREF_SESSION_ID) remove(PREF_CREDENTIALS) } invokeSignInStateListeners( - SignInState.SignInError(e.message ?: "Invalid login credentials" )) + SignInState.SignInError(e.message ?: "Invalid login credentials") + ) } finally { processing.postValue(false) } @@ -182,16 +182,18 @@ class AuthRepository( executor.execute { refreshCredentials() } - return Transformations.map(prefs.liveStringSet(PREF_CREDENTIALS, emptySet())) { set -> + return prefs.liveStringSet(PREF_CREDENTIALS, emptySet()).map { set -> parseCredentials(set) } } @WorkerThread private fun refreshCredentials() { - val token = prefs.getString(PREF_TOKEN, null)!! + val sessionId = prefs.getString(PREF_SESSION_ID, null)!! + val result = api.getKeys(sessionId) prefs.edit(commit = true) { - putStringSet(PREF_CREDENTIALS, api.getKeys(token).toStringSet()) + result.sessionId?.let { putString(PREF_SESSION_ID, it) } + putStringSet(PREF_CREDENTIALS, result.data.toStringSet()) } } @@ -210,13 +212,12 @@ class AuthRepository( } /** - * Clears the sign-in token. The sign-in state will proceed to [SignInState.SigningIn]. + * Clears the credentials. The sign-in state will proceed to [SignInState.SigningIn]. */ - fun clearToken() { + fun clearCredentials() { executor.execute { val username = prefs.getString(PREF_USERNAME, null)!! prefs.edit(commit = true) { - remove(PREF_TOKEN) remove(PREF_CREDENTIALS) } invokeSignInStateListeners(SignInState.SigningIn(username)) @@ -231,7 +232,7 @@ class AuthRepository( executor.execute { prefs.edit(commit = true) { remove(PREF_USERNAME) - remove(PREF_TOKEN) + remove(PREF_SESSION_ID) remove(PREF_CREDENTIALS) } invokeSignInStateListeners(SignInState.SignedOut) @@ -242,16 +243,18 @@ class AuthRepository( * Starts to register a new credential to the server. This should be called only when the * sign-in state is [SignInState.SignedIn]. */ - fun registerRequest(processing: MutableLiveData): LiveData { - val result = MutableLiveData() + fun registerRequest(processing: MutableLiveData): LiveData { + val result = MutableLiveData() executor.execute { fido2ApiClient?.let { client -> processing.postValue(true) try { - val token = prefs.getString(PREF_TOKEN, null)!! - val (options, challenge) = api.registerRequest(token) - lastKnownChallenge = challenge - val task: Task = client.getRegisterIntent(options) + val sessionId = prefs.getString(PREF_SESSION_ID, null)!! + val apiResult = api.registerRequest(sessionId) + prefs.edit(commit = true) { + apiResult.sessionId?.let { putString(PREF_SESSION_ID, it) } + } + val task = client.getRegisterPendingIntent(apiResult.data) result.postValue(Tasks.await(task)) } catch (e: Exception) { Log.e(TAG, "Cannot call registerRequest", e) @@ -271,15 +274,15 @@ class AuthRepository( executor.execute { processing.postValue(true) try { - val token = prefs.getString(PREF_TOKEN, null)!! - val challenge = lastKnownChallenge!! + val sessionId = prefs.getString(PREF_SESSION_ID, null)!! val response = AuthenticatorAttestationResponse.deserializeFromBytes( data.getByteArrayExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA)!! ) val credentialId = response.keyHandle.toBase64() - val credentials = api.registerResponse(token, challenge, response) + val result = api.registerResponse(sessionId, response) prefs.edit { - putStringSet(PREF_CREDENTIALS, credentials.toStringSet()) + result.sessionId?.let { putString(PREF_SESSION_ID, it) } + putStringSet(PREF_CREDENTIALS, result.data.toStringSet()) putString(PREF_LOCAL_CREDENTIAL_ID, credentialId) } } catch (e: ApiException) { @@ -297,8 +300,8 @@ class AuthRepository( executor.execute { processing.postValue(true) try { - val token = prefs.getString(PREF_TOKEN, null)!! - api.removeKey(token, credentialId) + val sessionId = prefs.getString(PREF_SESSION_ID, null)!! + api.removeKey(sessionId, credentialId) refreshCredentials() } catch (e: ApiException) { Log.e(TAG, "Cannot call removeKey", e) @@ -312,18 +315,19 @@ class AuthRepository( * Starts to sign in with a FIDO2 credential. This should only be called when the sign-in state * is [SignInState.SigningIn]. */ - fun signinRequest(processing: MutableLiveData): LiveData { - val result = MutableLiveData() + fun signinRequest(processing: MutableLiveData): LiveData { + val result = MutableLiveData() executor.execute { fido2ApiClient?.let { client -> processing.postValue(true) try { - val username = prefs.getString(PREF_USERNAME, null)!! + val sessionId = prefs.getString(PREF_SESSION_ID, null)!! val credentialId = prefs.getString(PREF_LOCAL_CREDENTIAL_ID, null) - val (options, challenge) = api.signinRequest(username, credentialId) - lastKnownChallenge = challenge - val task = client.getSignIntent(options) - result.postValue(Tasks.await(task)) + if (credentialId != null) { + val apiResult = api.signinRequest(sessionId, credentialId) + val task = client.getSignPendingIntent(apiResult.data) + result.postValue(Tasks.await(task)) + } } finally { processing.postValue(false) } @@ -341,18 +345,18 @@ class AuthRepository( processing.postValue(true) try { val username = prefs.getString(PREF_USERNAME, null)!! - val challenge = lastKnownChallenge!! + val sessionId = prefs.getString(PREF_SESSION_ID, null)!! val response = AuthenticatorAssertionResponse.deserializeFromBytes( data.getByteArrayExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA) ) val credentialId = response.keyHandle.toBase64() - val (credentials, token) = api.signinResponse(username, challenge, response) + val result = api.signinResponse(sessionId, response) prefs.edit(commit = true) { - putString(PREF_TOKEN, token) - putStringSet(PREF_CREDENTIALS, credentials.toStringSet()) + result.sessionId?.let { putString(PREF_SESSION_ID, it) } + putStringSet(PREF_CREDENTIALS, result.data.toStringSet()) putString(PREF_LOCAL_CREDENTIAL_ID, credentialId) } - invokeSignInStateListeners(SignInState.SignedIn(username, token)) + invokeSignInStateListeners(SignInState.SignedIn(username)) } catch (e: ApiException) { Log.e(TAG, "Cannot call registerResponse", e) } finally { diff --git a/android/app/src/main/java/com/example/android/fido2/repository/SignInState.kt b/android/app/src/main/java/com/example/android/fido2/repository/SignInState.kt index 5453eed..059e0a6 100644 --- a/android/app/src/main/java/com/example/android/fido2/repository/SignInState.kt +++ b/android/app/src/main/java/com/example/android/fido2/repository/SignInState.kt @@ -45,7 +45,6 @@ sealed class SignInState { * The user is signed in. */ data class SignedIn( - val username: String, - val token: String + val username: String ) : SignInState() } diff --git a/android/app/src/main/java/com/example/android/fido2/ui/LiveDataExt.kt b/android/app/src/main/java/com/example/android/fido2/ui/LiveDataExt.kt index 9525b8f..9407ac4 100644 --- a/android/app/src/main/java/com/example/android/fido2/ui/LiveDataExt.kt +++ b/android/app/src/main/java/com/example/android/fido2/ui/LiveDataExt.kt @@ -20,9 +20,9 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.Observer -fun LiveData.observeOnce(lifecycleOwner: LifecycleOwner, onChanged: (T) -> Unit) { - val observer = object : Observer { - override fun onChanged(t: T) { +fun LiveData.observeOnce(lifecycleOwner: LifecycleOwner, onChanged: (T) -> Unit) { + val observer = object : Observer { + override fun onChanged(t: T?) { if (t != null) { onChanged(t) removeObserver(this) diff --git a/android/app/src/main/java/com/example/android/fido2/ui/auth/AuthFragment.kt b/android/app/src/main/java/com/example/android/fido2/ui/auth/AuthFragment.kt index c04476e..b36de3c 100644 --- a/android/app/src/main/java/com/example/android/fido2/ui/auth/AuthFragment.kt +++ b/android/app/src/main/java/com/example/android/fido2/ui/auth/AuthFragment.kt @@ -16,24 +16,27 @@ package com.example.android.fido2.ui.auth +import android.app.Activity import android.content.Intent -import android.content.IntentSender import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.observe -import com.example.android.fido2.MainActivity +import com.example.android.fido2.R import com.example.android.fido2.databinding.AuthFragmentBinding import com.example.android.fido2.ui.observeOnce +import com.google.android.gms.fido.Fido +import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse class AuthFragment : Fragment() { companion object { private const val TAG = "AuthFragment" + const val REQUEST_FIDO2_SIGNIN = 2 } private val viewModel: AuthViewModel by viewModels() @@ -62,20 +65,38 @@ class AuthFragment : Fragment() { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) - viewModel.signinIntent.observeOnce(this) { intent -> - val a = activity - if (intent.hasPendingIntent() && a != null) { - try { - intent.launchPendingIntent(a, MainActivity.REQUEST_FIDO2_SIGNIN) - } catch (e: IntentSender.SendIntentException) { - Log.e(TAG, "Error launching pending intent for signin request", e) - } - } + viewModel.signinRequest().observeOnce(this) { intent -> + startIntentSenderForResult( + intent.intentSender, + REQUEST_FIDO2_SIGNIN, + null, + 0, + 0, + 0, + null + ) } } - fun handleSignin(data: Intent) { - viewModel.signinResponse(data) - } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_FIDO2_SIGNIN) { + val errorExtra = data?.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA) + if (errorExtra != null) { + val error = AuthenticatorErrorResponse.deserializeFromBytes(errorExtra) + error.errorMessage?.let { errorMessage -> + Toast.makeText(requireContext(), errorMessage, Toast.LENGTH_LONG).show() + Log.e(TAG, errorMessage) + } + } else if (resultCode != Activity.RESULT_OK) { + Toast.makeText(requireContext(), R.string.cancelled, Toast.LENGTH_SHORT).show() + } else { + if (data != null) { + viewModel.signinResponse(data) + } + } + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } } diff --git a/android/app/src/main/java/com/example/android/fido2/ui/auth/AuthViewModel.kt b/android/app/src/main/java/com/example/android/fido2/ui/auth/AuthViewModel.kt index 302a636..d90416a 100644 --- a/android/app/src/main/java/com/example/android/fido2/ui/auth/AuthViewModel.kt +++ b/android/app/src/main/java/com/example/android/fido2/ui/auth/AuthViewModel.kt @@ -17,12 +17,13 @@ package com.example.android.fido2.ui.auth import android.app.Application +import android.app.PendingIntent import android.content.Intent import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations +import androidx.lifecycle.map import com.example.android.fido2.repository.AuthRepository import com.example.android.fido2.repository.SignInState @@ -48,16 +49,17 @@ class AuthViewModel(application: Application) : AndroidViewModel(application) { addSource(password) { update(_processing.value == true, it) } } - val signinIntent = repository.signinRequest(_processing) + fun signinRequest(): LiveData { + return repository.signinRequest(_processing) + } - val currentUsername: LiveData = - Transformations.map(repository.getSignInState()) { state -> - when (state) { - is SignInState.SigningIn -> state.username - is SignInState.SignedIn -> state.username - else -> "(user)" - } + val currentUsername: LiveData = repository.getSignInState().map { state -> + when (state) { + is SignInState.SigningIn -> state.username + is SignInState.SignedIn -> state.username + else -> "(user)" } + } fun auth() { repository.password(password.value ?: "", _processing) diff --git a/android/app/src/main/java/com/example/android/fido2/ui/home/HomeFragment.kt b/android/app/src/main/java/com/example/android/fido2/ui/home/HomeFragment.kt index edbf77f..0485e85 100644 --- a/android/app/src/main/java/com/example/android/fido2/ui/home/HomeFragment.kt +++ b/android/app/src/main/java/com/example/android/fido2/ui/home/HomeFragment.kt @@ -16,27 +16,29 @@ package com.example.android.fido2.ui.home +import android.app.Activity import android.content.Intent -import android.content.IntentSender import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.observe import androidx.recyclerview.widget.LinearLayoutManager -import com.example.android.fido2.MainActivity import com.example.android.fido2.R import com.example.android.fido2.databinding.HomeFragmentBinding import com.example.android.fido2.ui.observeOnce +import com.google.android.gms.fido.Fido +import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse class HomeFragment : Fragment(), DeleteConfirmationFragment.Listener { companion object { private const val TAG = "HomeFragment" private const val FRAGMENT_DELETE_CONFIRMATION = "delete_confirmation" + const val REQUEST_FIDO2_REGISTER = 1 } private val viewModel: HomeViewModel by viewModels() @@ -97,14 +99,15 @@ class HomeFragment : Fragment(), DeleteConfirmationFragment.Listener { // FAB binding.add.setOnClickListener { viewModel.registerRequest().observeOnce(requireActivity()) { intent -> - val a = activity - if (intent.hasPendingIntent() && a != null) { - try { - intent.launchPendingIntent(a, MainActivity.REQUEST_FIDO2_REGISTER) - } catch (e: IntentSender.SendIntentException) { - Log.e(TAG, "Error launching pending intent for register request", e) - } - } + startIntentSenderForResult( + intent.intentSender, + REQUEST_FIDO2_REGISTER, + null, + 0, + 0, + 0, + null + ) } } } @@ -113,8 +116,25 @@ class HomeFragment : Fragment(), DeleteConfirmationFragment.Listener { viewModel.removeKey(credentialId) } - fun handleRegister(data: Intent) { - viewModel.registerResponse(data) + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_FIDO2_REGISTER) { + val errorExtra = data?.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA) + when { + errorExtra != null -> { + val error = AuthenticatorErrorResponse.deserializeFromBytes(errorExtra) + error.errorMessage?.let { errorMessage -> + Toast.makeText(requireContext(), errorMessage, Toast.LENGTH_LONG).show() + Log.e(TAG, errorMessage) + } + } + resultCode != Activity.RESULT_OK -> { + Toast.makeText(requireContext(), R.string.cancelled, Toast.LENGTH_SHORT).show() + } + data != null -> viewModel.registerResponse(data) + } + } else { + super.onActivityResult(requestCode, resultCode, data) + } } } diff --git a/android/app/src/main/java/com/example/android/fido2/ui/home/HomeViewModel.kt b/android/app/src/main/java/com/example/android/fido2/ui/home/HomeViewModel.kt index 55a6fc1..8b4ba49 100644 --- a/android/app/src/main/java/com/example/android/fido2/ui/home/HomeViewModel.kt +++ b/android/app/src/main/java/com/example/android/fido2/ui/home/HomeViewModel.kt @@ -17,14 +17,14 @@ package com.example.android.fido2.ui.home import android.app.Application +import android.app.PendingIntent import android.content.Intent import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations +import androidx.lifecycle.map import com.example.android.fido2.repository.AuthRepository import com.example.android.fido2.repository.SignInState -import com.google.android.gms.fido.fido2.Fido2PendingIntent class HomeViewModel(application: Application) : AndroidViewModel(application) { @@ -34,26 +34,25 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) { val processing: LiveData get() = _processing - val currentUsername: LiveData = - Transformations.map(repository.getSignInState()) { state -> - when (state) { - is SignInState.SigningIn -> state.username - is SignInState.SignedIn -> state.username - else -> "User" - } + val currentUsername: LiveData = repository.getSignInState().map { state -> + when (state) { + is SignInState.SigningIn -> state.username + is SignInState.SignedIn -> state.username + else -> "User" } + } val credentials = repository.getCredentials() fun reauth() { - repository.clearToken() + repository.clearCredentials() } fun signOut() { repository.signOut() } - fun registerRequest(): LiveData { + fun registerRequest(): LiveData { return repository.registerRequest(_processing) } diff --git a/android/app/src/main/java/com/example/android/fido2/ui/username/UsernameFragment.kt b/android/app/src/main/java/com/example/android/fido2/ui/username/UsernameFragment.kt index 4676794..aab4839 100644 --- a/android/app/src/main/java/com/example/android/fido2/ui/username/UsernameFragment.kt +++ b/android/app/src/main/java/com/example/android/fido2/ui/username/UsernameFragment.kt @@ -23,7 +23,6 @@ import android.view.ViewGroup import android.view.inputmethod.EditorInfo import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.observe import com.example.android.fido2.databinding.UsernameFragmentBinding class UsernameFragment : Fragment() { diff --git a/android/build.gradle b/android/build.gradle index d477f3a..cb0a158 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -21,7 +21,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:4.0.2' + classpath 'com.android.tools.build:gradle:4.1.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index a1a6e00..40ffa7b 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Mon Oct 19 15:05:18 JST 2020 +#Tue Nov 17 16:25:58 JST 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip