[API/Usos] Implement OAuth authorization flow.

This commit is contained in:
Kuba Szczodrzyński 2022-10-14 21:44:58 +02:00
parent 6c96875c83
commit 2ff784066e
No known key found for this signature in database
GPG Key ID: 70CB8A85BA1633CB
14 changed files with 225 additions and 26 deletions

View File

@ -99,3 +99,12 @@ const val PODLASIE_API_VERSION = "1.0.62"
const val PODLASIE_API_URL = "https://cpdklaser.zeto.bialystok.pl/api"
const val PODLASIE_API_USER_ENDPOINT = "/pobierzDaneUcznia"
const val PODLASIE_API_LOGOUT_DEVICES_ENDPOINT = "/wyczyscUrzadzenia"
const val USOS_API_OAUTH_REDIRECT_URL = "szkolny://redirect/usos"
val USOS_API_SCOPES by lazy { listOf(
"offline_access",
"studies",
"grades",
"events",
) }

View File

@ -12,6 +12,7 @@ import pl.szczodrzynski.edziennik.data.api.edziennik.librus.Librus
import pl.szczodrzynski.edziennik.data.api.edziennik.mobidziennik.Mobidziennik
import pl.szczodrzynski.edziennik.data.api.edziennik.podlasie.Podlasie
import pl.szczodrzynski.edziennik.data.api.edziennik.template.Template
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.Usos
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.Vulcan
import pl.szczodrzynski.edziennik.data.api.events.RegisterAvailabilityEvent
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikCallback
@ -113,6 +114,7 @@ open class EdziennikTask(override val profileId: Int, val request: Any) : IApiTa
LOGIN_TYPE_VULCAN -> Vulcan(app, profile, loginStore, taskCallback)
LOGIN_TYPE_PODLASIE -> Podlasie(app, profile, loginStore, taskCallback)
LOGIN_TYPE_TEMPLATE -> Template(app, profile, loginStore, taskCallback)
LOGIN_TYPE_USOS -> Usos(app, profile, loginStore, taskCallback)
else -> null
}
if (edziennikInterface == null) {

View File

@ -27,11 +27,21 @@ class DataUsos(
override fun generateUserCode() = "USOS:TEST"
var schoolId: String?
get() { mSchoolId = mSchoolId ?: loginStore.getLoginData("schoolId", null); return mSchoolId }
set(value) { loginStore.putLoginData("schoolId", value); mSchoolId = value }
private var mSchoolId: String? = null
var instanceUrl: String?
get() { mInstanceUrl = mInstanceUrl ?: loginStore.getLoginData("instanceUrl", null); return mInstanceUrl }
set(value) { loginStore.putLoginData("instanceUrl", value); mInstanceUrl = value }
private var mInstanceUrl: String? = null
var oauthLoginResponse: String?
get() { mOauthLoginResponse = mOauthLoginResponse ?: loginStore.getLoginData("oauthLoginResponse", null); return mOauthLoginResponse }
set(value) { loginStore.putLoginData("oauthLoginResponse", value); mOauthLoginResponse = value }
private var mOauthLoginResponse: String? = null
var oauthConsumerKey: String?
get() { mOauthConsumerKey = mOauthConsumerKey ?: loginStore.getLoginData("oauthConsumerKey", null); return mOauthConsumerKey }
set(value) { loginStore.putLoginData("oauthConsumerKey", value); mOauthConsumerKey = value }

View File

@ -6,6 +6,7 @@ package pl.szczodrzynski.edziennik.data.api.edziennik.usos
import com.google.gson.JsonObject
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.firstlogin.UsosFirstLogin
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.login.UsosLogin
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikCallback
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface
@ -71,9 +72,9 @@ class Usos(
override fun getEvent(eventFull: EventFull) {}
override fun firstLogin() {
/*UsosFirstLogin(data) {
UsosFirstLogin(data) {
completed()
}*/
}
}
override fun cancel() {

View File

@ -78,7 +78,7 @@ open class UsosApi(open val data: DataUsos, open val lastSync: Long?) {
responseType: ResponseType,
onSuccess: (data: T) -> Unit,
) {
val url = "${data.instanceUrl}/services/$service"
val url = "${data.instanceUrl}services/$service"
d(tag, "Request: Usos/Api - $url")
val formData = params.mapValues {
valueToString(it.value)
@ -92,7 +92,11 @@ open class UsosApi(open val data: DataUsos, open val lastSync: Long?) {
"oauth_token" to (data.oauthTokenKey ?: ""),
"oauth_version" to "1.0",
)
val signature = buildSignature("POST", url, formData + auth)
val signature = buildSignature(
method = "POST",
url = url,
params = formData + auth.filterKeys { it.startsWith("oauth_") },
)
auth["oauth_signature"] = signature
val authString = auth.map {
@ -152,10 +156,10 @@ open class UsosApi(open val data: DataUsos, open val lastSync: Long?) {
private fun <T> processResponse(
response: Response?,
data: T?,
data: T,
onSuccess: (data: T) -> Unit,
) {
onSuccess(data)
}
private fun processError(

View File

@ -41,7 +41,7 @@ class UsosData(val data: DataUsos, val onSuccess: () -> Unit) {
when (endpointId) {
ENDPOINT_USOS_API_USER -> {
data.startProgress(R.string.edziennik_progress_endpoint_student_info)
TemplateWebSample(data, lastSync, onSuccess)
// TemplateWebSample(data, lastSync, onSuccess)
}
else -> onSuccess(endpointId)
}

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-14.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik.usos.firstlogin
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.DataUsos
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.UsosApi
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.login.UsosLoginApi
class UsosFirstLogin(val data: DataUsos, val onSuccess: () -> Unit) {
companion object {
private const val TAG = "UsosFirstLogin"
}
private val api = UsosApi(data, null)
init {
UsosLoginApi(data) {
}
}
}

View File

@ -4,10 +4,17 @@
package pl.szczodrzynski.edziennik.data.api.edziennik.usos.login
import pl.szczodrzynski.edziennik.data.api.ERROR_PROFILE_MISSING
import pl.szczodrzynski.edziennik.data.api.ERROR_USOS_OAUTH_LOGIN_REQUEST
import pl.szczodrzynski.edziennik.data.api.USOS_API_OAUTH_REDIRECT_URL
import pl.szczodrzynski.edziennik.data.api.USOS_API_SCOPES
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.DataUsos
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.UsosApi
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.ext.Bundle
import pl.szczodrzynski.edziennik.ext.fromQueryString
import pl.szczodrzynski.edziennik.ext.toBundle
import pl.szczodrzynski.edziennik.ext.toQueryString
import pl.szczodrzynski.edziennik.utils.Utils.d
class UsosLoginApi(val data: DataUsos, val onSuccess: () -> Unit) {
companion object {
@ -15,15 +22,47 @@ class UsosLoginApi(val data: DataUsos, val onSuccess: () -> Unit) {
}
init { run {
if (data.profile == null) {
data.error(ApiError(TAG, ERROR_PROFILE_MISSING))
return@run
}
if (data.isApiLoginValid()) {
onSuccess()
} else if (data.oauthLoginResponse != null) {
login()
} else {
data.error(ApiError(TAG, ERROR_USOS_OAUTH_LOGIN_REQUEST))
authorize()
}
}}
private fun authorize() {
val api = UsosApi(data, null)
api.apiRequest<String>(
tag = TAG,
service = "oauth/request_token",
params = mapOf(
"oauth_callback" to USOS_API_OAUTH_REDIRECT_URL,
"scopes" to USOS_API_SCOPES,
),
responseType = UsosApi.ResponseType.PLAIN,
) {
val response = it.fromQueryString()
data.oauthTokenKey = response["oauth_token"]
data.oauthTokenSecret = response["oauth_token_secret"]
val authUrl = "${data.instanceUrl}services/oauth/authorize"
val authParams = mapOf(
"interactivity" to "confirm_user",
"oauth_token" to (data.oauthTokenKey ?: ""),
)
val params = Bundle(
"authorizeUrl" to "$authUrl?${authParams.toQueryString()}",
"redirectUrl" to USOS_API_OAUTH_REDIRECT_URL,
"responseStoreKey" to "oauthLoginResponse",
"extras" to data.loginStore.data.toBundle(),
)
data.error(ApiError(TAG, ERROR_USOS_OAUTH_LOGIN_REQUEST).withParams(params))
}
}
private fun login() {
d(TAG, "Login to ${data.schoolId} with ${data.oauthLoginResponse} (${data.oauthTokenSecret})")
}
}

View File

@ -18,6 +18,7 @@ import android.text.style.StyleSpan
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.mikepenz.materialdrawer.holder.StringHolder
import java.net.URLDecoder
import java.net.URLEncoder
fun CharSequence?.isNotNullNorEmpty(): Boolean {
@ -346,8 +347,14 @@ fun CharSequence.toStringHolder() = StringHolder(this)
fun @receiver:StringRes Int.resolveString(context: Context) = context.getString(this)
fun String.urlEncode(): String = URLEncoder.encode(this, "UTF-8").replace("+", "%20")
fun String.urlDecode(): String = URLDecoder.decode(this, "UTF-8")
fun Map<String, String>.toQueryString() = this
.map { it.key.urlEncode() to it.value.urlEncode() }
.sortedBy { it.first }
.joinToString("&") { "${it.first}=${it.second}" }
fun String.fromQueryString() = this
.split("&")
.map { it.split("=") }
.associate { it[0].urlDecode() to it[1].urlDecode() }

View File

@ -0,0 +1,67 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-14.
*/
package pl.szczodrzynski.edziennik.ui.dialogs
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.updateLayoutParams
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.ext.dp
import pl.szczodrzynski.edziennik.ui.dialogs.base.ViewDialog
import pl.szczodrzynski.edziennik.utils.Utils.d
class OAuthLoginDialog(
activity: AppCompatActivity,
private val authorizeUrl: String,
private val redirectUrl: String,
private val onSuccess: (responseUrl: String) -> Unit,
private val onFailure: (() -> Unit)?,
onShowListener: ((tag: String) -> Unit)? = null,
onDismissListener: ((tag: String) -> Unit)? = null,
) : ViewDialog<WebView>(activity, onShowListener, onDismissListener) {
override val TAG = "OAuthLoginDialog"
override fun getTitleRes() = R.string.oauth_dialog_title
override fun getPositiveButtonText() = R.string.close
private var isSuccessful = false
@SuppressLint("SetJavaScriptEnabled")
override fun getRootView(): WebView {
val webView = WebView(activity)
webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
d(TAG, "Navigating to $url")
if (url.startsWith(redirectUrl)) {
isSuccessful = true
onSuccess(url)
dismiss()
}
}
}
webView.settings.javaScriptEnabled = true
return webView
}
override suspend fun onShow() {
dialog.window?.setLayout(MATCH_PARENT, MATCH_PARENT)
root.minimumHeight = activity.windowManager.defaultDisplay?.height?.div(2) ?: 300.dp
root.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
root.loadUrl(authorizeUrl)
}
override fun onDismiss() {
root.stopLoading()
if (!isSuccessful)
onFailure?.invoke()
}
}

View File

@ -31,6 +31,7 @@ class LoginActivity : AppCompatActivity(), CoroutineScope {
private val app: App by lazy { applicationContext as App }
private lateinit var b: LoginActivityBinding
lateinit var navOptions: NavOptions
lateinit var navOptionsBuilder: NavOptions.Builder
val nav by lazy { Navigation.findNavController(this, R.id.nav_host_fragment) }
val errorSnackbar: ErrorSnackbar by lazy { ErrorSnackbar(this) }
val swipeRefreshLayout: SwipeRefreshLayoutNoTouch by lazy { b.swipeRefreshLayout }
@ -87,12 +88,12 @@ class LoginActivity : AppCompatActivity(), CoroutineScope {
super.onCreate(savedInstanceState)
setTheme(R.style.AppTheme_Light)
navOptions = NavOptions.Builder()
navOptionsBuilder = NavOptions.Builder()
.setEnterAnim(R.anim.slide_in_right)
.setExitAnim(R.anim.slide_out_left)
.setPopEnterAnim(R.anim.slide_in_left)
.setPopExitAnim(R.anim.slide_out_right)
.build()
navOptions = navOptionsBuilder.build()
b = LoginActivityBinding.inflate(layoutInflater)
setContentView(b.root)

View File

@ -14,6 +14,8 @@ import android.widget.Toast
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.navigation.NavOptions
import androidx.navigation.navOptions
import androidx.viewbinding.ViewBinding
import com.google.android.material.textfield.TextInputLayout
import com.mikepenz.iconics.IconicsDrawable
@ -304,6 +306,14 @@ class LoginFormFragment : Fragment(), CoroutineScope {
if (hasErrors)
return
nav.navigate(R.id.loginProgressFragment, payload, activity.navOptions)
val navOptions =
if (credentials.isEmpty())
activity.navOptionsBuilder
.setPopUpTo(R.id.loginPlatformListFragment, inclusive = false)
.build()
else
activity.navOptions
nav.navigate(R.id.loginProgressFragment, payload, navOptions)
}
}

View File

@ -21,6 +21,7 @@ import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.ext.*
import pl.szczodrzynski.edziennik.ui.captcha.LibrusCaptchaDialog
import pl.szczodrzynski.edziennik.ui.dialogs.OAuthLoginDialog
import pl.szczodrzynski.edziennik.utils.Utils.d
class UserActionManager(val app: App) {
@ -89,9 +90,13 @@ class UserActionManager(val app: App) {
onFailure: (() -> Unit)? = null
) {
d(TAG, "Running user action ($type) with params: ${params?.toJsonObject()}")
when (type) {
val isSuccessful = when (type) {
UserActionRequiredEvent.CAPTCHA_LIBRUS -> executeLibrus(activity, profileId, params, onSuccess, onFailure)
UserActionRequiredEvent.OAUTH_USOS -> executeOauth(activity, profileId, params, onSuccess, onFailure)
else -> false
}
if (!isSuccessful) {
onFailure?.invoke()
}
}
@ -101,9 +106,9 @@ class UserActionManager(val app: App) {
params: Bundle?,
onSuccess: ((params: Bundle) -> Unit)?,
onFailure: (() -> Unit)?,
) {
): Boolean {
if (profileId == null)
return
return false
val extras = params?.getBundle("extras")
// show captcha dialog
// use passed onSuccess listener, else sync profile
@ -117,14 +122,14 @@ class UserActionManager(val app: App) {
if (extras != null)
args.putAll(extras)
if (onSuccess != null) {
if (onSuccess != null)
onSuccess(args)
} else {
else
EdziennikTask.syncProfile(profileId, arguments = args.toJsonObject()).enqueue(activity)
}
},
onFailure = onFailure
onFailure = onFailure,
).show()
return true
}
private fun executeOauth(
@ -133,10 +138,30 @@ class UserActionManager(val app: App) {
params: Bundle?,
onSuccess: ((params: Bundle) -> Unit)?,
onFailure: (() -> Unit)?,
) {
): Boolean {
if (profileId == null || params == null)
return
return false
val extras = params.getBundle("extras")
val storeKey = params.getString("responseStoreKey") ?: return false
OAuthLoginDialog(
activity = activity,
authorizeUrl = params.getString("authorizeUrl") ?: return false,
redirectUrl = params.getString("redirectUrl") ?: return false,
onSuccess = { responseUrl ->
val args = Bundle(
storeKey to responseUrl,
)
if (extras != null)
args.putAll(extras)
if (onSuccess != null)
onSuccess(args)
else
EdziennikTask.syncProfile(profileId, arguments = args.toJsonObject()).enqueue(activity)
},
onFailure = onFailure,
).show()
return true
}
}

View File

@ -1542,4 +1542,5 @@
<string name="login_mode_usos_oauth">Logowanie z użyciem przeglądarki</string>
<string name="login_mode_usos_oauth_guide">TODO</string>
<string name="notification_user_action_required_oauth_usos">USOS - wymagane logowanie z użyciem przeglądarki</string>
<string name="oauth_dialog_title">Zaloguj się</string>
</resources>