[API/Librus] Fix login. (#176)

This commit is contained in:
B.O.S.S 2023-03-24 22:09:03 +01:00 committed by GitHub
parent 8177d4aa2d
commit 07ab1b984f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 126 additions and 60 deletions

View File

@ -26,9 +26,10 @@ val LIBRUS_USER_AGENT = "${SYSTEM_USER_AGENT}LibrusMobileApp"
const val SYNERGIA_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101 Firefox/62.0" const val SYNERGIA_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101 Firefox/62.0"
const val LIBRUS_CLIENT_ID = "VaItV6oRutdo8fnjJwysnTjVlvaswf52ZqmXsJGP" const val LIBRUS_CLIENT_ID = "VaItV6oRutdo8fnjJwysnTjVlvaswf52ZqmXsJGP"
const val LIBRUS_REDIRECT_URL = "app://librus" const val LIBRUS_REDIRECT_URL = "app://librus"
const val LIBRUS_AUTHORIZE_URL = "https://portal.librus.pl/oauth2/authorize?client_id=$LIBRUS_CLIENT_ID&redirect_uri=$LIBRUS_REDIRECT_URL&response_type=code" const val LIBRUS_AUTHORIZE_URL = "https://portal.librus.pl/konto-librus/redirect/dru"
const val LIBRUS_LOGIN_URL = "https://portal.librus.pl/rodzina/login/action" const val LIBRUS_LOGIN_URL = "https://portal.librus.pl/konto-librus/login/action"
const val LIBRUS_TOKEN_URL = "https://portal.librus.pl/oauth2/access_token" const val LIBRUS_TOKEN_URL = "https://portal.librus.pl/oauth2/access_token"
const val LIBRUS_HEADER = "pl.librus.synergiaDru2"
const val LIBRUS_ACCOUNT_URL = "/v3/SynergiaAccounts/fresh/" // + login const val LIBRUS_ACCOUNT_URL = "/v3/SynergiaAccounts/fresh/" // + login
const val LIBRUS_ACCOUNTS_URL = "/v3/SynergiaAccounts" const val LIBRUS_ACCOUNTS_URL = "/v3/SynergiaAccounts"
@ -59,9 +60,6 @@ const val LIBRUS_SANDBOX_URL = "https://sandbox.librus.pl/index.php?action="
const val LIBRUS_SYNERGIA_HOMEWORK_ATTACHMENT_URL = "https://synergia.librus.pl/homework/downloadFile" const val LIBRUS_SYNERGIA_HOMEWORK_ATTACHMENT_URL = "https://synergia.librus.pl/homework/downloadFile"
const val LIBRUS_SYNERGIA_MESSAGES_ATTACHMENT_URL = "https://synergia.librus.pl/wiadomosci/pobierz_zalacznik" const val LIBRUS_SYNERGIA_MESSAGES_ATTACHMENT_URL = "https://synergia.librus.pl/wiadomosci/pobierz_zalacznik"
const val LIBRUS_PORTAL_RECAPTCHA_KEY = "6Lf48moUAAAAAB9ClhdvHr46gRWR"
const val LIBRUS_PORTAL_RECAPTCHA_REFERER = "https://portal.librus.pl/rodzina/login"
val MOBIDZIENNIK_USER_AGENT = SYSTEM_USER_AGENT val MOBIDZIENNIK_USER_AGENT = SYSTEM_USER_AGENT

View File

@ -24,6 +24,25 @@ object Regexes {
"""^\[META:([A-z0-9-&=]+)]""".toRegex() """^\[META:([A-z0-9-&=]+)]""".toRegex()
} }
val HTML_INPUT_HIDDEN by lazy {
"""<input .*?type="hidden".+?>""".toRegex()
}
val HTML_INPUT_NAME by lazy {
"""name="(.+?)"""".toRegex()
}
val HTML_INPUT_VALUE by lazy {
"""value="(.+?)"""".toRegex()
}
val HTML_CSRF_TOKEN by lazy {
"""name="csrf-token" content="([A-z0-9=+/\-_]+?)"""".toRegex()
}
val HTML_FORM_ACTION by lazy {
"""<form .*?action="(.+?)"""".toRegex()
}
val HTML_RECAPTCHA_KEY by lazy {
"""data-sitekey="(.+?)"""".toRegex()
}
val MOBIDZIENNIK_GRADES_SUBJECT_NAME by lazy { val MOBIDZIENNIK_GRADES_SUBJECT_NAME by lazy {

View File

@ -24,6 +24,9 @@ class LibrusLoginPortal(val data: DataLibrus, val onSuccess: () -> Unit) {
private const val TAG = "LoginLibrusPortal" private const val TAG = "LoginLibrusPortal"
} }
// loop failsafe
private var loginPerformed = false
init { run { init { run {
if (data.loginStore.mode != LoginMode.LIBRUS_EMAIL) { if (data.loginStore.mode != LoginMode.LIBRUS_EMAIL) {
data.error(ApiError(TAG, ERROR_INVALID_LOGIN_MODE)) data.error(ApiError(TAG, ERROR_INVALID_LOGIN_MODE))
@ -33,6 +36,7 @@ class LibrusLoginPortal(val data: DataLibrus, val onSuccess: () -> Unit) {
data.error(ApiError(TAG, ERROR_LOGIN_DATA_MISSING)) data.error(ApiError(TAG, ERROR_LOGIN_DATA_MISSING))
return@run return@run
} }
loginPerformed = false
// succeed having a non-expired access token and a refresh token // succeed having a non-expired access token and a refresh token
if (data.isPortalLoginValid()) { if (data.isPortalLoginValid()) {
@ -58,18 +62,23 @@ class LibrusLoginPortal(val data: DataLibrus, val onSuccess: () -> Unit) {
} }
}} }}
private fun authorize(url: String?) { private fun authorize(url: String, referer: String? = null) {
d(TAG, "Request: Librus/Login/Portal - $url") d(TAG, "Request: Librus/Login/Portal - $url")
Request.builder() Request.builder()
.url(url) .url(url)
.userAgent(LIBRUS_USER_AGENT) .userAgent(LIBRUS_USER_AGENT)
.also {
if (referer != null)
it.addHeader("Referer", referer)
}
.addHeader("X-Requested-With", LIBRUS_HEADER)
.withClient(data.app.httpLazy) .withClient(data.app.httpLazy)
.callback(object : TextCallbackHandler() { .callback(object : TextCallbackHandler() {
override fun onSuccess(text: String, response: Response) { override fun onSuccess(text: String, response: Response) {
val location = response.headers().get("Location") val location = response.headers().get("Location")
if (location != null) { if (location != null) {
val authMatcher = Pattern.compile("$LIBRUS_REDIRECT_URL\\?code=([A-z0-9]+?)$", Pattern.DOTALL or Pattern.MULTILINE).matcher(location) val authMatcher = Pattern.compile("$LIBRUS_REDIRECT_URL\\?code=([^&?]+)", Pattern.DOTALL or Pattern.MULTILINE).matcher(location)
when { when {
authMatcher.find() -> { authMatcher.find() -> {
accessToken(authMatcher.group(1), null) accessToken(authMatcher.group(1), null)
@ -83,16 +92,31 @@ class LibrusLoginPortal(val data: DataLibrus, val onSuccess: () -> Unit) {
authorize(location) authorize(location)
} }
} }
} else { return
val csrfMatcher = Pattern.compile("name=\"csrf-token\" content=\"([A-z0-9=+/\\-_]+?)\"", Pattern.DOTALL).matcher(text) }
if (csrfMatcher.find()) {
login(csrfMatcher.group(1) ?: "") if (checkError(text, response))
} else { return
data.error(ApiError(TAG, ERROR_LOGIN_LIBRUS_PORTAL_CSRF_MISSING)
.withResponse(response) var loginUrl = if (data.fakeLogin) FAKE_LIBRUS_LOGIN else LIBRUS_LOGIN_URL
.withApiResponse(text)) val csrfToken = Regexes.HTML_CSRF_TOKEN.find(text)?.get(1) ?: ""
for (match in Regexes.HTML_FORM_ACTION.findAll(text)) {
val form = match.value.lowercase()
if ("login" in form && "post" in form) {
loginUrl = match[1]
} }
} }
val params = mutableMapOf<String, String>()
for (match in Regexes.HTML_INPUT_HIDDEN.findAll(text)) {
val input = match.value
val name = Regexes.HTML_INPUT_NAME.find(input)?.get(1) ?: continue
val value = Regexes.HTML_INPUT_VALUE.find(input)?.get(1) ?: continue
params[name] = value
}
login(url = loginUrl, referer = url, csrfToken, params)
} }
override fun onFailure(response: Response, throwable: Throwable) { override fun onFailure(response: Response, throwable: Throwable) {
@ -105,8 +129,54 @@ class LibrusLoginPortal(val data: DataLibrus, val onSuccess: () -> Unit) {
.enqueue() .enqueue()
} }
private fun login(csrfToken: String) { private fun checkError(text: String, response: Response): Boolean {
d(TAG, "Request: Librus/Login/Portal - ${if (data.fakeLogin) FAKE_LIBRUS_LOGIN else LIBRUS_LOGIN_URL}") when {
text.contains("librus_account_settings_main") -> return false
text.contains("Sesja logowania wygasła") -> ERROR_LOGIN_LIBRUS_PORTAL_CSRF_EXPIRED
text.contains("Upewnij się, że nie") -> ERROR_LOGIN_LIBRUS_PORTAL_INVALID_LOGIN
text.contains("Podany adres e-mail jest nieprawidłowy.") -> ERROR_LOGIN_LIBRUS_PORTAL_INVALID_LOGIN
else -> null // no error for now
}?.let { errorCode ->
data.error(ApiError(TAG, errorCode)
.withApiResponse(text)
.withResponse(response))
return true
}
if ("robotem" in text || "g-recaptcha" in text || "captchaValidate" in text) {
val siteKey = Regexes.HTML_RECAPTCHA_KEY.find(text)?.get(1)
if (siteKey == null) {
data.error(ApiError(TAG, ERROR_LOGIN_LIBRUS_PORTAL_ACTION_ERROR)
.withApiResponse(text)
.withResponse(response))
return true
}
data.requireUserAction(
type = UserActionRequiredEvent.Type.RECAPTCHA,
params = Bundle(
"siteKey" to siteKey,
"referer" to response.request().url().toString(),
"userAgent" to LIBRUS_USER_AGENT,
),
errorText = R.string.notification_user_action_required_captcha_librus,
)
return true
}
return false
}
private fun login(
url: String,
referer: String,
csrfToken: String?,
params: Map<String, String>,
) {
if (loginPerformed) {
data.error(ApiError(TAG, ERROR_LOGIN_LIBRUS_PORTAL_ACTION_ERROR))
return
}
d(TAG, "Request: Librus/Login/Portal - $url")
val recaptchaCode = data.arguments?.getString("recaptchaCode") ?: data.loginStore.getLoginData("recaptchaCode", null) val recaptchaCode = data.arguments?.getString("recaptchaCode") ?: data.loginStore.getLoginData("recaptchaCode", null)
val recaptchaTime = data.arguments?.getLong("recaptchaTime") ?: data.loginStore.getLoginData("recaptchaTime", 0L) val recaptchaTime = data.arguments?.getLong("recaptchaTime") ?: data.loginStore.getLoginData("recaptchaTime", 0L)
@ -116,67 +186,46 @@ class LibrusLoginPortal(val data: DataLibrus, val onSuccess: () -> Unit) {
Request.builder() Request.builder()
.url(if (data.fakeLogin) FAKE_LIBRUS_LOGIN else LIBRUS_LOGIN_URL) .url(if (data.fakeLogin) FAKE_LIBRUS_LOGIN else LIBRUS_LOGIN_URL)
.userAgent(LIBRUS_USER_AGENT) .userAgent(LIBRUS_USER_AGENT)
.addHeader("X-Requested-With", LIBRUS_HEADER)
.addHeader("Referer", referer)
.withClient(data.app.httpLazy)
.addParameter("email", data.portalEmail) .addParameter("email", data.portalEmail)
.addParameter("password", data.portalPassword) .addParameter("password", data.portalPassword)
.also { .also {
if (recaptchaCode != null && System.currentTimeMillis() - recaptchaTime < 2*60*1000 /* 2 minutes */) if (recaptchaCode != null && System.currentTimeMillis() - recaptchaTime < 2*60*1000 /* 2 minutes */)
it.addParameter("g-recaptcha-response", recaptchaCode) it.addParameter("g-recaptcha-response", recaptchaCode)
if (csrfToken != null)
it.addHeader("X-CSRF-TOKEN", csrfToken)
for ((key, value) in params) {
it.addParameter(key, value)
}
} }
.addHeader("X-CSRF-TOKEN", csrfToken) .contentType(MediaTypeUtils.APPLICATION_FORM)
.allowErrorCode(HTTP_BAD_REQUEST)
.allowErrorCode(HTTP_FORBIDDEN)
.contentType(MediaTypeUtils.APPLICATION_JSON)
.post() .post()
.callback(object : JsonCallbackHandler() { .callback(object : TextCallbackHandler() {
override fun onSuccess(json: JsonObject?, response: Response) { override fun onSuccess(text: String?, response: Response) {
loginPerformed = true
val location = response.headers()?.get("Location") val location = response.headers()?.get("Location")
if (location == "$LIBRUS_REDIRECT_URL?command=close") { if (location == "$LIBRUS_REDIRECT_URL?command=close") {
data.error(ApiError(TAG, ERROR_LIBRUS_PORTAL_MAINTENANCE) data.error(ApiError(TAG, ERROR_LIBRUS_PORTAL_MAINTENANCE)
.withApiResponse(json) .withApiResponse(text)
.withResponse(response)) .withResponse(response))
return return
} }
if (text == null) {
if (json == null) {
if (response.parserErrorBody?.contains("wciąż nieaktywne") == true) {
data.error(ApiError(TAG, ERROR_LOGIN_LIBRUS_PORTAL_NOT_ACTIVATED)
.withResponse(response))
return
}
data.error(ApiError(TAG, ERROR_RESPONSE_EMPTY) data.error(ApiError(TAG, ERROR_RESPONSE_EMPTY)
.withResponse(response)) .withResponse(response))
return return
} }
val error = if (response.code() == 200) null else
json.getJsonArray("errors")?.getString(0)
?: json.getJsonObject("errors")?.entrySet()?.firstOrNull()?.value?.asString
if (error?.contains("robotem") == true || json.getBoolean("captchaRequired") == true) { authorize(
data.requireUserAction( url = location
type = UserActionRequiredEvent.Type.RECAPTCHA, ?: if (data.fakeLogin)
params = Bundle( FAKE_LIBRUS_AUTHORIZE
"siteKey" to LIBRUS_PORTAL_RECAPTCHA_KEY, else
"referer" to LIBRUS_PORTAL_RECAPTCHA_REFERER, LIBRUS_AUTHORIZE_URL,
), referer = referer,
errorText = R.string.notification_user_action_required_captcha_librus, )
)
return
}
error?.let { code ->
when {
code.contains("Sesja logowania wygasła") -> ERROR_LOGIN_LIBRUS_PORTAL_CSRF_EXPIRED
code.contains("Upewnij się, że nie") -> ERROR_LOGIN_LIBRUS_PORTAL_INVALID_LOGIN
code.contains("Podany adres e-mail jest nieprawidłowy.") -> ERROR_LOGIN_LIBRUS_PORTAL_INVALID_LOGIN
else -> ERROR_LOGIN_LIBRUS_PORTAL_ACTION_ERROR
}.let { errorCode ->
data.error(ApiError(TAG, errorCode)
.withApiResponse(json)
.withResponse(response))
return
}
}
authorize(json.getString("redirect", LIBRUS_AUTHORIZE_URL))
} }
override fun onFailure(response: Response, throwable: Throwable) { override fun onFailure(response: Response, throwable: Throwable) {