Files
sam-api/app/Services/MemberService.php
hskwon fdef567863 feat: 로그인 API에 roles 정보 추가
- MemberService::getUserInfoForLogin(): 사용자 역할 조회 로직 추가
- ApiController::login(): 응답에 roles 포함
- AuthApi (Swagger): roles 응답 스키마 추가
- 로그인 시 해당 회원의 역할 목록 반환 (id, name, description)

fix: 회원가입 시 UserTenant 생성 누락으로 인한 로그인 실패 수정

근본 원인:
- RegisterService는 TenantUserProfile만 생성
- MemberService::getUserInfoForLogin()은 UserTenant 조회
- 회원가입 직후 로그인 시 테넌트 조회 실패 (userTenants->isEmpty() = true)

해결 방안:
- RegisterService에 UserTenant::create() 추가
- TenantUserProfile: 프로필 정보 (부서, 직급 등)
- UserTenant: 접근 권한 관리 (is_active, is_default, joined_at)

영향도:
- 신규 사용자: 로그인 가능하게 수정
- 기존 사용자: 영향 없음 (user_tenants 데이터 이미 존재)

fix: 로그인 시 테넌트 없는 경우 roles 누락 오류 수정

- MemberService::getUserInfoForLogin(): 테넌트가 없는 경우에도 roles 빈 배열 반환
- Undefined array key 'roles' 에러 해결

MemberService.php 권한 조회 로직 수정 - 역할 기반 권한 지원

[문제]
- 회원가입 후 로그인 시 메뉴 리스트가 표시되지 않음
- RegisterService에서 역할에 권한 할당(role_has_permissions)하고
  사용자에게 역할 부여(model_has_roles)
- 하지만 MemberService는 model_has_permissions(직접 사용자 권한)만 조회

[원인]
- Spatie Permission 아키텍처 불일치
- 권한이 역할에 저장되었으나 직접 사용자 권한 테이블만 조회

[해결]
- model_has_roles → role_has_permissions → permissions 조인 쿼리로 변경
- UNION으로 직접 권한(model_has_permissions)도 지원하는 하이브리드 방식
- 역할 기반 권한과 직접 권한 모두 조회 가능

[변경 파일]
- app/Services/MemberService.php (getUserInfoForLogin 메서드, 239-259줄)
2025-11-11 10:56:39 +09:00

336 lines
11 KiB
PHP

<?php
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
{
/**
* 회원 조회(리스트)
*/
public static function getMembers($request)
{
$pageNo = $request->page ?? 1;
$pageSize = $request->size ?? 10;
$query = User::whereHas('userTenants', function ($q) {
$q->active();
})->debug();
$query = $query->paginate($pageSize, ['*'], 'page', $pageNo);
return $query;
}
/**
* 단일 회원 조회
*/
public static function getMember(int $userNo)
{
$query = User::whereHas('userTenants', function ($q) {
$q->active();
})->where('id', $userNo);
return $query->first();
}
/**
* 내정보 확인
*/
public static function getMyInfo()
{
$apiUser = app('api_user');
$user = User::find($apiUser);
$data['user'] = $user;
$tenantId = app('tenant_id');
if ($tenantId) {
$tenant = Tenant::find($tenantId);
$data['tenant'] = $tenant;
}
return $data;
}
/**
* 내정보 수정
*/
public static function getMyUpdate($request)
{
$apiUser = app('api_user');
// 요청으로 받은 수정 데이터 유효성 검사
$validatedData = $request->validate([
'name' => 'sometimes|string|max:255',
'phone' => 'sometimes|string|max:20',
'email' => 'sometimes|email|max:100',
'options' => 'nullable|json',
'profile_photo_path' => 'nullable|string|max:255',
]);
$user = User::find($apiUser);
if (! $user) {
return ['error' => 'User not found.', 'code' => 404];
}
// 사용자 정보 업데이트
$user->update($validatedData);
// 수정 성공 시 success 반환
return 'success';
}
/**
* 내 비밀번호 수정
*/
public static function setMyPassword($request)
{
$apiUserId = app('api_user'); // 현재 로그인한 사용자 PK
// 유효성 검사 (확인 비밀번호는 선택)
$validated = $request->validate([
'current_password' => 'required|string',
'new_password' => 'required|string|min:8|max:64',
]);
// 선택적으로 확인 비밀번호가 온 경우 체크
if ($request->filled('new_password_confirmation') &&
$request->input('new_password_confirmation') !== $validated['new_password']) {
return ['error' => '비밀번호 확인이 일치하지 않습니다.', 'code' => 400];
}
// 유저 조회
$user = User::find($apiUserId);
if (! $user) {
return ['error' => '유저를 찾을 수 없음', 'code' => 404];
}
// 현재 비밀번호 확인
if (! Hash::check($validated['current_password'], $user->password)) {
return ['error' => '현재 비밀번호가 일치하지 않습니다.', 'code' => 400];
}
// 기존 비밀번호와 동일한지 방지
if (Hash::check($validated['new_password'], $user->password)) {
return ['error' => '새 비밀번호가 기존 비밀번호와 동일합니다.', 'code' => 400];
}
// 비밀번호 변경 (guarded 우회: 직접 대입 + save)
$user->password = Hash::make($validated['new_password']);
$saved = $user->save();
// (선택) 모든 기존 토큰 무효화하려면 아래 주석 해제
// $user->tokens()->delete();
return 'success';
}
/**
* 나의 테넌트 목록
*/
public static function getMyTenants()
{
$apiUser = app('api_user');
$data = UserTenant::join('tenants', 'user_tenants.tenant_id', '=', 'tenants.id')
->where('user_tenants.user_id', $apiUser)
->get([
'tenants.id',
'tenants.company_name',
'user_tenants.is_active',
'user_tenants.is_default',
]);
return $data;
}
/**
* 나의 테넌트 전환
*/
public static function switchMyTenant(int $tenantId)
{
$apiUser = app('api_user');
// 1) 현재 유저의 기본 테넌트를 모두 해제
UserTenant::withoutGlobalScopes()
->where('user_id', $apiUser)
->where('is_default', 1)
->update(['is_default' => 0]);
// 2) 지정한 tenant_id를 기본 테넌트로 설정
$updated = UserTenant::withoutGlobalScopes()
->where('user_id', $apiUser)
->where('tenant_id', $tenantId)
->update(['is_default' => 1]);
if (! $updated) {
return ['error' => '해당 테넌트를 찾을 수 없습니다.', 'code' => 404];
}
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' => [],
'roles' => [],
];
}
$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. 역할 기반 권한 + 직접 권한 조회 (하이브리드)
$rolePermissions = DB::table('model_has_roles')
->join('role_has_permissions', 'model_has_roles.role_id', '=', 'role_has_permissions.role_id')
->join('permissions', 'role_has_permissions.permission_id', '=', 'permissions.id')
->where('model_has_roles.model_type', User::class)
->where('model_has_roles.model_id', $userId)
->where('model_has_roles.tenant_id', $tenant->id)
->where('permissions.name', 'like', 'menu:%.view')
->select('permissions.name')
->union(
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')
->select('permissions.name')
)
->pluck('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', 'is_external', 'external_url'])
->toArray();
}
// 6. 역할(Role) 정보 조회
$roles = DB::table('model_has_roles')
->join('roles', 'model_has_roles.role_id', '=', 'roles.id')
->where('model_has_roles.model_type', User::class)
->where('model_has_roles.model_id', $userId)
->where('model_has_roles.tenant_id', $tenant->id)
->select('roles.id', 'roles.name', 'roles.description')
->get()
->toArray();
return [
'user' => $userInfo,
'tenant' => $tenantInfo,
'menus' => $menus,
'roles' => $roles,
];
}
}