Skip to content

Commit

Permalink
Update ischool login method with retry mechanism (#225)
Browse files Browse the repository at this point in the history
* iSchool login method change

* add ischool login test

* remove outdated I-School retry loop

* add session out_of_date error

* add retry mechanis, for getLoginOAuth

* remove unused import from test

* remove unused parameter

* rewrite login methods

* add getSSOIndexResponse function

* remove unnecessary redirection

* update comments

* dart format

* add retry mechanism in oauth server request

* update comments

* refactoring the retry logic

* making logEventToFirebase optional

* convert to named argument

* remove unnecessary else statement

* rewrite login retry mechanism on step 2

* add log

* extend ischool connection state

* add error handling

* update comment

* dart format

---------

Co-authored-by: jj30462281 <[email protected]>
  • Loading branch information
James-Lu-none and umeow0716 authored Feb 28, 2024
1 parent 97a78a3 commit 3bf8666
Show file tree
Hide file tree
Showing 4 changed files with 129 additions and 64 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release

# Test files
**/test/ischool_plus_connector_test/credential.json
118 changes: 56 additions & 62 deletions lib/src/connector/ischool_plus_connector.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// TODO: remove sdk version selector after migrating to null-safety.
// @dart=2.10
import 'dart:convert';
import 'dart:developer';
import 'dart:io';

import 'package:dio/dio.dart';
Expand All @@ -17,7 +18,7 @@ import '../model/course/course_student.dart';
import 'core/connector_parameter.dart';
import 'ntut_connector.dart';

enum ISchoolPlusConnectorStatus { loginSuccess, loginFail, unknownError }
enum ISchoolPlusConnectorStatus { loginSuccess, loginGetSSOIndexError, loginRedirectionError, unknownError }

enum IPlusReturnStatus { success, fail, noPermission }

Expand All @@ -38,80 +39,73 @@ class ISchoolPlusConnector {

/// The Authorization Step of ISchool (2023-10-21)
/// 1. GET https://app.ntut.edu.tw/ssoIndex.do
/// 2. POST https://app.ntut.edu.tw/oauth2Server.do (It should be. See the comment on step 2)
/// 3. GET https://istudy.ntut.edu.tw/login2.php (It should be. See the comment on step 3)
/// 4. do something...
static Future<ISchoolPlusConnectorStatus> login(String account) async {
String result;
/// 2-1. POST https://app.ntut.edu.tw/oauth2Server.do (It should be. See the comment on step 2-1)
/// 2-2. follow the redirection to https://istudy.ntut.edu.tw/login2.php (It should be. See the comment on step 2-2)
static Future<ISchoolPlusConnectorStatus> login(String account, {bool logEventToFirebase = true}) async {
try {
ConnectorParameter parameter;
html.Document tagNode;
List<html.Element> nodes;
final data = {
"apUrl": "https://istudy.ntut.edu.tw/login.php",
"apOu": "ischool_plus_oauth",
"sso": "true",
"datetime1": DateTime.now().millisecondsSinceEpoch.toString()
};
final ssoIndexResponse = await getSSOIndexResponse();
if (ssoIndexResponse.isEmpty) return ISchoolPlusConnectorStatus.loginGetSSOIndexError;

// Step 1
parameter = ConnectorParameter(_ssoLoginUrl);
parameter.data = data;
result = (await Connector.getDataByGet(parameter));
final ssoIndexTagNode = html.parse(ssoIndexResponse);
final ssoIndexNodes = ssoIndexTagNode.getElementsByTagName("input");
final ssoIndexJumpUrl = ssoIndexTagNode.getElementsByTagName("form")[0].attributes["action"];

tagNode = html.parse(result.toString().trim());
nodes = tagNode.getElementsByTagName("input");
data.clear();
for (final node in nodes) {
final Map<String, String> oauthData = {};
for (final node in ssoIndexNodes) {
final name = node.attributes['name'];
final value = node.attributes['value'];
data[name] = value;
oauthData[name] = value;
}

// Step 2
// The `jumpUrl` should be "oauth2Server.do".
// If not, it means that the school server has changed.
// TODO: Add a validation measurement to check whether Step 1 is died or not. (It should not die if auth is correct)
final jumpUrl = tagNode.getElementsByTagName("form")[0].attributes["action"];
parameter = ConnectorParameter("${NTUTConnector.host}$jumpUrl");
parameter.data = data;

Response<dynamic> jumpResult = (await Connector.getDataByPostResponse(parameter));
tagNode = html.parse(jumpResult.data.toString().trim());
nodes = tagNode.getElementsByTagName("a");

// Step 3
// The redirectUrl is provided by <a> HTML DOM on Step 2.
// It should be https://istudy.ntut.edu.tw/login2.php with lot of the parameters.
final redirectUrl = nodes.first.attributes["href"];
parameter = ConnectorParameter(redirectUrl);
await Connector.getDataByGet(parameter);

// Perform retry for cryptic API errors (?).
// If the string `connect lost` be found in the response, we will do the retry.

// [2023-10-21] We may not need this since the step was changed.
// TODO: Remove I-School retry loop since it's outdated.

int retryTimes = 3;
do {
if (jumpResult.data.toString().contains('connect lost')) {
// Take a short delay to avoid being blocked.
for (int retry = 0; retry < 3; retry++) {
// Step 2-1
// The ssoIndexJumpUrl should be "oauth2Server.do", and the response should contain redirection location.
// If not, a retry of getting redirection location will perform.
final jumpParameter = ConnectorParameter("${NTUTConnector.host}$ssoIndexJumpUrl");
jumpParameter.data = oauthData;
final jumpResult = (await Connector.getDataByPostResponse(jumpParameter));
if (jumpResult.statusCode != 302) {
log("[TAT] ischool_plus_connector.dart: failed to get redirection location from oauth2Server, retrying...");
await Future.delayed(const Duration(milliseconds: 100));
jumpResult = (await Connector.getDataByPostResponse(parameter));
} else {
break;
continue;
}
} while ((retryTimes--) > 0);
// Step 2-2
// The redirect location should be "https://istudy.ntut.edu.tw/login2.php", and the response should not contain
// "connection `lost`", if it does, a retry of getting redirection location will perform.
final login2Parameter = ConnectorParameter(jumpResult.headers['location'][0]);
final login2Result = await Connector.getDataByGet(login2Parameter);
if (login2Result.contains("lost")) {
log("[TAT] ischool_plus_connector.dart: connection lost during redirection, retrying...");
await Future.delayed(const Duration(milliseconds: 100));
continue;
}
return ISchoolPlusConnectorStatus.loginSuccess;
}

await FirebaseAnalytics.instance.logLogin(
loginMethod: 'ntut_iplus',
);
return ISchoolPlusConnectorStatus.loginSuccess;
if (logEventToFirebase) {
await FirebaseAnalytics.instance.logLogin(
loginMethod: 'ntut_iplus',
);
}
return ISchoolPlusConnectorStatus.loginRedirectionError;
} catch (e, stack) {
Log.eWithStack(e.toString(), stack);
return ISchoolPlusConnectorStatus.loginFail;
rethrow;
}
}

static Future<String> getSSOIndexResponse() async {
final data = {"apOu": "ischool_plus_oauth", "datetime1": DateTime.now().millisecondsSinceEpoch.toString()};
for (int retry = 0; retry < 5; retry++) {
final parameter = ConnectorParameter(_ssoLoginUrl);
parameter.data = data;

final response = (await Connector.getDataByGet(parameter)).toString().trim();
if (response.contains("ssoForm")) return response;
log("[TAT] ischool_plus_connector.dart: failed to get ssoForm, retrying...");
await Future.delayed(const Duration(milliseconds: 100));
}
return "";
}

static Future<ReturnWithStatus<List<CourseStudent>>> getCourseStudent(String courseId) async {
Expand Down
10 changes: 8 additions & 2 deletions lib/src/task/iplus/iplus_system_task.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,14 @@ class IPlusSystemTask<T> extends NTUTTask<T> {
final value = await ISchoolPlusConnector.login(studentId);
super.onEnd();

if (value != ISchoolPlusConnectorStatus.loginSuccess) {
return onError(R.current.loginISchoolPlusError);
//TODO: generate string for this
switch (value) {
case ISchoolPlusConnectorStatus.loginGetSSOIndexError:
return onError("ischool login get SSO index error");
case ISchoolPlusConnectorStatus.loginRedirectionError:
return onError("ischool login redirection error");
default:
break;
}
}
return status;
Expand Down
62 changes: 62 additions & 0 deletions test/ischool_plus_connector_test/ischool_plus_connector_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// TODO: remove sdk version selector after migrating to null-safety.
// @dart=2.10
import 'dart:io';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:flutter_app/src/connector/blocked_cookies.dart';
import 'package:flutter_app/src/connector/core/dio_connector.dart';
import 'package:flutter_app/src/connector/interceptors/request_interceptor.dart';
import 'package:flutter_app/src/connector/ischool_plus_connector.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:path/path.dart';
import 'package:tat_core/tat_core.dart';
import 'dart:developer' as dev;
import 'dart:convert';

Future<void> main() async {
final tempDir = await Directory.systemTemp.createTemp();

final appDocDir = join(tempDir.path, '.cookies');
final CookieJar cookieJar = PersistCookieJar(storage: FileStorage('$appDocDir/.cookies'));
Get.put(cookieJar);
final apiInterceptors = [
ResponseCookieFilter(blockedCookieNamePatterns: blockedCookieNamePatterns),
CookieManager(cookieJar),
RequestInterceptors(),
];
await DioConnector.instance.init(interceptors: apiInterceptors);
final schoolApiService = SchoolApiService(interceptors: apiInterceptors);

final simpleLoginRepository = SimpleLoginRepository(apiService: schoolApiService);
// final checkSessionRepository = CheckSessionRepository(apiService: schoolApiService);

final simpleLoginUseCase = SimpleLoginUseCase(simpleLoginRepository);
// final checkSessionIsAliveUseCase = CheckSessionUseCase(checkSessionRepository);

const credentialFilePath = 'test/ischool_plus_connector_test/credential.json';
final file = File(credentialFilePath);
final json = jsonDecode(await file.readAsString());
final userId = json['userId'];
final password = json['password'];

dev.log('userId: $userId');
dev.log('password: $password');

final Stopwatch stopwatch = Stopwatch()..start();
test('ntut_login', () async {
final loginCredential = LoginCredential(userId: userId, password: password);
final loginResult = await simpleLoginUseCase(credential: loginCredential);
expect(loginResult.isSuccess, isTrue);
dev.log('ntut login Done Test execution time: ${stopwatch.elapsed}');

// final isCurrentSessionAlive = await checkSessionIsAliveUseCase();
// expect(isCurrentSessionAlive, isTrue);
});
test('ischool_login', () async {
final result = await ISchoolPlusConnector.login(userId, logEventToFirebase: false);
expect(result, ISchoolPlusConnectorStatus.loginSuccess);
dev.log('ischool login Done Test execution time: ${stopwatch.elapsed}');
stopwatch.stop();
});
}

0 comments on commit 3bf8666

Please sign in to comment.