diff --git a/mobile/lib/app/hepan_app.dart b/mobile/lib/app/hepan_app.dart index f20dcb3..bb485d6 100644 --- a/mobile/lib/app/hepan_app.dart +++ b/mobile/lib/app/hepan_app.dart @@ -5,24 +5,36 @@ import 'app_theme.dart'; import '../widgets/glass_dock.dart'; import '../features/timetable/timetable_page.dart'; import '../features/profile/profile_page.dart'; +import '../features/school/school_session.dart'; class HepanApp extends StatelessWidget { - const HepanApp({super.key, required this.controller, this.schoolBuilder}); + const HepanApp({ + super.key, + required this.controller, + this.schoolBuilder, + this.school, + }); final AppController controller; final Widget Function()? schoolBuilder; + final SchoolSession? school; @override Widget build(BuildContext context) => MaterialApp( debugShowCheckedModeBanner: false, title: '河畔Everyday', theme: hepanTheme(), - home: _Shell(controller: controller, schoolBuilder: schoolBuilder), + home: _Shell( + controller: controller, + schoolBuilder: schoolBuilder, + school: school, + ), ); } class _Shell extends StatefulWidget { - const _Shell({required this.controller, this.schoolBuilder}); + const _Shell({required this.controller, this.schoolBuilder, this.school}); final AppController controller; final Widget Function()? schoolBuilder; + final SchoolSession? school; @override State<_Shell> createState() => _ShellState(); } @@ -71,9 +83,41 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { } } + Future _logout() async { + final choice = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('退出学校登录'), + content: const Text('已导入的课表是否保留在手机中?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, 0), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(context, 1), + child: const Text('保留课表'), + ), + TextButton( + onPressed: () => Navigator.pop(context, 2), + child: const Text('清除课表'), + ), + ], + ), + ); + if (choice == null || choice == 0) return; + await widget.school?.logout(); + if (choice == 2) { + await widget.controller.clearCourses(); + } + } + @override Widget build(BuildContext context) => AnimatedBuilder( - animation: widget.controller, + animation: Listenable.merge([ + widget.controller, + if (widget.school != null) widget.school!, + ]), builder: (context, _) => Scaffold( body: Container( decoration: const BoxDecoration( @@ -118,6 +162,10 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { ProfilePage( controller: widget.controller, onLogin: _school, + authStatus: widget.school?.statusLabel ?? '未登录', + onLogout: widget.school?.hasOpened == true + ? _logout + : null, ), ], ), diff --git a/mobile/lib/features/school/school_auth_page.dart b/mobile/lib/features/school/school_auth_page.dart new file mode 100644 index 0000000..b3780f3 --- /dev/null +++ b/mobile/lib/features/school/school_auth_page.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +import '../../app/app_controller.dart'; +import '../../app/app_theme.dart'; +import '../profile/import_preview.dart'; +import 'school_session.dart'; + +class SchoolAuthPage extends StatefulWidget { + const SchoolAuthPage({ + super.key, + required this.session, + required this.controller, + }); + final SchoolSession session; + final AppController controller; + @override + State createState() => _SchoolAuthPageState(); +} + +class _SchoolAuthPageState extends State { + final _username = TextEditingController(), + _password = TextEditingController(); + bool _showWeb = false, _previewing = false; + @override + void initState() { + super.initState(); + widget.session.addListener(_changed); + if (!widget.session.hasOpened) { + widget.session.open(); + } + } + + void _changed() { + if (!mounted) return; + setState(() {}); + final draft = widget.session.pendingDraft; + if (draft != null && !_previewing) { + _previewing = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => + ImportPreview(draft: draft, controller: widget.controller), + ); + widget.session.pendingDraft = null; + _previewing = false; + }); + } + } + + @override + void dispose() { + widget.session.removeListener(_changed); + _password.clear(); + _username.clear(); + _password.dispose(); + _username.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_username.text.trim().isEmpty || _password.text.isEmpty) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('请输入学号和密码'))); + return; + } + try { + await widget.session.submitCredentials( + _username.text.trim(), + _password.text, + ); + } finally { + _password.clear(); + if (mounted) { + setState(() => _showWeb = true); + } + } + } + + @override + Widget build(BuildContext context) { + final s = widget.session; + return Scaffold( + appBar: AppBar( + title: const Text( + '学校认证', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700), + ), + actions: [ + IconButton( + tooltip: _showWeb ? '河畔登录界面' : '学校页面', + onPressed: () => setState(() => _showWeb = !_showWeb), + icon: Icon( + _showWeb ? Icons.person_outline : Icons.language_rounded, + ), + ), + IconButton( + tooltip: '打开学校课表', + onPressed: () { + setState(() => _showWeb = true); + s.openTimetable(); + }, + icon: const Icon(Icons.calendar_month_outlined), + ), + ], + ), + body: Column( + children: [ + if (s.busy) const LinearProgressIndicator(minHeight: 2), + Container( + width: double.infinity, + padding: const EdgeInsets.all(13), + color: river.withValues(alpha: 0.07), + child: Text( + s.message, + style: const TextStyle(fontSize: 12, height: 1.5, color: ink), + ), + ), + Expanded( + child: _showWeb + ? WebViewWidget(controller: s.web) + : ListView( + padding: const EdgeInsets.all(24), + children: [ + const SizedBox(height: 20), + const Icon(Icons.school_outlined, size: 48, color: river), + const SizedBox(height: 20), + const Text( + '登录,连接你的每一天。', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 23, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + Text( + '电子科技大学统一身份认证', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: ink.withValues(alpha: 0.5), + ), + ), + const SizedBox(height: 30), + SurfaceCard( + child: Column( + children: [ + TextField( + controller: _username, + textInputAction: TextInputAction.next, + autocorrect: false, + decoration: const InputDecoration( + labelText: '学号/工号', + prefixIcon: Icon(Icons.person_outline), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _password, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: const InputDecoration( + labelText: '学校密码', + prefixIcon: Icon(Icons.lock_outline), + ), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: !s.busy && s.formReady + ? _submit + : null, + child: Text(s.busy ? '正在准备学校认证…' : '登录并打开课表'), + ), + ), + const SizedBox(height: 10), + TextButton( + onPressed: () { + _password.clear(); + setState(() => _showWeb = true); + s.openAuthentication(); + }, + child: const Text('使用学校页面登录'), + ), + ], + ), + ), + const SizedBox(height: 20), + Text( + '密码仅交给学校处理,不在河畔保存。需要验证码或其他验证时,将由学校页面继续处理。', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + height: 1.7, + color: ink.withValues(alpha: 0.45), + ), + ), + const SizedBox(height: 14), + Text( + '学校课表识别待登录后联调;也可通过「我的」导入课表文件。', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + height: 1.6, + color: ink.withValues(alpha: 0.45), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/school/school_navigation_policy.dart b/mobile/lib/features/school/school_navigation_policy.dart new file mode 100644 index 0000000..909e78a --- /dev/null +++ b/mobile/lib/features/school/school_navigation_policy.dart @@ -0,0 +1,19 @@ +class SchoolNavigationPolicy { + static bool canNavigate(Uri uri, {required bool isMainFrame}) => isMainFrame + ? isSchoolPage(uri) + : (uri.scheme == 'https' && uri.userInfo.isEmpty) || + (uri.scheme == 'about' && uri.path == 'blank'); + static bool _https(Uri uri) => + uri.scheme == 'https' && uri.port == 443 && uri.userInfo.isEmpty; + static bool canInjectCredentials(Uri uri) => + _https(uri) && + uri.host == 'idas.uestc.edu.cn' && + uri.path == '/authserver/login'; + static bool isTimetable(Uri uri) => + _https(uri) && + uri.host == 'yjsjy.uestc.edu.cn' && + uri.path == '/pyxx/pygl/xskbcx/index/1'; + static bool isSchoolPage(Uri uri) => + _https(uri) && + const {'idas.uestc.edu.cn', 'yjsjy.uestc.edu.cn'}.contains(uri.host); +} diff --git a/mobile/lib/features/school/school_page_adapter.dart b/mobile/lib/features/school/school_page_adapter.dart new file mode 100644 index 0000000..267ad7e --- /dev/null +++ b/mobile/lib/features/school/school_page_adapter.dart @@ -0,0 +1,36 @@ +import '../../data/json_schedule_import.dart'; +import 'school_navigation_policy.dart'; + +class UnsupportedSchoolLayout implements Exception { + @override + String toString() => '学校课表识别待联调,当前课表保持不变。'; +} + +class SchoolPageAdapter { + ImportDraft extract(String html, Uri source) { + // A real authenticated timetable fixture is required before registering a parser. + // Unknown HTML must never erase the student's last valid imported timetable. + throw UnsupportedSchoolLayout(); + } +} + +class SchoolReader { + SchoolReader(this.adapter); + final SchoolPageAdapter adapter; + Future? _pending; + bool get busy => _pending != null; + Future read(Uri origin, Future Function() getHtml) { + if (_pending != null) return _pending!; + _pending = Future(() async { + try { + if (!SchoolNavigationPolicy.isTimetable(origin)) { + throw UnsupportedSchoolLayout(); + } + return adapter.extract(await getHtml(), origin); + } finally { + _pending = null; + } + }); + return _pending!; + } +} diff --git a/mobile/lib/features/school/school_session.dart b/mobile/lib/features/school/school_session.dart new file mode 100644 index 0000000..26771dd --- /dev/null +++ b/mobile/lib/features/school/school_session.dart @@ -0,0 +1,254 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +import '../../data/json_schedule_import.dart'; +import 'school_navigation_policy.dart'; +import 'school_page_adapter.dart'; + +enum SchoolConnectionState { + signedOut, + authenticating, + connected, + expired, + unknown, +} + +class SchoolSession extends ChangeNotifier { + SchoolSession() { + web = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onNavigationRequest: (request) { + final uri = Uri.tryParse(request.url); + if (uri == null || + !SchoolNavigationPolicy.canNavigate( + uri, + isMainFrame: request.isMainFrame, + )) { + message = '学校跳转到了其他地址,已停止自动交接登录信息。'; + notifyListeners(); + return NavigationDecision.prevent; + } + return NavigationDecision.navigate; + }, + onPageStarted: (_) { + if (!hasOpened) { + return; + } + _loading = true; + formReady = false; + notifyListeners(); + }, + onPageFinished: _loaded, + onWebResourceError: (error) { + if (error.isForMainFrame == true) { + _loading = false; + message = '学校页面暂时未能加载,请检查网络或使用统一认证入口。'; + notifyListeners(); + } + }, + onHttpError: (error) async { + final current = Uri.tryParse(await web.currentUrl() ?? ''); + if (!hasOpened || + error.request == null || + error.request!.uri != current) { + return; + } + _loading = false; + message = '学校页面请求未完成,可稍后重试。'; + notifyListeners(); + }, + ), + ); + } + late final WebViewController web; + final _reader = SchoolReader(SchoolPageAdapter()); + SchoolConnectionState connectionState = SchoolConnectionState.signedOut; + bool _loading = false, + _submitting = false, + hasOpened = false, + formReady = false; + bool get busy => _loading || _submitting || _reader.busy; + ImportDraft? pendingDraft; + String message = '请通过学校统一身份认证登录。'; + String get statusLabel => switch (connectionState) { + SchoolConnectionState.signedOut => '未登录', + SchoolConnectionState.authenticating => '请在学校页面完成认证', + SchoolConnectionState.connected => '学校认证已确认', + SchoolConnectionState.expired => '学校登录已过期', + SchoolConnectionState.unknown => '学校页面已打开 · 课表识别待联调', + }; + static final authUrl = Uri.parse( + 'https://idas.uestc.edu.cn/authserver/login', + ); + static final timetableUrl = Uri.parse( + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1', + ); + Future open() => openTimetable(); + Future openTimetable() async { + hasOpened = true; + _loading = true; + pendingDraft = null; + message = '正在打开研究生课表页面…'; + notifyListeners(); + try { + await web.loadRequest(timetableUrl); + } catch (_) { + _loading = false; + message = '学校课表页面无法打开,请检查校园网络。'; + notifyListeners(); + } + } + + Future openAuthentication() async { + hasOpened = true; + _loading = true; + message = '正在准备学校认证…'; + notifyListeners(); + try { + await web.loadRequest(authUrl); + } catch (_) { + _loading = false; + message = '学校认证页面暂时不可用。'; + notifyListeners(); + } + } + + static const _readyScript = """(() => { + if (location.origin !== 'https://idas.uestc.edu.cn' || location.pathname !== '/authserver/login') return false; + const f = document.querySelector('.login-main #pwdFromId'); + if (!f || f.method.toLowerCase() !== 'post') return false; + const action = new URL(f.action, location.href); + return action.origin === location.origin && action.pathname === '/authserver/login' + && !!f.querySelector('#username') && !!f.querySelector('#password') + && !!f.querySelector('#saltPassword') && !!f.querySelector('#pwdEncryptSalt') + && !!f.querySelector('#login_submit') && typeof window.startLogin === 'function' + && typeof window.encryptPassword === 'function' && typeof window.checkUserCaptcha === 'function'; + })()"""; + bool _truth(Object value) => value == true || value.toString() == 'true'; + Future _loaded(String url) async { + if (!hasOpened) { + return; + } + _loading = false; + final uri = Uri.tryParse(url); + if (uri != null && SchoolNavigationPolicy.canInjectCredentials(uri)) { + connectionState = connectionState == SchoolConnectionState.connected + ? SchoolConnectionState.expired + : SchoolConnectionState.authenticating; + try { + formReady = _truth( + await web.runJavaScriptReturningResult(_readyScript), + ); + } catch (_) { + formReady = false; + } + message = formReady ? '可以使用河畔界面登录;验证码会在学校页面完成。' : '请使用学校原页面完成认证。'; + } else if (uri != null && SchoolNavigationPolicy.isTimetable(uri)) { + connectionState = SchoolConnectionState.unknown; + message = '已打开课表页面,正在核对课程结构。'; + try { + pendingDraft = await readTimetable(); + connectionState = SchoolConnectionState.connected; + } catch (error) { + message = error is UnsupportedSchoolLayout + ? error.toString() + : '课表读取未完成,已有课表保持不变。'; + } + } else { + connectionState = SchoolConnectionState.unknown; + message = '可点击右上角日历打开学校课表。'; + } + notifyListeners(); + } + + Future submitCredentials(String username, String password) async { + if (busy) return; + _submitting = true; + message = '正在交接学校认证…'; + notifyListeners(); + try { + final url = Uri.tryParse(await web.currentUrl() ?? ''); + if (url == null || + !SchoolNavigationPolicy.canInjectCredentials(url) || + !_truth(await web.runJavaScriptReturningResult(_readyScript))) { + throw StateError('学校表单尚未准备好,请使用学校页面登录'); + } + final result = await web.runJavaScriptReturningResult('''(() => { + if (!($_readyScript)) return false; + const f = document.querySelector('.login-main #pwdFromId'); + const u = f.querySelector('#username'), p = f.querySelector('#password'); + u.value = ${jsonEncode(username)}; p.value = ${jsonEncode(password)}; + p.removeAttribute('readonly'); + u.dispatchEvent(new Event('input', {bubbles:true})); + p.dispatchEvent(new Event('input', {bubbles:true})); + window.checkUserCaptcha(); return true; + })()'''); + if (!_truth(result)) throw StateError('学校表单已变化,请使用学校页面登录'); + var settled = false; + for (var i = 0; i < 20; i++) { + if (_truth( + await web.runJavaScriptReturningResult( + '!!window.jQuery && window.jQuery.active === 0', + ), + )) { + settled = true; + break; + } + await Future.delayed(const Duration(milliseconds: 200)); + } + final current = Uri.tryParse(await web.currentUrl() ?? ''); + if (!settled || + current == null || + !SchoolNavigationPolicy.canInjectCredentials(current)) { + message = '请在学校页面继续完成验证。'; + return; + } + await web.runJavaScript('''(() => { + if (!($_readyScript)) return; + window.startLogin(document.querySelector('.login-main #pwdFromId #login_submit')); + })()'''); + message = '已交给学校处理;如有验证码,请在学校页面完成。'; + } catch (_) { + message = '登录交接未完成,请使用学校页面继续。'; + } finally { + _submitting = false; + notifyListeners(); + } + } + + Future readTimetable() async { + final uri = Uri.tryParse(await web.currentUrl() ?? ''); + if (uri == null) throw UnsupportedSchoolLayout(); + return _reader.read(uri, () async { + final raw = await web.runJavaScriptReturningResult( + 'JSON.stringify(document.documentElement.outerHTML)', + ); + final value = raw is String ? raw : raw.toString(); + try { + final decoded = jsonDecode(value); + return decoded is String ? decoded : value; + } catch (_) { + return value; + } + }); + } + + Future logout() async { + hasOpened = false; + await WebViewCookieManager().clearCookies(); + await web.clearCache(); + await web.clearLocalStorage(); + await web.loadHtmlString(''); + _loading = false; + hasOpened = false; + pendingDraft = null; + formReady = false; + connectionState = SchoolConnectionState.signedOut; + message = '已退出学校登录。'; + notifyListeners(); + } +} diff --git a/mobile/test/fixtures/school/README.md b/mobile/test/fixtures/school/README.md new file mode 100644 index 0000000..33f2436 --- /dev/null +++ b/mobile/test/fixtures/school/README.md @@ -0,0 +1,7 @@ +# 学校页面验证状态 + +当前没有登录后的研究生课表样本,也没有以人工构造表格冒充学校样本。 + +2026-10-12 已核对公开认证页面:密码表单位于 `.login-main #pwdFromId`,使用 username/passwordText 输入、saltPassword 隐藏加密字段、pwdEncryptSalt 与 startLogin/checkForm/encryptPassword 的学校原流程。自定义界面只填充此经过运行时检查的表单,并等待学校验证码检查结束后触发学校登录按钮。表单不匹配时回到学校页面。 + +实际账号认证、验证码、研究生 SSO 跳转与课表识别还需本人设备登录验证。无需在聊天中提供账号密码。SchoolPageAdapter 当前拒绝全部未验证布局,保持已导入的离线课表不变。 diff --git a/mobile/test/school/navigation_policy_test.dart b/mobile/test/school/navigation_policy_test.dart new file mode 100644 index 0000000..3dc404a --- /dev/null +++ b/mobile/test/school/navigation_policy_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/features/school/school_navigation_policy.dart'; + +void main() { + test('secure captcha frames are allowed without authorizing credential injection', () { + final captcha = Uri.parse('https://captcha.example.test/challenge'); + expect( + SchoolNavigationPolicy.canNavigate(captcha, isMainFrame: false), + isTrue, + ); + expect( + SchoolNavigationPolicy.canNavigate(captcha, isMainFrame: true), + isFalse, + ); + expect(SchoolNavigationPolicy.canInjectCredentials(captcha), isFalse); + }); + test('credentials stay on the exact school HTTPS login endpoint', () { + expect( + SchoolNavigationPolicy.canInjectCredentials( + Uri.parse('https://idas.uestc.edu.cn/authserver/login?service=school'), + ), + isTrue, + ); + for (final value in [ + 'http://idas.uestc.edu.cn/authserver/login', + 'https://idas.uestc.edu.cn.evil.test/authserver/login', + 'https://user@idas.uestc.edu.cn/authserver/login', + 'https://idas.uestc.edu.cn:444/authserver/login', + 'https://idas.uestc.edu.cn/other', + ]) { + expect( + SchoolNavigationPolicy.canInjectCredentials(Uri.parse(value)), + isFalse, + reason: value, + ); + } + }); + test('reading is limited to the supplied timetable path', () { + expect( + SchoolNavigationPolicy.isTimetable( + Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1'), + ), + isTrue, + ); + expect( + SchoolNavigationPolicy.isTimetable( + Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/login'), + ), + isFalse, + ); + expect( + SchoolNavigationPolicy.isSchoolPage(Uri.parse('javascript:alert(1)')), + isFalse, + ); + }); +} diff --git a/mobile/test/school/school_page_adapter_test.dart b/mobile/test/school/school_page_adapter_test.dart new file mode 100644 index 0000000..7aea85d --- /dev/null +++ b/mobile/test/school/school_page_adapter_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'dart:async'; + +import 'package:hepan_everyday/features/school/school_page_adapter.dart'; + +void main() { + test( + 'a rejected origin does not poison later legitimate page reading', + () async { + final reader = SchoolReader(SchoolPageAdapter()); + await expectLater( + reader.read( + Uri.parse('https://idas.uestc.edu.cn/authserver/login'), + () async => 'x', + ), + throwsA(isA()), + ); + var reads = 0; + await expectLater( + reader.read( + Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1'), + () async { + reads++; + return 'unknown'; + }, + ), + throwsA(isA()), + ); + expect(reads, 1); + }, + ); + test('concurrent timetable reading obtains page content once', () async { + final reader = SchoolReader(SchoolPageAdapter()); + final body = Completer(); + var reads = 0; + Future getHtml() { + reads++; + return body.future; + } + + final target = Uri.parse( + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1', + ); + final a = reader.read(target, getHtml), b = reader.read(target, getHtml); + final checks = Future.wait([ + expectLater(a, throwsA(isA())), + expectLater(b, throwsA(isA())), + ]); + body.complete('unknown'); + await checks; + expect(reads, 1); + }); + test('unknown school markup never becomes an empty successful import', () { + final adapter = SchoolPageAdapter(); + final target = Uri.parse( + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1', + ); + for (final html in [ + '', + '验证码', + '
课程名称
', + ]) { + expect( + () => adapter.extract(html, target), + throwsA(isA()), + ); + } + }); +}