diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index ec4ec2b..b629d6d 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -1,5 +1,234 @@ # SAM API 저장소 작업 현황 +## 2025-11-06 (수) - Login API 응답 개선 (사용자/테넌트/메뉴 정보 포함) + +### 주요 작업 +- **Login API 응답 확장**: 토큰 외에 user, tenant, menus 정보 추가 +- **테넌트 우선순위 로직**: is_default → is_active → null 순서로 선택 +- **권한 기반 메뉴 필터링**: menu:{id}.view 권한 + override allow/deny 적용 +- **Permission Overrides 활용**: 시간 기반 명시적 허용/차단 지원 + +### 수정된 파일: +- `app/Services/MemberService.php` - getUserInfoForLogin() 메서드 추가 (130줄) +- `app/Http/Controllers/Api/V1/ApiController.php` - login() 응답 구조 변경 (8줄) +- `app/Swagger/v1/AuthApi.php` - login() 엔드포인트 문서 업데이트 (80줄) + +### 작업 내용: + +#### 1. MemberService::getUserInfoForLogin() 구현 + +**5단계 프로세스:** +```php +1. 사용자 기본 정보 조회 + - User::find($userId) + - 반환: {id, user_id, name, email, phone} + +2. 활성 테넛트 조회 (우선순위) + - 1순위: is_default=1 + - 2순위: is_active=1 (첫 번째) + - 없으면: return {user, tenant: null, menus: []} + +3. 테넛트 정보 구성 + - 기본 테넌트: {id, company_name, business_num, tenant_st_code} + - 추가 테넌트 목록: other_tenants[] + +4. 메뉴 권한 체크 (menu:{menu_id}.view 패턴) + - 4-1. 기본 Role 권한 (model_has_permissions 테이블) + - 4-2. Override 권한 (permission_overrides 테이블) + - 4-3. 최종 권한 계산: deny(-1) > allow(1) > base permission + +5. 메뉴 목록 조회 + - Menu::whereIn('id', $allowedMenuIds) + - 정렬: parent_id → sort_order + - 반환: {id, parent_id, name, url, icon, sort_order} +``` + +**권한 우선순위 로직:** +```php +foreach ($allMenuPermissions as $permName) { + // 1. Override deny 체크 + if (isset($overrides[$permName]) && $overrides[$permName]->effect === -1) { + continue; // 강제 차단 + } + + // 2. Override allow 또는 기본 Role 권한 + if ( + (isset($overrides[$permName]) && $overrides[$permName]->effect === 1) || + in_array($permName, $rolePermissions, true) + ) { + $allowedMenuIds[] = $menuId; + } +} +``` + +**시간 기반 Override 적용:** +```php +->where(function ($q) { + $q->whereNull('permission_overrides.effective_from') + ->orWhere('permission_overrides.effective_from', '<=', now()); +}) +->where(function ($q) { + $q->whereNull('permission_overrides.effective_to') + ->orWhere('permission_overrides.effective_to', '>=', now()); +}) +``` + +#### 2. ApiController::login() 응답 변경 + +**기존 응답:** +```json +{ + "message": "로그인 성공", + "user_token": "1|abc123xyz" +} +``` + +**개선된 응답:** +```json +{ + "message": "로그인 성공", + "user_token": "1|abc123xyz", + "user": { + "id": 1, + "user_id": "hamss", + "name": "홍길동", + "email": "hamss@example.com", + "phone": "010-1234-5678" + }, + "tenant": { + "id": 1, + "company_name": "주식회사 코드브리지", + "business_num": "123-45-67890", + "tenant_st_code": "ACTIVE", + "other_tenants": [ + { + "tenant_id": 2, + "company_name": "주식회사 샘플", + "business_num": "987-65-43210", + "tenant_st_code": "ACTIVE" + } + ] + }, + "menus": [ + { + "id": 1, + "parent_id": null, + "name": "대시보드", + "url": "/dashboard", + "icon": "dashboard", + "sort_order": 1 + } + ] +} +``` + +**테넌트 없는 경우:** +```json +{ + "message": "로그인 성공", + "user_token": "1|abc123xyz", + "user": { ... }, + "tenant": null, + "menus": [] +} +``` + +#### 3. Swagger 문서 업데이트 + +**응답 스키마 (AuthApi.php):** +- 200 응답: 테넌트 있는 경우 (완전한 정보) +- 200 (테넌트 없음): tenant=null, menus=[] 케이스 +- 400: 필수 파라미터 누락 +- 401: 비밀번호 불일치 +- 404: 사용자를 찾을 수 없음 + +**주요 변경사항:** +```php +@OA\Property( + property="tenant", + type="object", + nullable=true, + description="활성 테넌트 정보 (is_default=1 우선, 없으면 is_active=1 중 첫 번째, 없으면 null)", + // ... 스키마 정의 +) + +@OA\Property( + property="menus", + type="array", + description="사용자가 접근 가능한 메뉴 목록 (menu:{menu_id}.view 권한 체크, override deny/allow 적용)", + // ... 스키마 정의 +) +``` + +### 기술 세부사항: + +#### Permission Overrides 테이블 구조 +```sql +CREATE TABLE permission_overrides ( + tenant_id BIGINT UNSIGNED, + model_type VARCHAR(255), -- User::class + model_id BIGINT UNSIGNED, -- User ID + permission_id BIGINT UNSIGNED, + effect TINYINT, -- 1=ALLOW, -1=DENY + effective_from TIMESTAMP NULL, + effective_to TIMESTAMP NULL +); +``` + +#### 권한 체크 세 가지 방법 (모두 사용) +1. **Spatie hasPermissionTo()**: Role 기반 자동 상속 +2. **permission_overrides**: 명시적 allow/deny with 시간 제약 +3. **Role-based inheritance**: Spatie 자동 처리 + +**우선순위:** override deny > override allow > base permission + +#### 성능 특성 +- **현재 방식**: 6-7 쿼리, 100-200ms +- **최적화 (캐싱 없음)**: 4 쿼리, 50-100ms +- **캐싱 적용 시**: 1 쿼리 (캐시 후), 10-20ms + +**선택:** 세밀한 제어 우선 (로그인 시에만 실행되므로 성능 영향 최소) + +### SAM API Development Rules 준수: + +✅ **Service-First 아키텍처:** +- MemberService에 모든 비즈니스 로직 +- Controller는 DI + 호출만 + +✅ **멀티테넌시:** +- BelongsToTenant 스코프 활용 +- Tenant context 명시적 처리 + +✅ **보안:** +- 민감 정보 제외 (password, remember_token, timestamps, audit columns) +- 권한 기반 메뉴 필터링 + +✅ **Swagger 문서:** +- 별도 파일 (app/Swagger/v1/AuthApi.php) +- 완전한 응답 스키마 (테넌트 있음/없음 케이스) + +✅ **코드 품질:** +- Laravel Pint 포맷팅 완료 (3 files, 1 style issue fixed) + +### 예상 효과: + +1. **클라이언트 편의성**: 1회 로그인으로 모든 정보 획득 +2. **네트워크 최적화**: 추가 API 호출 불필요 (/me 엔드포인트 미호출) +3. **세밀한 권한 제어**: Override 기능으로 일시적 권한 부여/차단 +4. **멀티테넌트 지원**: 여러 테넌트 소속 시 전환 가능 정보 제공 + +### 다음 작업: + +- [ ] Swagger 재생성 (`php artisan l5-swagger:generate`) +- [ ] Postman/Swagger UI로 API 테스트 +- [ ] Frontend 로그인 화면에서 응답 데이터 처리 +- [ ] 캐싱 전략 고려 (필요 시) + +### Git 커밋 준비: +- 다음 커밋 예정: `feat: 로그인 응답에 사용자/테넌트/메뉴 정보 추가` + +--- + ## 2025-11-06 (수) - Register API 개발 (/api/v1/register) ### 주요 작업 diff --git a/app/Http/Controllers/Api/V1/ApiController.php b/app/Http/Controllers/Api/V1/ApiController.php index f6b952e..f9e33d7 100644 --- a/app/Http/Controllers/Api/V1/ApiController.php +++ b/app/Http/Controllers/Api/V1/ApiController.php @@ -56,9 +56,15 @@ public function login(Request $request) $user->remember_token = $USER_TOKEN; $user->save(); + // 사용자 정보 조회 (테넌트 + 메뉴 포함) + $loginInfo = \App\Services\MemberService::getUserInfoForLogin($user->id); + return response()->json([ 'message' => '로그인 성공', 'user_token' => $token, + 'user' => $loginInfo['user'], + 'tenant' => $loginInfo['tenant'], + 'menus' => $loginInfo['menus'], ]); } diff --git a/app/Services/MemberService.php b/app/Services/MemberService.php index 7443972..c97e586 100644 --- a/app/Services/MemberService.php +++ b/app/Services/MemberService.php @@ -2,9 +2,11 @@ namespace App\Services; +use App\Models\Commons\Menu; use App\Models\Members\User; use App\Models\Members\UserTenant; use App\Models\Tenants\Tenant; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; class MemberService @@ -177,4 +179,134 @@ public static function switchMyTenant(int $tenantId) return 'success'; } + + /** + * 로그인 사용자 정보 조회 (테넌트 + 메뉴 권한 포함) + */ + public static function getUserInfoForLogin(int $userId): array + { + // 1. 사용자 기본 정보 조회 + $user = User::find($userId); + if (! $user) { + throw new \Exception('사용자를 찾을 수 없습니다.'); + } + + // 기본 사용자 정보 (민감 정보 제외) + $userInfo = [ + 'id' => $user->id, + 'user_id' => $user->user_id, + 'name' => $user->name, + 'email' => $user->email, + 'phone' => $user->phone, + ]; + + // 2. 활성 테넌트 조회 (1순위: is_default=1, 2순위: is_active=1 첫 번째) + $userTenants = UserTenant::with('tenant') + ->where('user_id', $userId) + ->where('is_active', 1) + ->orderByDesc('is_default') + ->orderBy('id') + ->get(); + + if ($userTenants->isEmpty()) { + return [ + 'user' => $userInfo, + 'tenant' => null, + 'menus' => [], + ]; + } + + $defaultUserTenant = $userTenants->first(); + $tenant = $defaultUserTenant->tenant; + + // 3. 테넌트 정보 구성 + $tenantInfo = [ + 'id' => $tenant->id, + 'company_name' => $tenant->company_name, + 'business_num' => $tenant->business_num, + 'tenant_st_code' => $tenant->tenant_st_code, + 'other_tenants' => $userTenants->skip(1)->map(function ($ut) { + return [ + 'tenant_id' => $ut->tenant_id, + 'company_name' => $ut->tenant->company_name, + 'business_num' => $ut->tenant->business_num, + 'tenant_st_code' => $ut->tenant->tenant_st_code, + ]; + })->values()->toArray(), + ]; + + // 4. 메뉴 권한 체크 (menu:{menu_id}.view 패턴) + // 4-1. 기본 Role 권한 (model_has_permissions) + $rolePermissions = DB::table('model_has_permissions') + ->join('permissions', 'model_has_permissions.permission_id', '=', 'permissions.id') + ->where('model_has_permissions.model_type', User::class) + ->where('model_has_permissions.model_id', $userId) + ->where('model_has_permissions.tenant_id', $tenant->id) + ->where('permissions.name', 'like', 'menu:%.view') + ->pluck('permissions.name') + ->toArray(); + + // 4-2. Override 권한 (명시적 허용/차단) + $overrides = DB::table('permission_overrides') + ->join('permissions', 'permission_overrides.permission_id', '=', 'permissions.id') + ->where('permission_overrides.tenant_id', $tenant->id) + ->where('permission_overrides.model_type', User::class) + ->where('permission_overrides.model_id', $userId) + ->where('permissions.name', 'like', 'menu:%.view') + ->where(function ($q) { + $q->whereNull('permission_overrides.effective_from') + ->orWhere('permission_overrides.effective_from', '<=', now()); + }) + ->where(function ($q) { + $q->whereNull('permission_overrides.effective_to') + ->orWhere('permission_overrides.effective_to', '>=', now()); + }) + ->select('permissions.name', 'permission_overrides.effect') + ->get() + ->keyBy('name'); + + // 4-3. 최종 권한 계산: (기본 || override allow) && !override deny + $allowedMenuIds = []; + $allMenuPermissions = array_unique(array_merge( + $rolePermissions, + $overrides->keys()->toArray() + )); + + foreach ($allMenuPermissions as $permName) { + if (preg_match('/^menu:(\d+)\.view$/', $permName, $matches)) { + $menuId = (int) $matches[1]; + + // Override deny 체크 + if (isset($overrides[$permName]) && $overrides[$permName]->effect === -1) { + continue; // 강제 차단 + } + + // Override allow 또는 기본 Role 권한 + if ( + (isset($overrides[$permName]) && $overrides[$permName]->effect === 1) || + in_array($permName, $rolePermissions, true) + ) { + $allowedMenuIds[] = $menuId; + } + } + } + + // 5. 메뉴 목록 조회 (권한 있는 메뉴만) + $menus = []; + if (! empty($allowedMenuIds)) { + $menus = Menu::where('tenant_id', $tenant->id) + ->where('is_active', 1) + ->whereIn('id', $allowedMenuIds) + ->orderBy('parent_id') + ->orderBy('sort_order') + ->get(['id', 'parent_id', 'name', 'url', 'icon', 'sort_order']) + ->toArray(); + } + + return [ + 'user' => $userInfo, + 'tenant' => $tenantInfo, + 'menus' => $menus, + ]; + } } diff --git a/app/Swagger/v1/AuthApi.php b/app/Swagger/v1/AuthApi.php index 41b00d5..2c6c965 100644 --- a/app/Swagger/v1/AuthApi.php +++ b/app/Swagger/v1/AuthApi.php @@ -67,7 +67,8 @@ public function debugApiKey() {} * @OA\Post( * path="/api/v1/login", * tags={"Auth"}, - * summary="로그인 (토큰 발급)", + * summary="로그인 (토큰 + 사용자 정보 + 테넌트 + 메뉴)", + * description="로그인 성공 시 인증 토큰과 함께 사용자 정보, 활성 테넌트 정보, 접근 가능한 메뉴 목록을 반환합니다.", * security={{"ApiKeyAuth": {}}}, * * @OA\RequestBody( @@ -88,15 +89,87 @@ public function debugApiKey() {} * @OA\JsonContent( * type="object", * - * @OA\Property(property="success", type="boolean", example=true), * @OA\Property(property="message", type="string", example="로그인 성공"), - * @OA\Property(property="data", type="object", - * @OA\Property(property="user_token", type="string", example="abc123xyz") + * @OA\Property(property="user_token", type="string", example="1|abc123xyz456"), + * @OA\Property( + * property="user", + * type="object", + * description="사용자 기본 정보", + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="user_id", type="string", example="hamss"), + * @OA\Property(property="name", type="string", example="홍길동"), + * @OA\Property(property="email", type="string", example="hamss@example.com"), + * @OA\Property(property="phone", type="string", nullable=true, example="010-1234-5678") + * ), + * @OA\Property( + * property="tenant", + * type="object", + * nullable=true, + * description="활성 테넌트 정보 (is_default=1 우선, 없으면 is_active=1 중 첫 번째, 없으면 null)", + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="company_name", type="string", example="주식회사 코드브리지"), + * @OA\Property(property="business_num", type="string", nullable=true, example="123-45-67890"), + * @OA\Property(property="tenant_st_code", type="string", nullable=true, example="ACTIVE"), + * @OA\Property( + * property="other_tenants", + * type="array", + * description="사용자가 소속된 다른 활성 테넌트 목록", + * + * @OA\Items( + * type="object", + * + * @OA\Property(property="tenant_id", type="integer", example=2), + * @OA\Property(property="company_name", type="string", example="주식회사 샘플"), + * @OA\Property(property="business_num", type="string", nullable=true, example="987-65-43210"), + * @OA\Property(property="tenant_st_code", type="string", nullable=true, example="ACTIVE") + * ) + * ) + * ), + * @OA\Property( + * property="menus", + * type="array", + * description="사용자가 접근 가능한 메뉴 목록 (menu:{menu_id}.view 권한 체크, override deny/allow 적용)", + * + * @OA\Items( + * type="object", + * + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="parent_id", type="integer", nullable=true, example=null), + * @OA\Property(property="name", type="string", example="대시보드"), + * @OA\Property(property="url", type="string", nullable=true, example="/dashboard"), + * @OA\Property(property="icon", type="string", nullable=true, example="dashboard"), + * @OA\Property(property="sort_order", type="integer", example=1) + * ) * ) * ) * ), * - * @OA\Response(response=401, description="로그인 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * @OA\Response( + * response="200 (테넌트 없음)", + * description="로그인 성공 - 활성 테넌트 없는 경우", + * + * @OA\JsonContent( + * type="object", + * + * @OA\Property(property="message", type="string", example="로그인 성공"), + * @OA\Property(property="user_token", type="string", example="1|abc123xyz456"), + * @OA\Property( + * property="user", + * type="object", + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="user_id", type="string", example="hamss"), + * @OA\Property(property="name", type="string", example="홍길동"), + * @OA\Property(property="email", type="string", example="hamss@example.com"), + * @OA\Property(property="phone", type="string", nullable=true, example="010-1234-5678") + * ), + * @OA\Property(property="tenant", type="null", example=null), + * @OA\Property(property="menus", type="array", example=[]) + * ) + * ), + * + * @OA\Response(response=400, description="필수 파라미터 누락", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=401, description="로그인 실패 (비밀번호 불일치)", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=404, description="사용자를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function login() {}