296 lines
11 KiB
PHP
296 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Admin;
|
|
|
|
use App\Repositories\Admin\AdminUserRepository;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
final class AdminUserManageService
|
|
{
|
|
public function __construct(
|
|
private readonly AdminUserRepository $repo,
|
|
private readonly AdminAuditService $audit,
|
|
) {}
|
|
|
|
public function getAllRoles(): array
|
|
{
|
|
return $this->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 '-';
|
|
}
|
|
}
|