결재 API 계약 보완 및 테스트 정리
This commit is contained in:
82
lib/features/auth/application/auth_service.dart
Normal file
82
lib/features/auth/application/auth_service.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/network/interceptors/auth_interceptor.dart';
|
||||
import '../../../core/services/token_storage.dart';
|
||||
import '../domain/entities/auth_session.dart';
|
||||
import '../domain/entities/login_request.dart';
|
||||
import '../domain/repositories/auth_repository.dart';
|
||||
|
||||
/// 인증 세션을 관리하고 토큰을 보관하는 서비스.
|
||||
class AuthService extends ChangeNotifier {
|
||||
AuthService({
|
||||
required AuthRepository repository,
|
||||
required TokenStorage tokenStorage,
|
||||
}) : _repository = repository,
|
||||
_tokenStorage = tokenStorage;
|
||||
|
||||
final AuthRepository _repository;
|
||||
final TokenStorage _tokenStorage;
|
||||
|
||||
AuthSession? _session;
|
||||
bool _rememberMe = false;
|
||||
|
||||
/// 현재 로그인된 세션 (없으면 null)
|
||||
AuthSession? get session => _session;
|
||||
|
||||
/// 사용자가 마지막으로 선택한 자동 로그인 여부
|
||||
bool get rememberMe => _rememberMe;
|
||||
|
||||
/// 로그인 후 세션을 저장하고 토큰을 보관한다.
|
||||
Future<AuthSession> login(LoginRequest request) async {
|
||||
final session = await _repository.login(request);
|
||||
_rememberMe = request.rememberMe;
|
||||
await _persistSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/// 저장된 리프레시 토큰으로 세션을 갱신한다.
|
||||
Future<AuthSession?> refreshSession() async {
|
||||
final refreshToken = await _tokenStorage.readRefreshToken();
|
||||
if (refreshToken == null || refreshToken.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final session = await _repository.refresh(refreshToken);
|
||||
await _persistSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/// 앱 내에서 명시적으로 로그아웃할 때 호출한다.
|
||||
Future<void> clearSession() async {
|
||||
_session = null;
|
||||
_rememberMe = false;
|
||||
await _tokenStorage.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 인터셉터에서 사용할 토큰 쌍을 반환한다.
|
||||
Future<TokenPair?> refreshForInterceptor() async {
|
||||
try {
|
||||
final session = await refreshSession();
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
return TokenPair(
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistSession(AuthSession session) async {
|
||||
_session = session;
|
||||
await _tokenStorage.writeAccessToken(session.accessToken);
|
||||
if (session.hasRefreshToken) {
|
||||
await _tokenStorage.writeRefreshToken(session.refreshToken);
|
||||
} else {
|
||||
await _tokenStorage.writeRefreshToken(null);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
162
lib/features/auth/data/dtos/auth_session_dto.dart
Normal file
162
lib/features/auth/data/dtos/auth_session_dto.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
import '../../../../core/common/utils/json_utils.dart';
|
||||
import '../../domain/entities/auth_permission.dart';
|
||||
import '../../domain/entities/auth_session.dart';
|
||||
import '../../domain/entities/authenticated_user.dart';
|
||||
|
||||
/// 로그인/토큰 갱신 응답을 역직렬화하는 DTO.
|
||||
class AuthSessionDto {
|
||||
AuthSessionDto({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.user,
|
||||
this.expiresAt,
|
||||
this.permissions = const [],
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final DateTime? expiresAt;
|
||||
final AuthenticatedUser user;
|
||||
final List<AuthPermissionDto> permissions;
|
||||
|
||||
factory AuthSessionDto.fromJson(Map<String, dynamic> json) {
|
||||
final token = _readString(json, 'access_token');
|
||||
final refresh = _readString(json, 'refresh_token');
|
||||
final expires = _parseDate(_readString(json, 'expires_at'));
|
||||
final userMap = _readMap(json, 'user');
|
||||
final permissionList = _readList(json, 'permissions');
|
||||
return AuthSessionDto(
|
||||
accessToken: token ?? '',
|
||||
refreshToken: refresh ?? '',
|
||||
expiresAt: expires,
|
||||
user: _parseUser(userMap),
|
||||
permissions: permissionList
|
||||
.map(AuthPermissionDto.fromJson)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
AuthSession toEntity() {
|
||||
return AuthSession(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
expiresAt: expiresAt,
|
||||
user: user,
|
||||
permissions: permissions
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthPermissionDto {
|
||||
const AuthPermissionDto({required this.resource, required this.actions});
|
||||
|
||||
final String resource;
|
||||
final List<String> actions;
|
||||
|
||||
factory AuthPermissionDto.fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) {
|
||||
throw const FormatException('권한 정보가 비어 있습니다.');
|
||||
}
|
||||
final resource = _readString(json, 'resource') ?? '';
|
||||
final actions = <String>[];
|
||||
final rawActions = json['actions'];
|
||||
if (rawActions is List) {
|
||||
for (final item in rawActions) {
|
||||
if (item is String) {
|
||||
final normalized = item.trim().toLowerCase();
|
||||
if (normalized.isNotEmpty) {
|
||||
actions.add(normalized);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (item is Map<String, dynamic>) {
|
||||
for (final entry in item.entries) {
|
||||
final normalized = entry.value.toString().trim().toLowerCase();
|
||||
if (normalized.isNotEmpty) {
|
||||
actions.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return AuthPermissionDto(
|
||||
resource: resource,
|
||||
actions: actions.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
AuthPermission toEntity() =>
|
||||
AuthPermission(resource: resource, actions: actions);
|
||||
}
|
||||
|
||||
AuthenticatedUser _parseUser(Map<String, dynamic> json) {
|
||||
final id = _readOptionalInt(json, 'id') ?? 0;
|
||||
final name = _readString(json, 'name') ?? '';
|
||||
final employeeNo = _readString(json, 'employee_no');
|
||||
final email = _readString(json, 'email');
|
||||
final group = JsonUtils.extractMap(
|
||||
json,
|
||||
keys: const ['group', 'primary_group'],
|
||||
);
|
||||
return AuthenticatedUser(
|
||||
id: id,
|
||||
name: name,
|
||||
employeeNo: employeeNo,
|
||||
email: email,
|
||||
primaryGroupId: _readOptionalInt(group, 'id'),
|
||||
primaryGroupName: _readString(group, 'name'),
|
||||
);
|
||||
}
|
||||
|
||||
String? _readString(Map<String, dynamic>? source, String key) {
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
final value = source[key];
|
||||
if (value is String) {
|
||||
return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _readList(Map<String, dynamic> source, String key) {
|
||||
final value = source[key];
|
||||
if (value is List) {
|
||||
return value.whereType<Map<String, dynamic>>().toList(growable: false);
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
Map<String, dynamic> _readMap(Map<String, dynamic> source, String key) {
|
||||
final value = source[key];
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value;
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
|
||||
DateTime? _parseDate(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
|
||||
int? _readOptionalInt(Map<String, dynamic>? source, String key) {
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
final value = source[key];
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is String) {
|
||||
return int.tryParse(value);
|
||||
}
|
||||
if (value is double) {
|
||||
return value.round();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../core/network/api_routes.dart';
|
||||
import '../../domain/entities/auth_session.dart';
|
||||
import '../../domain/entities/login_request.dart';
|
||||
import '../../domain/repositories/auth_repository.dart';
|
||||
import '../dtos/auth_session_dto.dart';
|
||||
|
||||
/// 인증 관련 엔드포인트를 호출하는 원격 저장소 구현체.
|
||||
class AuthRepositoryRemote implements AuthRepository {
|
||||
AuthRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
static const _basePath = '${ApiRoutes.apiV1}/auth';
|
||||
|
||||
@override
|
||||
Future<AuthSession> login(LoginRequest request) async {
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'$_basePath/login',
|
||||
data: {
|
||||
'identifier': request.identifier,
|
||||
'password': request.password,
|
||||
'remember_me': request.rememberMe,
|
||||
},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
final json = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
||||
return AuthSessionDto.fromJson(json).toEntity();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthSession> refresh(String refreshToken) async {
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'$_basePath/refresh',
|
||||
data: {'refresh_token': refreshToken},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
final json = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
||||
return AuthSessionDto.fromJson(json).toEntity();
|
||||
}
|
||||
}
|
||||
32
lib/features/auth/domain/entities/auth_permission.dart
Normal file
32
lib/features/auth/domain/entities/auth_permission.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import '../../../../core/permissions/permission_manager.dart';
|
||||
import '../../../../core/permissions/permission_resources.dart';
|
||||
|
||||
/// 로그인 응답에서 내려오는 단일 권한(리소스 + 액션 목록)을 표현한다.
|
||||
class AuthPermission {
|
||||
const AuthPermission({required this.resource, required this.actions});
|
||||
|
||||
/// 서버가 반환한 리소스 식별자 (예: `/stock-transactions`)
|
||||
final String resource;
|
||||
|
||||
/// 허용된 액션 문자열 목록 (예: `view`, `create`)
|
||||
final List<String> actions;
|
||||
|
||||
/// [PermissionManager]가 이해할 수 있는 포맷으로 변환한다.
|
||||
Map<String, Set<PermissionAction>> toPermissionMap() {
|
||||
final normalized = PermissionResources.normalize(resource);
|
||||
final actionSet = <PermissionAction>{};
|
||||
for (final raw in actions) {
|
||||
final matched = PermissionAction.values.where(
|
||||
(action) => action.name == raw.trim().toLowerCase(),
|
||||
);
|
||||
if (matched.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
actionSet.addAll(matched);
|
||||
}
|
||||
if (actionSet.isEmpty) {
|
||||
return <String, Set<PermissionAction>>{};
|
||||
}
|
||||
return {normalized: actionSet};
|
||||
}
|
||||
}
|
||||
31
lib/features/auth/domain/entities/auth_session.dart
Normal file
31
lib/features/auth/domain/entities/auth_session.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'auth_permission.dart';
|
||||
import 'authenticated_user.dart';
|
||||
|
||||
/// 로그인 또는 토큰 갱신 결과를 표현하는 세션 모델.
|
||||
class AuthSession {
|
||||
const AuthSession({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.expiresAt,
|
||||
required this.user,
|
||||
this.permissions = const [],
|
||||
});
|
||||
|
||||
/// API 인증에 사용되는 액세스 토큰.
|
||||
final String accessToken;
|
||||
|
||||
/// 토큰 갱신에 사용되는 리프레시 토큰.
|
||||
final String refreshToken;
|
||||
|
||||
/// 액세스 토큰 만료 시각.
|
||||
final DateTime? expiresAt;
|
||||
|
||||
/// 로그인한 사용자 정보.
|
||||
final AuthenticatedUser user;
|
||||
|
||||
/// 사용자에게 할당된 권한 목록.
|
||||
final List<AuthPermission> permissions;
|
||||
|
||||
/// 리프레시 토큰이 유효한지 여부를 단순 판단한다.
|
||||
bool get hasRefreshToken => refreshToken.isNotEmpty;
|
||||
}
|
||||
29
lib/features/auth/domain/entities/authenticated_user.dart
Normal file
29
lib/features/auth/domain/entities/authenticated_user.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
/// 로그인 성공 시 반환되는 사용자 정보.
|
||||
class AuthenticatedUser {
|
||||
const AuthenticatedUser({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.employeeNo,
|
||||
this.email,
|
||||
this.primaryGroupId,
|
||||
this.primaryGroupName,
|
||||
});
|
||||
|
||||
/// 사용자 식별자
|
||||
final int id;
|
||||
|
||||
/// 이름
|
||||
final String name;
|
||||
|
||||
/// 사번
|
||||
final String? employeeNo;
|
||||
|
||||
/// 이메일
|
||||
final String? email;
|
||||
|
||||
/// 기본 소속 그룹 ID
|
||||
final int? primaryGroupId;
|
||||
|
||||
/// 기본 소속 그룹명
|
||||
final String? primaryGroupName;
|
||||
}
|
||||
21
lib/features/auth/domain/entities/login_request.dart
Normal file
21
lib/features/auth/domain/entities/login_request.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
/// 로그인 요청 값을 표현하는 도메인 모델.
|
||||
///
|
||||
/// - identifier: 사번 또는 이메일 등 사용자가 입력한 계정 식별자
|
||||
/// - password: 평문 비밀번호
|
||||
/// - rememberMe: 재로그인 시 토큰 유지를 원하는지 여부
|
||||
class LoginRequest {
|
||||
const LoginRequest({
|
||||
required this.identifier,
|
||||
required this.password,
|
||||
this.rememberMe = false,
|
||||
});
|
||||
|
||||
/// 사번 또는 이메일
|
||||
final String identifier;
|
||||
|
||||
/// 평문 비밀번호
|
||||
final String password;
|
||||
|
||||
/// 재로그인 시 토큰 지속 여부
|
||||
final bool rememberMe;
|
||||
}
|
||||
11
lib/features/auth/domain/repositories/auth_repository.dart
Normal file
11
lib/features/auth/domain/repositories/auth_repository.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
import '../entities/auth_session.dart';
|
||||
import '../entities/login_request.dart';
|
||||
|
||||
/// 인증 관련 API 호출을 추상화한 저장소 인터페이스.
|
||||
abstract class AuthRepository {
|
||||
/// 로그인 API를 호출해 세션을 생성한다.
|
||||
Future<AuthSession> login(LoginRequest request);
|
||||
|
||||
/// 리프레시 토큰으로 새로운 세션을 발급받는다.
|
||||
Future<AuthSession> refresh(String refreshToken);
|
||||
}
|
||||
Reference in New Issue
Block a user