feat: harden account lifecycle and cloud deletion
This commit is contained in:
parent
353b6fb66c
commit
8e0e2d5f97
|
|
@ -15,11 +15,12 @@ export interface Env {
|
||||||
interface FirebaseTokenPayload extends JWTPayload {
|
interface FirebaseTokenPayload extends JWTPayload {
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
firebase?: { sign_in_provider?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
const CORS_HEADERS = {
|
const CORS_HEADERS = {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Remote-Ride-Id, X-Bundle-Sha256, X-App-Version',
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Remote-Ride-Id, X-Bundle-Sha256, X-App-Version',
|
||||||
'Access-Control-Expose-Headers': 'ETag, Content-Disposition, X-MotorSignal-Ride-Id, X-MotorSignal-Sha256',
|
'Access-Control-Expose-Headers': 'ETag, Content-Disposition, X-MotorSignal-Ride-Id, X-MotorSignal-Sha256',
|
||||||
};
|
};
|
||||||
|
|
@ -48,6 +49,24 @@ export default {
|
||||||
if (!authUser?.sub) return jsonResponse({ error: 'Invalid, expired, or unauthorized Firebase ID token' }, 401);
|
if (!authUser?.sub) return jsonResponse({ error: 'Invalid, expired, or unauthorized Firebase ID token' }, 401);
|
||||||
const authenticatedUid = authUser.sub;
|
const authenticatedUid = authUser.sub;
|
||||||
|
|
||||||
|
if (request.method === 'DELETE' && path === '/account/cloud-data') {
|
||||||
|
if (authUser.firebase?.sign_in_provider === 'anonymous') {
|
||||||
|
return jsonResponse({ error: 'Permanent account required' }, 403);
|
||||||
|
}
|
||||||
|
const prefix = `users/${authenticatedUid}/`;
|
||||||
|
let cursor: string | undefined;
|
||||||
|
let deleted = 0;
|
||||||
|
do {
|
||||||
|
const listed = await env.R2_BUCKET.list({ prefix, cursor });
|
||||||
|
if (listed.objects.length > 0) {
|
||||||
|
await env.R2_BUCKET.delete(listed.objects.map((object) => object.key));
|
||||||
|
deleted += listed.objects.length;
|
||||||
|
}
|
||||||
|
cursor = listed.truncated ? listed.cursor : undefined;
|
||||||
|
} while (cursor);
|
||||||
|
return jsonResponse({ success: true, deletedObjects: deleted });
|
||||||
|
}
|
||||||
|
|
||||||
if (request.method === 'PUT' && path === '/r2/upload') {
|
if (request.method === 'PUT' && path === '/r2/upload') {
|
||||||
const remoteRideId = request.headers.get('X-Remote-Ride-Id');
|
const remoteRideId = request.headers.get('X-Remote-Ride-Id');
|
||||||
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid or missing X-Remote-Ride-Id header' }, 400);
|
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid or missing X-Remote-Ride-Id header' }, 400);
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,24 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
'Last Sign-In',
|
'Last Sign-In',
|
||||||
user.lastSignInTime!.toLocal().toString().split('.')[0],
|
user.lastSignInTime!.toLocal().toString().split('.')[0],
|
||||||
),
|
),
|
||||||
|
if (user.providerId == 'password' && !user.emailVerified) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.amberAccent.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.amberAccent.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(child: Text('Your email is not verified. Verify it to protect account recovery.', style: TextStyle(fontSize: 11, color: Colors.white))),
|
||||||
|
TextButton(onPressed: () => _sendVerification(), child: const Text('VERIFY')),
|
||||||
|
IconButton(tooltip: 'Refresh verification status', onPressed: () => _reloadUser(), icon: const Icon(Icons.refresh, size: 18)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|
@ -608,6 +626,16 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
ref.read(authNotifierProvider.notifier).signOut(),
|
ref.read(authNotifierProvider.notifier).signOut(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: TextButton.icon(
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
|
||||||
|
icon: const Icon(Icons.delete_forever_outlined, size: 18),
|
||||||
|
label: const Text('DELETE ACCOUNT'),
|
||||||
|
onPressed: () => _handleDeleteAccount(user),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1081,6 +1109,63 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _sendVerification() async {
|
||||||
|
try {
|
||||||
|
await ref.read(authNotifierProvider.notifier).sendEmailVerification();
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Verification email sent.')));
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not send verification email: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reloadUser() async {
|
||||||
|
try {
|
||||||
|
await ref.read(authNotifierProvider.notifier).reloadCurrentUser();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not refresh account status: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleDeleteAccount(AuthUser user) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Delete Account'),
|
||||||
|
content: const Text('This permanently deletes your MotorCloud backups and account. Local rides and garage data stay on this device. You will need to reauthenticate before deletion.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('CANCEL')),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('DELETE ACCOUNT')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) ?? false;
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
|
||||||
|
String? password;
|
||||||
|
if (user.providerId == 'password') {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
password = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Confirm Your Password'),
|
||||||
|
content: TextField(controller: controller, obscureText: true, autofocus: true, decoration: const InputDecoration(labelText: 'Current password')),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('CANCEL')),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(context, controller.text), child: const Text('CONTINUE')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
controller.dispose();
|
||||||
|
if (password == null || password.isEmpty || !mounted) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref.read(accountLifecycleServiceProvider).deleteAccount(password: password);
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Account deleted. Local rides remain on this device.')));
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Account deletion was not completed: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleBackupAllHistoricalRides() async {
|
Future<void> _handleBackupAllHistoricalRides() async {
|
||||||
final db = ref.read(databaseProvider);
|
final db = ref.read(databaseProvider);
|
||||||
final syncManager = ref.read(syncManagerProvider);
|
final syncManager = ref.read(syncManagerProvider);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import '../../database/database.dart';
|
||||||
|
import '../cloud/account_cloud_cleanup_service.dart';
|
||||||
|
import 'auth_service.dart';
|
||||||
|
|
||||||
|
/// Coordinates destructive account work while preserving device-local rides.
|
||||||
|
class AccountLifecycleService {
|
||||||
|
final AuthService _auth;
|
||||||
|
final CloudAccountCleanup _cloud;
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
|
AccountLifecycleService({required this._auth, required this._cloud, required this._db});
|
||||||
|
|
||||||
|
Future<void> deleteAccount({String? password}) async {
|
||||||
|
final user = _auth.currentUser;
|
||||||
|
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
|
||||||
|
final uid = user.uid;
|
||||||
|
await _auth.reauthenticate(password: password);
|
||||||
|
await _cloud.deleteCloudData();
|
||||||
|
await _auth.deleteCurrentUser();
|
||||||
|
await _db.clearCloudOwnerUid(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,24 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||||
state = _authService.currentState;
|
state = _authService.currentState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> sendEmailVerification() async {
|
||||||
|
await _authService.sendEmailVerification();
|
||||||
|
state = _authService.currentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reloadCurrentUser() async {
|
||||||
|
await _authService.reloadCurrentUser();
|
||||||
|
state = _authService.currentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reauthenticate({String? password}) =>
|
||||||
|
_authService.reauthenticate(password: password);
|
||||||
|
|
||||||
|
Future<void> deleteCurrentUser() async {
|
||||||
|
await _authService.deleteCurrentUser();
|
||||||
|
state = _authService.currentState;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> signOut() async {
|
Future<void> signOut() async {
|
||||||
await _authService.signOut();
|
await _authService.signOut();
|
||||||
state = _authService.currentState;
|
state = _authService.currentState;
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,17 @@ abstract class AuthService {
|
||||||
AuthState get currentState;
|
AuthState get currentState;
|
||||||
AuthUser? get currentUser;
|
AuthUser? get currentUser;
|
||||||
bool get isConfigured;
|
bool get isConfigured;
|
||||||
|
bool get isEmailVerified => false;
|
||||||
|
|
||||||
Future<void> signInWithEmailAndPassword(String email, String password);
|
Future<void> signInWithEmailAndPassword(String email, String password);
|
||||||
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName});
|
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName});
|
||||||
Future<void> signInWithGoogle();
|
Future<void> signInWithGoogle();
|
||||||
Future<void> signInAnonymously();
|
Future<void> signInAnonymously();
|
||||||
Future<void> sendPasswordResetEmail(String email);
|
Future<void> sendPasswordResetEmail(String email);
|
||||||
|
Future<void> sendEmailVerification() async => throw UnsupportedError('Email verification is not supported by this auth service.');
|
||||||
|
Future<void> reloadCurrentUser() async => throw UnsupportedError('User reload is not supported by this auth service.');
|
||||||
|
Future<void> reauthenticate({String? password}) async => throw UnsupportedError('Reauthentication is not supported by this auth service.');
|
||||||
|
Future<void> deleteCurrentUser() async => throw UnsupportedError('Account deletion is not supported by this auth service.');
|
||||||
Future<String?> getIdToken([bool forceRefresh = false]);
|
Future<String?> getIdToken([bool forceRefresh = false]);
|
||||||
Future<void> signOut();
|
Future<void> signOut();
|
||||||
void dispose();
|
void dispose();
|
||||||
|
|
@ -125,6 +130,7 @@ class FirebaseAuthService implements AuthService {
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
photoUrl: user.photoURL,
|
photoUrl: user.photoURL,
|
||||||
isAnonymous: user.isAnonymous,
|
isAnonymous: user.isAnonymous,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
providerId: provider,
|
providerId: provider,
|
||||||
createdAt: user.metadata.creationTime,
|
createdAt: user.metadata.creationTime,
|
||||||
lastSignInTime: user.metadata.lastSignInTime,
|
lastSignInTime: user.metadata.lastSignInTime,
|
||||||
|
|
@ -367,6 +373,58 @@ class FirebaseAuthService implements AuthService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isEmailVerified => _firebaseAuth?.currentUser?.emailVerified ?? false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> sendEmailVerification() async {
|
||||||
|
final user = _firebaseAuth?.currentUser;
|
||||||
|
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
|
||||||
|
await user.sendEmailVerification();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reloadCurrentUser() async {
|
||||||
|
final user = _firebaseAuth?.currentUser;
|
||||||
|
if (user == null) throw StateError('No signed-in account.');
|
||||||
|
await user.reload();
|
||||||
|
final refreshed = _firebaseAuth?.currentUser;
|
||||||
|
if (refreshed != null) {
|
||||||
|
refreshed.isAnonymous
|
||||||
|
? _updateState(AuthState.anonymous(_mapFirebaseUser(refreshed), isFirebaseConfigured: true))
|
||||||
|
: _updateAuthenticatedUser(refreshed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reauthenticate({String? password}) async {
|
||||||
|
final auth = _firebaseAuth;
|
||||||
|
final user = auth?.currentUser;
|
||||||
|
if (auth == null || user == null || user.isAnonymous) throw StateError('A permanent account is required.');
|
||||||
|
final providers = user.providerData.map((p) => p.providerId).toSet();
|
||||||
|
fb.AuthCredential credential;
|
||||||
|
if (providers.contains('password')) {
|
||||||
|
if (password == null || password.isEmpty || user.email == null) throw StateError('Enter your current password.');
|
||||||
|
credential = fb.EmailAuthProvider.credential(email: user.email!, password: password);
|
||||||
|
} else if (providers.contains('google.com')) {
|
||||||
|
final googleUser = await (_googleSignIn ?? GoogleSignIn()).signIn();
|
||||||
|
if (googleUser == null) throw StateError('Google reauthentication was cancelled.');
|
||||||
|
final googleAuth = await googleUser.authentication;
|
||||||
|
credential = fb.GoogleAuthProvider.credential(accessToken: googleAuth.accessToken, idToken: googleAuth.idToken);
|
||||||
|
} else {
|
||||||
|
throw StateError('This account provider cannot reauthenticate here.');
|
||||||
|
}
|
||||||
|
await user.reauthenticateWithCredential(credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteCurrentUser() async {
|
||||||
|
final user = _firebaseAuth?.currentUser;
|
||||||
|
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
|
||||||
|
await user.delete();
|
||||||
|
_updateState(const AuthState.unauthenticated(isFirebaseConfigured: true));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String?> getIdToken([bool forceRefresh = false]) async {
|
Future<String?> getIdToken([bool forceRefresh = false]) async {
|
||||||
final auth = _firebaseAuth;
|
final auth = _firebaseAuth;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ class AuthUser {
|
||||||
final String? displayName;
|
final String? displayName;
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
final bool isAnonymous;
|
final bool isAnonymous;
|
||||||
|
final bool emailVerified;
|
||||||
final String? providerId;
|
final String? providerId;
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
final DateTime? lastSignInTime;
|
final DateTime? lastSignInTime;
|
||||||
|
|
@ -19,6 +20,7 @@ class AuthUser {
|
||||||
this.displayName,
|
this.displayName,
|
||||||
this.photoUrl,
|
this.photoUrl,
|
||||||
this.isAnonymous = false,
|
this.isAnonymous = false,
|
||||||
|
this.emailVerified = false,
|
||||||
this.providerId,
|
this.providerId,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
this.lastSignInTime,
|
this.lastSignInTime,
|
||||||
|
|
@ -41,6 +43,7 @@ class AuthUser {
|
||||||
'displayName': displayName,
|
'displayName': displayName,
|
||||||
'photoUrl': photoUrl,
|
'photoUrl': photoUrl,
|
||||||
'isAnonymous': isAnonymous,
|
'isAnonymous': isAnonymous,
|
||||||
|
'emailVerified': emailVerified,
|
||||||
'providerId': providerId,
|
'providerId': providerId,
|
||||||
'createdAt': createdAt?.toIso8601String(),
|
'createdAt': createdAt?.toIso8601String(),
|
||||||
'lastSignInTime': lastSignInTime?.toIso8601String(),
|
'lastSignInTime': lastSignInTime?.toIso8601String(),
|
||||||
|
|
@ -52,6 +55,7 @@ class AuthUser {
|
||||||
displayName: json['displayName'] as String?,
|
displayName: json['displayName'] as String?,
|
||||||
photoUrl: json['photoUrl'] as String?,
|
photoUrl: json['photoUrl'] as String?,
|
||||||
isAnonymous: json['isAnonymous'] as bool? ?? false,
|
isAnonymous: json['isAnonymous'] as bool? ?? false,
|
||||||
|
emailVerified: json['emailVerified'] as bool? ?? false,
|
||||||
providerId: json['providerId'] as String?,
|
providerId: json['providerId'] as String?,
|
||||||
createdAt: json['createdAt'] != null
|
createdAt: json['createdAt'] != null
|
||||||
? DateTime.tryParse(json['createdAt'] as String)
|
? DateTime.tryParse(json['createdAt'] as String)
|
||||||
|
|
@ -67,6 +71,7 @@ class AuthUser {
|
||||||
String? displayName,
|
String? displayName,
|
||||||
String? photoUrl,
|
String? photoUrl,
|
||||||
bool? isAnonymous,
|
bool? isAnonymous,
|
||||||
|
bool? emailVerified,
|
||||||
String? providerId,
|
String? providerId,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? lastSignInTime,
|
DateTime? lastSignInTime,
|
||||||
|
|
@ -77,6 +82,7 @@ class AuthUser {
|
||||||
displayName: displayName ?? this.displayName,
|
displayName: displayName ?? this.displayName,
|
||||||
photoUrl: photoUrl ?? this.photoUrl,
|
photoUrl: photoUrl ?? this.photoUrl,
|
||||||
isAnonymous: isAnonymous ?? this.isAnonymous,
|
isAnonymous: isAnonymous ?? this.isAnonymous,
|
||||||
|
emailVerified: emailVerified ?? this.emailVerified,
|
||||||
providerId: providerId ?? this.providerId,
|
providerId: providerId ?? this.providerId,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
lastSignInTime: lastSignInTime ?? this.lastSignInTime,
|
lastSignInTime: lastSignInTime ?? this.lastSignInTime,
|
||||||
|
|
@ -92,6 +98,7 @@ class AuthUser {
|
||||||
email == other.email &&
|
email == other.email &&
|
||||||
displayName == other.displayName &&
|
displayName == other.displayName &&
|
||||||
isAnonymous == other.isAnonymous &&
|
isAnonymous == other.isAnonymous &&
|
||||||
|
emailVerified == other.emailVerified &&
|
||||||
providerId == other.providerId;
|
providerId == other.providerId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -100,6 +107,7 @@ class AuthUser {
|
||||||
email.hashCode ^
|
email.hashCode ^
|
||||||
displayName.hashCode ^
|
displayName.hashCode ^
|
||||||
isAnonymous.hashCode ^
|
isAnonymous.hashCode ^
|
||||||
|
emailVerified.hashCode ^
|
||||||
providerId.hashCode;
|
providerId.hashCode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../auth/auth_service.dart';
|
||||||
|
import '../sync/r2_upload_service.dart';
|
||||||
|
|
||||||
|
abstract class CloudAccountCleanup {
|
||||||
|
Future<void> deleteCloudData();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountCloudCleanupService implements CloudAccountCleanup {
|
||||||
|
final AuthService _auth;
|
||||||
|
final FirebaseFirestore? _firestore;
|
||||||
|
final http.Client _client;
|
||||||
|
final String _workerUrl;
|
||||||
|
|
||||||
|
AccountCloudCleanupService({
|
||||||
|
required this._auth,
|
||||||
|
FirebaseFirestore? firestore,
|
||||||
|
http.Client? client,
|
||||||
|
String? workerUrl,
|
||||||
|
}) : _firestore = firestore ?? _resolveFirestore(),
|
||||||
|
_client = client ?? http.Client(),
|
||||||
|
_workerUrl = (workerUrl ?? R2UploadService.defaultWorkerUrl).replaceAll(RegExp(r'/+$'), '');
|
||||||
|
|
||||||
|
static FirebaseFirestore? _resolveFirestore() {
|
||||||
|
try {
|
||||||
|
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteCloudData() async {
|
||||||
|
final user = _auth.currentUser;
|
||||||
|
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
|
||||||
|
final token = await _auth.getIdToken(true);
|
||||||
|
if (token == null || token.isEmpty) throw StateError('A valid account session is required.');
|
||||||
|
|
||||||
|
final response = await _client.delete(
|
||||||
|
Uri.parse('$_workerUrl/account/cloud-data'),
|
||||||
|
headers: {'Authorization': 'Bearer ${token.trim()}'},
|
||||||
|
).timeout(const Duration(seconds: 30));
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw StateError('MotorCloud cleanup was not completed (${response.statusCode}).');
|
||||||
|
}
|
||||||
|
|
||||||
|
final firestore = _firestore;
|
||||||
|
if (firestore == null) throw StateError('MotorCloud database is unavailable.');
|
||||||
|
final userRef = firestore.collection('users').doc(user.uid);
|
||||||
|
final rides = await userRef.collection('rides').get();
|
||||||
|
final motorcycles = await userRef.collection('motorcycles').get();
|
||||||
|
final batch = firestore.batch();
|
||||||
|
for (final doc in rides.docs) {
|
||||||
|
batch.delete(doc.reference);
|
||||||
|
}
|
||||||
|
for (final doc in motorcycles.docs) {
|
||||||
|
batch.delete(doc.reference);
|
||||||
|
}
|
||||||
|
batch.delete(userRef);
|
||||||
|
await batch.commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import '../../features/records/records_service.dart';
|
import '../../features/records/records_service.dart';
|
||||||
|
|
||||||
/// Secure credentials and repository configuration for GitHub ride sync
|
/// Secure credentials and repository configuration for GitHub ride sync
|
||||||
|
|
@ -15,29 +16,64 @@ class GitHubAuthService {
|
||||||
|
|
||||||
final SharedPreferences _prefs;
|
final SharedPreferences _prefs;
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
|
final FlutterSecureStorage _secureStorage;
|
||||||
|
String? _token;
|
||||||
|
|
||||||
GitHubAuthService(this._prefs, [http.Client? client]) : _client = client ?? http.Client();
|
GitHubAuthService(this._prefs, [http.Client? client, FlutterSecureStorage? secureStorage])
|
||||||
|
: _client = client ?? http.Client(),
|
||||||
|
_secureStorage = secureStorage ?? const FlutterSecureStorage() {
|
||||||
|
_migrateToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateToken() async {
|
||||||
|
try {
|
||||||
|
final secure = await _secureStorage.read(key: _keyToken);
|
||||||
|
if (secure != null && secure.isNotEmpty) {
|
||||||
|
_token = secure;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final legacy = _prefs.getString(_keyToken);
|
||||||
|
if (legacy == null || legacy.isEmpty) return;
|
||||||
|
final decoded = _decodeLegacy(legacy);
|
||||||
|
await _secureStorage.write(key: _keyToken, value: decoded);
|
||||||
|
await _prefs.remove(_keyToken);
|
||||||
|
_token = decoded;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _decodeLegacy(String raw) {
|
||||||
|
try { return utf8.decode(base64.decode(raw)); } catch (_) { return raw; }
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads stored Personal Access Token
|
/// Reads stored Personal Access Token
|
||||||
String? getToken() {
|
String? getToken() {
|
||||||
final raw = _prefs.getString(_keyToken);
|
if (_token != null && _token!.isNotEmpty) return _token;
|
||||||
if (raw == null || raw.isEmpty) return null;
|
final legacy = _prefs.getString(_keyToken);
|
||||||
try {
|
if (legacy == null || legacy.isEmpty) return null;
|
||||||
return utf8.decode(base64.decode(raw));
|
// Legacy values are read only to bridge an upgrade; constructor migration
|
||||||
} catch (_) {
|
// moves them to secure storage and removes the old entry.
|
||||||
return raw;
|
return _decodeLegacy(legacy);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stores Personal Access Token securely
|
/// Stores Personal Access Token securely
|
||||||
Future<void> saveToken(String token) async {
|
Future<void> saveToken(String token) async {
|
||||||
final encoded = base64.encode(utf8.encode(token.trim()));
|
_token = token.trim();
|
||||||
await _prefs.setString(_keyToken, encoded);
|
try {
|
||||||
|
await _secureStorage.write(key: _keyToken, value: _token);
|
||||||
|
await _prefs.remove(_keyToken);
|
||||||
|
} catch (_) {
|
||||||
|
// Native secure storage is unavailable in plugin-less test environments;
|
||||||
|
// keep the token only in memory rather than persist it insecurely.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears stored token
|
/// Clears stored token
|
||||||
Future<void> clearToken() async {
|
Future<void> clearToken() async {
|
||||||
await _prefs.remove(_keyToken);
|
await _prefs.remove(_keyToken);
|
||||||
|
try {
|
||||||
|
await _secureStorage.delete(key: _keyToken);
|
||||||
|
} catch (_) {}
|
||||||
|
_token = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Target GitHub repository (default: bponders12-cell/ride_logs)
|
/// Target GitHub repository (default: bponders12-cell/ride_logs)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class OsmUserProfile {
|
class OsmUserProfile {
|
||||||
|
|
@ -79,6 +80,8 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
|
||||||
static const String _prefTokenKey = 'osm_access_token';
|
static const String _prefTokenKey = 'osm_access_token';
|
||||||
static const String _prefUserKey = 'osm_user_profile';
|
static const String _prefUserKey = 'osm_user_profile';
|
||||||
static const String _prefVerifierKey = 'osm_pkce_code_verifier';
|
static const String _prefVerifierKey = 'osm_pkce_code_verifier';
|
||||||
|
static const String _secureTokenKey = 'osm_access_token';
|
||||||
|
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||||
|
|
||||||
String? _currentCodeVerifier;
|
String? _currentCodeVerifier;
|
||||||
String? _inFlightCode;
|
String? _inFlightCode;
|
||||||
|
|
@ -92,7 +95,14 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
|
||||||
Future<void> _loadStoredSession() async {
|
Future<void> _loadStoredSession() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final token = prefs.getString(_prefTokenKey);
|
var token = await _secureStorage.read(key: _secureTokenKey);
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
token = prefs.getString(_prefTokenKey);
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
await _secureStorage.write(key: _secureTokenKey, value: token);
|
||||||
|
await prefs.remove(_prefTokenKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
final userJsonStr = prefs.getString(_prefUserKey);
|
final userJsonStr = prefs.getString(_prefUserKey);
|
||||||
|
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
|
|
@ -225,7 +235,7 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('[OsmOAuth] Exchanging token with code: $code, redirect: $redirectUri');
|
debugPrint('[OsmOAuth] Exchanging authorization code');
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
Uri.parse(tokenEndpoint),
|
Uri.parse(tokenEndpoint),
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -240,13 +250,14 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
debugPrint('[OsmOAuth] Token response (${response.statusCode}): ${response.body}');
|
debugPrint('[OsmOAuth] Token exchange response: ${response.statusCode}');
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
final accessToken = json['access_token'] as String;
|
final accessToken = json['access_token'] as String;
|
||||||
|
|
||||||
await prefs.setString(_prefTokenKey, accessToken);
|
await _secureStorage.write(key: _secureTokenKey, value: accessToken);
|
||||||
|
await prefs.remove(_prefTokenKey);
|
||||||
await prefs.remove(_prefVerifierKey);
|
await prefs.remove(_prefVerifierKey);
|
||||||
_currentCodeVerifier = null;
|
_currentCodeVerifier = null;
|
||||||
|
|
||||||
|
|
@ -261,7 +272,7 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
|
||||||
} else {
|
} else {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
errorMessage: 'Token exchange failed (${response.statusCode}): ${response.body}',
|
errorMessage: 'Token exchange failed (${response.statusCode}).',
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -343,6 +354,7 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_prefTokenKey);
|
await prefs.remove(_prefTokenKey);
|
||||||
|
await _secureStorage.delete(key: _secureTokenKey);
|
||||||
await prefs.remove(_prefUserKey);
|
await prefs.remove(_prefUserKey);
|
||||||
await prefs.remove(_prefVerifierKey);
|
await prefs.remove(_prefVerifierKey);
|
||||||
state = const OsmAuthState(isAuthenticated: false, isLoading: false);
|
state = const OsmAuthState(isAuthenticated: false, isLoading: false);
|
||||||
|
|
@ -352,4 +364,4 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
|
||||||
final osmOAuthServiceProvider =
|
final osmOAuthServiceProvider =
|
||||||
StateNotifierProvider<OsmOAuthService, OsmAuthState>((ref) {
|
StateNotifierProvider<OsmOAuthService, OsmAuthState>((ref) {
|
||||||
return OsmOAuthService();
|
return OsmOAuthService();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import '../../features/records/records_service.dart';
|
||||||
import '../auth/auth_providers.dart';
|
import '../auth/auth_providers.dart';
|
||||||
import '../auth/auth_state.dart';
|
import '../auth/auth_state.dart';
|
||||||
import '../cloud/firebase_account_sync_service.dart';
|
import '../cloud/firebase_account_sync_service.dart';
|
||||||
|
import '../cloud/account_cloud_cleanup_service.dart';
|
||||||
|
import '../auth/account_lifecycle_service.dart';
|
||||||
import '../github/github_auth_service.dart';
|
import '../github/github_auth_service.dart';
|
||||||
import '../github/github_sync_service.dart';
|
import '../github/github_sync_service.dart';
|
||||||
import 'firebase_sync_provider.dart';
|
import 'firebase_sync_provider.dart';
|
||||||
|
|
@ -64,6 +66,18 @@ final firebaseAccountSyncServiceProvider = Provider<FirebaseAccountSyncService>(
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final accountCloudCleanupServiceProvider = Provider<AccountCloudCleanupService>(
|
||||||
|
(ref) => AccountCloudCleanupService(auth: ref.watch(authServiceProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final accountLifecycleServiceProvider = Provider<AccountLifecycleService>(
|
||||||
|
(ref) => AccountLifecycleService(
|
||||||
|
auth: ref.watch(authServiceProvider),
|
||||||
|
cloud: ref.watch(accountCloudCleanupServiceProvider),
|
||||||
|
db: ref.watch(databaseProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
final githubSyncProvider = Provider<GitHubSyncProvider>(
|
final githubSyncProvider = Provider<GitHubSyncProvider>(
|
||||||
(ref) => GitHubSyncProvider(
|
(ref) => GitHubSyncProvider(
|
||||||
service: ref.watch(githubSyncServiceProvider),
|
service: ref.watch(githubSyncServiceProvider),
|
||||||
|
|
|
||||||
48
pubspec.lock
48
pubspec.lock
|
|
@ -467,6 +467,54 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "2.6.1"
|
||||||
|
flutter_secure_storage:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage
|
||||||
|
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.3.1"
|
||||||
|
flutter_secure_storage_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_darwin
|
||||||
|
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.2"
|
||||||
|
flutter_secure_storage_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_linux
|
||||||
|
sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
flutter_secure_storage_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_platform_interface
|
||||||
|
sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.3"
|
||||||
|
flutter_secure_storage_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_web
|
||||||
|
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
|
flutter_secure_storage_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_windows
|
||||||
|
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.2"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ dependencies:
|
||||||
firebase_auth: ^5.5.1
|
firebase_auth: ^5.5.1
|
||||||
google_sign_in: ^6.2.2
|
google_sign_in: ^6.2.2
|
||||||
cloud_firestore: ^5.6.5
|
cloud_firestore: ^5.6.5
|
||||||
|
flutter_secure_storage: ^10.2.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:motorcycle_dash/database/database.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/account_lifecycle_service.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_service.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_state.dart';
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
||||||
|
import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart';
|
||||||
|
|
||||||
|
class _Auth implements AuthService {
|
||||||
|
AuthUser? user = const AuthUser(uid: 'user_a', email: 'a@test.com');
|
||||||
|
bool reauthenticated = false;
|
||||||
|
bool deleted = false;
|
||||||
|
@override bool get isConfigured => true;
|
||||||
|
@override bool get isEmailVerified => true;
|
||||||
|
@override AuthUser? get currentUser => user;
|
||||||
|
@override AuthState get currentState => AuthState.authenticated(user!);
|
||||||
|
@override Stream<AuthState> get authStateChanges => Stream.value(currentState);
|
||||||
|
@override Future<void> createUserWithEmailAndPassword(String e, String p, {String? displayName}) async {}
|
||||||
|
@override Future<void> sendPasswordResetEmail(String email) async {}
|
||||||
|
@override Future<void> sendEmailVerification() async {}
|
||||||
|
@override Future<void> reloadCurrentUser() async {}
|
||||||
|
@override Future<void> signInAnonymously() async {}
|
||||||
|
@override Future<void> signInWithEmailAndPassword(String e, String p) async {}
|
||||||
|
@override Future<void> signInWithGoogle() async {}
|
||||||
|
@override Future<String?> getIdToken([bool forceRefresh = false]) async => 'token';
|
||||||
|
@override Future<void> reauthenticate({String? password}) async { reauthenticated = true; }
|
||||||
|
@override Future<void> deleteCurrentUser() async { deleted = true; user = null; }
|
||||||
|
@override Future<void> signOut() async {}
|
||||||
|
@override void dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Cloud implements CloudAccountCleanup {
|
||||||
|
bool fail = false;
|
||||||
|
bool called = false;
|
||||||
|
@override Future<void> deleteCloudData() async { called = true; if (fail) throw StateError('network'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('successful deletion preserves rides and clears only deleted owner', () async {
|
||||||
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
|
final owned = await db.startRide();
|
||||||
|
final other = await db.startRide();
|
||||||
|
await db.claimRideForCloudOwner(owned, 'user_a');
|
||||||
|
await db.claimRideForCloudOwner(other, 'user_b');
|
||||||
|
final auth = _Auth();
|
||||||
|
final cloud = _Cloud();
|
||||||
|
|
||||||
|
await AccountLifecycleService(auth: auth, cloud: cloud, db: db).deleteAccount(password: 'secret');
|
||||||
|
|
||||||
|
expect(auth.reauthenticated, isTrue);
|
||||||
|
expect(cloud.called, isTrue);
|
||||||
|
expect(auth.deleted, isTrue);
|
||||||
|
expect(await db.getCloudOwnerUid(owned), isNull);
|
||||||
|
expect(await db.getCloudOwnerUid(other), 'user_b');
|
||||||
|
expect(await db.getCloudRideId(owned), isNotNull);
|
||||||
|
expect((await db.select(db.rides).get()).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cloud failure does not delete auth or clear ownership', () async {
|
||||||
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
|
final ride = await db.startRide();
|
||||||
|
await db.claimRideForCloudOwner(ride, 'user_a');
|
||||||
|
final auth = _Auth();
|
||||||
|
final cloud = _Cloud()..fail = true;
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
AccountLifecycleService(auth: auth, cloud: cloud, db: db).deleteAccount(),
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
expect(auth.deleted, isFalse);
|
||||||
|
expect(await db.getCloudOwnerUid(ride), 'user_a');
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue