feat: add school authentication entry with guarded import
This commit is contained in:
@@ -5,24 +5,36 @@ import 'app_theme.dart';
|
|||||||
import '../widgets/glass_dock.dart';
|
import '../widgets/glass_dock.dart';
|
||||||
import '../features/timetable/timetable_page.dart';
|
import '../features/timetable/timetable_page.dart';
|
||||||
import '../features/profile/profile_page.dart';
|
import '../features/profile/profile_page.dart';
|
||||||
|
import '../features/school/school_session.dart';
|
||||||
|
|
||||||
class HepanApp extends StatelessWidget {
|
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 AppController controller;
|
||||||
final Widget Function()? schoolBuilder;
|
final Widget Function()? schoolBuilder;
|
||||||
|
final SchoolSession? school;
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => MaterialApp(
|
Widget build(BuildContext context) => MaterialApp(
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
title: '河畔Everyday',
|
title: '河畔Everyday',
|
||||||
theme: hepanTheme(),
|
theme: hepanTheme(),
|
||||||
home: _Shell(controller: controller, schoolBuilder: schoolBuilder),
|
home: _Shell(
|
||||||
|
controller: controller,
|
||||||
|
schoolBuilder: schoolBuilder,
|
||||||
|
school: school,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Shell extends StatefulWidget {
|
class _Shell extends StatefulWidget {
|
||||||
const _Shell({required this.controller, this.schoolBuilder});
|
const _Shell({required this.controller, this.schoolBuilder, this.school});
|
||||||
final AppController controller;
|
final AppController controller;
|
||||||
final Widget Function()? schoolBuilder;
|
final Widget Function()? schoolBuilder;
|
||||||
|
final SchoolSession? school;
|
||||||
@override
|
@override
|
||||||
State<_Shell> createState() => _ShellState();
|
State<_Shell> createState() => _ShellState();
|
||||||
}
|
}
|
||||||
@@ -71,9 +83,41 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
|
final choice = await showDialog<int>(
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) => AnimatedBuilder(
|
Widget build(BuildContext context) => AnimatedBuilder(
|
||||||
animation: widget.controller,
|
animation: Listenable.merge([
|
||||||
|
widget.controller,
|
||||||
|
if (widget.school != null) widget.school!,
|
||||||
|
]),
|
||||||
builder: (context, _) => Scaffold(
|
builder: (context, _) => Scaffold(
|
||||||
body: Container(
|
body: Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
@@ -118,6 +162,10 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
|||||||
ProfilePage(
|
ProfilePage(
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
onLogin: _school,
|
onLogin: _school,
|
||||||
|
authStatus: widget.school?.statusLabel ?? '未登录',
|
||||||
|
onLogout: widget.school?.hasOpened == true
|
||||||
|
? _logout
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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<SchoolAuthPage> createState() => _SchoolAuthPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SchoolAuthPageState extends State<SchoolAuthPage> {
|
||||||
|
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<bool>(
|
||||||
|
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<void> _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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<ImportDraft>? _pending;
|
||||||
|
bool get busy => _pending != null;
|
||||||
|
Future<ImportDraft> read(Uri origin, Future<String> Function() getHtml) {
|
||||||
|
if (_pending != null) return _pending!;
|
||||||
|
_pending = Future<ImportDraft>(() async {
|
||||||
|
try {
|
||||||
|
if (!SchoolNavigationPolicy.isTimetable(origin)) {
|
||||||
|
throw UnsupportedSchoolLayout();
|
||||||
|
}
|
||||||
|
return adapter.extract(await getHtml(), origin);
|
||||||
|
} finally {
|
||||||
|
_pending = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return _pending!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> open() => openTimetable();
|
||||||
|
Future<void> openTimetable() async {
|
||||||
|
hasOpened = true;
|
||||||
|
_loading = true;
|
||||||
|
pendingDraft = null;
|
||||||
|
message = '正在打开研究生课表页面…';
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
await web.loadRequest(timetableUrl);
|
||||||
|
} catch (_) {
|
||||||
|
_loading = false;
|
||||||
|
message = '学校课表页面无法打开,请检查校园网络。';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> _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<void> 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<void>.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<ImportDraft> 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<void> logout() async {
|
||||||
|
hasOpened = false;
|
||||||
|
await WebViewCookieManager().clearCookies();
|
||||||
|
await web.clearCache();
|
||||||
|
await web.clearLocalStorage();
|
||||||
|
await web.loadHtmlString('<html></html>');
|
||||||
|
_loading = false;
|
||||||
|
hasOpened = false;
|
||||||
|
pendingDraft = null;
|
||||||
|
formReady = false;
|
||||||
|
connectionState = SchoolConnectionState.signedOut;
|
||||||
|
message = '已退出学校登录。';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# 学校页面验证状态
|
||||||
|
|
||||||
|
当前没有登录后的研究生课表样本,也没有以人工构造表格冒充学校样本。
|
||||||
|
|
||||||
|
2026-10-12 已核对公开认证页面:密码表单位于 `.login-main #pwdFromId`,使用 username/passwordText 输入、saltPassword 隐藏加密字段、pwdEncryptSalt 与 startLogin/checkForm/encryptPassword 的学校原流程。自定义界面只填充此经过运行时检查的表单,并等待学校验证码检查结束后触发学校登录按钮。表单不匹配时回到学校页面。
|
||||||
|
|
||||||
|
实际账号认证、验证码、研究生 SSO 跳转与课表识别还需本人设备登录验证。无需在聊天中提供账号密码。SchoolPageAdapter 当前拒绝全部未验证布局,保持已导入的离线课表不变。
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<UnsupportedSchoolLayout>()),
|
||||||
|
);
|
||||||
|
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<UnsupportedSchoolLayout>()),
|
||||||
|
);
|
||||||
|
expect(reads, 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('concurrent timetable reading obtains page content once', () async {
|
||||||
|
final reader = SchoolReader(SchoolPageAdapter());
|
||||||
|
final body = Completer<String>();
|
||||||
|
var reads = 0;
|
||||||
|
Future<String> 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<UnsupportedSchoolLayout>())),
|
||||||
|
expectLater(b, throwsA(isA<UnsupportedSchoolLayout>())),
|
||||||
|
]);
|
||||||
|
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 [
|
||||||
|
'',
|
||||||
|
'<html>验证码</html>',
|
||||||
|
'<table><tr><td>课程名称</td></tr></table>',
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
() => adapter.extract(html, target),
|
||||||
|
throwsA(isA<UnsupportedSchoolLayout>()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user