Support MFA during Wear OS standalone login (#2131)

* Initial Wear MFA support

 - Added support for authentication with MFA to the Wear OS login flow
 - Renamed the existing Authentication* stuff to PasswordAuthentication*
 - For the authentication flow, specify a base class used for responses and depending on the response return the appropriate data class that extends the base class

* Add MFA screens

  - Adjusting the existing view to add a new title and input and showing/hiding based on state created a buggy experience on the emulator where the keyboard confirm/next buttons didn't work properly any more, so the MFA input is a full new activity. Copied from the existing activity for username/password.

* Fix MFA activity tools:context

* Move MFA in to the existing AuthenticationActivity

 - Actually it is possible to get the MFA input in the same activity with a little effort, but the end result works great and doesn't use as much duplicate code

* Remove MFA activity, rename PasswordAuthentication* back

 - Remove the MFA activity now that MFA is integrated into the other activity for authentication
 - Rename back PasswordAuthentication* to Authentication* (end result compared to existing app: no change)

* Remove unnecessary JsonIgnoreProperties annotation
This commit is contained in:
Joris Pelgröm 2022-01-18 02:01:57 +01:00 committed by GitHub
parent 8a8772d5e7
commit 050d449ef2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 179 additions and 41 deletions

View file

@ -1,14 +1,16 @@
package io.homeassistant.companion.android.common.data.authentication
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowCreateEntry
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowInit
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowResponse
import java.net.URL
interface AuthenticationRepository {
suspend fun initiateLoginFlow(): LoginFlowInit
suspend fun initiateLoginFlow(): LoginFlowForm
suspend fun loginAuthentication(flowId: String, username: String, password: String): LoginFlowCreateEntry
suspend fun loginAuthentication(flowId: String, username: String, password: String): LoginFlowResponse?
suspend fun loginCode(flowId: String, code: String): LoginFlowResponse?
suspend fun registerAuthorizationCode(authorizationCode: String)

View file

@ -1,17 +1,25 @@
package io.homeassistant.companion.android.common.data.authentication.impl
import android.util.Log
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import io.homeassistant.companion.android.common.data.LocalStorage
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.authentication.AuthorizationException
import io.homeassistant.companion.android.common.data.authentication.SessionState
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowAuthentication
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowCreateEntry
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowInit
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowMfaCode
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowRequest
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowResponse
import io.homeassistant.companion.android.common.data.url.UrlRepository
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.ResponseBody
import java.net.URL
import javax.inject.Inject
import javax.inject.Named
@ -31,7 +39,11 @@ class AuthenticationRepositoryImpl @Inject constructor(
private const val PREF_BIOMETRIC_ENABLED = "biometric_enabled"
}
override suspend fun initiateLoginFlow(): LoginFlowInit {
private val mapper = jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
override suspend fun initiateLoginFlow(): LoginFlowForm {
val url = urlRepository.getUrl()?.toHttpUrlOrNull().toString()
return authenticationService.initializeLogin(
url + "auth/login_flow",
@ -43,16 +55,41 @@ class AuthenticationRepositoryImpl @Inject constructor(
)
}
override suspend fun loginAuthentication(flowId: String, username: String, password: String): LoginFlowCreateEntry {
val url = urlRepository.getUrl()?.toHttpUrlOrNull().toString()
return authenticationService.authenticate(
url + AuthenticationService.AUTHENTICATE_BASE_PATH + flowId,
override suspend fun loginAuthentication(flowId: String, username: String, password: String): LoginFlowResponse? {
val url = urlRepository.getUrl()?.toHttpUrlOrNull()
if (url == null) {
Log.e(TAG, "Unable to authenticate with username/password.")
throw AuthorizationException()
}
val response = authenticationService.authenticatePassword(
url.newBuilder().addPathSegments(AuthenticationService.AUTHENTICATE_BASE_PATH + flowId).build(),
LoginFlowAuthentication(
AuthenticationService.CLIENT_ID,
username,
password
)
)
if (!response.isSuccessful || response.body() == null) throw AuthorizationException()
return mapLoginFlowResponse(response.body()!!)
}
override suspend fun loginCode(flowId: String, code: String): LoginFlowResponse? {
val url = urlRepository.getUrl()?.toHttpUrlOrNull()
if (url == null) {
Log.e(TAG, "Unable to authenticate with MFA code.")
throw AuthorizationException()
}
val response = authenticationService.authenticateMfa(
url.newBuilder().addPathSegments(AuthenticationService.AUTHENTICATE_BASE_PATH + flowId).build(),
LoginFlowMfaCode(
AuthenticationService.CLIENT_ID,
code
)
)
if (!response.isSuccessful || response.body() == null) throw AuthorizationException()
return mapLoginFlowResponse(response.body()!!)
}
override suspend fun registerAuthorizationCode(authorizationCode: String) {
@ -129,6 +166,16 @@ class AuthenticationRepositoryImpl @Inject constructor(
return "Bearer " + ensureValidSession().accessToken
}
private fun mapLoginFlowResponse(responseBody: ResponseBody): LoginFlowResponse? {
val responseText = responseBody.charStream().readText()
val message: JsonNode? = mapper.readValue(responseText)
return when (message?.get("type")?.textValue()) {
"form" -> mapper.readValue(responseText, LoginFlowForm::class.java)
"create_entry" -> mapper.readValue(responseText, LoginFlowCreateEntry::class.java)
else -> null
}
}
private fun convertSession(session: Session): String {
return ObjectMapper().writeValueAsString(
mapOf(

View file

@ -1,11 +1,12 @@
package io.homeassistant.companion.android.common.data.authentication.impl
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowAuthentication
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowCreateEntry
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowInit
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowMfaCode
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowRequest
import io.homeassistant.companion.android.common.data.authentication.impl.entities.Token
import okhttp3.HttpUrl
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.Field
@ -52,8 +53,11 @@ interface AuthenticationService {
)
@POST
suspend fun initializeLogin(@Url url: String, @Body body: LoginFlowRequest): LoginFlowInit
suspend fun initializeLogin(@Url url: String, @Body body: LoginFlowRequest): LoginFlowForm
@POST
suspend fun authenticate(@Url url: String, @Body body: LoginFlowAuthentication): LoginFlowCreateEntry
suspend fun authenticatePassword(@Url url: HttpUrl, @Body body: LoginFlowAuthentication): Response<ResponseBody>
@POST
suspend fun authenticateMfa(@Url url: HttpUrl, @Body body: LoginFlowMfaCode): Response<ResponseBody>
}

View file

@ -3,12 +3,12 @@ package io.homeassistant.companion.android.common.data.authentication.impl.entit
import com.fasterxml.jackson.annotation.JsonProperty
data class LoginFlowCreateEntry(
@JsonProperty("type")
override val type: String,
@JsonProperty("flow_id")
override val flowId: String,
@JsonProperty("version")
val version: Int,
@JsonProperty("type")
val type: String,
@JsonProperty("flow_id")
val flowId: String,
@JsonProperty("result")
val result: String
)
) : LoginFlowResponse()

View file

@ -2,13 +2,13 @@ package io.homeassistant.companion.android.common.data.authentication.impl.entit
import com.fasterxml.jackson.annotation.JsonProperty
data class LoginFlowInit(
data class LoginFlowForm(
@JsonProperty("type")
val type: String,
override val type: String,
@JsonProperty("flow_id")
val flowId: String,
override val flowId: String,
@JsonProperty("step_id")
val stepId: String,
@JsonProperty("errors")
val errors: Map<String, String>
)
) : LoginFlowResponse()

View file

@ -0,0 +1,10 @@
package io.homeassistant.companion.android.common.data.authentication.impl.entities
import com.fasterxml.jackson.annotation.JsonProperty
data class LoginFlowMfaCode(
@JsonProperty("client_id")
val clientId: String,
@JsonProperty("code")
val code: String
)

View file

@ -0,0 +1,6 @@
package io.homeassistant.companion.android.common.data.authentication.impl.entities
sealed class LoginFlowResponse {
abstract val type: String
abstract val flowId: String
}

View file

@ -289,6 +289,8 @@
<string name="message_checking">Checking Wear Devices with App</string>
<string name="message_missing_all">The Wear app is missing on your watch, click the button below to install the app.\n\nNote: Currently the Wear OS app requires you to be enrolled in the beta for the phone app. If the button does not work then please join the beta: https://play.google.com/apps/testing/io.homeassistant.companion.android</string>
<string name="message_some_installed">The Wear app is installed on some of your wear devices: (%1$s)\n\nClick the button below to install the app on the other devices.\n\nNote: Currently the Wear OS app requires you to be enrolled in the beta for the phone app. If the button does not work then please join the beta: https://play.google.com/apps/testing/io.homeassistant.companion.android</string>
<string name="mfa_title">Two-factor\nAuthentication</string>
<string name="mfa_hint">Code</string>
<string name="missing_command_permission">Please open the Home Assistant app and send the command again in order to grant the proper permissions.</string>
<string name="areas">Areas</string>
<string name="more_entities">More entities</string>

View file

@ -8,7 +8,7 @@ import com.google.android.gms.wearable.DataMap
import com.google.android.gms.wearable.DataMapItem
import dagger.hilt.android.qualifiers.ActivityContext
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowInit
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
import io.homeassistant.companion.android.common.data.url.UrlRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -73,10 +73,10 @@ class OnboardingPresenterImpl @Inject constructor(
// Initiate login flow
try {
val flowInit: LoginFlowInit = authenticationUseCase.initiateLoginFlow()
Log.d(TAG, "Created login flow step ${flowInit.stepId}: ${flowInit.flowId}")
val flowForm: LoginFlowForm = authenticationUseCase.initiateLoginFlow()
Log.d(TAG, "Created login flow step ${flowForm.stepId}: ${flowForm.flowId}")
view.startAuthentication(flowInit.flowId)
view.startAuthentication(flowForm.flowId)
} catch (e: Exception) {
Log.e(TAG, "Unable to initiate login flow", e)
view.showError()

View file

@ -44,7 +44,8 @@ class AuthenticationActivity : AppCompatActivity(), AuthenticationView {
presenter.onNextClicked(
intent.getStringExtra("flowId")!!,
binding.username.text.toString(),
binding.password.text.toString()
binding.password.text.toString(),
binding.code.text.toString()
)
}
}
@ -59,6 +60,16 @@ class AuthenticationActivity : AppCompatActivity(), AuthenticationView {
startActivity(MobileAppIntegrationActivity.newInstance(this))
}
override fun showMfa() {
binding.titleLogin.visibility = View.GONE
binding.titleMfa.visibility = View.VISIBLE
binding.username.visibility = View.GONE
binding.password.visibility = View.GONE
binding.code.visibility = View.VISIBLE
binding.loadingView.visibility = View.GONE
}
override fun showLoading() {
binding.loadingView.visibility = View.VISIBLE
}

View file

@ -2,7 +2,7 @@ package io.homeassistant.companion.android.onboarding.authentication
interface AuthenticationPresenter {
fun onNextClicked(flowId: String, username: String, password: String)
fun onNextClicked(flowId: String, username: String?, password: String?, code: String?)
fun onFinish()
}

View file

@ -5,6 +5,7 @@ import android.util.Log
import dagger.hilt.android.qualifiers.ActivityContext
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowCreateEntry
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -20,20 +21,46 @@ class AuthenticationPresenterImpl @Inject constructor(
private const val TAG = "AuthenticationPresenter"
}
enum class AuthenticationPhase {
PASSWORD, MFA
}
private var authenticationPhase = AuthenticationPhase.PASSWORD
private val view = context as AuthenticationView
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
override fun onNextClicked(flowId: String, username: String, password: String) {
override fun onNextClicked(flowId: String, username: String?, password: String?, code: String?) {
view.showLoading()
Log.d(TAG, "onNextClicked")
mainScope.launch {
try {
val flowCreateEntry: LoginFlowCreateEntry = authenticationUseCase.loginAuthentication(flowId, username, password)
Log.d(TAG, "Authenticated result: ${flowCreateEntry.result}")
authenticationUseCase.registerAuthorizationCode(flowCreateEntry.result)
Log.d(TAG, "Finished!")
val flowResult = when (authenticationPhase) {
AuthenticationPhase.PASSWORD -> authenticationUseCase.loginAuthentication(flowId, username.orEmpty(), password.orEmpty())
AuthenticationPhase.MFA -> authenticationUseCase.loginCode(flowId, code.orEmpty())
}
Log.d(TAG, "Authenticated result type: ${flowResult?.type}")
when (flowResult?.type) {
"form" -> {
if (authenticationPhase == AuthenticationPhase.PASSWORD && (flowResult as LoginFlowForm).stepId == "mfa") {
Log.d(TAG, "MFA required to authenticate")
authenticationPhase = AuthenticationPhase.MFA
view.showMfa()
} else {
Log.e(TAG, "Unable to authenticate")
view.showError()
}
}
"create_entry" -> {
authenticationUseCase.registerAuthorizationCode((flowResult as LoginFlowCreateEntry).result)
Log.d(TAG, "Finished!")
view.startIntegration()
view.startIntegration()
}
else -> {
Log.e(TAG, "Unable to authenticate")
view.showError()
}
}
} catch (e: Exception) {
Log.e(TAG, "Unable to authenticate", e)
view.showError()

View file

@ -3,6 +3,8 @@ package io.homeassistant.companion.android.onboarding.authentication
interface AuthenticationView {
fun startIntegration()
fun showMfa()
fun showLoading()
fun showError()

View file

@ -4,7 +4,7 @@ import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ActivityContext
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowInit
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
import io.homeassistant.companion.android.common.data.url.UrlRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -33,10 +33,10 @@ class ManualSetupPresenterImpl @Inject constructor(
// Initiate login flow
try {
val flowInit: LoginFlowInit = authenticationUseCase.initiateLoginFlow()
Log.d(TAG, "Created login flow step ${flowInit.stepId}: ${flowInit.flowId}")
val flowForm: LoginFlowForm = authenticationUseCase.initiateLoginFlow()
Log.d(TAG, "Created login flow step ${flowForm.stepId}: ${flowForm.flowId}")
view.startAuthentication(flowInit.flowId)
view.startAuthentication(flowForm.flowId)
} catch (e: Exception) {
Log.e(TAG, "Unable to initiate login flow", e)
view.showError()

View file

@ -25,7 +25,7 @@
app:srcCompat="@drawable/ic_button_arrow_forward" />
<TextView
android:id="@+id/textView2"
android:id="@+id/titleLogin"
style="@style/HeaderText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -34,6 +34,18 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/titleMfa"
style="@style/HeaderText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/mfa_title"
android:gravity="center"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:visibility="gone"/>
<EditText
android:id="@+id/username"
android:layout_width="wrap_content"
@ -44,7 +56,7 @@
android:inputType="textPersonName"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView2" />
app:layout_constraintTop_toBottomOf="@+id/titleLogin" />
<EditText
android:id="@+id/password"
@ -56,7 +68,22 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/username"
android:imeOptions="actionDone"
android:importantForAutofill="no" />
<EditText
android:id="@+id/code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ems="10"
android:hint="@string/mfa_hint"
android:inputType="number"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleMfa"
android:imeOptions="actionDone"
android:importantForAutofill="no"
android:visibility="gone"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<io.homeassistant.companion.android.util.LoadingView