-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathportal_service.dart
More file actions
296 lines (260 loc) · 10.1 KB
/
portal_service.dart
File metadata and controls
296 lines (260 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio_redirect_interceptor/dio_redirect_interceptor.dart';
import 'package:html/parser.dart';
import 'package:http_parser/http_parser.dart';
import 'package:riverpod/riverpod.dart';
import 'package:tattoo/utils/http.dart';
/// Represents a logged-in NTUT Portal user.
typedef UserDto = ({
/// User's display name from NTUT Portal (givenName).
String? name,
/// Filename of the user's profile photo (e.g., "111360109_temp1714460935341.jpeg").
String? avatarFilename,
/// User's NTUT email address (e.g., "t111360109@ntut.edu.tw").
String? email,
/// Number of days until the password expires.
///
/// When non-null, indicates the user should change their password soon.
/// The value corresponds to the `passwordExpiredRemind` field from the login API.
/// Null if there is no expiration warning.
int? passwordExpiresInDays,
});
// dart format off
/// Identification codes for NTUT services used in SSO authentication.
///
/// These codes are passed to [PortalService.sso] to authenticate with
/// different NTUT web services.
enum PortalServiceCode {
studentQueryService('sa_003_oauth'),
courseService('aa_0010-oauth'),
iSchoolPlusService('ischool_plus_oauth');
final String code;
const PortalServiceCode(this.code);
}
// dart format on
/// Provides the singleton [PortalService] instance.
final portalServiceProvider = Provider<PortalService>((ref) => PortalService());
/// Service for authenticating with NTUT Portal and performing SSO.
///
/// This service handles:
/// - User authentication (login/logout)
/// - Session management
/// - Single sign-on (SSO) to other NTUT services
/// - User profile and avatar retrieval
///
/// All HTTP clients in the application share a single cookie jar, so logging in
/// through this service provides authentication for all other services after
/// calling [sso] for each required service.
class PortalService {
late final Dio _portalDio;
/// The portal Dio instance, for use by services that share the portal host
/// (e.g., [CalendarService]).
Dio get portalDio => _portalDio;
PortalService() {
// Emulate the NTUT iOS app's HTTP client
_portalDio = createDio()
..options.baseUrl = 'https://app.ntut.edu.tw/'
..options.headers = {
'User-Agent': 'Direk ios App',
// Prevent keep-alive connection reuse — NTUT servers close their end
// after multipart uploads, causing stale connection errors.
'Connection': 'close',
};
}
/// Authenticates a user with NTUT Portal credentials.
///
/// Sets the JSESSIONID cookie in the app.ntut.edu.tw domain for subsequent
/// authenticated requests. This session cookie is shared across all services.
///
/// Returns user profile information including name, email, and avatar filename.
///
/// Throws an [Exception] if login fails due to invalid credentials.
Future<UserDto> login(String username, String password) async {
final response = await _portalDio.post(
'login.do',
queryParameters: {'muid': username, 'mpassword': password},
);
final body = jsonDecode(response.data);
if (!body['success']) {
throw Exception('Login failed. Please check your credentials.');
}
final String? passwordExpiredRemind = body['passwordExpiredRemind'];
// Normalize empty strings to null for consistency
String? normalizeEmpty(String? value) =>
value?.isNotEmpty == true ? value : null;
return (
name: normalizeEmpty(body['givenName']),
avatarFilename: normalizeEmpty(body['userPhoto']),
email: normalizeEmpty(body['userMail']),
passwordExpiresInDays: passwordExpiredRemind != null
? int.tryParse(passwordExpiredRemind)
: null,
);
}
/// Changes the user's NTUT Portal password.
///
/// Requires an active session (call [login] first).
///
/// Throws an [Exception] if the password change fails (e.g., incorrect
/// current password or the new password doesn't meet requirements).
Future<void> changePassword(
String currentPassword,
String newPassword,
) async {
final response = await _portalDio.post(
'passwordMdy.do',
queryParameters: {
"oldPassword": currentPassword,
"userPassword": newPassword,
"pwdForceMdy": "profile",
},
);
final body = jsonDecode(response.data);
// API returns "success": "false" on failure (note the string "false")
if (body['success'] != true) {
throw Exception(
body['returnMsg'] ?? 'Password change failed. Please try again.',
);
}
}
/// Downloads a user's avatar from NTUT Portal.
///
/// If [filename] is omitted or empty, the server returns a dynamically
/// generated placeholder avatar (a colored square with the user's name).
///
/// Returns the avatar image as raw bytes.
Future<Uint8List> getAvatar([String? filename]) async {
final response = await _portalDio.get(
'photoView.do',
queryParameters: {'realname': filename ?? ''},
options: Options(responseType: ResponseType.bytes),
);
final contentType = response.headers.value('content-type') ?? '';
final mediaType = MediaType.parse(contentType);
if (mediaType.type != 'image') {
throw FormatException(
'Expected image response, got Content-Type: $contentType',
);
}
return response.data;
}
/// Uploads a new profile photo to NTUT Portal, replacing the current one.
///
/// [oldFilename] should be the current avatar filename
/// (from [UserDto.avatarFilename], or empty string if none).
///
/// Returns the new avatar filename assigned by the server.
Future<String> uploadAvatar(Uint8List imageBytes, String? oldFilename) async {
final response = await _portalDio.post(
'photoUpload.do',
queryParameters: {
'uploadQuota': '20', // max file size in MB
// current avatar filename for server-side cleanup
'ldapPhoto': oldFilename ?? '',
},
data: FormData.fromMap({
'file[]': MultipartFile.fromBytes(
imageBytes,
filename: 'avatar.jpg', // required by server
contentType: DioMediaType('application', 'octet-stream'),
),
}),
);
final body = jsonDecode(response.data);
return body['ldapPhoto'];
}
/// Performs single sign-on (SSO) to authenticate with a target NTUT service.
///
/// This method must be called after [login] to obtain session cookies for
/// other NTUT services (Course Service, Score Service, or I-School Plus).
///
/// The SSO process:
/// 1. Fetches an SSO form from Portal with the service code
/// 2. Submits the form to the target service
/// 3. Sets the necessary authentication cookies for that service
///
/// All services share the same cookie jar, so SSO only needs to be called once
/// per service during a session.
///
/// Throws an [Exception] if the SSO form is not found (user may not be logged in).
Future<void> sso(PortalServiceCode serviceCode) async {
final (actionUrl, formData) = await _fetchSsoForm(serviceCode.code);
// Prepend the invalid cookie filter interceptor for i-School Plus SSO
if (serviceCode == PortalServiceCode.iSchoolPlusService) {
_portalDio.interceptors.insert(0, InvalidCookieFilter());
_portalDio.transformer = PlainTextTransformer();
}
// Submit the SSO form and follow redirects
// Sets the necessary cookies for the target service
await _portalDio.post(
actionUrl,
data: formData,
options: Options(contentType: Headers.formUrlEncodedContentType),
);
}
/// Returns a URL that authenticates the user with a target NTUT service
/// via OAuth2 authorization code.
///
/// The returned URL contains an authorization code. Opening it
/// in any HTTP client (including a system browser) will establish a session
/// for that service — no cookies from this app are needed.
///
/// This enables "open in browser" functionality: the app performs login and
/// SSO negotiation, then hands off the resulting URL to the system browser.
///
/// Requires an active portal session (call [login] first).
///
/// Throws an [Exception] if the SSO form is not found (user may not be logged in).
Future<Uri> getSsoUrl(PortalServiceCode serviceCode) async {
final apOu = serviceCode.code;
final (actionUrl, formData) = await _fetchSsoForm(apOu);
// Clone and strip RedirectInterceptor so we can capture the 302 Location
// instead of following it.
final dioWithoutRedirects = _portalDio.clone()
..interceptors.removeWhere(
(interceptor) => interceptor is RedirectInterceptor,
);
final response = await dioWithoutRedirects.post(
actionUrl,
data: formData,
options: Options(
contentType: Headers.formUrlEncodedContentType,
followRedirects: false,
validateStatus: (status) => status != null && status < 400,
),
);
final location = response.headers.value('location');
if (location == null) {
throw Exception('SSO redirect not received. Are you logged in?');
}
// The portal may return http:// URLs; upgrade to https://
var uri = Uri.parse(location);
if (uri.scheme == 'http') {
uri = uri.replace(scheme: 'https');
}
return uri;
}
/// Fetches and parses the SSO form for a given apOu code.
///
/// Returns (actionUrl, formData) for submitting the form.
Future<(String, Map<String, dynamic>)> _fetchSsoForm(String apOu) async {
final response = await _portalDio.get(
'ssoIndex.do',
queryParameters: {'apOu': apOu},
);
final document = parse(response.data);
final form = document.querySelector('form[name="ssoForm"]');
if (form == null) {
throw Exception('SSO form not found. Are you logged in?');
}
final actionUrl = form.attributes['action']!;
final inputs = form.querySelectorAll('input');
final formData = <String, dynamic>{
for (final input in inputs)
if (input.attributes['name'] != null)
input.attributes['name']!: input.attributes['value'] ?? '',
};
return (actionUrl, formData);
}
}