feat: Implement role-based session management and refactor authentication flow
This commit is contained in:
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
@@ -159,6 +160,20 @@ class DataConnectService with DataErrorHandler, SessionHandlerMixin {
|
||||
clearCache();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> fetchUserRole(String userId) async {
|
||||
try {
|
||||
final fdc.QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables>
|
||||
response = await executeProtected(
|
||||
() => connector.getUserById(id: userId).execute(),
|
||||
);
|
||||
return response.data.user?.userRole;
|
||||
} catch (e) {
|
||||
debugPrint('Failed to fetch user role: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose all resources (call on app shutdown).
|
||||
Future<void> dispose() async {
|
||||
await disposeSessionHandler();
|
||||
|
||||
@@ -47,9 +47,25 @@ mixin SessionHandlerMixin {
|
||||
final StreamController<SessionState> _sessionStateController =
|
||||
StreamController<SessionState>.broadcast();
|
||||
|
||||
/// Last emitted session state (for late subscribers).
|
||||
SessionState? _lastSessionState;
|
||||
|
||||
/// Public stream for listening to session state changes.
|
||||
Stream<SessionState> get onSessionStateChanged =>
|
||||
_sessionStateController.stream;
|
||||
/// Late subscribers will immediately receive the last emitted state.
|
||||
Stream<SessionState> get onSessionStateChanged {
|
||||
// Create a custom stream that emits the last state before forwarding new events
|
||||
return _createStreamWithLastState();
|
||||
}
|
||||
|
||||
/// Creates a stream that emits the last state before subscribing to new events.
|
||||
Stream<SessionState> _createStreamWithLastState() async* {
|
||||
// If we have a last state, emit it immediately to late subscribers
|
||||
if (_lastSessionState != null) {
|
||||
yield _lastSessionState!;
|
||||
}
|
||||
// Then forward all subsequent events
|
||||
yield* _sessionStateController.stream;
|
||||
}
|
||||
|
||||
/// Last token refresh timestamp to avoid excessive checks.
|
||||
DateTime? _lastTokenRefreshTime;
|
||||
@@ -66,8 +82,13 @@ mixin SessionHandlerMixin {
|
||||
/// Firebase Auth instance (to be provided by implementing class).
|
||||
firebase_auth.FirebaseAuth get auth;
|
||||
|
||||
/// List of allowed roles for this app (to be set during initialization).
|
||||
List<String> _allowedRoles = <String>[];
|
||||
|
||||
/// Initialize the auth state listener (call once on app startup).
|
||||
void initializeAuthListener() {
|
||||
void initializeAuthListener({List<String> allowedRoles = const <String>[]}) {
|
||||
_allowedRoles = allowedRoles;
|
||||
|
||||
// Cancel any existing subscription first
|
||||
_authStateSubscription?.cancel();
|
||||
|
||||
@@ -86,6 +107,25 @@ mixin SessionHandlerMixin {
|
||||
);
|
||||
}
|
||||
|
||||
/// Validates if user has one of the allowed roles.
|
||||
/// Returns true if user role is in allowed roles, false otherwise.
|
||||
Future<bool> validateUserRole(
|
||||
String userId,
|
||||
List<String> allowedRoles,
|
||||
) async {
|
||||
try {
|
||||
final String? userRole = await fetchUserRole(userId);
|
||||
return userRole != null && allowedRoles.contains(userRole);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to validate user role: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches user role from Data Connect.
|
||||
/// To be implemented by concrete class.
|
||||
Future<String?> fetchUserRole(String userId);
|
||||
|
||||
/// Ensures the Firebase auth token is valid and refreshes if needed.
|
||||
/// Retries up to 3 times with exponential backoff before emitting error.
|
||||
Future<void> ensureSessionValid() async {
|
||||
@@ -111,8 +151,8 @@ mixin SessionHandlerMixin {
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
// Get token result (doesn't fetch from network unless needed)
|
||||
final firebase_auth.IdTokenResult idToken =
|
||||
await user.getIdTokenResult();
|
||||
final firebase_auth.IdTokenResult idToken = await user
|
||||
.getIdTokenResult();
|
||||
|
||||
// Extract expiration time
|
||||
final DateTime? expiryTime = idToken.expirationTime;
|
||||
@@ -152,7 +192,9 @@ mixin SessionHandlerMixin {
|
||||
final Duration backoffDuration = Duration(
|
||||
seconds: 1 << (retryCount - 1), // 2^(retryCount-1)
|
||||
);
|
||||
debugPrint('Retrying token validation in ${backoffDuration.inSeconds}s');
|
||||
debugPrint(
|
||||
'Retrying token validation in ${backoffDuration.inSeconds}s',
|
||||
);
|
||||
await Future<void>.delayed(backoffDuration);
|
||||
}
|
||||
}
|
||||
@@ -163,6 +205,19 @@ mixin SessionHandlerMixin {
|
||||
try {
|
||||
_emitSessionState(SessionState.loading());
|
||||
|
||||
// Validate role if allowed roles are specified
|
||||
if (_allowedRoles.isNotEmpty) {
|
||||
final bool isAuthorized = await validateUserRole(
|
||||
user.uid,
|
||||
_allowedRoles,
|
||||
);
|
||||
if (!isAuthorized) {
|
||||
await auth.signOut();
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get fresh token to validate session
|
||||
final firebase_auth.IdTokenResult idToken = await user.getIdTokenResult();
|
||||
if (idToken.expirationTime != null &&
|
||||
@@ -186,6 +241,7 @@ mixin SessionHandlerMixin {
|
||||
|
||||
/// Emit session state update.
|
||||
void _emitSessionState(SessionState state) {
|
||||
_lastSessionState = state;
|
||||
if (!_sessionStateController.isClosed) {
|
||||
_sessionStateController.add(state);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user