Beginning of implementation of OAuth2 authentication mechanism.
This commit is contained in:
parent
63c8dbe09e
commit
0f7703be70
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue