feat: complete leave approval MVP
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
class AuthSession {
|
||||
const AuthSession({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.idToken,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String? refreshToken;
|
||||
final String? idToken;
|
||||
final DateTime expiresAt;
|
||||
|
||||
bool get needsRefresh => expiresAt.isBefore(
|
||||
DateTime.now().toUtc().add(const Duration(minutes: 1)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:aioa_mobile/core/auth/auth_session.dart';
|
||||
import 'package:aioa_mobile/core/config/runtime_config.dart';
|
||||
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
String get oidcIssuer => RuntimeConfig.oidcIssuer;
|
||||
const oidcClientId = 'aioa-mobile';
|
||||
const oidcRedirectUrl = 'aioa://oauth/callback';
|
||||
const oidcLogoutRedirectUrl = 'aioa://oauth/logout';
|
||||
|
||||
final authSessionProvider =
|
||||
AsyncNotifierProvider<AuthSessionController, AuthSession?>(
|
||||
AuthSessionController.new,
|
||||
);
|
||||
|
||||
class AuthSessionController extends AsyncNotifier<AuthSession?> {
|
||||
static const _storage = FlutterSecureStorage();
|
||||
static const _appAuth = FlutterAppAuth();
|
||||
|
||||
@override
|
||||
Future<AuthSession?> build() => _restore();
|
||||
|
||||
Future<void> login() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final result = await _appAuth.authorizeAndExchangeCode(
|
||||
AuthorizationTokenRequest(
|
||||
oidcClientId,
|
||||
oidcRedirectUrl,
|
||||
issuer: oidcIssuer,
|
||||
scopes: const ['openid', 'profile', 'email', 'offline_access'],
|
||||
promptValues: const ['login'],
|
||||
allowInsecureConnections: oidcIssuer.startsWith('http://'),
|
||||
),
|
||||
);
|
||||
return _storeResponse(result);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
final current = state.value;
|
||||
try {
|
||||
if (current?.idToken != null) {
|
||||
await _appAuth.endSession(
|
||||
EndSessionRequest(
|
||||
idTokenHint: current!.idToken,
|
||||
postLogoutRedirectUrl: oidcLogoutRedirectUrl,
|
||||
issuer: oidcIssuer,
|
||||
allowInsecureConnections: oidcIssuer.startsWith('http://'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await _clear();
|
||||
state = const AsyncData(null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> invalidateDeviceSession() async {
|
||||
await _clear();
|
||||
state = const AsyncData(null);
|
||||
}
|
||||
|
||||
Future<String?> validAccessToken() async {
|
||||
final current = state.value;
|
||||
if (current == null) return null;
|
||||
if (!current.needsRefresh) return current.accessToken;
|
||||
final refreshToken = current.refreshToken;
|
||||
if (refreshToken == null) {
|
||||
await _clear();
|
||||
state = const AsyncData(null);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final refreshed = await _refresh(current);
|
||||
state = AsyncData(refreshed);
|
||||
return refreshed.accessToken;
|
||||
} catch (_) {
|
||||
await _clear();
|
||||
state = const AsyncData(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<AuthSession?> _restore() async {
|
||||
final values = await Future.wait([
|
||||
_storage.read(key: 'access_token'),
|
||||
_storage.read(key: 'refresh_token'),
|
||||
_storage.read(key: 'id_token'),
|
||||
_storage.read(key: 'expires_at'),
|
||||
]);
|
||||
final accessToken = values[0];
|
||||
final expiresAt = DateTime.tryParse(values[3] ?? '');
|
||||
if (accessToken == null || expiresAt == null) return null;
|
||||
final session = AuthSession(
|
||||
accessToken: accessToken,
|
||||
refreshToken: values[1],
|
||||
idToken: values[2],
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
if (session.needsRefresh) {
|
||||
if (session.refreshToken == null) {
|
||||
await _clear();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await _refresh(session);
|
||||
} catch (_) {
|
||||
await _clear();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
Future<AuthSession> _refresh(AuthSession current) async {
|
||||
final response = await _appAuth.token(
|
||||
TokenRequest(
|
||||
oidcClientId,
|
||||
oidcRedirectUrl,
|
||||
issuer: oidcIssuer,
|
||||
refreshToken: current.refreshToken,
|
||||
scopes: const ['openid', 'profile', 'email', 'offline_access'],
|
||||
allowInsecureConnections: oidcIssuer.startsWith('http://'),
|
||||
),
|
||||
);
|
||||
return _storeResponse(response, fallback: current);
|
||||
}
|
||||
|
||||
Future<AuthSession> _storeResponse(
|
||||
TokenResponse response, {
|
||||
AuthSession? fallback,
|
||||
}) async {
|
||||
final accessToken = response.accessToken ?? fallback?.accessToken;
|
||||
final expiresAt =
|
||||
response.accessTokenExpirationDateTime ?? fallback?.expiresAt;
|
||||
if (accessToken == null || expiresAt == null) {
|
||||
throw StateError('OIDC token response is incomplete');
|
||||
}
|
||||
final session = AuthSession(
|
||||
accessToken: accessToken,
|
||||
refreshToken: response.refreshToken ?? fallback?.refreshToken,
|
||||
idToken: response.idToken ?? fallback?.idToken,
|
||||
expiresAt: expiresAt.toUtc(),
|
||||
);
|
||||
await Future.wait([
|
||||
_storage.write(key: 'access_token', value: session.accessToken),
|
||||
_storage.write(key: 'refresh_token', value: session.refreshToken),
|
||||
_storage.write(key: 'id_token', value: session.idToken),
|
||||
_storage.write(
|
||||
key: 'expires_at',
|
||||
value: session.expiresAt.toIso8601String(),
|
||||
),
|
||||
]);
|
||||
return session;
|
||||
}
|
||||
|
||||
Future<void> _clear() async {
|
||||
await Future.wait([
|
||||
_storage.delete(key: 'access_token'),
|
||||
_storage.delete(key: 'refresh_token'),
|
||||
_storage.delete(key: 'id_token'),
|
||||
_storage.delete(key: 'expires_at'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
|
||||
import 'package:aioa_mobile/core/config/runtime_config.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
String get apiBaseUrl => RuntimeConfig.apiBaseUrl;
|
||||
|
||||
final authenticatedHttpClientProvider = Provider<http.Client>((ref) {
|
||||
final inner = http.Client();
|
||||
return AuthenticatedHttpClient(
|
||||
inner: inner,
|
||||
apiOrigin: Uri.parse(apiBaseUrl).origin,
|
||||
tokenProvider: ref.read(authSessionProvider.notifier).validAccessToken,
|
||||
deviceRegistration: ref.watch(deviceRegistrationProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final deviceRegistrationProvider = Provider<DeviceRegistration>((ref) {
|
||||
return DeviceRegistration(
|
||||
inner: http.Client(),
|
||||
baseUrl: apiBaseUrl,
|
||||
onRevoked: ref.read(authSessionProvider.notifier).invalidateDeviceSession,
|
||||
);
|
||||
});
|
||||
|
||||
class AuthenticatedHttpClient extends http.BaseClient {
|
||||
AuthenticatedHttpClient({
|
||||
required this.inner,
|
||||
required this.apiOrigin,
|
||||
required this.tokenProvider,
|
||||
this.deviceRegistration,
|
||||
});
|
||||
|
||||
final http.Client inner;
|
||||
final String apiOrigin;
|
||||
final Future<String?> Function() tokenProvider;
|
||||
final DeviceRegistration? deviceRegistration;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
if (request.url.origin == apiOrigin &&
|
||||
!request.headers.containsKey('Authorization')) {
|
||||
final token = await tokenProvider();
|
||||
if (token != null) {
|
||||
request.headers['Authorization'] = 'Bearer $token';
|
||||
final registration = deviceRegistration;
|
||||
if (registration != null) {
|
||||
request.headers['X-AIOA-Device-Id'] = await registration.ensure(
|
||||
token,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
final response = await inner.send(request);
|
||||
if (response.headers['x-aioa-auth-error'] == 'DEVICE_REVOKED') {
|
||||
await deviceRegistration?.revoked();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() => inner.close();
|
||||
}
|
||||
|
||||
class DeviceRegistration {
|
||||
DeviceRegistration({
|
||||
required this.inner,
|
||||
required this.baseUrl,
|
||||
required this.onRevoked,
|
||||
});
|
||||
|
||||
static const _storage = FlutterSecureStorage();
|
||||
final http.Client inner;
|
||||
final String baseUrl;
|
||||
final Future<void> Function() onRevoked;
|
||||
String? _registeredToken;
|
||||
String? _deviceId;
|
||||
|
||||
Future<String> ensure(String token) async {
|
||||
final id = _deviceId ??= await _loadOrCreateId();
|
||||
if (_registeredToken == token) return id;
|
||||
final platform = Platform.isIOS
|
||||
? 'IOS'
|
||||
: Platform.isAndroid
|
||||
? 'ANDROID'
|
||||
: 'OTHER';
|
||||
final response = await inner.post(
|
||||
Uri.parse('$baseUrl/devices/register'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'id': id,
|
||||
'name': Platform.localHostname.isEmpty
|
||||
? '$platform 设备'
|
||||
: Platform.localHostname,
|
||||
'platform': platform,
|
||||
}),
|
||||
);
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
await revoked();
|
||||
throw const DeviceRevokedException();
|
||||
}
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw DeviceRegistrationException(response.statusCode);
|
||||
}
|
||||
_registeredToken = token;
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<void> revoked() async {
|
||||
_registeredToken = null;
|
||||
await onRevoked();
|
||||
}
|
||||
|
||||
Future<String> currentId() async => _deviceId ??= await _loadOrCreateId();
|
||||
|
||||
Future<String> _loadOrCreateId() async {
|
||||
final existing = await _storage.read(key: 'device_id');
|
||||
if (existing != null) return existing;
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
String hex(int start, int end) => bytes
|
||||
.sublist(start, end)
|
||||
.map((value) => value.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final id =
|
||||
'${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}';
|
||||
await _storage.write(key: 'device_id', value: id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceRevokedException implements Exception {
|
||||
const DeviceRevokedException();
|
||||
}
|
||||
|
||||
class DeviceRegistrationException implements Exception {
|
||||
const DeviceRegistrationException(this.statusCode);
|
||||
final int statusCode;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:aioa_mobile/app/theme.dart';
|
||||
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class LoginPage extends ConsumerWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authSessionProvider);
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAioaTheme(),
|
||||
home: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 36,
|
||||
child: Icon(Icons.apartment_rounded, size: 38),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'AIOA',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium
|
||||
?.copyWith(fontWeight: FontWeight.w900),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('使用企业账号安全登录', textAlign: TextAlign.center),
|
||||
if (auth.hasError) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'登录失败:${auth.error}',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 28),
|
||||
FilledButton.icon(
|
||||
onPressed: auth.isLoading
|
||||
? null
|
||||
: ref.read(authSessionProvider.notifier).login,
|
||||
icon: auth.isLoading
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.login),
|
||||
label: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 14),
|
||||
child: Text('Keycloak 登录'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user