관리자 변경 로그 저장 작업

This commit is contained in:
sungro815 2026-02-05 21:03:38 +09:00
parent 81d5d2d6e1
commit af3b2e7534
13 changed files with 782 additions and 118 deletions

View File

@ -29,7 +29,15 @@ final class AdminAdminsController
public function update(Request $request, int $id)
{
$res = $this->service->update($id, $request->all(), (int)auth('admin')->id());
$res = $this->service->update(
$id,
$request->all(),
(int)auth('admin')->id(),
$request->ip(),
(string)($request->userAgent() ?? '')
);
if (!($res['ok'] ?? false)) {
return redirect()->back()->withInput()
@ -50,9 +58,14 @@ final class AdminAdminsController
]);
}
public function resetPassword(int $id)
public function resetPassword(Request $request, int $id)
{
$res = $this->service->resetPasswordToEmail($id, (int)auth('admin')->id());
$res = $this->service->resetPasswordToEmail(
$id,
(int)auth('admin')->id(),
$request->ip(),
(string)($request->userAgent() ?? '')
);
if (!($res['ok'] ?? false)) {
return redirect()->back()->withInput()
@ -76,7 +89,13 @@ final class AdminAdminsController
public function unlock(int $id)
{
$res = $this->service->unlock($id, (int)auth('admin')->id());
$res = $this->service->unlock(
$id,
(int)auth('admin')->id(),
$request->ip(),
(string)($request->userAgent() ?? '')
);
if (!($res['ok'] ?? false)) {
return redirect()->back()->withInput()
->with('toast', [
@ -131,7 +150,11 @@ final class AdminAdminsController
'nickname' => $data['nickname'],
'phone_digits' => $phoneDigits,
'role' => $data['role'],
], $actorId);
],
$actorId,
$request->ip(),
(string)($request->userAgent() ?? ''),
);
if (!($res['ok'] ?? false)) {
return back()->withErrors(['email' => (string)($res['message'] ?? '등록에 실패했습니다.')])->withInput();

View File

@ -126,6 +126,36 @@ final class AdminAuthController extends Controller
]);
}
if ($state === 'totp_required') {
$adminId = (int) ($res['admin_id'] ?? 0);
if ($adminId <= 0) {
return back()
->withInput()
->withErrors(['login_id' => '로그인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.']);
}
// (권장) 세션 고정 공격 방지
$request->session()->regenerate();
$ttl = (int) config('admin.totp_ttl', 300);
$request->session()->put('admin_totp', [
'admin_id' => $adminId,
'remember' => $remember,
'ip' => $ip,
'attempts' => 0,
'max_attempts' => (int) config('admin.totp_max_attempts', 5),
'expires_at' => time() + $ttl,
]);
return redirect()
->route('admin.totp.form')
->with('toast', [
'type' => 'info',
'title' => 'Google OTP 인증',
'message' => 'Google Authenticator 앱의 6자리 코드를 입력해 주세요.',
]);
}
// ✅ 6) OTP 발송 성공
if ($state === 'otp_sent') {
$request->session()->put('admin_2fa', [
@ -134,6 +164,9 @@ final class AdminAuthController extends Controller
'expires_at' => time() + (int) config('admin.sms_ttl', 180),
]);
// (권장) 세션 고정 공격 방지
$request->session()->regenerate();
return redirect()
->route('admin.otp.form')
->with('toast', [
@ -195,10 +228,12 @@ final class AdminAuthController extends Controller
]);
$ip = (string) $request->ip();
$ua = (string) $request->userAgent();
$res = $this->authService->resetPassword(
adminId: (int) $pending['admin_id'],
newPassword: (string) $data['password'],
ip: $ip
ip: $ip,
ua: $ua,
);
if (!($res['ok'] ?? false)) {
@ -276,6 +311,10 @@ final class AdminAuthController extends Controller
Auth::guard('admin')->login($admin, (bool)($res['remember'] ?? false));
// 권한/관리자 정보 세션 적재
$ctx = $this->authService->buildAdminSessionContext((int)$admin->id);
if (!empty($ctx)) $request->session()->put('admin_ctx', $ctx);
$request->session()->forget('admin_2fa');
return redirect()->route('admin.home');
@ -285,6 +324,8 @@ final class AdminAuthController extends Controller
{
Auth::guard('admin')->logout();
$request->session()->forget('admin_ctx');
$request->session()->invalidate();
$request->session()->regenerateToken();
@ -303,7 +344,12 @@ final class AdminAuthController extends Controller
public function totpStart(Request $request)
{
$adminId = (int) auth()->guard('admin')->id();
$res = $this->authService->totpStart($adminId, false);
$res = $this->authService->totpStart(
$adminId,
false,
(string)$request->ip(),
(string)($request->userAgent() ?? '')
);
return back()->with($res['ok'] ? 'status' : 'error', $res['message']);
}
@ -315,7 +361,12 @@ final class AdminAuthController extends Controller
'code' => ['required', 'string', 'max:10'],
]);
$res = $this->authService->totpConfirm($adminId, (string) $data['code']);
$res = $this->authService->totpConfirm(
$adminId,
(string) $data['code'],
(string)$request->ip(),
(string)($request->userAgent() ?? '')
);
return redirect()->route('admin.security')
->with($res['ok'] ? 'status' : 'error', $res['message']);
@ -324,7 +375,11 @@ final class AdminAuthController extends Controller
public function totpDisable(Request $request)
{
$adminId = (int) auth()->guard('admin')->id();
$res = $this->authService->totpDisable($adminId);
$res = $this->authService->totpDisable(
$adminId,
(string)$request->ip(),
(string)($request->userAgent() ?? '')
);
return redirect()->route('admin.security')
->with($res['ok'] ? 'status' : 'error', $res['message']);
@ -346,9 +401,108 @@ final class AdminAuthController extends Controller
'totp_enabled' => ['required', 'in:0,1'],
]);
$res = $this->authService->totpMode($adminId, (int) $data['totp_enabled']);
$res = $this->authService->totpMode(
$adminId,
(int) $data['totp_enabled'],
(string)$request->ip(),
(string)($request->userAgent() ?? '')
);
return redirect()->route('admin.security')
->with($res['ok'] ? 'status' : 'error', $res['message']);
}
public function showTotp(Request $request)
{
$pending = (array) $request->session()->get('admin_totp', []);
if (empty($pending['admin_id'])) {
return redirect()->route('admin.login.form')
->withErrors(['login_id' => '인증 정보가 없습니다. 다시 로그인해 주세요.']);
}
if ((int)($pending['expires_at'] ?? 0) < time()) {
$request->session()->forget('admin_totp');
return redirect()->route('admin.login.form')
->withErrors(['login_id' => '인증이 만료되었습니다. 다시 로그인해 주세요.']);
}
return view('admin.auth.totp');
}
public function verifyTotp(Request $request)
{
$data = $request->validate([
'code' => ['required', 'digits:6'],
]);
$pending = (array) $request->session()->get('admin_totp', []);
$adminId = (int)($pending['admin_id'] ?? 0);
if ($adminId <= 0) {
return redirect()->route('admin.login.form')
->withErrors(['login_id' => '인증 정보가 없습니다. 다시 로그인해 주세요.']);
}
if ((int)($pending['expires_at'] ?? 0) < time()) {
$request->session()->forget('admin_totp');
return redirect()->route('admin.login.form')
->withErrors(['login_id' => '인증이 만료되었습니다. 다시 로그인해 주세요.']);
}
// IP 고정 (SMS와 동일 정책)
$ip = (string) $request->ip();
if (!hash_equals((string)($pending['ip'] ?? ''), $ip)) {
$request->session()->forget('admin_totp');
return redirect()->route('admin.login.form')
->withErrors(['login_id' => '접속 정보가 변경되었습니다. 다시 로그인해 주세요.']);
}
// 시도 횟수 제한
$attempts = (int)($pending['attempts'] ?? 0);
$max = (int)($pending['max_attempts'] ?? 5);
if ($attempts >= $max) {
$request->session()->forget('admin_totp');
return redirect()->route('admin.login.form')
->withErrors(['login_id' => '시도 횟수를 초과했습니다. 다시 로그인해 주세요.']);
}
$pending['attempts'] = $attempts + 1;
$request->session()->put('admin_totp', $pending);
$res = $this->authService->verifyTotp(
adminId: $adminId,
code: (string) $data['code'],
ip: $ip
);
if (!($res['ok'] ?? false)) {
$reason = (string)($res['reason'] ?? '');
$msg = match ($reason) {
'blocked' => '로그인 할 수 없는 계정입니다.',
'not_registered' => 'OTP 등록 상태가 올바르지 않습니다. 최고관리자에게 문의해 주세요.',
default => '인증코드가 올바르지 않습니다.',
};
// invalid는 페이지에서 재입력, 나머지는 로그인으로
if ($reason !== 'invalid') {
$request->session()->forget('admin_totp');
return redirect()->route('admin.login.form')->withErrors(['login_id' => $msg]);
}
return back()->withErrors(['code' => $msg]);
}
/** @var \App\Models\AdminUser $admin */
$admin = $res['admin'];
Auth::guard('admin')->login($admin, (bool)($pending['remember'] ?? false));
$request->session()->forget('admin_totp');
$ctx = $this->authService->buildAdminSessionContext((int)$admin->id);
if (!empty($ctx)) $request->session()->put('admin_ctx', $ctx);
return redirect()->route('admin.home');
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
final class AdminRole
{
public function handle(Request $request, Closure $next, ...$roles)
{
$ctx = (array) session('admin_ctx', []);
$roleNames = (array)($ctx['role_names'] ?? []);
if (in_array('super_admin', $roleNames, true)) {
return $next($request);
}
if (empty($roles)) {
return $next($request);
}
// 하나라도 매칭되면 통과
foreach ($roles as $r) {
if (in_array($r, $roleNames, true)) {
return $next($request);
}
}
abort(403);
}
}

View File

@ -2,15 +2,20 @@
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Fortify;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
if (class_exists(Fortify::class)) {
Fortify::ignoreRoutes();
}
$this->app->singleton(CiSeedCrypto::class, function () {
$key = (string) config('legacy.seed_user_key_default', '');
$iv = (string) config('legacy.iv', '');

View File

@ -3,18 +3,66 @@
namespace App\Services\Admin;
use App\Repositories\Admin\AdminUserRepository;
use App\Services\Admin\AdminAuditService;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class AdminAdminsService
{
public function __construct(
private readonly AdminUserRepository $repo,
private readonly AdminAuditService $audit,
) {}
private function snapAdmin(?object $admin): ?array
{
if (!$admin) return null;
return [
'id' => (int)($admin->id ?? 0),
'email' => (string)($admin->email ?? ''),
'name' => (string)($admin->name ?? ''),
'nickname' => (string)($admin->nickname ?? ''),
'status' => (string)($admin->status ?? ''),
'totp_enabled' => (int)($admin->totp_enabled ?? 0),
'totp_verified_at' => $admin->totp_verified_at ? (string)$admin->totp_verified_at : null,
// ❗시크릿은 절대 로그 저장 금지
'totp_secret_set' => !empty($admin->totp_secret_enc) ? 1 : 0,
'locked_until' => $admin->locked_until ? (string)$admin->locked_until : null,
'failed_login_count' => (int)($admin->failed_login_count ?? 0),
'must_reset_password' => (int)($admin->must_reset_password ?? 0),
];
}
private function packRoles(array $roles): array
{
// repo->getRolesForUser()가 반환하는 형태(id, code, name)를
// 네가 원하는 id/name/label로 정리
return array_map(function ($r) {
$r = (array)$r;
return [
'id' => (int)($r['id'] ?? 0),
'name' => (string)($r['code'] ?? ''), // ex) super_admin
'label' => (string)($r['name'] ?? ''), // ex) 최고관리자
];
}, $roles);
}
private function snapRolesForUser(int $adminId): array
{
$roles = $this->packRoles($this->repo->getRolesForUser($adminId));
return [
'roles' => $roles,
'role_ids' => array_values(array_map(fn($x)=> (int)$x['id'], $roles)),
'role_names' => array_values(array_map(fn($x)=> (string)$x['name'], $roles)),
];
}
public function list(array $filters): array
{
$page = $this->repo->paginateUsers($filters, 20);
@ -57,20 +105,28 @@ final class AdminAdminsService
];
}
public function update(int $id, array $input, int $actorId): array
public function update(int $id, array $input, int $actorId, string $ip = '', string $ua = ''): array
{
$admin = $this->repo->find($id);
if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.');
// 본인 계정 비활성화 방지(사고 방지)
// before snapshot (admin + roles)
$before = array_merge(
['admin' => $this->snapAdmin($admin)],
$this->snapRolesForUser($id),
);
// 본인 계정 비활성화 방지
if ((int)$id === (int)$actorId && isset($input['status']) && $input['status'] !== 'active') {
return $this->fail('본인 계정은 비활성화할 수 없습니다.');
}
$wantTotp = (int)($data['totp_enabled'] ?? 0) === 1;
// 버그 수정: $data가 아니라 $input 기준
$wantTotp = (int)($input['totp_enabled'] ?? 0) === 1;
if ($wantTotp && empty($admin->totp_secret_enc)) {
return $this->fail('Google OTP가 미등록 상태라 선택할 수 없습니다.');
// TOTP를 켜려면 "등록 완료" 상태여야 함 (secret + verified_at)
if ($wantTotp && (empty($admin->totp_secret_enc) || empty($admin->totp_verified_at))) {
return $this->fail('Google OTP가 등록 완료 상태가 아니라 선택할 수 없습니다.');
}
$data = [
@ -91,6 +147,8 @@ final class AdminAdminsService
if ($hashKey === '') return $this->fail('ADMIN_PHONE_HASH_KEY 가 설정되지 않았습니다.');
$e164 = $this->toE164Kr($phone);
// update는 e164로 해시하니, 여기서도 e164 기준 유지
$hash = hash_hmac('sha256', $e164, $hashKey);
if (method_exists($this->repo, 'existsPhoneHash') && $this->repo->existsPhoneHash($hash, $id)) {
@ -105,10 +163,14 @@ final class AdminAdminsService
}
}
// null은 업데이트 제외(컬럼 유무 필터는 repo에서 함)
// null만 제외 (0은 유지)
$data = array_filter($data, fn($v)=>$v !== null);
$ok = $this->repo->updateById($id, $data);
// ⚠️ updateById가 "변경사항 없음"이면 0 리턴할 수도 있음
// 지금은 0이면 실패 처리인데, 원하면 아래처럼 완화 가능:
// if (!$ok && empty($data)) { $ok = true; }
if (!$ok) return $this->fail('저장에 실패했습니다. 잠시 후 다시 시도해 주세요.');
// roles sync
@ -118,37 +180,75 @@ final class AdminAdminsService
$this->repo->syncRoles($id, $roleIds, $actorId);
}
// after snapshot
$afterAdmin = $this->repo->find($id);
$after = array_merge(
['admin' => $this->snapAdmin($afterAdmin)],
$this->snapRolesForUser($id),
);
// Audit log (actor=수정한 관리자, target=수정당한 관리자)
$this->audit->log(
actorAdminId: $actorId,
action: 'admin_user_update',
targetType: 'admin_users',
targetId: $id,
before: $before,
after: $after,
ip: $ip,
ua: $ua,
);
return $this->ok('변경되었습니다.');
}
public function resetPasswordToEmail(int $id, int $actorId): array
public function resetPasswordToEmail(int $id, int $actorId, string $ip = '', string $ua = ''): array
{
$admin = $this->repo->find($id);
if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.');
$before = ['admin' => $this->snapAdmin($admin)];
$email = (string)($admin->email ?? '');
if ($email === '') return $this->fail('이메일이 비어 있어 초기화할 수 없습니다.');
$payload = [
'password' => Hash::make($email),
'password' => Hash::make($email), // ❗비번 원문/해시는 로그에 남기지 않음
'must_reset_password' => 1,
'password_changed_at' => now(),
'updated_by' => $actorId ?: null,
'updated_at' => now(),
];
// 임시 비밀번호 = 이메일
$ok = $this->repo->updateById($id, $payload);
if (!$ok) return $this->fail('비밀번호 초기화에 실패했습니다.');
$afterAdmin = $this->repo->find($id);
$after = ['admin' => $this->snapAdmin($afterAdmin)];
$this->audit->log(
actorAdminId: $actorId,
action: 'admin_user_reset_password_to_email',
targetType: 'admin_users',
targetId: $id,
before: $before,
after: $after,
ip: $ip,
ua: $ua,
);
return $this->ok('임시 비밀번호가 이메일로 초기화되었습니다. (로그인 후 OTP 진행)');
}
public function unlock(int $id, int $actorId): array
public function unlock(int $id, int $actorId, string $ip = '', string $ua = ''): array
{
$admin = $this->repo->find($id);
if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.');
$before = ['admin' => $this->snapAdmin($admin)];
$ok = $this->repo->updateById($id, [
'locked_until' => null,
'failed_login_count' => 0,
@ -156,9 +256,25 @@ final class AdminAdminsService
]);
if (!$ok) return $this->fail('잠금 해제에 실패했습니다.');
$afterAdmin = $this->repo->find($id);
$after = ['admin' => $this->snapAdmin($afterAdmin)];
$this->audit->log(
actorAdminId: $actorId,
action: 'admin_user_unlock',
targetType: 'admin_users',
targetId: $id,
before: $before,
after: $after,
ip: $ip,
ua: $ua,
);
return $this->ok('잠금이 해제되었습니다.');
}
private function ok(string $msg): array { return ['ok'=>true,'message'=>$msg]; }
private function fail(string $msg): array { return ['ok'=>false,'message'=>$msg]; }
@ -210,7 +326,7 @@ final class AdminAdminsService
];
}
public function createAdmin(array $payload, int $actorId): array
public function createAdmin(array $payload, int $actorId, string $ip = '', string $ua = ''): array
{
$email = (string)($payload['email'] ?? '');
$name = (string)($payload['name'] ?? '');
@ -247,7 +363,7 @@ final class AdminAdminsService
$email, $name, $nick,
$phoneEnc, $phoneHash, $phoneLast4,
$pwHash, $tempPassword,
$roleId, $actorId
$roleId, $actorId, $ip, $ua
) {
// 중복 방어(이메일/휴대폰)
if ($this->repo->existsByEmail($email)) {
@ -257,7 +373,7 @@ final class AdminAdminsService
return $this->fail('이미 등록된 휴대폰 번호입니다.');
}
// admin_users insert
// admin_users insert
$adminId = $this->repo->insertAdminUser([
'email' => $email,
'password' => $pwHash,
@ -292,6 +408,23 @@ final class AdminAdminsService
$this->repo->deleteRoleMappingsExcept($adminId, $roleId);
$this->repo->upsertRoleMapping($adminId, $roleId);
$afterAdmin = $this->repo->find($adminId);
$after = array_merge(
['admin' => $this->snapAdmin($afterAdmin)],
$this->snapRolesForUser($adminId),
);
$this->audit->log(
actorAdminId: $actorId,
action: 'admin_user_create',
targetType: 'admin_users',
targetId: $adminId,
before: null,
after: $after,
ip: $ip,
ua: $ua,
);
return [
'ok' => true,
'admin_id' => $adminId,

View File

@ -15,6 +15,7 @@ use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use App\Services\Admin\AdminAuditService;
final class AdminAuthService
{
@ -22,6 +23,7 @@ final class AdminAuthService
private readonly AdminUserRepository $users,
private readonly SmsService $sms,
private readonly TwoFactorAuthenticationProvider $totpProvider,
private readonly AdminAuditService $audit,
) {}
/**
@ -74,6 +76,29 @@ final class AdminAuthService
return ['state' => 'must_reset', 'admin_id' => (int)$admin->id];
}
// ✅ TOTP 모드면 SMS 발송 없이 "TOTP 입력"으로 보냄
$totpEnabled = (int)($admin->totp_enabled ?? 0) === 1;
$totpReady = $totpEnabled
&& !empty($admin->totp_secret_enc)
&& !empty($admin->totp_verified_at);
if ($totpReady) {
return [
'state' => 'totp_required',
'admin_id' => (int)$admin->id,
];
}
// totp_enabled=1인데 등록이 깨진 상태면 로그 남기고 SMS로 fallback (락아웃 방지)
if ($totpEnabled && !$totpReady) {
Log::warning('[admin-auth] totp_enabled=1 but not registered/verified', [
'admin_id' => (int)$admin->id,
'has_secret' => !empty($admin->totp_secret_enc),
'has_verified_at' => !empty($admin->totp_verified_at),
]);
}
// phone_enc 복호화(E164 or digits)
$phoneDigits = $this->decryptPhoneToDigits($admin);
if ($phoneDigits === '') {
@ -106,8 +131,8 @@ final class AdminAuthService
$smsPayload = [
'from_number' => config('services.sms.from', '1833-4856'),
//'to_number' => $phoneDigits,
'to_number' => '01036828958',
'to_number' => $phoneDigits,
//'to_number' => '01036828958',
'message' => "[PIN FOR YOU] 인증번호 {$code} 를 입력해 주세요. ({$ttl}초 이내)",
'sms_type' => 'sms',
];
@ -202,20 +227,81 @@ final class AdminAuthService
];
}
/**
* return:
* - ['ok'=>false,'reason'=>'expired|invalid|attempts|ip|blocked|not_registered']
* - ['ok'=>true,'admin'=>AdminUser]
*/
public function verifyTotp(int $adminId, string $code, string $ip): array
{
$admin = $this->users->findActiveById($adminId);
if (!$admin) {
return ['ok' => false, 'reason' => 'blocked'];
}
if (empty($admin->totp_secret_enc) || empty($admin->totp_verified_at)) {
return ['ok' => false, 'reason' => 'not_registered'];
}
try {
$secret = Crypt::decryptString((string) $admin->totp_secret_enc);
} catch (\Throwable $e) {
return ['ok' => false, 'reason' => 'not_registered'];
}
$code = preg_replace('/\D+/', '', $code);
if (strlen($code) !== 6) {
return ['ok' => false, 'reason' => 'invalid'];
}
$valid = $this->totpProvider->verify($secret, $code);
if (!$valid) {
return ['ok' => false, 'reason' => 'invalid'];
}
// 로그인 메타 업데이트
$this->users->touchLogin($admin, $ip);
return [
'ok' => true,
'admin' => $admin,
];
}
/**
* 비밀번호 초기화 처리
* return: ['ok'=>true] | ['ok'=>false]
*/
public function resetPassword(int $adminId, string $newPassword, string $ip): array
public function resetPassword(int $adminId, string $newPassword, string $ip, string $ua = ''): array
{
$beforeAdmin = $this->users->find($adminId);
$before = $this->snapPwPolicy($beforeAdmin);
$admin = $this->users->findActiveById($adminId);
if (!$admin) return ['ok' => false];
$this->users->setPassword($admin, $newPassword);
$afterAdmin = $this->users->find($adminId);
$after = $this->snapPwPolicy($afterAdmin);
$this->audit->log(
actorAdminId: $adminId,
action: 'password_force_reset_done',
targetType: 'admin_users',
targetId: $adminId,
before: $before,
after: $after,
ip: $ip,
ua: $ua,
);
return ['ok' => true];
}
private function otpKey(string $challengeId): string
{
$prefix = (string)config('admin.redis_prefix', 'admin:2fa:');
@ -326,28 +412,48 @@ final class AdminAuthService
}
/** 등록 시작(시크릿 생성) */
public function totpStart(int $adminId, bool $forceReset = false): array
public function totpStart(int $adminId, bool $forceReset = false, string $ip = '', string $ua = ''): array
{
$admin = $this->users->find($adminId);
$beforeAdmin = $this->users->find($adminId);
$before = $this->snapAdmin2fa($beforeAdmin);
$admin = $beforeAdmin;
if (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
if (!$forceReset && !empty($admin->totp_verified_at) && !empty($admin->totp_secret_enc)) {
return ['ok' => false, 'message' => '이미 Google OTP가 등록되어 있습니다.'];
}
// 새 시크릿 생성(기본 16)
$secret = $this->totpProvider->generateSecretKey(16); // :contentReference[oaicite:2]{index=2}
$secret = $this->totpProvider->generateSecretKey(16);
$secretEnc = Crypt::encryptString($secret);
$ok = $this->users->updateTotpStart($adminId, $secretEnc);
if (!$ok) return ['ok' => false, 'message' => 'OTP 등록 시작에 실패했습니다.'];
$afterAdmin = $this->users->find($adminId);
$after = $this->snapAdmin2fa($afterAdmin);
// Audit
$this->audit->log(
actorAdminId: $adminId,
action: $forceReset ? 'totp_reset' : 'totp_start',
targetType: 'admin_users',
targetId: $adminId,
before: $before,
after: $after,
ip: $ip,
ua: $ua,
);
return ['ok' => true, 'message' => 'Google OTP 등록을 시작합니다. 앱에서 QR을 스캔한 뒤 인증코드를 입력해 주세요.'];
}
/** 등록 확인(코드 검증) */
public function totpConfirm(int $adminId, string $code): array
public function totpConfirm(int $adminId, string $code, string $ip = '', string $ua = ''): array
{
$beforeAdmin = $this->users->find($adminId);
$before = $this->snapAdmin2fa($beforeAdmin);
$vm = $this->totpViewModel($adminId);
if (!($vm['ok'] ?? false)) return ['ok' => false, 'message' => $vm['message'] ?? '오류'];
@ -368,21 +474,53 @@ final class AdminAuthService
$ok = $this->users->confirmTotp($adminId);
if (!$ok) return ['ok' => false, 'message' => 'OTP 등록 완료 처리에 실패했습니다.'];
$afterAdmin = $this->users->find($adminId);
$after = $this->snapAdmin2fa($afterAdmin);
$this->audit->log(
actorAdminId: $adminId,
action: 'totp_confirm',
targetType: 'admin_users',
targetId: $adminId,
before: $before,
after: $after,
ip: $ip,
ua: $ua,
);
return ['ok' => true, 'message' => 'Google OTP 등록이 완료되었습니다.'];
}
/** 삭제(해제) */
public function totpDisable(int $adminId): array
public function totpDisable(int $adminId, string $ip = '', string $ua = ''): array
{
$admin = $this->users->find($adminId);
$beforeAdmin = $this->users->find($adminId);
$before = $this->snapAdmin2fa($beforeAdmin);
$admin = $beforeAdmin;
if (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
$ok = $this->users->disableTotp($adminId);
if (!$ok) return ['ok' => false, 'message' => 'Google OTP 삭제에 실패했습니다.'];
$afterAdmin = $this->users->find($adminId);
$after = $this->snapAdmin2fa($afterAdmin);
$this->audit->log(
actorAdminId: $adminId,
action: 'totp_disable',
targetType: 'admin_users',
targetId: $adminId,
before: $before,
after: $after,
ip: $ip,
ua: $ua,
);
return ['ok' => true, 'message' => 'Google OTP가 삭제되었습니다. 이제 SMS 인증을 사용합니다.'];
}
/** 재등록(새 시크릿 발급) */
public function totpReset(int $adminId): array
{
@ -391,9 +529,12 @@ final class AdminAuthService
}
/** sms/otp 모드 저장(선택) */
public function totpMode(int $adminId, int $enabled): array
public function totpMode(int $adminId, int $enabled, string $ip = '', string $ua = ''): array
{
$admin = $this->users->find($adminId);
$beforeAdmin = $this->users->find($adminId);
$before = $this->snapAdmin2fa($beforeAdmin);
$admin = $beforeAdmin;
if (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
if ($enabled === 1) {
@ -405,6 +546,74 @@ final class AdminAuthService
$ok = $this->users->updateTotpMode($adminId, $enabled ? 1 : 0);
if (!$ok) return ['ok' => false, 'message' => '2차 인증방법 저장에 실패했습니다.'];
$afterAdmin = $this->users->find($adminId);
$after = $this->snapAdmin2fa($afterAdmin);
$this->audit->log(
actorAdminId: $adminId,
action: 'totp_mode',
targetType: 'admin_users',
targetId: $adminId,
before: $before,
after: $after,
ip: $ip,
ua: $ua,
);
return ['ok' => true, 'message' => '2차 인증방법이 저장되었습니다.'];
}
public function buildAdminSessionContext(int $adminId): array
{
$admin = $this->users->findActiveById($adminId);
if (!$admin) return [];
// repo가 반환: ['id'=>?, 'code'=>?, 'name'=>?] (code=권한코드, name=표시명)
$rows = $this->users->getRolesForUser($adminId);
$roles = array_map(fn($r) => [
'id' => (int)($r['id'] ?? 0),
'name' => (string)($r['code'] ?? ''), // 체크용
'label' => (string)($r['name'] ?? ''), // 표시용
], $rows);
$roleIds = array_values(array_filter(array_map(fn($x) => (int)$x['id'], $roles)));
$roleNames = array_values(array_filter(array_map(fn($x) => (string)$x['name'], $roles)));
return [
'id' => (int)$admin->id,
'email' => (string)($admin->email ?? ''),
'roles' => $roles,
'role_ids' => $roleIds,
'role_names' => $roleNames,
'at' => time(),
];
}
private function snapAdmin2fa(?object $admin): ?array
{
if (!$admin) return null;
return [
'id' => (int)($admin->id ?? 0),
'email' => (string)($admin->email ?? ''),
'totp_enabled' => (int)($admin->totp_enabled ?? 0),
'totp_verified_at' => $admin->totp_verified_at ? (string)$admin->totp_verified_at : null,
// 시크릿 원문/암호문 저장 금지
'totp_secret_set' => !empty($admin->totp_secret_enc) ? 1 : 0,
];
}
private function snapPwPolicy(?object $admin): ?array
{
if (!$admin) return null;
return [
'id' => (int)($admin->id ?? 0),
'email' => (string)($admin->email ?? ''),
'must_reset_password' => (int)($admin->must_reset_password ?? 0),
'password_changed_at' => $admin->password_changed_at ? (string)$admin->password_changed_at : null,
];
}
}

View File

@ -36,6 +36,7 @@ return Application::configure(basePath: dirname(__DIR__))
'legacy.auth' => \App\Http\Middleware\LegacyAuth::class,
'legacy.guest' => \App\Http\Middleware\LegacyGuest::class,
'admin.ip' => \App\Http\Middleware\AdminIpAllowlist::class,
'admin.role' => \App\Http\Middleware\AdminRole::class,
]);
// ✅ guest redirect (auth 미들웨어가 login 라우트 찾다 터지는거 방지)

View File

@ -1,6 +1,6 @@
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
\App\Providers\AppServiceProvider::class,
\App\Providers\FortifyServiceProvider::class,
];

View File

@ -0,0 +1,39 @@
@extends('admin.layouts.auth')
@section('hide_flash', '1')
@section('title','Google OTP 인증')
@section('heading', 'Google OTP 인증')
@section('subheading', 'Google Authenticator 앱의 6자리 코드를 입력해 주세요.')
@section('content')
<form method="POST" action="{{ route('admin.totp.store') }}" class="a-form">
@csrf
<div class="a-field">
<label class="a-label" for="code">인증코드</label>
<input
class="a-input a-otp-input"
id="code"
name="code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
placeholder="6자리 입력"
autofocus
>
@error('code')
<div class="a-error">{{ $message }}</div>
@enderror
</div>
<button class="a-btn a-btn--primary" type="submit">
인증 완료
</button>
<div class="a-help a-otp-help">
<small class="a-muted">
시간이 틀어지면 코드가 맞아도 실패할 있습니다. (휴대폰 시간 자동 설정 권장)
</small>
</div>
</form>
@endsection

View File

@ -30,7 +30,7 @@
<p class="a-muted">
@yield('subheading', '승인된 관리자만 접근할 수 있습니다.')
<br><br>
@yield('subheading', '모든 로그인·조회·변경 시도는 기록되며, 무단 접근 및 오남용은 정책 및 관련 법령에 따라 조치될 수 있습니다.')
@yield('mess', '모든 로그인·조회·변경 시도는 기록되며, 무단 접근 및 오남용은 정책 및 관련 법령에 따라 조치될 수 있습니다.')
</p>
</div>

View File

@ -122,18 +122,18 @@
</div>
</div>
<div class="a-meinfo__row">
<div class="a-meinfo__k"> 권한</div>
<div class="a-meinfo__v">
<div class="a-chips">
@forelse(($perms ?? []) as $p)
<span class="a-chip">{{ $p['code'] }}</span>
@empty
<span class="a-muted">권한 정보가 없습니다.</span>
@endforelse
</div>
</div>
</div>
{{-- <div class="a-meinfo__row">--}}
{{-- <div class="a-meinfo__k"> 권한</div>--}}
{{-- <div class="a-meinfo__v">--}}
{{-- <div class="a-chips">--}}
{{-- @forelse(($perms ?? []) as $p)--}}
{{-- <span class="a-chip">{{ $p['code'] }}</span>--}}
{{-- @empty--}}
{{-- <span class="a-muted">권한 정보가 없습니다.</span>--}}
{{-- @endforelse--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- </div>--}}
<div class="a-meinfo__row">
<div class="a-meinfo__k">최근 로그인</div>

View File

@ -1,4 +1,5 @@
@php
$roles = session('admin_ctx.role_names', []);
$menu = [
[
'title' => '대시보드',
@ -9,103 +10,134 @@
[
'title' => '콘솔 관리',
'items' => [
['label' => '내 정보', 'route' => 'admin.me'], // 추후 라우트
['label' => '관리자 계정 관리', 'route' => 'admin.admins.index'],
['label' => '권한/역할 관리', 'route' => 'admin.roles.index'],
['label' => '접근 IP 허용목록', 'route' => 'admin.allowip.index'],
['label' => '내 정보', 'route' => 'admin.me'],
['label' => '관리자 계정 관리', 'route' => 'admin.admins.index' ,'roles' => ['super_admin']],
],
],
[
'title' => '알림/메시지',
'items' => [
['label' => '관리자 SMS 발송', 'route' => 'admin.sms.send'],
['label' => 'SMS 발송 이력', 'route' => 'admin.sms.logs'],
['label' => '알림 템플릿', 'route' => 'admin.templates.index'],
['label' => '관리자 SMS 발송', 'route' => 'admin.sms.send','roles' => ['super_admin','finance','product','support']],
['label' => 'SMS 발송 이력', 'route' => 'admin.sms.logs','roles' => ['super_admin','finance','product','support']],
['label' => '알림 템플릿', 'route' => 'admin.templates.index','roles' => ['super_admin','finance','product','support']],
],
],
[
'title' => '고객지원',
'items' => [
['label' => '공지사항', 'route' => 'admin.notice.index'],
['label' => '1:1 문의', 'route' => 'admin.inquiry.index'],
['label' => 'FAQ 코드 관리', 'route' => 'admin.faqcodes.index'],
['label' => 'QnA 코드 관리', 'route' => 'admin.qnacodes.index'],
['label' => '공지사항', 'route' => 'admin.notice.index','roles' => ['super_admin','support']],
['label' => '1:1 문의', 'route' => 'admin.inquiry.index','roles' => ['super_admin','support']],
['label' => 'FAQ 코드 관리', 'route' => 'admin.faqcodes.index','roles' => ['super_admin','support']],
['label' => 'QnA 코드 관리', 'route' => 'admin.qnacodes.index','roles' => ['super_admin','support']],
],
],
[
'title' => '상품권 관리',
'items' => [
['label' => '상품 리스트', 'route' => 'admin.products.index'],
['label' => '상품 등록', 'route' => 'admin.products.create'],
['label' => '판매 코드 관리', 'route' => 'admin.sale-codes.index'],
['label' => '핀 번호 관리', 'route' => 'admin.pins.index'],
['label' => '메인 노출 관리', 'route' => 'admin.exposure.index'],
['label' => '결제 수수료/정책', 'route' => 'admin.fees.index'],
['label' => '상품 리스트', 'route' => 'admin.products.index','roles' => ['super_admin','product']],
['label' => '상품 등록', 'route' => 'admin.products.create','roles' => ['super_admin','product']],
['label' => '판매 코드 관리', 'route' => 'admin.sale-codes.index','roles' => ['super_admin','product']],
['label' => '핀 번호 관리', 'route' => 'admin.pins.index','roles' => ['super_admin','product']],
['label' => '메인 노출 관리', 'route' => 'admin.exposure.index','roles' => ['super_admin','product']],
['label' => '결제 수수료/정책', 'route' => 'admin.fees.index','roles' => ['super_admin','product']],
],
],
[
'title' => '매입/정산',
'items' => [
['label' => '핀 매입 현황(출금)', 'route' => 'admin.buyback.index'],
['label' => '출금 요청 관리', 'route' => 'admin.withdraw.index'],
['label' => '정산 리포트', 'route' => 'admin.settlement.index'],
['label' => '핀 매입 현황(출금)', 'route' => 'admin.buyback.index','roles' => ['super_admin','finance']],
['label' => '출금 요청 관리', 'route' => 'admin.withdraw.index','roles' => ['super_admin','finance']],
['label' => '정산 리포트', 'route' => 'admin.settlement.index','roles' => ['super_admin','finance']],
],
],
[
'title' => '거래/매출',
'items' => [
['label' => '상품권 거래 장부', 'route' => 'admin.ledger.index'],
['label' => '매출 리포트', 'route' => 'admin.sales.index'],
['label' => '환불/취소 내역', 'route' => 'admin.refunds.index'],
['label' => '상품권 거래 장부', 'route' => 'admin.ledger.index','roles' => ['super_admin','finance']],
['label' => '매출 리포트', 'route' => 'admin.sales.index','roles' => ['super_admin','finance']],
['label' => '환불/취소 내역', 'route' => 'admin.refunds.index','roles' => ['super_admin','finance']],
],
],
[
'title' => '회원/정책',
'items' => [
['label' => '회원 관리', 'route' => 'admin.members.index'],
['label' => '회원가입 필터 설정', 'route' => 'admin.signup-filter.index'],
['label' => '블랙리스트/제재', 'route' => 'admin.sanctions.index'],
['label' => '마케팅 수신동의', 'route' => 'admin.marketing.index'],
['label' => '회원 관리', 'route' => 'admin.members.index','roles' => ['super_admin','support']],
['label' => '회원가입 필터 설정', 'route' => 'admin.signup-filter.index','roles' => ['super_admin','support']],
['label' => '블랙리스트/제재', 'route' => 'admin.sanctions.index','roles' => ['super_admin','support']],
['label' => '마케팅 수신동의', 'route' => 'admin.marketing.index','roles' => ['super_admin','support']],
],
],
[
'title' => '시스템 로그',
'items' => [
['label' => '로그인 로그', 'route' => 'admin.logs.login'],
['label' => '다날 인증 로그', 'route' => 'admin.logs.danal'],
['label' => '결제 로그', 'route' => 'admin.logs.pay'],
['label' => '기타 로그', 'route' => 'admin.logs.misc'],
['label' => '관리자 활동 로그', 'route' => 'admin.logs.audit'],
['label' => '로그인 로그', 'route' => 'admin.logs.login','roles' => ['super_admin','finance','product','support']],
['label' => '다날 인증 로그', 'route' => 'admin.logs.danal','roles' => ['super_admin','finance','product','support']],
['label' => '결제 로그', 'route' => 'admin.logs.pay','roles' => ['super_admin','finance','product','support']],
['label' => '기타 로그', 'route' => 'admin.logs.misc','roles' => ['super_admin','finance','product','support']],
['label' => '관리자 활동 로그', 'route' => 'admin.logs.audit','roles' => ['super_admin','finance','product','support']],
],
],
];
$roleNames = (array) data_get(session('admin_ctx', []), 'role_names', []);
$isSuper = in_array('super_admin', $roleNames, true);
@endphp
<nav class="a-nav">
@foreach($menu as $group)
<div class="a-nav__group">
<div class="a-nav__title">{{ $group['title'] }}</div>
@php
// 그룹 아이템 필터링 (roles 체크 + route 존재 여부는 아래에서 disabled로 처리)
$visibleItems = [];
foreach (($group['items'] ?? []) as $it) {
$need = $it['roles'] ?? null;
@foreach($group['items'] as $it)
@php
$has = \Illuminate\Support\Facades\Route::has($it['route']);
// index면 admin.admins.* 전체를 active로 잡아줌
$base = preg_replace('/\.index$/', '', $it['route']);
$isActive = $has ? request()->routeIs($base.'*') : false;
@endphp
// roles 미지정: 전체 노출
if ($need === null) {
$visibleItems[] = $it;
continue;
}
@if($has)
<a class="a-nav__item {{ $isActive ? 'is-active' : '' }}" href="{{ route($it['route']) }}">
<span class="a-nav__dot" aria-hidden="true"></span>
<span class="a-nav__label">{{ $it['label'] }}</span>
</a>
@else
<span class="a-nav__item is-disabled" title="준비중">
<span class="a-nav__dot" aria-hidden="true"></span>
<span class="a-nav__label">{{ $it['label'] }}</span>
</span>
@endif
@endforeach
</div>
// super_admin: 무조건 통과
if ($isSuper) {
$visibleItems[] = $it;
continue;
}
// roles 배열과 교집합이 있으면 노출
if (is_array($need) && !empty(array_intersect($need, $roleNames))) {
$visibleItems[] = $it;
}
}
@endphp
@if(!empty($visibleItems))
<div class="a-nav__group">
<div class="a-nav__title">{{ $group['title'] }}</div>
@foreach($visibleItems as $it)
@php
$routeName = (string)($it['route'] ?? '');
$has = $routeName !== '' ? \Illuminate\Support\Facades\Route::has($routeName) : false;
// index면 admin.admins.* 전체를 active로 잡아줌
$base = $routeName !== '' ? preg_replace('/\.index$/', '', $routeName) : '';
$isActive = $has && $base !== '' ? request()->routeIs($base . '.*') || request()->routeIs($base) : false;
@endphp
@if($has)
<a class="a-nav__item {{ $isActive ? 'is-active' : '' }}" href="{{ route($routeName) }}">
<span class="a-nav__dot" aria-hidden="true"></span>
<span class="a-nav__label">{{ $it['label'] }}</span>
</a>
@else
<span class="a-nav__item is-disabled" title="준비중">
<span class="a-nav__dot" aria-hidden="true"></span>
<span class="a-nav__label">{{ $it['label'] }}</span>
</span>
@endif
@endforeach
</div>
@endif
@endforeach
</nav>

View File

@ -24,24 +24,37 @@ Route::middleware(['web'])->group(function () {
->middleware('throttle:admin-login')
->name('admin.password.reset.store');
// OTP(sms) 2차 인증
Route::get('/otp', [AdminAuthController::class, 'showOtp'])
->name('admin.otp.form');
Route::post('/otp', [AdminAuthController::class, 'verifyOtp'])
->middleware('throttle:admin-otp')
->name('admin.otp.store');
// TOTP(구글 OTP) 2차 인증
Route::get('/totp', [AdminAuthController::class, 'showTotp'])
->name('admin.totp.form');
Route::post('/totp', [AdminAuthController::class, 'verifyTotp'])
->middleware('throttle:admin-otp') // 필요하면 throttle:admin-totp 로 분리 가능
->name('admin.totp.store');
});
// 로그인 이후
Route::middleware(['auth:admin', \App\Http\Middleware\NoStore::class])->group(function () {
// ✅ 대시보드: 전체 허용
Route::get('/', fn() => view('admin.home'))->name('admin.home');
// ✅ 내 정보: 전체 허용
Route::get('/me', [MeController::class, 'show'])->name('admin.me');
Route::post('/me', [MeController::class, 'update'])->name('admin.me.update');
Route::get('/me/password', [MeController::class, 'showPassword'])->name('admin.me.password.form');
Route::post('/me/password', [MeController::class, 'updatePassword'])->name('admin.me.password.update');
// ✅ 보안/OTP 등록(자기계정 설정): 전체 허용
Route::get('/security', [AdminAuthController::class, 'security'])->name('admin.security');
Route::post('/totp/start', [AdminAuthController::class, 'totpStart'])->name('admin.totp.start');
@ -52,22 +65,45 @@ Route::middleware(['web'])->group(function () {
Route::post('/logout', [AdminAuthController::class, 'logout'])->name('admin.logout');
Route::prefix('/admins')->name('admin.admins.')->group(function () {
Route::get('/', [AdminAdminsController::class, 'index'])->name('index');
Route::get('/create', [AdminAdminsController::class, 'create'])->name('create');
Route::post('/', [AdminAdminsController::class, 'store'])->name('store');
Route::get('/{id}', [AdminAdminsController::class, 'edit'])->name('edit');
Route::post('/{id}', [AdminAdminsController::class, 'update'])->name('update');
Route::post('/{id}/reset-password', [AdminAdminsController::class, 'resetPassword'])->name('reset_password');
Route::post('/{id}/unlock', [AdminAdminsController::class, 'unlock'])->name('unlock');
});
// ✅ 관리자 계정 관리: super_admin 전용
Route::prefix('/admins')
->name('admin.admins.')
->middleware('admin.role:super_admin')
->group(function () {
Route::get('/', [AdminAdminsController::class, 'index'])->name('index');
Route::get('/create', [AdminAdminsController::class, 'create'])->name('create');
Route::post('/', [AdminAdminsController::class, 'store'])->name('store');
Route::get('/{id}', [AdminAdminsController::class, 'edit'])->name('edit');
Route::post('/{id}', [AdminAdminsController::class, 'update'])->name('update');
Route::post('/{id}/reset-password', [AdminAdminsController::class, 'resetPassword'])->name('reset_password');
Route::post('/{id}/unlock', [AdminAdminsController::class, 'unlock'])->name('unlock');
});
/**
* 아래는 메뉴는 있지만 실제 라우트/컨트롤러가 아직 없으니,
* 구현 시점에만 같은 패턴으로 그룹에 admin.role 붙이면 .
*
* )
* - support 전용:
* Route::prefix('/inquiry')->name('admin.inquiry.')
* ->middleware('admin.role:support')
* ->group(...)
*
* - finance 전용:
* Route::prefix('/settlement')->name('admin.settlement.')
* ->middleware('admin.role:finance')
* ->group(...)
*
* - product 전용:
* Route::prefix('/products')->name('admin.products.')
* ->middleware('admin.role:product')
* ->group(...)
*/
});
});
/* 개발용 페이지 세션 보기 */
if (config('app.debug') || app()->environment('local')) {
require __DIR__.'/dev_admin.php';