관리자 변경 로그 저장 작업
This commit is contained in:
parent
81d5d2d6e1
commit
af3b2e7534
@ -29,7 +29,15 @@ final class AdminAdminsController
|
|||||||
|
|
||||||
public function update(Request $request, int $id)
|
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)) {
|
if (!($res['ok'] ?? false)) {
|
||||||
return redirect()->back()->withInput()
|
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)) {
|
if (!($res['ok'] ?? false)) {
|
||||||
return redirect()->back()->withInput()
|
return redirect()->back()->withInput()
|
||||||
@ -76,7 +89,13 @@ final class AdminAdminsController
|
|||||||
|
|
||||||
public function unlock(int $id)
|
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)) {
|
if (!($res['ok'] ?? false)) {
|
||||||
return redirect()->back()->withInput()
|
return redirect()->back()->withInput()
|
||||||
->with('toast', [
|
->with('toast', [
|
||||||
@ -131,7 +150,11 @@ final class AdminAdminsController
|
|||||||
'nickname' => $data['nickname'],
|
'nickname' => $data['nickname'],
|
||||||
'phone_digits' => $phoneDigits,
|
'phone_digits' => $phoneDigits,
|
||||||
'role' => $data['role'],
|
'role' => $data['role'],
|
||||||
], $actorId);
|
],
|
||||||
|
$actorId,
|
||||||
|
$request->ip(),
|
||||||
|
(string)($request->userAgent() ?? ''),
|
||||||
|
);
|
||||||
|
|
||||||
if (!($res['ok'] ?? false)) {
|
if (!($res['ok'] ?? false)) {
|
||||||
return back()->withErrors(['email' => (string)($res['message'] ?? '등록에 실패했습니다.')])->withInput();
|
return back()->withErrors(['email' => (string)($res['message'] ?? '등록에 실패했습니다.')])->withInput();
|
||||||
|
|||||||
@ -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 발송 성공
|
// ✅ 6) OTP 발송 성공
|
||||||
if ($state === 'otp_sent') {
|
if ($state === 'otp_sent') {
|
||||||
$request->session()->put('admin_2fa', [
|
$request->session()->put('admin_2fa', [
|
||||||
@ -134,6 +164,9 @@ final class AdminAuthController extends Controller
|
|||||||
'expires_at' => time() + (int) config('admin.sms_ttl', 180),
|
'expires_at' => time() + (int) config('admin.sms_ttl', 180),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// (권장) 세션 고정 공격 방지
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('admin.otp.form')
|
->route('admin.otp.form')
|
||||||
->with('toast', [
|
->with('toast', [
|
||||||
@ -195,10 +228,12 @@ final class AdminAuthController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$ip = (string) $request->ip();
|
$ip = (string) $request->ip();
|
||||||
|
$ua = (string) $request->userAgent();
|
||||||
$res = $this->authService->resetPassword(
|
$res = $this->authService->resetPassword(
|
||||||
adminId: (int) $pending['admin_id'],
|
adminId: (int) $pending['admin_id'],
|
||||||
newPassword: (string) $data['password'],
|
newPassword: (string) $data['password'],
|
||||||
ip: $ip
|
ip: $ip,
|
||||||
|
ua: $ua,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!($res['ok'] ?? false)) {
|
if (!($res['ok'] ?? false)) {
|
||||||
@ -276,6 +311,10 @@ final class AdminAuthController extends Controller
|
|||||||
|
|
||||||
Auth::guard('admin')->login($admin, (bool)($res['remember'] ?? false));
|
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');
|
$request->session()->forget('admin_2fa');
|
||||||
|
|
||||||
return redirect()->route('admin.home');
|
return redirect()->route('admin.home');
|
||||||
@ -285,6 +324,8 @@ final class AdminAuthController extends Controller
|
|||||||
{
|
{
|
||||||
Auth::guard('admin')->logout();
|
Auth::guard('admin')->logout();
|
||||||
|
|
||||||
|
$request->session()->forget('admin_ctx');
|
||||||
|
|
||||||
$request->session()->invalidate();
|
$request->session()->invalidate();
|
||||||
$request->session()->regenerateToken();
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
@ -303,7 +344,12 @@ final class AdminAuthController extends Controller
|
|||||||
public function totpStart(Request $request)
|
public function totpStart(Request $request)
|
||||||
{
|
{
|
||||||
$adminId = (int) auth()->guard('admin')->id();
|
$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']);
|
return back()->with($res['ok'] ? 'status' : 'error', $res['message']);
|
||||||
}
|
}
|
||||||
@ -315,7 +361,12 @@ final class AdminAuthController extends Controller
|
|||||||
'code' => ['required', 'string', 'max:10'],
|
'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')
|
return redirect()->route('admin.security')
|
||||||
->with($res['ok'] ? 'status' : 'error', $res['message']);
|
->with($res['ok'] ? 'status' : 'error', $res['message']);
|
||||||
@ -324,7 +375,11 @@ final class AdminAuthController extends Controller
|
|||||||
public function totpDisable(Request $request)
|
public function totpDisable(Request $request)
|
||||||
{
|
{
|
||||||
$adminId = (int) auth()->guard('admin')->id();
|
$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')
|
return redirect()->route('admin.security')
|
||||||
->with($res['ok'] ? 'status' : 'error', $res['message']);
|
->with($res['ok'] ? 'status' : 'error', $res['message']);
|
||||||
@ -346,9 +401,108 @@ final class AdminAuthController extends Controller
|
|||||||
'totp_enabled' => ['required', 'in:0,1'],
|
'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')
|
return redirect()->route('admin.security')
|
||||||
->with($res['ok'] ? 'status' : 'error', $res['message']);
|
->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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/Http/Middleware/AdminRole.php
Normal file
32
app/Http/Middleware/AdminRole.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,15 +2,20 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Laravel\Fortify\Fortify;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
|
if (class_exists(Fortify::class)) {
|
||||||
|
Fortify::ignoreRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
$this->app->singleton(CiSeedCrypto::class, function () {
|
$this->app->singleton(CiSeedCrypto::class, function () {
|
||||||
$key = (string) config('legacy.seed_user_key_default', '');
|
$key = (string) config('legacy.seed_user_key_default', '');
|
||||||
$iv = (string) config('legacy.iv', '');
|
$iv = (string) config('legacy.iv', '');
|
||||||
|
|||||||
@ -3,18 +3,66 @@
|
|||||||
namespace App\Services\Admin;
|
namespace App\Services\Admin;
|
||||||
|
|
||||||
use App\Repositories\Admin\AdminUserRepository;
|
use App\Repositories\Admin\AdminUserRepository;
|
||||||
|
use App\Services\Admin\AdminAuditService;
|
||||||
use Illuminate\Support\Facades\Crypt;
|
use Illuminate\Support\Facades\Crypt;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
|
||||||
final class AdminAdminsService
|
final class AdminAdminsService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AdminUserRepository $repo,
|
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
|
public function list(array $filters): array
|
||||||
{
|
{
|
||||||
$page = $this->repo->paginateUsers($filters, 20);
|
$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);
|
$admin = $this->repo->find($id);
|
||||||
if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.');
|
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') {
|
if ((int)$id === (int)$actorId && isset($input['status']) && $input['status'] !== 'active') {
|
||||||
return $this->fail('본인 계정은 비활성화할 수 없습니다.');
|
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)) {
|
// TOTP를 켜려면 "등록 완료" 상태여야 함 (secret + verified_at)
|
||||||
return $this->fail('Google OTP가 미등록 상태라 선택할 수 없습니다.');
|
if ($wantTotp && (empty($admin->totp_secret_enc) || empty($admin->totp_verified_at))) {
|
||||||
|
return $this->fail('Google OTP가 등록 완료 상태가 아니라 선택할 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
@ -91,6 +147,8 @@ final class AdminAdminsService
|
|||||||
if ($hashKey === '') return $this->fail('ADMIN_PHONE_HASH_KEY 가 설정되지 않았습니다.');
|
if ($hashKey === '') return $this->fail('ADMIN_PHONE_HASH_KEY 가 설정되지 않았습니다.');
|
||||||
|
|
||||||
$e164 = $this->toE164Kr($phone);
|
$e164 = $this->toE164Kr($phone);
|
||||||
|
|
||||||
|
// update는 e164로 해시하니, 여기서도 e164 기준 유지
|
||||||
$hash = hash_hmac('sha256', $e164, $hashKey);
|
$hash = hash_hmac('sha256', $e164, $hashKey);
|
||||||
|
|
||||||
if (method_exists($this->repo, 'existsPhoneHash') && $this->repo->existsPhoneHash($hash, $id)) {
|
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);
|
$data = array_filter($data, fn($v)=>$v !== null);
|
||||||
|
|
||||||
$ok = $this->repo->updateById($id, $data);
|
$ok = $this->repo->updateById($id, $data);
|
||||||
|
|
||||||
|
// ⚠️ updateById가 "변경사항 없음"이면 0 리턴할 수도 있음
|
||||||
|
// 지금은 0이면 실패 처리인데, 원하면 아래처럼 완화 가능:
|
||||||
|
// if (!$ok && empty($data)) { $ok = true; }
|
||||||
if (!$ok) return $this->fail('저장에 실패했습니다. 잠시 후 다시 시도해 주세요.');
|
if (!$ok) return $this->fail('저장에 실패했습니다. 잠시 후 다시 시도해 주세요.');
|
||||||
|
|
||||||
// roles sync
|
// roles sync
|
||||||
@ -118,37 +180,75 @@ final class AdminAdminsService
|
|||||||
$this->repo->syncRoles($id, $roleIds, $actorId);
|
$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('변경되었습니다.');
|
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);
|
$admin = $this->repo->find($id);
|
||||||
if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.');
|
if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.');
|
||||||
|
|
||||||
|
$before = ['admin' => $this->snapAdmin($admin)];
|
||||||
|
|
||||||
$email = (string)($admin->email ?? '');
|
$email = (string)($admin->email ?? '');
|
||||||
if ($email === '') return $this->fail('이메일이 비어 있어 초기화할 수 없습니다.');
|
if ($email === '') return $this->fail('이메일이 비어 있어 초기화할 수 없습니다.');
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'password' => Hash::make($email),
|
'password' => Hash::make($email), // ❗비번 원문/해시는 로그에 남기지 않음
|
||||||
'must_reset_password' => 1,
|
'must_reset_password' => 1,
|
||||||
'password_changed_at' => now(),
|
'password_changed_at' => now(),
|
||||||
'updated_by' => $actorId ?: null,
|
'updated_by' => $actorId ?: null,
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 임시 비밀번호 = 이메일
|
|
||||||
$ok = $this->repo->updateById($id, $payload);
|
$ok = $this->repo->updateById($id, $payload);
|
||||||
|
|
||||||
if (!$ok) return $this->fail('비밀번호 초기화에 실패했습니다.');
|
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 진행)');
|
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);
|
$admin = $this->repo->find($id);
|
||||||
if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.');
|
if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.');
|
||||||
|
|
||||||
|
$before = ['admin' => $this->snapAdmin($admin)];
|
||||||
|
|
||||||
$ok = $this->repo->updateById($id, [
|
$ok = $this->repo->updateById($id, [
|
||||||
'locked_until' => null,
|
'locked_until' => null,
|
||||||
'failed_login_count' => 0,
|
'failed_login_count' => 0,
|
||||||
@ -156,9 +256,25 @@ final class AdminAdminsService
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$ok) return $this->fail('잠금 해제에 실패했습니다.');
|
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('잠금이 해제되었습니다.');
|
return $this->ok('잠금이 해제되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private function ok(string $msg): array { return ['ok'=>true,'message'=>$msg]; }
|
private function ok(string $msg): array { return ['ok'=>true,'message'=>$msg]; }
|
||||||
private function fail(string $msg): array { return ['ok'=>false,'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'] ?? '');
|
$email = (string)($payload['email'] ?? '');
|
||||||
$name = (string)($payload['name'] ?? '');
|
$name = (string)($payload['name'] ?? '');
|
||||||
@ -247,7 +363,7 @@ final class AdminAdminsService
|
|||||||
$email, $name, $nick,
|
$email, $name, $nick,
|
||||||
$phoneEnc, $phoneHash, $phoneLast4,
|
$phoneEnc, $phoneHash, $phoneLast4,
|
||||||
$pwHash, $tempPassword,
|
$pwHash, $tempPassword,
|
||||||
$roleId, $actorId
|
$roleId, $actorId, $ip, $ua
|
||||||
) {
|
) {
|
||||||
// 중복 방어(이메일/휴대폰)
|
// 중복 방어(이메일/휴대폰)
|
||||||
if ($this->repo->existsByEmail($email)) {
|
if ($this->repo->existsByEmail($email)) {
|
||||||
@ -257,7 +373,7 @@ final class AdminAdminsService
|
|||||||
return $this->fail('이미 등록된 휴대폰 번호입니다.');
|
return $this->fail('이미 등록된 휴대폰 번호입니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ admin_users insert
|
// admin_users insert
|
||||||
$adminId = $this->repo->insertAdminUser([
|
$adminId = $this->repo->insertAdminUser([
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'password' => $pwHash,
|
'password' => $pwHash,
|
||||||
@ -292,6 +408,23 @@ final class AdminAdminsService
|
|||||||
$this->repo->deleteRoleMappingsExcept($adminId, $roleId);
|
$this->repo->deleteRoleMappingsExcept($adminId, $roleId);
|
||||||
$this->repo->upsertRoleMapping($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 [
|
return [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'admin_id' => $adminId,
|
'admin_id' => $adminId,
|
||||||
|
|||||||
@ -15,6 +15,7 @@ use BaconQrCode\Renderer\ImageRenderer;
|
|||||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||||
use BaconQrCode\Writer;
|
use BaconQrCode\Writer;
|
||||||
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
|
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
|
||||||
|
use App\Services\Admin\AdminAuditService;
|
||||||
|
|
||||||
final class AdminAuthService
|
final class AdminAuthService
|
||||||
{
|
{
|
||||||
@ -22,6 +23,7 @@ final class AdminAuthService
|
|||||||
private readonly AdminUserRepository $users,
|
private readonly AdminUserRepository $users,
|
||||||
private readonly SmsService $sms,
|
private readonly SmsService $sms,
|
||||||
private readonly TwoFactorAuthenticationProvider $totpProvider,
|
private readonly TwoFactorAuthenticationProvider $totpProvider,
|
||||||
|
private readonly AdminAuditService $audit,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -74,6 +76,29 @@ final class AdminAuthService
|
|||||||
return ['state' => 'must_reset', 'admin_id' => (int)$admin->id];
|
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)
|
// phone_enc 복호화(E164 or digits)
|
||||||
$phoneDigits = $this->decryptPhoneToDigits($admin);
|
$phoneDigits = $this->decryptPhoneToDigits($admin);
|
||||||
if ($phoneDigits === '') {
|
if ($phoneDigits === '') {
|
||||||
@ -106,8 +131,8 @@ final class AdminAuthService
|
|||||||
|
|
||||||
$smsPayload = [
|
$smsPayload = [
|
||||||
'from_number' => config('services.sms.from', '1833-4856'),
|
'from_number' => config('services.sms.from', '1833-4856'),
|
||||||
//'to_number' => $phoneDigits,
|
'to_number' => $phoneDigits,
|
||||||
'to_number' => '01036828958',
|
//'to_number' => '01036828958',
|
||||||
'message' => "[PIN FOR YOU] 인증번호 {$code} 를 입력해 주세요. ({$ttl}초 이내)",
|
'message' => "[PIN FOR YOU] 인증번호 {$code} 를 입력해 주세요. ({$ttl}초 이내)",
|
||||||
'sms_type' => 'sms',
|
'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]
|
* 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);
|
$admin = $this->users->findActiveById($adminId);
|
||||||
if (!$admin) return ['ok' => false];
|
if (!$admin) return ['ok' => false];
|
||||||
|
|
||||||
$this->users->setPassword($admin, $newPassword);
|
$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];
|
return ['ok' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private function otpKey(string $challengeId): string
|
private function otpKey(string $challengeId): string
|
||||||
{
|
{
|
||||||
$prefix = (string)config('admin.redis_prefix', 'admin:2fa:');
|
$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 (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
|
||||||
|
|
||||||
if (!$forceReset && !empty($admin->totp_verified_at) && !empty($admin->totp_secret_enc)) {
|
if (!$forceReset && !empty($admin->totp_verified_at) && !empty($admin->totp_secret_enc)) {
|
||||||
return ['ok' => false, 'message' => '이미 Google OTP가 등록되어 있습니다.'];
|
return ['ok' => false, 'message' => '이미 Google OTP가 등록되어 있습니다.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새 시크릿 생성(기본 16)
|
$secret = $this->totpProvider->generateSecretKey(16);
|
||||||
$secret = $this->totpProvider->generateSecretKey(16); // :contentReference[oaicite:2]{index=2}
|
|
||||||
$secretEnc = Crypt::encryptString($secret);
|
$secretEnc = Crypt::encryptString($secret);
|
||||||
|
|
||||||
$ok = $this->users->updateTotpStart($adminId, $secretEnc);
|
$ok = $this->users->updateTotpStart($adminId, $secretEnc);
|
||||||
if (!$ok) return ['ok' => false, 'message' => 'OTP 등록 시작에 실패했습니다.'];
|
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을 스캔한 뒤 인증코드를 입력해 주세요.'];
|
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);
|
$vm = $this->totpViewModel($adminId);
|
||||||
if (!($vm['ok'] ?? false)) return ['ok' => false, 'message' => $vm['message'] ?? '오류'];
|
if (!($vm['ok'] ?? false)) return ['ok' => false, 'message' => $vm['message'] ?? '오류'];
|
||||||
|
|
||||||
@ -368,21 +474,53 @@ final class AdminAuthService
|
|||||||
$ok = $this->users->confirmTotp($adminId);
|
$ok = $this->users->confirmTotp($adminId);
|
||||||
if (!$ok) return ['ok' => false, 'message' => 'OTP 등록 완료 처리에 실패했습니다.'];
|
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 등록이 완료되었습니다.'];
|
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' => '관리자 정보를 찾을 수 없습니다.'];
|
if (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
|
||||||
|
|
||||||
$ok = $this->users->disableTotp($adminId);
|
$ok = $this->users->disableTotp($adminId);
|
||||||
if (!$ok) return ['ok' => false, 'message' => 'Google OTP 삭제에 실패했습니다.'];
|
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 인증을 사용합니다.'];
|
return ['ok' => true, 'message' => 'Google OTP가 삭제되었습니다. 이제 SMS 인증을 사용합니다.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** 재등록(새 시크릿 발급) */
|
/** 재등록(새 시크릿 발급) */
|
||||||
public function totpReset(int $adminId): array
|
public function totpReset(int $adminId): array
|
||||||
{
|
{
|
||||||
@ -391,9 +529,12 @@ final class AdminAuthService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** sms/otp 모드 저장(선택) */
|
/** 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 (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
|
||||||
|
|
||||||
if ($enabled === 1) {
|
if ($enabled === 1) {
|
||||||
@ -405,6 +546,74 @@ final class AdminAuthService
|
|||||||
$ok = $this->users->updateTotpMode($adminId, $enabled ? 1 : 0);
|
$ok = $this->users->updateTotpMode($adminId, $enabled ? 1 : 0);
|
||||||
if (!$ok) return ['ok' => false, 'message' => '2차 인증방법 저장에 실패했습니다.'];
|
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차 인증방법이 저장되었습니다.'];
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'legacy.auth' => \App\Http\Middleware\LegacyAuth::class,
|
'legacy.auth' => \App\Http\Middleware\LegacyAuth::class,
|
||||||
'legacy.guest' => \App\Http\Middleware\LegacyGuest::class,
|
'legacy.guest' => \App\Http\Middleware\LegacyGuest::class,
|
||||||
'admin.ip' => \App\Http\Middleware\AdminIpAllowlist::class,
|
'admin.ip' => \App\Http\Middleware\AdminIpAllowlist::class,
|
||||||
|
'admin.role' => \App\Http\Middleware\AdminRole::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ✅ guest redirect (auth 미들웨어가 login 라우트 찾다 터지는거 방지)
|
// ✅ guest redirect (auth 미들웨어가 login 라우트 찾다 터지는거 방지)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
\App\Providers\AppServiceProvider::class,
|
||||||
App\Providers\FortifyServiceProvider::class,
|
\App\Providers\FortifyServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
39
resources/views/admin/auth/totp.blade.php
Normal file
39
resources/views/admin/auth/totp.blade.php
Normal 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
|
||||||
@ -30,7 +30,7 @@
|
|||||||
<p class="a-muted">
|
<p class="a-muted">
|
||||||
@yield('subheading', '승인된 관리자만 접근할 수 있습니다.')
|
@yield('subheading', '승인된 관리자만 접근할 수 있습니다.')
|
||||||
<br><br>
|
<br><br>
|
||||||
@yield('subheading', '모든 로그인·조회·변경 시도는 기록되며, 무단 접근 및 오남용은 정책 및 관련 법령에 따라 조치될 수 있습니다.')
|
@yield('mess', '모든 로그인·조회·변경 시도는 기록되며, 무단 접근 및 오남용은 정책 및 관련 법령에 따라 조치될 수 있습니다.')
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -122,18 +122,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="a-meinfo__row">
|
{{-- <div class="a-meinfo__row">--}}
|
||||||
<div class="a-meinfo__k">내 권한</div>
|
{{-- <div class="a-meinfo__k">내 권한</div>--}}
|
||||||
<div class="a-meinfo__v">
|
{{-- <div class="a-meinfo__v">--}}
|
||||||
<div class="a-chips">
|
{{-- <div class="a-chips">--}}
|
||||||
@forelse(($perms ?? []) as $p)
|
{{-- @forelse(($perms ?? []) as $p)--}}
|
||||||
<span class="a-chip">{{ $p['code'] }}</span>
|
{{-- <span class="a-chip">{{ $p['code'] }}</span>--}}
|
||||||
@empty
|
{{-- @empty--}}
|
||||||
<span class="a-muted">권한 정보가 없습니다.</span>
|
{{-- <span class="a-muted">권한 정보가 없습니다.</span>--}}
|
||||||
@endforelse
|
{{-- @endforelse--}}
|
||||||
</div>
|
{{-- </div>--}}
|
||||||
</div>
|
{{-- </div>--}}
|
||||||
</div>
|
{{-- </div>--}}
|
||||||
|
|
||||||
<div class="a-meinfo__row">
|
<div class="a-meinfo__row">
|
||||||
<div class="a-meinfo__k">최근 로그인</div>
|
<div class="a-meinfo__k">최근 로그인</div>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
@php
|
@php
|
||||||
|
$roles = session('admin_ctx.role_names', []);
|
||||||
$menu = [
|
$menu = [
|
||||||
[
|
[
|
||||||
'title' => '대시보드',
|
'title' => '대시보드',
|
||||||
@ -9,93 +10,122 @@
|
|||||||
[
|
[
|
||||||
'title' => '콘솔 관리',
|
'title' => '콘솔 관리',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => '내 정보', 'route' => 'admin.me'], // 추후 라우트
|
['label' => '내 정보', 'route' => 'admin.me'],
|
||||||
['label' => '관리자 계정 관리', 'route' => 'admin.admins.index'],
|
['label' => '관리자 계정 관리', 'route' => 'admin.admins.index' ,'roles' => ['super_admin']],
|
||||||
['label' => '권한/역할 관리', 'route' => 'admin.roles.index'],
|
|
||||||
['label' => '접근 IP 허용목록', 'route' => 'admin.allowip.index'],
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => '알림/메시지',
|
'title' => '알림/메시지',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => '관리자 SMS 발송', 'route' => 'admin.sms.send'],
|
['label' => '관리자 SMS 발송', 'route' => 'admin.sms.send','roles' => ['super_admin','finance','product','support']],
|
||||||
['label' => 'SMS 발송 이력', 'route' => 'admin.sms.logs'],
|
['label' => 'SMS 발송 이력', 'route' => 'admin.sms.logs','roles' => ['super_admin','finance','product','support']],
|
||||||
['label' => '알림 템플릿', 'route' => 'admin.templates.index'],
|
['label' => '알림 템플릿', 'route' => 'admin.templates.index','roles' => ['super_admin','finance','product','support']],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => '고객지원',
|
'title' => '고객지원',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => '공지사항', 'route' => 'admin.notice.index'],
|
['label' => '공지사항', 'route' => 'admin.notice.index','roles' => ['super_admin','support']],
|
||||||
['label' => '1:1 문의', 'route' => 'admin.inquiry.index'],
|
['label' => '1:1 문의', 'route' => 'admin.inquiry.index','roles' => ['super_admin','support']],
|
||||||
['label' => 'FAQ 코드 관리', 'route' => 'admin.faqcodes.index'],
|
['label' => 'FAQ 코드 관리', 'route' => 'admin.faqcodes.index','roles' => ['super_admin','support']],
|
||||||
['label' => 'QnA 코드 관리', 'route' => 'admin.qnacodes.index'],
|
['label' => 'QnA 코드 관리', 'route' => 'admin.qnacodes.index','roles' => ['super_admin','support']],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => '상품권 관리',
|
'title' => '상품권 관리',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => '상품 리스트', 'route' => 'admin.products.index'],
|
['label' => '상품 리스트', 'route' => 'admin.products.index','roles' => ['super_admin','product']],
|
||||||
['label' => '상품 등록', 'route' => 'admin.products.create'],
|
['label' => '상품 등록', 'route' => 'admin.products.create','roles' => ['super_admin','product']],
|
||||||
['label' => '판매 코드 관리', 'route' => 'admin.sale-codes.index'],
|
['label' => '판매 코드 관리', 'route' => 'admin.sale-codes.index','roles' => ['super_admin','product']],
|
||||||
['label' => '핀 번호 관리', 'route' => 'admin.pins.index'],
|
['label' => '핀 번호 관리', 'route' => 'admin.pins.index','roles' => ['super_admin','product']],
|
||||||
['label' => '메인 노출 관리', 'route' => 'admin.exposure.index'],
|
['label' => '메인 노출 관리', 'route' => 'admin.exposure.index','roles' => ['super_admin','product']],
|
||||||
['label' => '결제 수수료/정책', 'route' => 'admin.fees.index'],
|
['label' => '결제 수수료/정책', 'route' => 'admin.fees.index','roles' => ['super_admin','product']],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => '매입/정산',
|
'title' => '매입/정산',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => '핀 매입 현황(출금)', 'route' => 'admin.buyback.index'],
|
['label' => '핀 매입 현황(출금)', 'route' => 'admin.buyback.index','roles' => ['super_admin','finance']],
|
||||||
['label' => '출금 요청 관리', 'route' => 'admin.withdraw.index'],
|
['label' => '출금 요청 관리', 'route' => 'admin.withdraw.index','roles' => ['super_admin','finance']],
|
||||||
['label' => '정산 리포트', 'route' => 'admin.settlement.index'],
|
['label' => '정산 리포트', 'route' => 'admin.settlement.index','roles' => ['super_admin','finance']],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => '거래/매출',
|
'title' => '거래/매출',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => '상품권 거래 장부', 'route' => 'admin.ledger.index'],
|
['label' => '상품권 거래 장부', 'route' => 'admin.ledger.index','roles' => ['super_admin','finance']],
|
||||||
['label' => '매출 리포트', 'route' => 'admin.sales.index'],
|
['label' => '매출 리포트', 'route' => 'admin.sales.index','roles' => ['super_admin','finance']],
|
||||||
['label' => '환불/취소 내역', 'route' => 'admin.refunds.index'],
|
['label' => '환불/취소 내역', 'route' => 'admin.refunds.index','roles' => ['super_admin','finance']],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => '회원/정책',
|
'title' => '회원/정책',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => '회원 관리', 'route' => 'admin.members.index'],
|
['label' => '회원 관리', 'route' => 'admin.members.index','roles' => ['super_admin','support']],
|
||||||
['label' => '회원가입 필터 설정', 'route' => 'admin.signup-filter.index'],
|
['label' => '회원가입 필터 설정', 'route' => 'admin.signup-filter.index','roles' => ['super_admin','support']],
|
||||||
['label' => '블랙리스트/제재', 'route' => 'admin.sanctions.index'],
|
['label' => '블랙리스트/제재', 'route' => 'admin.sanctions.index','roles' => ['super_admin','support']],
|
||||||
['label' => '마케팅 수신동의', 'route' => 'admin.marketing.index'],
|
['label' => '마케팅 수신동의', 'route' => 'admin.marketing.index','roles' => ['super_admin','support']],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => '시스템 로그',
|
'title' => '시스템 로그',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => '로그인 로그', 'route' => 'admin.logs.login'],
|
['label' => '로그인 로그', 'route' => 'admin.logs.login','roles' => ['super_admin','finance','product','support']],
|
||||||
['label' => '다날 인증 로그', 'route' => 'admin.logs.danal'],
|
['label' => '다날 인증 로그', 'route' => 'admin.logs.danal','roles' => ['super_admin','finance','product','support']],
|
||||||
['label' => '결제 로그', 'route' => 'admin.logs.pay'],
|
['label' => '결제 로그', 'route' => 'admin.logs.pay','roles' => ['super_admin','finance','product','support']],
|
||||||
['label' => '기타 로그', 'route' => 'admin.logs.misc'],
|
['label' => '기타 로그', 'route' => 'admin.logs.misc','roles' => ['super_admin','finance','product','support']],
|
||||||
['label' => '관리자 활동 로그', 'route' => 'admin.logs.audit'],
|
['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
|
@endphp
|
||||||
|
|
||||||
<nav class="a-nav">
|
<nav class="a-nav">
|
||||||
@foreach($menu as $group)
|
@foreach($menu as $group)
|
||||||
|
@php
|
||||||
|
// 그룹 아이템 필터링 (roles 체크 + route 존재 여부는 아래에서 disabled로 처리)
|
||||||
|
$visibleItems = [];
|
||||||
|
foreach (($group['items'] ?? []) as $it) {
|
||||||
|
$need = $it['roles'] ?? null;
|
||||||
|
|
||||||
|
// roles 미지정: 전체 노출
|
||||||
|
if ($need === null) {
|
||||||
|
$visibleItems[] = $it;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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__group">
|
||||||
<div class="a-nav__title">{{ $group['title'] }}</div>
|
<div class="a-nav__title">{{ $group['title'] }}</div>
|
||||||
|
|
||||||
@foreach($group['items'] as $it)
|
@foreach($visibleItems as $it)
|
||||||
@php
|
@php
|
||||||
$has = \Illuminate\Support\Facades\Route::has($it['route']);
|
$routeName = (string)($it['route'] ?? '');
|
||||||
|
$has = $routeName !== '' ? \Illuminate\Support\Facades\Route::has($routeName) : false;
|
||||||
|
|
||||||
// index면 admin.admins.* 전체를 active로 잡아줌
|
// index면 admin.admins.* 전체를 active로 잡아줌
|
||||||
$base = preg_replace('/\.index$/', '', $it['route']);
|
$base = $routeName !== '' ? preg_replace('/\.index$/', '', $routeName) : '';
|
||||||
$isActive = $has ? request()->routeIs($base.'*') : false;
|
$isActive = $has && $base !== '' ? request()->routeIs($base . '.*') || request()->routeIs($base) : false;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if($has)
|
@if($has)
|
||||||
<a class="a-nav__item {{ $isActive ? 'is-active' : '' }}" href="{{ route($it['route']) }}">
|
<a class="a-nav__item {{ $isActive ? 'is-active' : '' }}" href="{{ route($routeName) }}">
|
||||||
<span class="a-nav__dot" aria-hidden="true"></span>
|
<span class="a-nav__dot" aria-hidden="true"></span>
|
||||||
<span class="a-nav__label">{{ $it['label'] }}</span>
|
<span class="a-nav__label">{{ $it['label'] }}</span>
|
||||||
</a>
|
</a>
|
||||||
@ -107,5 +137,7 @@
|
|||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@ -24,24 +24,37 @@ Route::middleware(['web'])->group(function () {
|
|||||||
->middleware('throttle:admin-login')
|
->middleware('throttle:admin-login')
|
||||||
->name('admin.password.reset.store');
|
->name('admin.password.reset.store');
|
||||||
|
|
||||||
|
// OTP(sms) 2차 인증
|
||||||
Route::get('/otp', [AdminAuthController::class, 'showOtp'])
|
Route::get('/otp', [AdminAuthController::class, 'showOtp'])
|
||||||
->name('admin.otp.form');
|
->name('admin.otp.form');
|
||||||
|
|
||||||
Route::post('/otp', [AdminAuthController::class, 'verifyOtp'])
|
Route::post('/otp', [AdminAuthController::class, 'verifyOtp'])
|
||||||
->middleware('throttle:admin-otp')
|
->middleware('throttle:admin-otp')
|
||||||
->name('admin.otp.store');
|
->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::middleware(['auth:admin', \App\Http\Middleware\NoStore::class])->group(function () {
|
||||||
|
|
||||||
|
// ✅ 대시보드: 전체 허용
|
||||||
Route::get('/', fn() => view('admin.home'))->name('admin.home');
|
Route::get('/', fn() => view('admin.home'))->name('admin.home');
|
||||||
|
|
||||||
|
// ✅ 내 정보: 전체 허용
|
||||||
Route::get('/me', [MeController::class, 'show'])->name('admin.me');
|
Route::get('/me', [MeController::class, 'show'])->name('admin.me');
|
||||||
Route::post('/me', [MeController::class, 'update'])->name('admin.me.update');
|
Route::post('/me', [MeController::class, 'update'])->name('admin.me.update');
|
||||||
|
|
||||||
Route::get('/me/password', [MeController::class, 'showPassword'])->name('admin.me.password.form');
|
Route::get('/me/password', [MeController::class, 'showPassword'])->name('admin.me.password.form');
|
||||||
Route::post('/me/password', [MeController::class, 'updatePassword'])->name('admin.me.password.update');
|
Route::post('/me/password', [MeController::class, 'updatePassword'])->name('admin.me.password.update');
|
||||||
|
|
||||||
|
// ✅ 보안/OTP 등록(자기계정 설정): 전체 허용
|
||||||
Route::get('/security', [AdminAuthController::class, 'security'])->name('admin.security');
|
Route::get('/security', [AdminAuthController::class, 'security'])->name('admin.security');
|
||||||
|
|
||||||
Route::post('/totp/start', [AdminAuthController::class, 'totpStart'])->name('admin.totp.start');
|
Route::post('/totp/start', [AdminAuthController::class, 'totpStart'])->name('admin.totp.start');
|
||||||
@ -52,7 +65,12 @@ Route::middleware(['web'])->group(function () {
|
|||||||
|
|
||||||
Route::post('/logout', [AdminAuthController::class, 'logout'])->name('admin.logout');
|
Route::post('/logout', [AdminAuthController::class, 'logout'])->name('admin.logout');
|
||||||
|
|
||||||
Route::prefix('/admins')->name('admin.admins.')->group(function () {
|
// ✅ 관리자 계정 관리: super_admin 전용
|
||||||
|
Route::prefix('/admins')
|
||||||
|
->name('admin.admins.')
|
||||||
|
->middleware('admin.role:super_admin')
|
||||||
|
->group(function () {
|
||||||
|
|
||||||
Route::get('/', [AdminAdminsController::class, 'index'])->name('index');
|
Route::get('/', [AdminAdminsController::class, 'index'])->name('index');
|
||||||
Route::get('/create', [AdminAdminsController::class, 'create'])->name('create');
|
Route::get('/create', [AdminAdminsController::class, 'create'])->name('create');
|
||||||
Route::post('/', [AdminAdminsController::class, 'store'])->name('store');
|
Route::post('/', [AdminAdminsController::class, 'store'])->name('store');
|
||||||
@ -61,13 +79,31 @@ Route::middleware(['web'])->group(function () {
|
|||||||
Route::post('/{id}/reset-password', [AdminAdminsController::class, 'resetPassword'])->name('reset_password');
|
Route::post('/{id}/reset-password', [AdminAdminsController::class, 'resetPassword'])->name('reset_password');
|
||||||
Route::post('/{id}/unlock', [AdminAdminsController::class, 'unlock'])->name('unlock');
|
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')) {
|
if (config('app.debug') || app()->environment('local')) {
|
||||||
require __DIR__.'/dev_admin.php';
|
require __DIR__.'/dev_admin.php';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user