added guardian login with views for their assigned childs
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// Binds a guardian login request to the device that started it (PKCE-style):
|
||||
/// the request carries only [challengeFor] of a secret that never leaves the
|
||||
/// device, verification sends the secret itself. A mail link opened on another
|
||||
/// device therefore cannot complete the login.
|
||||
abstract final class DeviceBinding {
|
||||
static String generateSecret([Random? random]) {
|
||||
final rng = random ?? Random.secure();
|
||||
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
|
||||
return _base64UrlNoPad(bytes);
|
||||
}
|
||||
|
||||
static String challengeFor(String secret) =>
|
||||
_base64UrlNoPad(sha256.convert(utf8.encode(secret)).bytes);
|
||||
|
||||
static String _base64UrlNoPad(List<int> bytes) =>
|
||||
base64Url.encode(bytes).replaceAll('=', '');
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'guardian_login_link.dart';
|
||||
|
||||
/// Bridges incoming App Links / Universal Links into [pending]; the login
|
||||
/// screen consumes it. Mirrors ShareIntentListener: [initialize] reads the
|
||||
/// cold-start link before `runApp` and then follows links while running.
|
||||
///
|
||||
/// Links are kept raw and parsed on consumption: the endpoint setting the
|
||||
/// link is checked against is only applied once the app is built.
|
||||
class GuardianLinkListener {
|
||||
GuardianLinkListener._();
|
||||
static final GuardianLinkListener instance = GuardianLinkListener._();
|
||||
|
||||
static final ValueNotifier<Uri?> pending = ValueNotifier(null);
|
||||
|
||||
final AppLinks _appLinks = AppLinks();
|
||||
bool _listening = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
try {
|
||||
final initial = await _appLinks.getInitialLink();
|
||||
if (initial != null) _publish(initial);
|
||||
} catch (e) {
|
||||
debugPrint('GuardianLinkListener.initialize failed: $e');
|
||||
}
|
||||
if (_listening) return;
|
||||
_listening = true;
|
||||
// Kept for the whole process lifetime; links can arrive at any time.
|
||||
_appLinks.uriLinkStream.listen(
|
||||
_publish,
|
||||
onError: (Object e) => debugPrint('GuardianLinkListener error: $e'),
|
||||
);
|
||||
}
|
||||
|
||||
// Cheap pre-filter; the host check against the active endpoint happens in
|
||||
// GuardianLoginLink.parse.
|
||||
void _publish(Uri uri) {
|
||||
if (!uri.path.endsWith(GuardianLoginLink.path)) return;
|
||||
pending.value = uri;
|
||||
}
|
||||
|
||||
static void clear() => pending.value = null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/// A sign-in link from the guardian login mail
|
||||
/// (`https://<connect-host>/app/guardian-login?rid=…<=…`).
|
||||
class GuardianLoginLink {
|
||||
static const path = '/app/guardian-login';
|
||||
|
||||
final String requestId;
|
||||
final String linkToken;
|
||||
|
||||
const GuardianLoginLink({required this.requestId, required this.linkToken});
|
||||
|
||||
/// Parses [uri] if it is a guardian login link for the server at [apiBase].
|
||||
/// Links of another server (e.g. live link while the app points at beta)
|
||||
/// are rejected: the request only exists on the server that sent the mail.
|
||||
static GuardianLoginLink? parse(Uri uri, {required Uri apiBase}) {
|
||||
if (uri.scheme != 'https' || uri.host != apiBase.host) return null;
|
||||
final basePath = apiBase.path.endsWith('/')
|
||||
? apiBase.path.substring(0, apiBase.path.length - 1)
|
||||
: apiBase.path;
|
||||
if (uri.path != '$basePath$path') return null;
|
||||
final requestId = uri.queryParameters['rid'];
|
||||
final linkToken = uri.queryParameters['lt'];
|
||||
if (requestId == null || requestId.isEmpty) return null;
|
||||
if (linkToken == null || linkToken.isEmpty) return null;
|
||||
return GuardianLoginLink(requestId: requestId, linkToken: linkToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// A guardian login request waiting for its code or link. Persisted because
|
||||
/// Android often kills the app while the user reads the mail.
|
||||
class PendingGuardianRequest {
|
||||
static const int defaultCodeLength = 6;
|
||||
|
||||
final String requestId;
|
||||
final String email;
|
||||
final String deviceSecret;
|
||||
final DateTime expiresAt;
|
||||
final DateTime resendAvailableAt;
|
||||
final int codeLength;
|
||||
|
||||
const PendingGuardianRequest({
|
||||
required this.requestId,
|
||||
required this.email,
|
||||
required this.deviceSecret,
|
||||
required this.expiresAt,
|
||||
required this.resendAvailableAt,
|
||||
this.codeLength = defaultCodeLength,
|
||||
});
|
||||
|
||||
bool isExpired(DateTime now) => !now.isBefore(expiresAt);
|
||||
|
||||
Map<String, Object> toJson() => {
|
||||
'requestId': requestId,
|
||||
'email': email,
|
||||
'deviceSecret': deviceSecret,
|
||||
'expiresAt': expiresAt.toIso8601String(),
|
||||
'resendAvailableAt': resendAvailableAt.toIso8601String(),
|
||||
'codeLength': codeLength,
|
||||
};
|
||||
|
||||
static PendingGuardianRequest? fromJson(Map<String, dynamic> json) {
|
||||
try {
|
||||
return PendingGuardianRequest(
|
||||
requestId: json['requestId'] as String,
|
||||
email: json['email'] as String,
|
||||
deviceSecret: json['deviceSecret'] as String,
|
||||
expiresAt: DateTime.parse(json['expiresAt'] as String),
|
||||
resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String),
|
||||
codeLength: json['codeLength'] as int? ?? defaultCodeLength,
|
||||
);
|
||||
} on Object {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PendingGuardianRequestStore {
|
||||
static const _key = 'guardian_login_pending_request';
|
||||
static const FlutterSecureStorage _storage = FlutterSecureStorage(
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
|
||||
const PendingGuardianRequestStore();
|
||||
|
||||
Future<PendingGuardianRequest?> read() async {
|
||||
try {
|
||||
final raw = await _storage.read(key: _key);
|
||||
if (raw == null) return null;
|
||||
return PendingGuardianRequest.fromJson(
|
||||
jsonDecode(raw) as Map<String, dynamic>,
|
||||
);
|
||||
} on Object {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> write(PendingGuardianRequest request) =>
|
||||
_storage.write(key: _key, value: jsonEncode(request.toJson()));
|
||||
|
||||
Future<void> clear() async {
|
||||
try {
|
||||
await _storage.delete(key: _key);
|
||||
} on Object {
|
||||
// Nothing stored or keystore unavailable.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user