mirror of
https://github.com/szkolny-eu/szkolny-android.git
synced 2024-11-24 10:54:36 -06:00
[API/Usos] Implement OAuth authorization flow.
This commit is contained in:
parent
6c96875c83
commit
2ff784066e
@ -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",
|
||||
) }
|
||||
|
@ -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) {
|
||||
|
@ -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 }
|
||||
|
@ -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() {
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
@ -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})")
|
||||
}
|
||||
}
|
||||
|
@ -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() }
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user