-
Notifications
You must be signed in to change notification settings - Fork 77
Expand file tree
/
Copy pathexchange_auth_screen.dart
More file actions
332 lines (300 loc) · 12.5 KB
/
Copy pathexchange_auth_screen.dart
File metadata and controls
332 lines (300 loc) · 12.5 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
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
import 'dart:convert';
import 'dart:io';
import 'package:bb_mobile/core/settings/domain/settings_entity.dart';
import 'package:bb_mobile/core/widgets/dialog/blurred_dialog.dart';
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:bb_mobile/core/utils/constants.dart';
import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bb_mobile/features/exchange/presentation/exchange_cubit.dart';
import 'package:bb_mobile/features/settings/presentation/bloc/settings_cubit.dart';
import 'package:bb_mobile/features/wallet/ui/wallet_router.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:webview_cookie_manager/webview_cookie_manager.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
class ExchangeAuthScreen extends StatefulWidget {
const ExchangeAuthScreen({super.key});
@override
State<ExchangeAuthScreen> createState() => _ExchangeAuthScreenState();
}
class _ExchangeAuthScreenState extends State<ExchangeAuthScreen> {
late final WebViewController _controller = WebViewController();
late final WebviewCookieManager _cookieManager = WebviewCookieManager();
late final String _bbAuthUrl;
String? _basicAuthUsername;
String? _basicAuthPassword;
bool _isGeneratingApiKey = false;
bool _isClosing = false;
@override
void initState() {
super.initState();
final settingsState = context.read<SettingsCubit>().state;
final isTestnet = settingsState.environment == Environment.testnet;
_bbAuthUrl = isTestnet
? ApiServiceConstants.bbAuthTestUrl
: ApiServiceConstants.bbAuthUrl;
if (isTestnet) {
_basicAuthUsername = settingsState.exchangeTestnetBasicAuthUsername;
_basicAuthPassword = settingsState.exchangeTestnetBasicAuthPassword;
}
_controller
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel(
'Flutter',
onMessageReceived: (JavaScriptMessage message) {
if (message.message == 'close') {
if (!_isClosing && mounted) {
_isClosing = true;
// Navigate immediately
context.goNamed(WalletRoute.walletHome.name);
}
}
},
)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (String url) {
// Direct JavaScript to handle "Back to App" button
_controller.runJavaScript('''
(function() {
function handleBackToApp() {
// NOTE : NEXT JS MAY CHANGE THE NAME OF THIS DIV!!!
// Method 1: Direct click on the specific div structure
const backDiv = document.querySelector('div.css-70qvj9');
if (backDiv && !backDiv.hasAttribute('data-handled')) {
backDiv.setAttribute('data-handled', 'true');
backDiv.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
Flutter.postMessage('close');
return false;
});
}
// Method 2: Click on parent div
// const parentDiv = document.querySelector('div.css-17lzdhk');
// if (parentDiv && !parentDiv.hasAttribute('data-handled')) {
// parentDiv.setAttribute('data-handled', 'true');
// parentDiv.addEventListener('click', function(e) {
// e.preventDefault();
// e.stopPropagation();
// Flutter.postMessage('close');
// return false;
// });
// }
}
// Run immediately
handleBackToApp();
// Run after delays to catch dynamic content
setTimeout(handleBackToApp, 500);
setTimeout(handleBackToApp, 1000);
setTimeout(handleBackToApp, 2000);
setTimeout(handleBackToApp, 5000);
// Watch for DOM changes
const observer = new MutationObserver(function(mutations) {
handleBackToApp();
});
observer.observe(document.body, {
childList: true,
subtree: true
});
})();
''');
},
onUrlChange: (UrlChange change) async {
final url = change.url;
if (url == null) return;
// During sign-up, the bb_session cookie is set before email
// verification. Skip processing on these paths so the WebView
// stays open and the user can complete email verification. Once
// done, the auth app navigates away and the API key is generated
// on the next URL change.
if (url.contains('/registration') ||
url.contains('/verification')) {
return;
}
// Check if the URL contains the bb_session cookie
final bbSessionCookie = await _tryGetBBSessionCookie(change.url!);
// If no bb_session cookie is found, do nothing as the user is not
// logged in yet.
if (bbSessionCookie == null) return;
// If the bb_session cookie is found, the user is logged in and
// we can proceed to try to generate and save the API key.
try {
// Set the flag to indicate that we are generating the API key
setState(() => _isGeneratingApiKey = true);
final apiKeyData = await _generateApiKey();
if (apiKeyData['error'] != null) {
setState(() => _isGeneratingApiKey = false);
return;
}
// Save the API key so it can be used for future requests
if (!mounted) return;
await context.read<ExchangeCubit>().storeApiKey(apiKeyData);
// Check if the API key was successfully stored
if (!mounted) return;
final saveApiKeyException = context
.read<ExchangeCubit>()
.state
.saveApiKeyException;
if (saveApiKeyException != null) {
throw saveApiKeyException;
}
} catch (e) {
log.severe(
message: 'Error generating or saving API key',
error: e,
trace: StackTrace.current,
);
await _handleLoginError();
} finally {
// Reset the flag after the API key generation process is done
// and if the widget is still mounted.
if (mounted) {
setState(() => _isGeneratingApiKey = false);
}
}
},
onNavigationRequest: (NavigationRequest request) {
// Exact host matching — a startsWith('https://accounts') prefix
// check would also match lookalike hosts such as
// https://accounts.evil.com.
final uri = Uri.tryParse(request.url);
if (uri == null || uri.scheme != 'https') {
// Host only — never the full URL, whose query params may
// carry tokens.
log.warning(
'Blocked webview navigation: '
'${uri == null ? 'unparseable URL' : 'non-https host ${uri.host}'}',
);
return NavigationDecision.prevent;
}
final authHost = Uri.parse(_bbAuthUrl).host;
if (uri.host == authHost) {
return NavigationDecision.navigate;
}
if (uri.host == 'www.bullbitcoin.com' &&
(request.url.contains('terms') ||
request.url.contains('privacy'))) {
return NavigationDecision.navigate;
}
// Logged so a legitimate navigation blocked in the field (e.g.
// a cross-host redirect between auth instances) is diagnosable
// from user logs.
log.warning('Blocked webview navigation to host: ${uri.host}');
return NavigationDecision.prevent;
},
onHttpAuthRequest: (HttpAuthRequest request) {
final username = _basicAuthUsername;
final password = _basicAuthPassword;
if (username == null || password == null) {
request.onCancel();
return;
}
request.onProceed(
WebViewCredential(user: username, password: password),
);
},
),
)
// TODO: Is this user agent necessary?
..setUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15.0 Safari/604.1',
)
..loadRequest(Uri.parse(_bbAuthUrl));
if (Platform.isAndroid) {
AndroidWebViewController.enableDebugging(false);
final platformController = _controller.platform;
if (platformController is AndroidWebViewController) {
platformController.setMediaPlaybackRequiresUserGesture(false);
}
} else if (Platform.isIOS) {
final platformController = _controller.platform;
if (platformController is WebKitWebViewController) {
platformController.setAllowsBackForwardNavigationGestures(true);
}
}
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: _isGeneratingApiKey
? const Center(child: CircularProgressIndicator())
: WebViewWidget(controller: _controller),
);
}
Future<String?> _tryGetBBSessionCookie(String url) async {
final cookies = await _cookieManager.getCookies(url);
String? bbSessionCookie;
for (final cookie in cookies) {
if (cookie.name == 'bb_session') {
bbSessionCookie = cookie.value;
break;
}
}
return bbSessionCookie;
}
Future<Map<String, dynamic>> _generateApiKey() async {
final url = '$_bbAuthUrl/api/generate-api-key';
final result =
await _controller.runJavaScriptReturningResult('''
(function() {
var xhr = new XMLHttpRequest();
xhr.open('POST', '$url', false);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.withCredentials = true;
try {
xhr.send(JSON.stringify({ apiKeyName: 'test-key-' + new Date().getTime() }));
if (xhr.status >= 200 && xhr.status < 300) {
try { return xhr.responseText; } catch (e) { return JSON.stringify({error: 'Failed to parse response: ' + e.toString()}); }
} else {
return JSON.stringify({ error: 'Request failed with status: ' + xhr.status, statusText: xhr.statusText || 'Unknown error' });
}
} catch (e) {
return JSON.stringify({error: 'XHR Error: ' + e.toString()});
}
})();
''')
as String;
String jsonString = result;
if (jsonString.startsWith('"') && jsonString.endsWith('"')) {
jsonString = jsonString
.substring(1, jsonString.length - 1)
.replaceAll(r'\"', '"')
.replaceAll(r'\\', '\\');
}
final responseData = json.decode(jsonString) as Map<String, dynamic>;
return responseData;
}
Future<void> _clearCacheAndCookies() async {
// Only clear cookies, not cache to avoid blank screen issues
await _cookieManager.clearCookies();
}
Future<void> _handleLoginError() async {
// Clear cache and cookies and reload the controller to
// allow the user to try logging in again
await _clearCacheAndCookies();
await _controller.reload();
if (!mounted) return;
await BlurredDialog.show(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.loc.exchangeAuthLoginFailedTitle),
content: Text(context.loc.exchangeAuthLoginFailedMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(context.loc.exchangeAuthLoginFailedOkButton),
),
],
),
);
}
@override
void dispose() {
_isClosing = true;
super.dispose();
}
}