Beginning of implementation of OAuth2 authentication mechanism.

This commit is contained in:
Antoine POPINEAU 2020-08-16 16:15:30 +02:00
parent 63c8dbe09e
commit 0f7703be70
No known key found for this signature in database
GPG Key ID: A78AC64694F84063
10 changed files with 194 additions and 129 deletions

View File

@ -47,6 +47,10 @@ android {
versionCode = androidGitVersion.code()
versionName = androidGitVersion.name()
manifestPlaceholders = mapOf(
"appAuthRedirectScheme" to "urn"
)
}
signingConfigs {
@ -133,6 +137,7 @@ dependencies {
implementation("com.google.android.exoplayer:exoplayer-ui:2.11.5")
implementation("com.google.android.exoplayer:extension-mediasession:2.11.5")
implementation("com.github.openid:AppAuth-Android:27b62d5da9")
implementation("com.aliassadi:power-preference-lib:1.4.1")
implementation("com.github.kittinunf.fuel:fuel:2.1.0")
implementation("com.github.kittinunf.fuel:fuel-coroutines:2.1.0")

View File

@ -4,7 +4,6 @@ import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.doOnLayout
@ -12,12 +11,13 @@ import androidx.lifecycle.lifecycleScope
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.LoginDialog
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.OAuth
import com.github.apognu.otter.utils.Userinfo
import com.github.apognu.otter.utils.log
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.github.kittinunf.result.Result
import com.google.gson.Gson
import com.preference.PowerPreference
import kotlinx.android.synthetic.main.activity_login.*
import kotlinx.coroutines.Dispatchers.Main
@ -37,20 +37,8 @@ class LoginActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
anonymous?.setOnCheckedChangeListener { _, isChecked ->
val state = when (isChecked) {
true -> View.GONE
false -> View.VISIBLE
}
username_field.visibility = state
password_field.visibility = state
}
login?.setOnClickListener {
var hostname = hostname.text.toString().trim()
val username = username.text.toString()
val password = password.text.toString()
try {
if (hostname.isEmpty()) throw Exception(getString(R.string.login_error_hostname))
@ -71,7 +59,7 @@ class LoginActivity : AppCompatActivity() {
hostname_field.error = ""
when (anonymous.isChecked) {
false -> authedLogin(hostname, username, password)
false -> authedLogin(hostname)
true -> anonymousLogin(hostname)
}
} catch (e: Exception) {
@ -90,64 +78,39 @@ class LoginActivity : AppCompatActivity() {
limitContainerWidth()
}
private fun authedLogin(hostname: String, username: String, password: String) {
val body = mapOf(
"username" to username,
"password" to password
).toList()
private fun authedLogin(hostname: String) {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).setString("hostname", hostname)
val dialog = LoginDialog().apply {
show(supportFragmentManager, "LoginDialog")
OAuth.init(hostname)
OAuth.register(this) {
OAuth.authorize(this)
}
}
lifecycleScope.launch(Main) {
try {
val (_, response, result) = Fuel.post("$hostname/api/v1/token/", body)
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (result) {
is Result.Success -> {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
setString("hostname", hostname)
setBoolean("anonymous", false)
setString("username", username)
setString("password", password)
setString("access_token", result.get().token)
}
data?.let {
when (requestCode) {
0 -> {
OAuth.exchange(this, data,
{
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).setBoolean("anonymous", false)
Userinfo.get()?.let {
dialog.dismiss()
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
lifecycleScope.launch(Main) {
Userinfo.get(this@LoginActivity)?.let {
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
return@launch finish()
}
return@launch finish()
}
throw Exception(getString(R.string.login_error_userinfo))
}
is Result.Failure -> {
dialog.dismiss()
val error = Gson().fromJson(String(response.data), FwCredentials::class.java)
hostname_field.error = null
username_field.error = null
if (error != null && error.non_field_errors?.isNotEmpty() == true) {
username_field.error = error.non_field_errors[0]
} else {
hostname_field.error = result.error.localizedMessage
}
}
throw Exception(getString(R.string.login_error_userinfo))
}
},
{ "error".log() }
)
}
} catch (e: Exception) {
dialog.dismiss()
val message =
if (e.message?.isEmpty() == true) getString(R.string.login_error_hostname)
else e.message
hostname_field.error = message
}
}
}

View File

@ -97,7 +97,7 @@ class MainActivity : AppCompatActivity() {
CommandBus.send(Command.RefreshService)
lifecycleScope.launch(IO) {
Userinfo.get()
Userinfo.get(this@MainActivity)
}
now_playing_toggle.setOnClickListener {
@ -515,7 +515,7 @@ class MainActivity : AppCompatActivity() {
try {
Fuel
.post(mustNormalizeUrl("/api/v1/history/listenings/"))
.authorize()
.authorize(this@MainActivity)
.header("Content-Type", "application/json")
.body(Gson().toJson(mapOf("track" to track.id)))
.awaitStringResponse()

View File

@ -80,7 +80,7 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
val body = Gson().toJson(request)
val (_, response, result) = Fuel.post(mustNormalizeUrl("/api/v1/radios/sessions/"))
.authorize()
.authorize(context)
.header("Content-Type", "application/json")
.body(body)
.awaitObjectResponseResult(gsonDeserializerOf(RadioSession::class.java))
@ -107,7 +107,7 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
try {
val body = Gson().toJson(RadioTrackBody(session))
val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/tracks/"))
.authorize()
.authorize(context)
.header("Content-Type", "application/json")
.apply {
cookie?.let {
@ -118,7 +118,7 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
.awaitObjectResult(gsonDeserializerOf(RadioTrack::class.java))
val trackResponse = Fuel.get(mustNormalizeUrl("/api/v1/tracks/${result.get().track.id}/"))
.authorize()
.authorize(context)
.awaitObjectResult(gsonDeserializerOf(Track::class.java))
val favorites = favoritedRepository.fetch(Repository.Origin.Cache.origin)

View File

@ -1,7 +1,6 @@
package com.github.apognu.otter.repositories
import android.content.Context
import androidx.lifecycle.lifecycleScope
import com.github.apognu.otter.Otter
import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.Fuel
@ -11,7 +10,6 @@ import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.BufferedReader

View File

@ -1,5 +1,6 @@
package com.github.apognu.otter.utils
import android.content.Context
import android.os.Build
import androidx.fragment.app.Fragment
import com.github.apognu.otter.R
@ -68,10 +69,12 @@ fun Picasso.maybeLoad(url: String?): RequestCreator {
else load(url)
}
fun Request.authorize(): Request {
fun Request.authorize(context: Context): Request {
return this.apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
OAuth.state().performActionWithFreshTokens(OAuth.service(context)) { token, _, _ ->
header("Authorization", "Bearer $token")
}
}
}
}

View File

@ -0,0 +1,144 @@
package com.github.apognu.otter.utils
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.github.kittinunf.fuel.gson.jsonBody
import com.github.kittinunf.result.Result
import com.preference.PowerPreference
import kotlinx.coroutines.runBlocking
import net.openid.appauth.*
import java.util.*
fun AuthState.save() {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
setString("state", jsonSerializeString())
}
}
object OAuth {
data class App(val client_id: String, val client_secret: String)
val REDIRECT_URI = Uri.parse("urn:/com.github.apognu.otter/oauth/callback")
fun state(): AuthState = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("state").run {
AuthState.jsonDeserialize(this)
}
fun init(hostname: String) {
AuthState(config(hostname)).save()
}
fun service(context: Context) = AuthorizationService(context)
fun register(context: Context, callback: () -> Unit) {
state().authorizationServiceConfiguration?.let { config ->
val body = mapOf(
"name" to UUID.randomUUID(),
"redirect_uris" to REDIRECT_URI.toString()
)
runBlocking {
val (_, _, result) = Fuel.post(config.registrationEndpoint.toString())
.header("Content-Type", "application/json")
.jsonBody(body)
.awaitObjectResponseResult(gsonDeserializerOf(App::class.java))
when (result) {
is Result.Success -> {
val app = result.get()
val response = RegistrationResponse.Builder(registration()!!)
.setClientId(app.client_id)
.setClientSecret(app.client_secret)
.setClientIdIssuedAt(0)
.setClientSecretExpiresAt(null)
.build()
state().apply {
update(response)
save()
callback()
}
}
is Result.Failure -> {
}
}
}
}
}
fun authorize(context: Activity) {
val intent = service(context).run {
authorizationRequest()?.let {
getAuthorizationRequestIntent(it)
}
}
context.startActivityForResult(intent, 0)
}
fun exchange(context: Activity, authorization: Intent, success: () -> Unit, error: () -> Unit) {
state().let { state ->
state.apply {
update(AuthorizationResponse.fromIntent(authorization), AuthorizationException.fromIntent(authorization))
save()
}
AuthorizationResponse.fromIntent(authorization)?.let {
val auth = ClientSecretPost(state().clientSecret)
service(context).performTokenRequest(it.createTokenExchangeRequest(), auth) { response, e ->
state
.apply {
update(response, e)
save()
}
if (response != null) success()
else error()
}
}
}
}
private fun config(hostname: String) = AuthorizationServiceConfiguration(
Uri.parse("$hostname/authorize"),
Uri.parse("$hostname/api/v1/oauth/token/"),
Uri.parse("$hostname/api/v1/oauth/apps/")
)
private fun registration() = state().authorizationServiceConfiguration?.let { config ->
RegistrationRequest.Builder(config, listOf(REDIRECT_URI)).build()
}
private fun authorizationRequest() = state().let { state ->
state.authorizationServiceConfiguration?.let { config ->
AuthorizationRequest.Builder(
config,
state.lastRegistrationResponse?.clientId ?: "",
ResponseTypeValues.CODE,
REDIRECT_URI
)
.setScopes(
/* "read:profile",
"read:libraries",
"write:libraries",
"read:favorites",
"write:favorites",
"read:playlists",
"write:playlists",
"read:radios",
"write:listenings" */
"read", "write"
)
.build()
}
}
}

View File

@ -1,5 +1,6 @@
package com.github.apognu.otter.utils
import android.content.Context
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
@ -7,11 +8,11 @@ import com.github.kittinunf.result.Result
import com.preference.PowerPreference
object Userinfo {
suspend fun get(): User? {
suspend fun get(context: Context): User? {
try {
val hostname = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("hostname")
val (_, _, result) = Fuel.get("$hostname/api/v1/users/users/me/")
.authorize()
.authorize(context)
.awaitObjectResponseResult(gsonDeserializerOf(User::class.java))
return when (result) {
@ -28,6 +29,7 @@ object Userinfo {
else -> null
}
} catch (e: Exception) {
e.printStackTrace()
return null
}
}

View File

@ -4,6 +4,7 @@ import android.content.Context
import android.widget.Toast
import com.google.android.exoplayer2.util.Log
import com.preference.PowerPreference
import net.openid.appauth.AuthState
import java.net.URI
fun Context?.toast(message: String, length: Int = Toast.LENGTH_SHORT) {
@ -71,8 +72,8 @@ fun toDurationString(duration: Long, showSeconds: Boolean = false): String {
}
object Settings {
fun hasAccessToken() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).contains("access_token")
fun getAccessToken(): String = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token", "")
fun hasAccessToken() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).contains("state") && OAuth.state().isAuthorized
fun getAccessToken() = OAuth.state().accessToken
fun isAnonymous() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getBoolean("anonymous", false)
fun areExperimentsEnabled() = PowerPreference.getDefaultFile().getBoolean("experiments", false)
fun getScope() = PowerPreference.getDefaultFile().getString("scope", "all")

View File

@ -69,57 +69,6 @@
android:text="@string/login_anonymous"
android:textColor="@android:color/white" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/username_field"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="@string/login_username"
android:textColorHint="@drawable/login_input"
app:boxBackgroundColor="@color/controlAccent"
app:boxBackgroundMode="filled"
app:boxStrokeColor="@drawable/login_input"
app:boxStrokeWidth="0dp"
app:hintTextColor="@drawable/login_input">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress"
android:lines="1"
android:text="@string/debug.username"
android:textColor="@android:color/white"
android:textCursorDrawable="@null" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/password_field"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="@string/login_password"
android:textColorHint="@drawable/login_input"
app:boxBackgroundColor="@color/controlAccent"
app:boxBackgroundMode="filled"
app:boxStrokeColor="@drawable/login_input"
app:boxStrokeWidth="0dp"
app:hintTextColor="@drawable/login_input"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:lines="1"
android:text="@string/debug.password"
android:textColor="@android:color/white"
android:textCursorDrawable="@null" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/login"
android:layout_width="wrap_content"