mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2025-12-23 23:17:50 -05:00
Nextcloud Login Flow: handle non-success status codes (#1878)
* Nextcloud Login Flow: handle non-success status codes * Update error message to use class name when localized message is null * Update dav4jvm to get HTTP reason phrases in HttpException
This commit is contained in:
@@ -5,11 +5,13 @@
|
|||||||
package at.bitfire.davdroid.network
|
package at.bitfire.davdroid.network
|
||||||
|
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import at.bitfire.dav4jvm.ktor.exception.HttpException
|
||||||
import at.bitfire.davdroid.settings.Credentials
|
import at.bitfire.davdroid.settings.Credentials
|
||||||
import at.bitfire.davdroid.ui.setup.LoginInfo
|
import at.bitfire.davdroid.ui.setup.LoginInfo
|
||||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||||
import at.bitfire.davdroid.util.withTrailingSlash
|
import at.bitfire.davdroid.util.withTrailingSlash
|
||||||
import at.bitfire.vcard4android.GroupMethod
|
import at.bitfire.vcard4android.GroupMethod
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
@@ -18,10 +20,12 @@ import io.ktor.http.URLBuilder
|
|||||||
import io.ktor.http.Url
|
import io.ktor.http.Url
|
||||||
import io.ktor.http.appendPathSegments
|
import io.ktor.http.appendPathSegments
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.http.isSuccess
|
||||||
import io.ktor.http.path
|
import io.ktor.http.path
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Provider
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implements Nextcloud Login Flow v2.
|
* Implements Nextcloud Login Flow v2.
|
||||||
@@ -29,11 +33,9 @@ import javax.inject.Inject
|
|||||||
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
||||||
*/
|
*/
|
||||||
class NextcloudLoginFlow @Inject constructor(
|
class NextcloudLoginFlow @Inject constructor(
|
||||||
httpClientBuilder: HttpClientBuilder
|
private val httpClientBuilder: Provider<HttpClientBuilder>
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val httpClient = httpClientBuilder.buildKtor()
|
|
||||||
|
|
||||||
// Login flow state
|
// Login flow state
|
||||||
var pollUrl: Url? = null
|
var pollUrl: Url? = null
|
||||||
var token: String? = null
|
var token: String? = null
|
||||||
@@ -44,6 +46,8 @@ class NextcloudLoginFlow @Inject constructor(
|
|||||||
* @param baseUrl Nextcloud login flow or base URL
|
* @param baseUrl Nextcloud login flow or base URL
|
||||||
*
|
*
|
||||||
* @return URL that should be opened in the browser (login screen)
|
* @return URL that should be opened in the browser (login screen)
|
||||||
|
*
|
||||||
|
* @throws HttpException on non-successful HTTP status
|
||||||
*/
|
*/
|
||||||
suspend fun start(baseUrl: Url): Url {
|
suspend fun start(baseUrl: Url): Url {
|
||||||
// reset fields in case something goes wrong
|
// reset fields in case something goes wrong
|
||||||
@@ -51,14 +55,18 @@ class NextcloudLoginFlow @Inject constructor(
|
|||||||
token = null
|
token = null
|
||||||
|
|
||||||
// POST to login flow URL in order to receive endpoint data
|
// POST to login flow URL in order to receive endpoint data
|
||||||
val result = httpClient.post(loginFlowUrl(baseUrl))
|
createClient().use { client ->
|
||||||
val endpointData: EndpointData = result.body()
|
val result = client.post(loginFlowUrl(baseUrl))
|
||||||
|
if (!result.status.isSuccess())
|
||||||
|
throw HttpException.fromResponse(result)
|
||||||
|
|
||||||
// save endpoint data for polling
|
// save endpoint data for polling
|
||||||
pollUrl = Url(endpointData.poll.endpoint)
|
val endpointData: EndpointData = result.body()
|
||||||
token = endpointData.poll.token
|
pollUrl = Url(endpointData.poll.endpoint)
|
||||||
|
token = endpointData.poll.token
|
||||||
|
|
||||||
return Url(endpointData.login)
|
return Url(endpointData.login)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
@@ -87,31 +95,45 @@ class NextcloudLoginFlow @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves login info from the polling endpoint using [pollUrl]/[token].
|
* Retrieves login info from the polling endpoint using [pollUrl]/[token].
|
||||||
|
*
|
||||||
|
* @throws HttpException on non-successful HTTP status
|
||||||
*/
|
*/
|
||||||
suspend fun fetchLoginInfo(): LoginInfo {
|
suspend fun fetchLoginInfo(): LoginInfo {
|
||||||
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
|
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
|
||||||
val token = token ?: throw IllegalArgumentException("Missing token")
|
val token = token ?: throw IllegalArgumentException("Missing token")
|
||||||
|
|
||||||
// send HTTP request to request server, login name and app password
|
// send HTTP request to request server, login name and app password
|
||||||
val result = httpClient.post(pollUrl) {
|
createClient().use { client ->
|
||||||
contentType(ContentType.Application.FormUrlEncoded)
|
val result = client.post(pollUrl) {
|
||||||
setBody("token=$token")
|
contentType(ContentType.Application.FormUrlEncoded)
|
||||||
|
setBody("token=$token")
|
||||||
|
}
|
||||||
|
if (!result.status.isSuccess())
|
||||||
|
throw HttpException.fromResponse(result)
|
||||||
|
|
||||||
|
// make sure server URL ends with a slash so that DAV_PATH can be appended
|
||||||
|
val loginData: LoginData = result.body()
|
||||||
|
val serverUrl = loginData.server.withTrailingSlash()
|
||||||
|
|
||||||
|
return LoginInfo(
|
||||||
|
baseUri = URI(serverUrl).resolve(DAV_PATH),
|
||||||
|
credentials = Credentials(
|
||||||
|
username = loginData.loginName,
|
||||||
|
password = loginData.appPassword.toSensitiveString()
|
||||||
|
),
|
||||||
|
suggestedGroupMethod = GroupMethod.CATEGORIES
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val loginData: LoginData = result.body()
|
|
||||||
|
|
||||||
// make sure server URL ends with a slash so that DAV_PATH can be appended
|
|
||||||
val serverUrl = loginData.server.withTrailingSlash()
|
|
||||||
|
|
||||||
return LoginInfo(
|
|
||||||
baseUri = URI(serverUrl).resolve(DAV_PATH),
|
|
||||||
credentials = Credentials(
|
|
||||||
username = loginData.loginName,
|
|
||||||
password = loginData.appPassword.toSensitiveString()
|
|
||||||
),
|
|
||||||
suggestedGroupMethod = GroupMethod.CATEGORIES
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Ktor HTTP client that follows redirects.
|
||||||
|
*/
|
||||||
|
private fun createClient(): HttpClient =
|
||||||
|
httpClientBuilder.get()
|
||||||
|
.followRedirects(true)
|
||||||
|
.buildKtor()
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the JSON response that is returned on the first call to `/login/v2`.
|
* Represents the JSON response that is returned on the first call to `/login/v2`.
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ class NextcloudLoginModel @AssistedInject constructor(
|
|||||||
|
|
||||||
uiState = uiState.copy(
|
uiState = uiState.copy(
|
||||||
inProgress = false,
|
inProgress = false,
|
||||||
error = e.toString()
|
error = e.localizedMessage ?: e.javaClass.simpleName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ class NextcloudLoginModel @AssistedInject constructor(
|
|||||||
logger.log(Level.WARNING, "Fetching login info failed", e)
|
logger.log(Level.WARNING, "Fetching login info failed", e)
|
||||||
uiState = uiState.copy(
|
uiState = uiState.copy(
|
||||||
inProgress = false,
|
inProgress = false,
|
||||||
error = e.toString()
|
error = e.localizedMessage ?: e.javaClass.simpleName
|
||||||
)
|
)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ androidx-test-rules = "1.7.0"
|
|||||||
androidx-test-junit = "1.3.0"
|
androidx-test-junit = "1.3.0"
|
||||||
androidx-work = "2.11.0"
|
androidx-work = "2.11.0"
|
||||||
bitfire-cert4android = "42d883e958"
|
bitfire-cert4android = "42d883e958"
|
||||||
bitfire-dav4jvm = "de16b12343"
|
bitfire-dav4jvm = "57321c95ad"
|
||||||
bitfire-synctools = "de78892b5c"
|
bitfire-synctools = "de78892b5c"
|
||||||
compose-accompanist = "0.37.3"
|
compose-accompanist = "0.37.3"
|
||||||
compose-bom = "2025.12.00"
|
compose-bom = "2025.12.00"
|
||||||
|
|||||||
Reference in New Issue
Block a user