Browse Source
#48: Implement OAuth2 authentication Closes #48 See merge request funkwhale/funkwhale-android!39enhancement/speed-up-pipelines
Ryan Harg
3 years ago
26 changed files with 835 additions and 436 deletions
@ -1,82 +1,81 @@
@@ -1,82 +1,81 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:tools="http://schemas.android.com/tools" |
||||
package="audio.funkwhale.ffa"> |
||||
package="audio.funkwhale.ffa"> |
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" /> |
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> |
||||
<uses-permission android:name="android.permission.INTERNET" /> |
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> |
||||
|
||||
<permission android:name="android.permission.MEDIA_CONTENT_CONTROL" /> |
||||
<permission android:name="android.permission.MEDIA_CONTENT_CONTROL" /> |
||||
|
||||
<application |
||||
android:name="audio.funkwhale.ffa.FFA" |
||||
android:allowBackup="false" |
||||
android:icon="@mipmap/ic_launcher" |
||||
android:label="@string/app_name" |
||||
android:networkSecurityConfig="@xml/security" |
||||
android:roundIcon="@mipmap/ic_launcher" |
||||
android:supportsRtl="true" |
||||
android:theme="@style/AppTheme" |
||||
android:usesCleartextTraffic="true"> |
||||
<application |
||||
android:name="audio.funkwhale.ffa.FFA" |
||||
android:allowBackup="false" |
||||
android:icon="@mipmap/ic_launcher" |
||||
android:label="@string/app_name" |
||||
android:networkSecurityConfig="@xml/security" |
||||
android:roundIcon="@mipmap/ic_launcher" |
||||
android:supportsRtl="true" |
||||
android:theme="@style/AppTheme" |
||||
android:usesCleartextTraffic="true"> |
||||
|
||||
<activity |
||||
android:name=".activities.SplashActivity" |
||||
android:launchMode="singleInstance" |
||||
android:noHistory="true"> |
||||
<activity |
||||
android:name=".activities.SplashActivity" |
||||
android:launchMode="singleInstance" |
||||
android:noHistory="true"> |
||||
|
||||
<intent-filter> |
||||
<action android:name="android.intent.action.MAIN" /> |
||||
<action android:name="android.intent.action.VIEW" /> |
||||
<intent-filter> |
||||
<action android:name="android.intent.action.MAIN" /> |
||||
<action android:name="android.intent.action.VIEW" /> |
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" /> |
||||
</intent-filter> |
||||
<category android:name="android.intent.category.LAUNCHER" /> |
||||
</intent-filter> |
||||
|
||||
</activity> |
||||
</activity> |
||||
|
||||
<activity |
||||
android:name=".activities.LoginActivity" |
||||
android:configChanges="screenSize|orientation" |
||||
android:launchMode="singleInstance" /> |
||||
<activity |
||||
android:name=".activities.LoginActivity" |
||||
android:configChanges="screenSize|orientation" |
||||
android:launchMode="singleInstance" /> |
||||
|
||||
<activity android:name=".activities.MainActivity" /> |
||||
<activity android:name=".activities.MainActivity" /> |
||||
|
||||
<activity |
||||
android:name=".activities.SearchActivity" |
||||
android:launchMode="singleTop" /> |
||||
<activity |
||||
android:name=".activities.SearchActivity" |
||||
android:launchMode="singleTop" /> |
||||
|
||||
<activity android:name=".activities.DownloadsActivity" /> |
||||
<activity android:name=".activities.DownloadsActivity" /> |
||||
|
||||
<activity android:name=".activities.SettingsActivity" /> |
||||
<activity android:name=".activities.SettingsActivity" /> |
||||
|
||||
<activity android:name=".activities.LicencesActivity" /> |
||||
<activity android:name=".activities.LicencesActivity" /> |
||||
|
||||
<service |
||||
android:name=".playback.PlayerService" |
||||
android:foregroundServiceType="mediaPlayback"> |
||||
<service |
||||
android:name=".playback.PlayerService" |
||||
android:foregroundServiceType="mediaPlayback"> |
||||
|
||||
<intent-filter> |
||||
<action android:name="android.intent.action.MEDIA_BUTTON" /> |
||||
</intent-filter> |
||||
<intent-filter> |
||||
<action android:name="android.intent.action.MEDIA_BUTTON" /> |
||||
</intent-filter> |
||||
|
||||
</service> |
||||
</service> |
||||
|
||||
<service |
||||
android:name=".playback.PinService" |
||||
android:exported="false"> |
||||
<service |
||||
android:name=".playback.PinService" |
||||
android:exported="false"> |
||||
|
||||
<intent-filter> |
||||
<action android:name="com.google.android.exoplayer.downloadService.action.RESTART" /> |
||||
<category android:name="android.intent.category.DEFAULT" /> |
||||
</intent-filter> |
||||
<intent-filter> |
||||
<action android:name="com.google.android.exoplayer.downloadService.action.RESTART" /> |
||||
<category android:name="android.intent.category.DEFAULT" /> |
||||
</intent-filter> |
||||
|
||||
</service> |
||||
</service> |
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver"> |
||||
<intent-filter> |
||||
<action android:name="android.intent.action.MEDIA_BUTTON" /> |
||||
</intent-filter> |
||||
</receiver> |
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver"> |
||||
<intent-filter> |
||||
<action android:name="android.intent.action.MEDIA_BUTTON" /> |
||||
</intent-filter> |
||||
</receiver> |
||||
|
||||
</application> |
||||
</application> |
||||
|
||||
</manifest> |
||||
|
@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
|
||||
package audio.funkwhale.ffa.playback |
||||
|
||||
import android.content.Context |
||||
import android.net.Uri |
||||
import audio.funkwhale.ffa.utils.OAuth |
||||
import com.google.android.exoplayer2.upstream.DataSource |
||||
import com.google.android.exoplayer2.upstream.DataSpec |
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory |
||||
import com.google.android.exoplayer2.upstream.HttpDataSource |
||||
import com.google.android.exoplayer2.upstream.TransferListener |
||||
|
||||
class OAuthDatasource( |
||||
private val context: Context, |
||||
private val http: HttpDataSource |
||||
) : DataSource { |
||||
|
||||
override fun addTransferListener(transferListener: TransferListener?) { |
||||
http.addTransferListener(transferListener) |
||||
} |
||||
|
||||
override fun open(dataSpec: DataSpec?): Long { |
||||
OAuth.tryRefreshAccessToken(context) |
||||
return http.open(dataSpec) |
||||
} |
||||
|
||||
override fun read(buffer: ByteArray?, offset: Int, readLength: Int): Int { |
||||
return http.read(buffer, offset, readLength) |
||||
} |
||||
|
||||
override fun getUri(): Uri? { |
||||
return http.uri |
||||
} |
||||
|
||||
override fun close() { |
||||
http.close() |
||||
} |
||||
|
||||
} |
||||
|
||||
class OAuth2DatasourceFactory( |
||||
private val context: Context, |
||||
private val http: DefaultHttpDataSourceFactory |
||||
) : DataSource.Factory { |
||||
|
||||
override fun createDataSource(): DataSource { |
||||
return OAuthDatasource(context, http.createDataSource()) |
||||
} |
||||
} |
@ -0,0 +1,196 @@
@@ -0,0 +1,196 @@
|
||||
package audio.funkwhale.ffa.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.AuthState |
||||
import net.openid.appauth.AuthorizationException |
||||
import net.openid.appauth.AuthorizationRequest |
||||
import net.openid.appauth.AuthorizationResponse |
||||
import net.openid.appauth.AuthorizationService |
||||
import net.openid.appauth.AuthorizationServiceConfiguration |
||||
import net.openid.appauth.ClientSecretPost |
||||
import net.openid.appauth.RegistrationRequest |
||||
import net.openid.appauth.RegistrationResponse |
||||
import net.openid.appauth.ResponseTypeValues |
||||
|
||||
fun AuthState.save() { |
||||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply { |
||||
val value = jsonSerializeString() |
||||
setString("state", value) |
||||
} |
||||
} |
||||
|
||||
object OAuth { |
||||
data class App(val client_id: String, val client_secret: String) |
||||
|
||||
private val REDIRECT_URI = |
||||
Uri.parse("urn:/audio.funkwhale.funkwhale-android/oauth/callback") |
||||
|
||||
fun isAuthorized(context: Context): Boolean { |
||||
val state = tryState() |
||||
return if (state != null) { |
||||
state.isAuthorized || tryRefreshAccessToken(context) |
||||
} else { |
||||
false |
||||
}.also { |
||||
it.log("isAuthorized()") |
||||
} |
||||
} |
||||
|
||||
fun state(): AuthState = tryState()!! |
||||
|
||||
fun tryRefreshAccessToken(context: Context, overrideNeedsTokenRefresh: Boolean = false): Boolean { |
||||
tryState()?.let { state -> |
||||
val shouldRefreshAccessToken = overrideNeedsTokenRefresh || state.needsTokenRefresh |
||||
if (shouldRefreshAccessToken && state.refreshToken != null) { |
||||
val refreshRequest = state.createTokenRefreshRequest() |
||||
val auth = ClientSecretPost(state.clientSecret) |
||||
runBlocking { |
||||
service(context).performTokenRequest(refreshRequest, auth) { response, e -> |
||||
state.apply { |
||||
update(response, e) |
||||
save() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
return (tryState()?.isAuthorized ?: false) |
||||
.also { |
||||
it.log("tryRefreshAccessToken()") |
||||
} |
||||
} |
||||
|
||||
fun tryState(): AuthState? { |
||||
|
||||
val savedState = PowerPreference |
||||
.getFileByName(AppContext.PREFS_CREDENTIALS) |
||||
.getString("state") |
||||
|
||||
return if (savedState != null && savedState.isNotEmpty()) { |
||||
return AuthState.jsonDeserialize(savedState) |
||||
} else { |
||||
null |
||||
} |
||||
} |
||||
|
||||
fun init(hostname: String) { |
||||
AuthState(config(hostname)).save() |
||||
} |
||||
|
||||
fun service(context: Context) = AuthorizationService(context) |
||||
|
||||
fun register(callback: () -> Unit) { |
||||
state().authorizationServiceConfiguration?.let { config -> |
||||
|
||||
runBlocking { |
||||
val (_, _, result) = Fuel.post(config.registrationEndpoint.toString()) |
||||
.header("Content-Type", "application/json") |
||||
.jsonBody(registrationBody()) |
||||
.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 -> { |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun registrationBody(): Map<String, String> { |
||||
return mapOf( |
||||
"name" to "Funkwhale for Android (${android.os.Build.MODEL})", |
||||
"redirect_uris" to REDIRECT_URI.toString(), |
||||
"scopes" to "read write" |
||||
) |
||||
} |
||||
|
||||
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", "write") |
||||
.build() |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue