integration, some parts works
This commit is contained in:
parent
ca166eb661
commit
90d34aed42
|
|
@ -13,7 +13,7 @@ Production-ready Kotlin/Spring Boot 3 modular monolith skeleton for patient-care
|
|||
docker compose up -d
|
||||
```
|
||||
|
||||
2) Run the API:
|
||||
2) Run the API (JWT resource server):
|
||||
|
||||
```bash
|
||||
./gradlew :app:bootRun -Dspring.profiles.active=local
|
||||
|
|
@ -25,14 +25,20 @@ docker compose up -d
|
|||
./gradlew :workers:notification-worker:bootRun
|
||||
```
|
||||
|
||||
## Auth (local profile)
|
||||
For local development, add headers:
|
||||
- `X-Local-User`: user id
|
||||
- `X-Local-Tenant`: tenant id
|
||||
## Auth
|
||||
- The backend is a JWT resource server and does not handle user passwords.
|
||||
- Local auth shortcut is available only when `SPRING_PROFILES_ACTIVE=local`
|
||||
and `ALLOW_LOCAL_AUTH=true`.
|
||||
|
||||
Local headers (dev only):
|
||||
- `X-Local-Email`: user id/email
|
||||
- `X-Local-Roles`: comma-separated roles (ADMIN, DOCTOR, CAREGIVER)
|
||||
- `X-Tenant-Id`: tenant id
|
||||
|
||||
## OpenAPI
|
||||
- http://localhost:8080/swagger-ui/index.html
|
||||
## Health
|
||||
- http://localhost:8080/health
|
||||
|
||||
## Key services
|
||||
- Postgres: localhost:5432 (mosenioring/mosenioring)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package com.mosenioring.app.api
|
||||
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
class HealthController {
|
||||
|
||||
@GetMapping("/health")
|
||||
fun health(): ResponseEntity<Map<String, String>> {
|
||||
return ResponseEntity.ok(mapOf("status" to "ok"))
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ class SecurityConfig {
|
|||
http
|
||||
.csrf { it.disable() }
|
||||
.authorizeHttpRequests {
|
||||
it.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
||||
it.requestMatchers("/actuator/**", "/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
||||
it.requestMatchers(HttpMethod.POST, "/api/v1/tenants").hasRole("ADMIN")
|
||||
it.anyRequest().authenticated()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import jakarta.servlet.FilterChain
|
|||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
|
|
@ -12,15 +13,21 @@ import org.springframework.web.filter.OncePerRequestFilter
|
|||
|
||||
@Component
|
||||
@Profile("local")
|
||||
class LocalAuthFilter : OncePerRequestFilter() {
|
||||
class LocalAuthFilter(
|
||||
@Value("\${ALLOW_LOCAL_AUTH:false}") private val allowLocalAuth: Boolean
|
||||
) : OncePerRequestFilter() {
|
||||
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
filterChain: FilterChain
|
||||
) {
|
||||
val userId = request.getHeader("X-Local-User")
|
||||
val tenantId = request.getHeader("X-Local-Tenant")
|
||||
if (!allowLocalAuth) {
|
||||
filterChain.doFilter(request, response)
|
||||
return
|
||||
}
|
||||
val userId = request.getHeader("X-Local-User") ?: request.getHeader("X-Local-Email")
|
||||
val tenantId = request.getHeader("X-Local-Tenant") ?: request.getHeader("X-Tenant-Id")
|
||||
val rolesHeader = request.getHeader("X-Local-Roles")
|
||||
if (!userId.isNullOrBlank() && !tenantId.isNullOrBlank()) {
|
||||
val roles = rolesHeader?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() }?.toSet() ?: emptySet()
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import jakarta.servlet.FilterChain
|
|||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.core.env.Environment
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
|
||||
@Component
|
||||
class TenantFilter(
|
||||
@Value("\${app.tenant.header:X-Tenant-Id}") private val tenantHeader: String
|
||||
@Value("\${app.tenant.header:X-Tenant-Id}") private val tenantHeader: String,
|
||||
private val environment: Environment
|
||||
) : OncePerRequestFilter() {
|
||||
|
||||
override fun doFilterInternal(
|
||||
|
|
@ -18,8 +20,13 @@ class TenantFilter(
|
|||
response: HttpServletResponse,
|
||||
filterChain: FilterChain
|
||||
) {
|
||||
val tenantId = SecurityUtils.currentTenantId()
|
||||
?: request.getHeader(tenantHeader)
|
||||
val tenantIdFromToken = SecurityUtils.currentTenantId()
|
||||
val tenantIdFromHeader = if (environment.activeProfiles.contains("local")) {
|
||||
request.getHeader(tenantHeader)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val tenantId = tenantIdFromToken ?: tenantIdFromHeader
|
||||
TenantContext.setTenantId(tenantId)
|
||||
try {
|
||||
filterChain.doFilter(request, response)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class ProblemDetailsAdvice {
|
|||
val detail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST)
|
||||
detail.title = "Validation failed"
|
||||
detail.type = java.net.URI.create("https://httpstatuses.io/400")
|
||||
detail.setProperty("code", "VALIDATION_FAILED")
|
||||
detail.setProperty("path", request.requestURI)
|
||||
detail.setProperty("errors", ex.bindingResult.fieldErrors.map { it.toErrorMap() })
|
||||
return detail
|
||||
|
|
@ -25,6 +26,7 @@ class ProblemDetailsAdvice {
|
|||
@ExceptionHandler(ErrorResponseException::class)
|
||||
fun handleErrorResponse(ex: ErrorResponseException, request: HttpServletRequest): ProblemDetail {
|
||||
val detail = ex.body
|
||||
detail.setProperty("code", "ERROR_RESPONSE")
|
||||
detail.setProperty("path", request.requestURI)
|
||||
return detail
|
||||
}
|
||||
|
|
@ -35,6 +37,7 @@ class ProblemDetailsAdvice {
|
|||
detail.title = "Invalid request"
|
||||
detail.detail = ex.message
|
||||
detail.type = java.net.URI.create("https://httpstatuses.io/400")
|
||||
detail.setProperty("code", "INVALID_REQUEST")
|
||||
detail.setProperty("path", request.requestURI)
|
||||
return detail
|
||||
}
|
||||
|
|
@ -45,6 +48,18 @@ class ProblemDetailsAdvice {
|
|||
detail.title = "Conflict"
|
||||
detail.detail = ex.message
|
||||
detail.type = java.net.URI.create("https://httpstatuses.io/409")
|
||||
detail.setProperty("code", "CONFLICT")
|
||||
detail.setProperty("path", request.requestURI)
|
||||
return detail
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception::class)
|
||||
fun handleUnexpected(ex: Exception, request: HttpServletRequest): ProblemDetail {
|
||||
val detail = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
detail.title = "Unexpected error"
|
||||
detail.detail = ex.message
|
||||
detail.type = java.net.URI.create("https://httpstatuses.io/500")
|
||||
detail.setProperty("code", "UNEXPECTED_ERROR")
|
||||
detail.setProperty("path", request.requestURI)
|
||||
return detail
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,24 @@ management, and a login flow ready to integrate with a Swagger/OpenAPI backend.
|
|||
|
||||
## Configuration
|
||||
|
||||
Update `lib/src/di/providers.dart` with your API base URL.
|
||||
Runtime configuration is provided via `--dart-define` values.
|
||||
|
||||
Auth + environment flags:
|
||||
- `API_BASE_URL` (default: `http://localhost:8080`)
|
||||
- `USE_LOCAL_AUTH` (`true`/`false`, default: `false`)
|
||||
- `LOCAL_TENANT_ID` (default: `local-tenant`)
|
||||
- `LOCAL_ROLES` (default: `CAREGIVER`)
|
||||
- `KEYCLOAK_ISSUER_URI` or `KEYCLOAK_ISSUER` (default: `http://localhost:8081/realms/mosenioring`)
|
||||
- `KEYCLOAK_CLIENT_ID` (default: `mosenioring-mobile`)
|
||||
- `KEYCLOAK_REDIRECT_URL` (default: `com.mosenioring.app://oauth2redirect`)
|
||||
|
||||
Local dev behavior:
|
||||
- When `USE_LOCAL_AUTH=true`, the app keeps the email/password fields and sends
|
||||
`X-Local-Email`, `X-Local-Roles`, and `X-Tenant-Id` headers to the backend.
|
||||
- The backend must run with `SPRING_PROFILES_ACTIVE=local` and `ALLOW_LOCAL_AUTH=true`.
|
||||
|
||||
Keycloak redirect URI to register:
|
||||
- `com.mosenioring.app://oauth2redirect` (Android + iOS)
|
||||
|
||||
## Swagger/OpenAPI integration
|
||||
|
||||
|
|
@ -29,3 +46,24 @@ When the spec is ready, generate a client and replace `ApiClient` usage:
|
|||
flutter pub get
|
||||
flutter run
|
||||
```
|
||||
|
||||
Quick start (Keycloak mode):
|
||||
```sh
|
||||
flutter run \
|
||||
--dart-define=API_BASE_URL=http://localhost:8080 \
|
||||
--dart-define=KEYCLOAK_ISSUER_URI=http://localhost:8081/realms/mosenioring \
|
||||
--dart-define=KEYCLOAK_CLIENT_ID=mosenioring-mobile \
|
||||
--dart-define=KEYCLOAK_REDIRECT_URL=com.mosenioring.app://oauth2redirect
|
||||
```
|
||||
|
||||
Quick start (local auth, no Keycloak):
|
||||
```sh
|
||||
export SPRING_PROFILES_ACTIVE=local
|
||||
export ALLOW_LOCAL_AUTH=true
|
||||
|
||||
flutter run \
|
||||
--dart-define=API_BASE_URL=http://localhost:8080 \
|
||||
--dart-define=USE_LOCAL_AUTH=true \
|
||||
--dart-define=LOCAL_TENANT_ID=local-tenant \
|
||||
--dart-define=LOCAL_ROLES=CAREGIVER
|
||||
```
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ android {
|
|||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
manifestPlaceholders["appAuthRedirectScheme"] = "com.mosenioring.app"
|
||||
manifestPlaceholders["appAuthRedirectHost"] = "oauth2redirect"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@
|
|||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data
|
||||
android:scheme="${appAuthRedirectScheme}"
|
||||
android:host="${appAuthRedirectHost}"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
|
|
|||
|
|
@ -45,5 +45,16 @@
|
|||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.mosenioring.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
class AppConfig {
|
||||
const AppConfig({required this.apiBaseUrl});
|
||||
const AppConfig({
|
||||
required this.apiBaseUrl,
|
||||
required this.useLocalAuth,
|
||||
required this.localTenantId,
|
||||
required this.localRoles,
|
||||
required this.keycloakIssuer,
|
||||
required this.keycloakClientId,
|
||||
required this.keycloakRedirectUrl,
|
||||
});
|
||||
|
||||
final String apiBaseUrl;
|
||||
final bool useLocalAuth;
|
||||
final String localTenantId;
|
||||
final String localRoles;
|
||||
final String keycloakIssuer;
|
||||
final String keycloakClientId;
|
||||
final String keycloakRedirectUrl;
|
||||
|
||||
String get keycloakDiscoveryUrl =>
|
||||
'$keycloakIssuer/.well-known/openid-configuration';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,17 +5,33 @@ class ApiClient {
|
|||
|
||||
final Dio _dio;
|
||||
|
||||
Future<Response<T>> request<T>(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
Object? data,
|
||||
Map<String, dynamic>? query,
|
||||
Options? options,
|
||||
}) {
|
||||
final requestOptions = (options ?? Options()).copyWith(method: method);
|
||||
return _dio.request<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: query,
|
||||
options: requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? query,
|
||||
}) {
|
||||
return _dio.get<T>(path, queryParameters: query);
|
||||
return request<T>(path, query: query);
|
||||
}
|
||||
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
Object? data,
|
||||
}) {
|
||||
return _dio.post<T>(path, data: data);
|
||||
return request<T>(path, method: 'POST', data: data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
|
|
@ -12,7 +13,39 @@ import '../features/auth/presentation/auth_controller.dart';
|
|||
import '../features/auth/presentation/auth_state.dart';
|
||||
|
||||
final appConfigProvider = Provider<AppConfig>((ref) {
|
||||
return const AppConfig(apiBaseUrl: 'https://api.example.com');
|
||||
const apiBaseUrl =
|
||||
String.fromEnvironment('API_BASE_URL', defaultValue: 'http://localhost:8080');
|
||||
const useLocalAuth =
|
||||
bool.fromEnvironment('USE_LOCAL_AUTH', defaultValue: false);
|
||||
const localTenantId =
|
||||
String.fromEnvironment('LOCAL_TENANT_ID', defaultValue: 'local-tenant');
|
||||
const localRoles =
|
||||
String.fromEnvironment('LOCAL_ROLES', defaultValue: 'CAREGIVER');
|
||||
const keycloakIssuer = String.fromEnvironment(
|
||||
'KEYCLOAK_ISSUER',
|
||||
defaultValue: '',
|
||||
);
|
||||
const keycloakIssuerUri = String.fromEnvironment(
|
||||
'KEYCLOAK_ISSUER_URI',
|
||||
defaultValue: 'http://localhost:8081/realms/mosenioring',
|
||||
);
|
||||
final resolvedIssuer =
|
||||
keycloakIssuer.isNotEmpty ? keycloakIssuer : keycloakIssuerUri;
|
||||
const keycloakClientId =
|
||||
String.fromEnvironment('KEYCLOAK_CLIENT_ID', defaultValue: 'mosenioring-mobile');
|
||||
const keycloakRedirectUrl = String.fromEnvironment(
|
||||
'KEYCLOAK_REDIRECT_URL',
|
||||
defaultValue: 'com.mosenioring.app://oauth2redirect',
|
||||
);
|
||||
return AppConfig(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
useLocalAuth: useLocalAuth,
|
||||
localTenantId: localTenantId,
|
||||
localRoles: localRoles,
|
||||
keycloakIssuer: resolvedIssuer,
|
||||
keycloakClientId: keycloakClientId,
|
||||
keycloakRedirectUrl: keycloakRedirectUrl,
|
||||
);
|
||||
});
|
||||
|
||||
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
|
||||
|
|
@ -23,9 +56,14 @@ final authLocalDataSourceProvider = Provider<AuthLocalDataSource>((ref) {
|
|||
return AuthLocalDataSource(ref.watch(secureStorageProvider));
|
||||
});
|
||||
|
||||
final flutterAppAuthProvider = Provider<FlutterAppAuth>((ref) {
|
||||
return const FlutterAppAuth();
|
||||
});
|
||||
|
||||
final dioProvider = Provider<Dio>((ref) {
|
||||
final config = ref.watch(appConfigProvider);
|
||||
final localDataSource = ref.watch(authLocalDataSourceProvider);
|
||||
final authRemoteDataSource = ref.watch(authRemoteDataSourceProvider);
|
||||
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
|
|
@ -38,12 +76,49 @@ final dioProvider = Provider<Dio>((ref) {
|
|||
dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final token = await localDataSource.readToken();
|
||||
if (token?.accessToken.isNotEmpty == true) {
|
||||
options.headers['Authorization'] = 'Bearer ${token!.accessToken}';
|
||||
if (config.useLocalAuth) {
|
||||
final session = await localDataSource.readLocalSession();
|
||||
if (session != null) {
|
||||
options.headers['X-Local-Email'] = session.email;
|
||||
options.headers['X-Local-Roles'] = session.roles;
|
||||
options.headers['X-Tenant-Id'] = session.tenantId;
|
||||
}
|
||||
} else {
|
||||
final token = await localDataSource.readToken();
|
||||
if (token?.accessToken.isNotEmpty == true) {
|
||||
options.headers['Authorization'] = 'Bearer ${token!.accessToken}';
|
||||
}
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (error, handler) async {
|
||||
if (config.useLocalAuth) {
|
||||
handler.next(error);
|
||||
return;
|
||||
}
|
||||
final response = error.response;
|
||||
final requestOptions = error.requestOptions;
|
||||
if (response?.statusCode != 401 || requestOptions.extra['retried'] == true) {
|
||||
handler.next(error);
|
||||
return;
|
||||
}
|
||||
final refreshToken = (await localDataSource.readToken())?.refreshToken;
|
||||
if (refreshToken == null || refreshToken.isEmpty) {
|
||||
handler.next(error);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final newToken =
|
||||
await authRemoteDataSource.refreshToken(refreshToken: refreshToken);
|
||||
await localDataSource.saveToken(newToken);
|
||||
requestOptions.extra['retried'] = true;
|
||||
requestOptions.headers['Authorization'] = 'Bearer ${newToken.accessToken}';
|
||||
final retryResponse = await dio.fetch(requestOptions);
|
||||
handler.resolve(retryResponse);
|
||||
} catch (_) {
|
||||
handler.next(error);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -55,7 +130,10 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
|||
});
|
||||
|
||||
final authRemoteDataSourceProvider = Provider<AuthRemoteDataSource>((ref) {
|
||||
return AuthRemoteDataSource(ref.watch(dioProvider));
|
||||
return AuthRemoteDataSource(
|
||||
ref.watch(flutterAppAuthProvider),
|
||||
ref.watch(appConfigProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ class AuthLocalDataSource {
|
|||
|
||||
static const _accessTokenKey = 'access_token';
|
||||
static const _refreshTokenKey = 'refresh_token';
|
||||
static const _localEmailKey = 'local_email';
|
||||
static const _localRolesKey = 'local_roles';
|
||||
static const _localTenantKey = 'local_tenant';
|
||||
|
||||
Future<AuthToken?> readToken() async {
|
||||
final accessToken = await _storage.read(key: _accessTokenKey);
|
||||
|
|
@ -26,8 +29,47 @@ class AuthLocalDataSource {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> saveLocalSession({
|
||||
required String email,
|
||||
required String roles,
|
||||
required String tenantId,
|
||||
}) async {
|
||||
await _storage.write(key: _localEmailKey, value: email);
|
||||
await _storage.write(key: _localRolesKey, value: roles);
|
||||
await _storage.write(key: _localTenantKey, value: tenantId);
|
||||
}
|
||||
|
||||
Future<LocalAuthSession?> readLocalSession() async {
|
||||
final email = await _storage.read(key: _localEmailKey);
|
||||
final roles = await _storage.read(key: _localRolesKey);
|
||||
final tenantId = await _storage.read(key: _localTenantKey);
|
||||
if (email == null || tenantId == null) {
|
||||
return null;
|
||||
}
|
||||
return LocalAuthSession(
|
||||
email: email,
|
||||
roles: roles?.isNotEmpty == true ? roles! : 'CAREGIVER',
|
||||
tenantId: tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await _storage.delete(key: _accessTokenKey);
|
||||
await _storage.delete(key: _refreshTokenKey);
|
||||
await _storage.delete(key: _localEmailKey);
|
||||
await _storage.delete(key: _localRolesKey);
|
||||
await _storage.delete(key: _localTenantKey);
|
||||
}
|
||||
}
|
||||
|
||||
class LocalAuthSession {
|
||||
const LocalAuthSession({
|
||||
required this.email,
|
||||
required this.roles,
|
||||
required this.tenantId,
|
||||
});
|
||||
|
||||
final String email;
|
||||
final String roles;
|
||||
final String tenantId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,62 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||
|
||||
import '../../../core/config/app_config.dart';
|
||||
import '../domain/models/auth_token.dart';
|
||||
|
||||
class AuthRemoteDataSource {
|
||||
AuthRemoteDataSource(this._dio);
|
||||
AuthRemoteDataSource(this._appAuth, this._config);
|
||||
|
||||
final Dio _dio;
|
||||
final FlutterAppAuth _appAuth;
|
||||
final AppConfig _config;
|
||||
|
||||
Future<AuthToken> login({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
final response = await _dio.post(
|
||||
'/auth/login',
|
||||
data: <String, dynamic>{
|
||||
'email': email,
|
||||
'password': password,
|
||||
},
|
||||
final _ = password;
|
||||
final response = await _appAuth.authorizeAndExchangeCode(
|
||||
AuthorizationTokenRequest(
|
||||
_config.keycloakClientId,
|
||||
_config.keycloakRedirectUrl,
|
||||
discoveryUrl: _config.keycloakDiscoveryUrl,
|
||||
loginHint: email,
|
||||
promptValues: const ['login'],
|
||||
scopes: const ['openid', 'profile', 'offline_access'],
|
||||
),
|
||||
);
|
||||
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw Exception('Unexpected login response');
|
||||
}
|
||||
|
||||
final accessToken =
|
||||
(data['access_token'] as String?) ?? (data['token'] as String?) ?? '';
|
||||
if (accessToken.isEmpty) {
|
||||
final accessToken = response?.accessToken;
|
||||
if (accessToken == null || accessToken.isEmpty) {
|
||||
throw Exception('Missing access token');
|
||||
}
|
||||
|
||||
return AuthToken(
|
||||
accessToken: accessToken,
|
||||
refreshToken: data['refresh_token'] as String?,
|
||||
refreshToken: response?.refreshToken,
|
||||
);
|
||||
}
|
||||
|
||||
Future<AuthToken> refreshToken({
|
||||
required String refreshToken,
|
||||
}) async {
|
||||
final response = await _appAuth.token(
|
||||
TokenRequest(
|
||||
_config.keycloakClientId,
|
||||
_config.keycloakRedirectUrl,
|
||||
discoveryUrl: _config.keycloakDiscoveryUrl,
|
||||
refreshToken: refreshToken,
|
||||
scopes: const ['openid', 'profile', 'offline_access'],
|
||||
),
|
||||
);
|
||||
|
||||
final accessToken = response?.accessToken;
|
||||
if (accessToken == null || accessToken.isEmpty) {
|
||||
throw Exception('Missing access token');
|
||||
}
|
||||
|
||||
return AuthToken(
|
||||
accessToken: accessToken,
|
||||
refreshToken: response?.refreshToken ?? refreshToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../di/providers.dart';
|
||||
import '../data/auth_local_data_source.dart';
|
||||
import '../domain/auth_repository.dart';
|
||||
import '../domain/models/auth_token.dart';
|
||||
import 'auth_state.dart';
|
||||
|
||||
class AuthController extends Notifier<AuthState> {
|
||||
|
|
@ -12,9 +14,19 @@ class AuthController extends Notifier<AuthState> {
|
|||
}
|
||||
|
||||
AuthRepository get _repository => ref.watch(authRepositoryProvider);
|
||||
AuthLocalDataSource get _localDataSource => ref.watch(authLocalDataSourceProvider);
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||
final config = ref.read(appConfigProvider);
|
||||
if (config.useLocalAuth) {
|
||||
final localSession = await _localDataSource.readLocalSession();
|
||||
state = AuthState(
|
||||
isLoading: false,
|
||||
token: localSession != null ? const AuthToken(accessToken: 'local') : null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final token = await _repository.getSavedToken();
|
||||
state = AuthState(isLoading: false, token: token);
|
||||
}
|
||||
|
|
@ -25,12 +37,24 @@ class AuthController extends Notifier<AuthState> {
|
|||
}) async {
|
||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||
try {
|
||||
final config = ref.read(appConfigProvider);
|
||||
if (config.useLocalAuth) {
|
||||
await _localDataSource.saveLocalSession(
|
||||
email: email,
|
||||
roles: config.localRoles,
|
||||
tenantId: config.localTenantId,
|
||||
);
|
||||
const token = AuthToken(accessToken: 'local');
|
||||
await _repository.persistToken(token);
|
||||
state = const AuthState(isLoading: false, token: token);
|
||||
return;
|
||||
}
|
||||
final token = await _repository.login(email: email, password: password);
|
||||
state = AuthState(isLoading: false, token: token);
|
||||
} catch (error) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: error.toString(),
|
||||
errorMessage: _friendlyError(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -39,4 +63,12 @@ class AuthController extends Notifier<AuthState> {
|
|||
await _repository.logout();
|
||||
state = const AuthState();
|
||||
}
|
||||
|
||||
String _friendlyError(Object error) {
|
||||
final message = error.toString();
|
||||
if (message.startsWith('Exception: ')) {
|
||||
return message.substring('Exception: '.length);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final config = ref.watch(appConfigProvider);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
|
|
@ -99,6 +100,13 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||
),
|
||||
),
|
||||
],
|
||||
if (config.useLocalAuth) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Local dev mode enabled',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@
|
|||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_appauth
|
||||
import flutter_secure_storage_darwin
|
||||
import path_provider_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ dependencies:
|
|||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
dio: ^5.7.0
|
||||
flutter_appauth: ^11.0.0
|
||||
flutter_riverpod: ^3.1.0
|
||||
flutter_secure_storage: ^10.0.0
|
||||
go_router: ^17.0.1
|
||||
|
|
|
|||
Loading…
Reference in a new issue