repo->getAllRoles(); // [id, code, name] } /** @return array{0:mixed,1:array,2:array} */ public function list(Request $request): array { $pager = $this->repo->paginateUsers([ 'q' => (string)$request->query('q', ''), 'status' => (string)$request->query('status', ''), 'role' => (string)$request->query('role', ''), ], 20); $ids = $pager->getCollection()->pluck('id')->map(fn($v)=>(int)$v)->all(); $roleMap = $this->repo->getRoleMapForUsers($ids); $permCntMap = $this->repo->getPermissionCountMapForUsers($ids); // last_login_ip가 binary일 수 있으니 텍스트 변환 보조값 붙이기 $pager->getCollection()->transform(function ($u) { $u->last_login_ip_text = $this->ipToText($u->last_login_ip ?? null); return $u; }); return [$pager, $roleMap, $permCntMap]; } public function editData(int $id, int $actorId): array { $u = $this->repo->find($id); if (!$u) { return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.']; } $rolesAll = $this->repo->getAllRoles(); $roleIds = $this->repo->getRoleIdsForUser($id); $roles = $this->repo->getRolesForUser($id); $perms = $this->repo->getPermissionsForUser($id); $u->last_login_ip_text = $this->ipToText($u->last_login_ip ?? null); return [ 'ok' => true, 'me_actor_id' => $actorId, 'user' => $u, 'rolesAll' => $rolesAll, 'roleIds' => $roleIds, 'roles' => $roles, 'perms' => $perms, ]; } public function update(int $id, int $actorId, Request $request): array { $u = $this->repo->find($id); if (!$u) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.']; // 입력값(컬럼 존재 여부와 무관하게 validate는 가능) $data = $request->validate([ 'nickname' => ['required','string','min:2','max:80'], 'name' => ['required','string','min:2','max:80'], 'email' => ['required','email','max:190'], 'status' => ['nullable','string','max:20'], 'two_factor_mode' => ['nullable','string','max:20'], 'totp_enabled' => ['nullable'], 'phone' => ['nullable','string','max:30'], 'roles' => ['array'], 'roles.*' => ['integer'], ]); // 안전장치: 자기 자신 비활성화 방지(사고 방지) if ($actorId === $id) { if (($data['status'] ?? '') && in_array($data['status'], ['disabled','suspended','deleted'], true)) { return ['ok'=>false,'message'=>'본인 계정은 비활성/정지할 수 없습니다.']; } } $before = $this->snapshotUser($u); // phone 처리(선택) $phoneDigits = ''; $phoneEnc = null; $phoneHash = null; $rawPhone = trim((string)($data['phone'] ?? '')); if ($rawPhone !== '') { $phoneDigits = $this->normalizeKoreanPhone($rawPhone); if ($phoneDigits === '') { return ['ok'=>false,'errors'=>['phone'=>'휴대폰 번호 형식이 올바르지 않습니다.']]; } $hashKey = (string) config('admin.phone_hash_key', env('ADMIN_PHONE_HASH_KEY', '')); if ($hashKey === '') { return ['ok'=>false,'message'=>'ADMIN_PHONE_HASH_KEY 설정이 필요합니다.']; } $phoneHash = hash_hmac('sha256', $phoneDigits, $hashKey); $phoneEnc = Crypt::encryptString($phoneDigits); // 중복 방지(운영용) — 테스트 정책이면 여기 조건 완화 가능 if ($this->repo->existsPhoneHash($phoneHash, $id)) { return ['ok'=>false,'errors'=>['phone'=>'이미 사용 중인 휴대폰 번호입니다.']]; } } $payload = [ 'nickname' => (string)$data['nickname'], 'name' => (string)$data['name'], 'email' => (string)$data['email'], 'status' => (string)($data['status'] ?? $u->status ?? 'active'), 'two_factor_mode' => (string)($data['two_factor_mode'] ?? $u->two_factor_mode ?? 'sms'), 'totp_enabled' => isset($data['totp_enabled']) ? (int)!!$data['totp_enabled'] : (int)($u->totp_enabled ?? 0), 'phone_enc' => $phoneEnc, 'phone_hash' => $phoneHash, 'updated_by' => $actorId, ]; $roleIds = array_values(array_unique(array_map('intval', $data['roles'] ?? []))); return DB::transaction(function () use ($id, $actorId, $request, $payload, $roleIds, $before) { $ok = $this->repo->updateById($id, $payload); if (!$ok) return ['ok'=>false,'message'=>'저장에 실패했습니다.']; // 역할 동기화(선택) if (!empty($roleIds)) { // 안전장치: 본인 super_admin 제거 금지 if ($actorId === $id) { $cur = $this->repo->getRolesForUser($id); $hasSuper = collect($cur)->contains(fn($r) => ($r['code'] ?? '') === 'super_admin'); if ($hasSuper) { $superId = $this->repo->findRoleIdByCode('super_admin'); if ($superId && !in_array($superId, $roleIds, true)) { return ['ok'=>false,'message'=>'본인 super_admin 역할은 제거할 수 없습니다.']; } } } $this->repo->syncRoles($id, $roleIds, $actorId); } $u2 = $this->repo->find($id); $after = $this->snapshotUser($u2); $this->audit->log( actorAdminId: $actorId, action: 'admin_user.update', targetType: 'admin_user', targetId: $id, before: $before, after: $after, ip: (string)$request->ip(), ua: (string)$request->userAgent(), ); return ['ok'=>true,'message'=>'관리자 정보가 저장되었습니다.']; }); } public function resetPasswordToEmail(int $id, int $actorId, Request $request): array { $u = $this->repo->find($id); if (!$u) return ['ok'=>false,'message'=>'관리자 정보를 찾을 수 없습니다.']; $before = $this->snapshotUser($u); // 임시 비밀번호 = 이메일 $temp = (string)($u->email ?? ''); if ($temp === '') return ['ok'=>false,'message'=>'이메일이 없어 초기화할 수 없습니다.']; return DB::transaction(function () use ($u, $id, $actorId, $request, $temp, $before) { // password cast(hashed) 있으면 plain 넣어도 자동 해싱됨 $this->repo->setTemporaryPassword($u, $temp, $actorId); $u2 = $this->repo->find($id); $after = $this->snapshotUser($u2); $this->audit->log( actorAdminId: $actorId, action: 'admin_user.password_reset_to_email', targetType: 'admin_user', targetId: $id, before: $before, after: $after, ip: (string)$request->ip(), ua: (string)$request->userAgent(), ); return ['ok'=>true,'message'=>'비밀번호가 이메일로 초기화되었습니다. (다음 로그인 시 변경 강제)']; }); } public function unlock(int $id, int $actorId, Request $request): array { $u = $this->repo->find($id); if (!$u) return ['ok'=>false,'message'=>'관리자 정보를 찾을 수 없습니다.']; $before = $this->snapshotUser($u); $payload = [ 'locked_until' => null, 'failed_login_count' => 0, 'updated_by' => $actorId, ]; return DB::transaction(function () use ($id, $actorId, $request, $payload, $before) { $ok = $this->repo->updateById($id, $payload); if (!$ok) return ['ok'=>false,'message'=>'잠금 해제에 실패했습니다.']; $u2 = $this->repo->find($id); $after = $this->snapshotUser($u2); $this->audit->log( actorAdminId: $actorId, action: 'admin_user.unlock', targetType: 'admin_user', targetId: $id, before: $before, after: $after, ip: (string)$request->ip(), ua: (string)$request->userAgent(), ); return ['ok'=>true,'message'=>'잠금이 해제되었습니다.']; }); } private function snapshotUser($u): array { if (!$u) return []; return [ 'id' => (int)($u->id ?? 0), 'email' => (string)($u->email ?? ''), 'nickname' => (string)($u->nickname ?? ''), 'name' => (string)($u->name ?? ''), 'status' => (string)($u->status ?? ''), 'two_factor_mode' => (string)($u->two_factor_mode ?? ''), 'totp_enabled' => (int)($u->totp_enabled ?? 0), 'locked_until' => (string)($u->locked_until ?? ''), 'last_login_at' => (string)($u->last_login_at ?? ''), ]; } private function normalizeKoreanPhone(string $raw): string { $n = preg_replace('/\D+/', '', $raw) ?? ''; if ($n === '') return ''; // 010xxxxxxxx 형태만 간단 허용(필요시 확장) if (str_starts_with($n, '010') && strlen($n) === 11) return $n; if (str_starts_with($n, '01') && strlen($n) >= 10 && strlen($n) <= 11) return $n; return ''; } private function ipToText($binOrText): string { if ($binOrText === null) return '-'; if (is_string($binOrText)) { // 이미 문자열 IP면 그대로 if (str_contains($binOrText, '.') || str_contains($binOrText, ':')) return $binOrText; // binary(4/16) 가능성 $len = strlen($binOrText); if ($len === 4 || $len === 16) { $t = @inet_ntop($binOrText); return $t ?: '-'; } } return '-'; } }