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 @@ |
|||||||
<?xml version="1.0" encoding="utf-8"?> |
<?xml version="1.0" encoding="utf-8"?> |
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
<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.INTERNET" /> |
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> |
<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 |
<application |
||||||
android:name="audio.funkwhale.ffa.FFA" |
android:name="audio.funkwhale.ffa.FFA" |
||||||
android:allowBackup="false" |
android:allowBackup="false" |
||||||
android:icon="@mipmap/ic_launcher" |
android:icon="@mipmap/ic_launcher" |
||||||
android:label="@string/app_name" |
android:label="@string/app_name" |
||||||
android:networkSecurityConfig="@xml/security" |
android:networkSecurityConfig="@xml/security" |
||||||
android:roundIcon="@mipmap/ic_launcher" |
android:roundIcon="@mipmap/ic_launcher" |
||||||
android:supportsRtl="true" |
android:supportsRtl="true" |
||||||
android:theme="@style/AppTheme" |
android:theme="@style/AppTheme" |
||||||
android:usesCleartextTraffic="true"> |
android:usesCleartextTraffic="true"> |
||||||
|
|
||||||
<activity |
<activity |
||||||
android:name=".activities.SplashActivity" |
android:name=".activities.SplashActivity" |
||||||
android:launchMode="singleInstance" |
android:launchMode="singleInstance" |
||||||
android:noHistory="true"> |
android:noHistory="true"> |
||||||
|
|
||||||
<intent-filter> |
<intent-filter> |
||||||
<action android:name="android.intent.action.MAIN" /> |
<action android:name="android.intent.action.MAIN" /> |
||||||
<action android:name="android.intent.action.VIEW" /> |
<action android:name="android.intent.action.VIEW" /> |
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" /> |
<category android:name="android.intent.category.LAUNCHER" /> |
||||||
</intent-filter> |
</intent-filter> |
||||||
|
|
||||||
</activity> |
</activity> |
||||||
|
|
||||||
<activity |
<activity |
||||||
android:name=".activities.LoginActivity" |
android:name=".activities.LoginActivity" |
||||||
android:configChanges="screenSize|orientation" |
android:configChanges="screenSize|orientation" |
||||||
android:launchMode="singleInstance" /> |
android:launchMode="singleInstance" /> |
||||||
|
|
||||||
<activity android:name=".activities.MainActivity" /> |
<activity android:name=".activities.MainActivity" /> |
||||||
|
|
||||||
<activity |
<activity |
||||||
android:name=".activities.SearchActivity" |
android:name=".activities.SearchActivity" |
||||||
android:launchMode="singleTop" /> |
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 |
<service |
||||||
android:name=".playback.PlayerService" |
android:name=".playback.PlayerService" |
||||||
android:foregroundServiceType="mediaPlayback"> |
android:foregroundServiceType="mediaPlayback"> |
||||||
|
|
||||||
<intent-filter> |
<intent-filter> |
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" /> |
<action android:name="android.intent.action.MEDIA_BUTTON" /> |
||||||
</intent-filter> |
</intent-filter> |
||||||
|
|
||||||
</service> |
</service> |
||||||
|
|
||||||
<service |
<service |
||||||
android:name=".playback.PinService" |
android:name=".playback.PinService" |
||||||
android:exported="false"> |
android:exported="false"> |
||||||
|
|
||||||
<intent-filter> |
<intent-filter> |
||||||
<action android:name="com.google.android.exoplayer.downloadService.action.RESTART" /> |
<action android:name="com.google.android.exoplayer.downloadService.action.RESTART" /> |
||||||
<category android:name="android.intent.category.DEFAULT" /> |
<category android:name="android.intent.category.DEFAULT" /> |
||||||
</intent-filter> |
</intent-filter> |
||||||
|
|
||||||
</service> |
</service> |
||||||
|
|
||||||
<receiver android:name="androidx.media.session.MediaButtonReceiver"> |
<receiver android:name="androidx.media.session.MediaButtonReceiver"> |
||||||
<intent-filter> |
<intent-filter> |
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" /> |
<action android:name="android.intent.action.MEDIA_BUTTON" /> |
||||||
</intent-filter> |
</intent-filter> |
||||||
</receiver> |
</receiver> |
||||||
|
|
||||||
</application> |
</application> |
||||||
|
|
||||||
</manifest> |
</manifest> |
||||||
|
@ -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 @@ |
|||||||
|
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