관리자 회원 totp 인증 추가

This commit is contained in:
sungro815 2026-02-05 17:29:37 +09:00
parent 722b1b8575
commit 416ca9f464
10 changed files with 715 additions and 9 deletions

View File

@ -4,6 +4,8 @@ PIN FOR YOU는 **상품권/모바일 교환권(기프티콘) 거래/구매**를
보안(핀번호/민감정보), 결제 연동, 회원 인증, 운영 안정성을 최우선으로 설계합니다. 보안(핀번호/민감정보), 결제 연동, 회원 인증, 운영 안정성을 최우선으로 설계합니다.
--- ---
컨테이너 안에서 tinker 사용방법
HOME=/tmp XDG_CONFIG_HOME=/tmp php artisan tinker
## 문서(Documentation) ## 문서(Documentation)

View File

@ -290,4 +290,65 @@ final class AdminAuthController extends Controller
return redirect()->route('admin.login.form'); return redirect()->route('admin.login.form');
} }
public function security(Request $request)
{
$adminId = (int) auth()->guard('admin')->id();
$vm = $this->authService->totpViewModel($adminId);
abort_unless(($vm['ok'] ?? false), 404);
return view('admin.me.security', $vm);
}
public function totpStart(Request $request)
{
$adminId = (int) auth()->guard('admin')->id();
$res = $this->authService->totpStart($adminId, false);
return back()->with($res['ok'] ? 'status' : 'error', $res['message']);
}
public function totpConfirm(Request $request)
{
$adminId = (int) auth()->guard('admin')->id();
$data = $request->validate([
'code' => ['required', 'string', 'max:10'],
]);
$res = $this->authService->totpConfirm($adminId, (string) $data['code']);
return redirect()->route('admin.security')
->with($res['ok'] ? 'status' : 'error', $res['message']);
}
public function totpDisable(Request $request)
{
$adminId = (int) auth()->guard('admin')->id();
$res = $this->authService->totpDisable($adminId);
return redirect()->route('admin.security')
->with($res['ok'] ? 'status' : 'error', $res['message']);
}
public function totpReset(Request $request)
{
$adminId = (int) auth()->guard('admin')->id();
$res = $this->authService->totpReset($adminId);
return redirect()->route('admin.security')
->with($res['ok'] ? 'status' : 'error', $res['message']);
}
public function totpMode(Request $request)
{
$adminId = (int) auth()->guard('admin')->id();
$data = $request->validate([
'totp_enabled' => ['required', 'in:0,1'],
]);
$res = $this->authService->totpMode($adminId, (int) $data['totp_enabled']);
return redirect()->route('admin.security')
->with($res['ok'] ? 'status' : 'error', $res['message']);
}
} }

View File

@ -486,4 +486,41 @@ final class AdminUserRepository
$vals $vals
); );
} }
public function updateTotpStart(int $id, string $secretEnc): bool
{
return DB::table('admin_users')->where('id', $id)->update([
'totp_secret_enc' => $secretEnc,
'totp_verified_at' => null,
'totp_enabled' => 0, // 등록 완료 전에는 SMS로 유지
'updated_at' => now(),
]) > 0;
}
public function confirmTotp(int $id): bool
{
return DB::table('admin_users')->where('id', $id)->update([
'totp_verified_at' => now(),
'totp_enabled' => 1, // 등록 완료되면 OTP 사용으로 전환(원하면 0 유지로 바꿔도 됨)
'updated_at' => now(),
]) > 0;
}
public function disableTotp(int $id): bool
{
return DB::table('admin_users')->where('id', $id)->update([
'totp_secret_enc' => null,
'totp_verified_at' => null,
'totp_enabled' => 0,
'updated_at' => now(),
]) > 0;
}
public function updateTotpMode(int $id, int $enabled): bool
{
return DB::table('admin_users')->where('id', $id)->update([
'totp_enabled' => $enabled ? 1 : 0,
'updated_at' => now(),
]) > 0;
}
} }

View File

@ -10,12 +10,18 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
final class AdminAuthService final class AdminAuthService
{ {
public function __construct( public function __construct(
private readonly AdminUserRepository $users, private readonly AdminUserRepository $users,
private readonly SmsService $sms, private readonly SmsService $sms,
private readonly TwoFactorAuthenticationProvider $totpProvider,
) {} ) {}
/** /**
@ -272,4 +278,133 @@ final class AdminAuthService
if (strlen($digits) < 7) return $digits; if (strlen($digits) < 7) return $digits;
return substr($digits, 0, 3) . '-****-' . substr($digits, -4); return substr($digits, 0, 3) . '-****-' . substr($digits, -4);
} }
public function totpViewModel(int $adminId): array
{
$admin = $this->users->find($adminId);
if (!$admin) {
return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
}
$secret = '';
if (!empty($admin->totp_secret_enc)) {
try {
$secret = Crypt::decryptString((string) $admin->totp_secret_enc);
} catch (\Throwable $e) {
$secret = '';
}
}
$isRegistered = ($secret !== '') && !empty($admin->totp_verified_at);
$isPending = ($secret !== '') && empty($admin->totp_verified_at);
$issuer = (string) (config('app.name') ?: 'Admin');
$email = (string) ($admin->email ?? '');
$otpauthUrl = '';
$qrSvg = '';
if ($isPending) {
// Fortify provider가 otpauth URL 생성
$otpauthUrl = $this->totpProvider->qrCodeUrl($issuer, $email, $secret);
// QR SVG 생성 (bacon/bacon-qr-code)
$writer = new Writer(
new ImageRenderer(new RendererStyle(180), new SvgImageBackEnd())
);
$qrSvg = $writer->writeString($otpauthUrl);
}
return [
'ok' => true,
'admin' => $admin,
'isRegistered'=> $isRegistered,
'isPending' => $isPending,
'secret' => $secret, // pending 때만 화면에서 노출
'qrSvg' => $qrSvg,
];
}
/** 등록 시작(시크릿 생성) */
public function totpStart(int $adminId, bool $forceReset = false): array
{
$admin = $this->users->find($adminId);
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}
$secretEnc = Crypt::encryptString($secret);
$ok = $this->users->updateTotpStart($adminId, $secretEnc);
if (!$ok) return ['ok' => false, 'message' => 'OTP 등록 시작에 실패했습니다.'];
return ['ok' => true, 'message' => 'Google OTP 등록을 시작합니다. 앱에서 QR을 스캔한 뒤 인증코드를 입력해 주세요.'];
}
/** 등록 확인(코드 검증) */
public function totpConfirm(int $adminId, string $code): array
{
$vm = $this->totpViewModel($adminId);
if (!($vm['ok'] ?? false)) return ['ok' => false, 'message' => $vm['message'] ?? '오류'];
if (!($vm['isPending'] ?? false)) {
return ['ok' => false, 'message' => 'OTP 등록 진행 상태가 아닙니다.'];
}
$secret = (string) ($vm['secret'] ?? '');
if ($secret === '') return ['ok' => false, 'message' => 'OTP 시크릿을 읽을 수 없습니다. 다시 등록을 시작해 주세요.'];
$code = preg_replace('/\D+/', '', $code);
if (strlen($code) !== 6) return ['ok' => false, 'message' => '인증코드는 6자리 숫자여야 합니다.'];
// Fortify provider로 검증 :contentReference[oaicite:3]{index=3}
$valid = $this->totpProvider->verify($secret, $code);
if (!$valid) return ['ok' => false, 'message' => '인증코드가 올바르지 않습니다. 다시 확인해 주세요.'];
$ok = $this->users->confirmTotp($adminId);
if (!$ok) return ['ok' => false, 'message' => 'OTP 등록 완료 처리에 실패했습니다.'];
return ['ok' => true, 'message' => 'Google OTP 등록이 완료되었습니다.'];
}
/** 삭제(해제) */
public function totpDisable(int $adminId): array
{
$admin = $this->users->find($adminId);
if (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
$ok = $this->users->disableTotp($adminId);
if (!$ok) return ['ok' => false, 'message' => 'Google OTP 삭제에 실패했습니다.'];
return ['ok' => true, 'message' => 'Google OTP가 삭제되었습니다. 이제 SMS 인증을 사용합니다.'];
}
/** 재등록(새 시크릿 발급) */
public function totpReset(int $adminId): array
{
// 그냥 start(force=true)로 처리
return $this->totpStart($adminId, true);
}
/** sms/otp 모드 저장(선택) */
public function totpMode(int $adminId, int $enabled): array
{
$admin = $this->users->find($adminId);
if (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
if ($enabled === 1) {
if (empty($admin->totp_secret_enc) || empty($admin->totp_verified_at)) {
return ['ok' => false, 'message' => 'Google OTP 미등록 상태에서는 OTP 인증으로 전환할 수 없습니다.'];
}
}
$ok = $this->users->updateTotpMode($adminId, $enabled ? 1 : 0);
if (!$ok) return ['ok' => false, 'message' => '2차 인증방법 저장에 실패했습니다.'];
return ['ok' => true, 'message' => '2차 인증방법이 저장되었습니다.'];
}
} }

View File

@ -2,4 +2,5 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
]; ];

View File

@ -7,6 +7,7 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"laravel/fortify": "^1.34",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1" "laravel/tinker": "^2.10.1"
}, },

291
composer.lock generated
View File

@ -4,8 +4,63 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c514d8f7b9fc5970bdd94287905ef584", "content-hash": "7dd899a9877c179228369caa0361b2cc",
"packages": [ "packages": [
{
"name": "bacon/bacon-qr-code",
"version": "v3.0.3",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/36a1cb2b81493fa5b82e50bf8068bf84d1542563",
"reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^8.1"
},
"require-dev": {
"phly/keep-a-changelog": "^2.12",
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
"spatie/phpunit-snapshot-assertions": "^5.1.5",
"spatie/pixelmatch-php": "^1.2.0",
"squizlabs/php_codesniffer": "^3.9"
},
"suggest": {
"ext-imagick": "to generate QR code images"
},
"type": "library",
"autoload": {
"psr-4": {
"BaconQrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "BaconQrCode is a QR code generator for PHP.",
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.3"
},
"time": "2025-11-19T17:15:36+00:00"
},
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.14.1", "version": "0.14.1",
@ -135,6 +190,56 @@
], ],
"time": "2024-02-09T16:56:22+00:00" "time": "2024-02-09T16:56:22+00:00"
}, },
{
"name": "dasprid/enum",
"version": "1.0.7",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"DASPRiD\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "PHP 7.1 enum implementation",
"keywords": [
"enum",
"map"
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
},
"time": "2025-09-16T12:23:56+00:00"
},
{ {
"name": "dflydev/dot-access-data", "name": "dflydev/dot-access-data",
"version": "v3.0.3", "version": "v3.0.3",
@ -1052,6 +1157,69 @@
], ],
"time": "2025-08-22T14:27:06+00:00" "time": "2025-08-22T14:27:06+00:00"
}, },
{
"name": "laravel/fortify",
"version": "v1.34.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/fortify.git",
"reference": "412575e9c0cb21d49a30b7045ad4902019f538c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/fortify/zipball/412575e9c0cb21d49a30b7045ad4902019f538c2",
"reference": "412575e9c0cb21d49a30b7045ad4902019f538c2",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^3.0",
"ext-json": "*",
"illuminate/console": "^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"php": "^8.1",
"pragmarx/google2fa": "^9.0"
},
"require-dev": {
"orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Fortify\\FortifyServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Fortify\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Backend controllers and scaffolding for Laravel authentication.",
"keywords": [
"auth",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/fortify/issues",
"source": "https://github.com/laravel/fortify"
},
"time": "2026-02-03T06:55:55+00:00"
},
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v12.45.1", "version": "v12.45.1",
@ -2526,6 +2694,75 @@
], ],
"time": "2025-11-20T02:34:59+00:00" "time": "2025-11-20T02:34:59+00:00"
}, },
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"infection/infection": "^0",
"nikic/php-fuzzer": "^0",
"phpunit/phpunit": "^9|^10|^11",
"vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:06:41+00:00"
},
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.9.5", "version": "1.9.5",
@ -2601,6 +2838,58 @@
], ],
"time": "2025-12-27T19:41:33+00:00" "time": "2025-12-27T19:41:33+00:00"
}, },
{
"name": "pragmarx/google2fa",
"version": "v9.0.0",
"source": {
"type": "git",
"url": "https://github.com/antonioribeiro/google2fa.git",
"reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf",
"reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1.0|^2.0|^3.0",
"php": "^7.1|^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.9",
"phpunit/phpunit": "^7.5.15|^8.5|^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"PragmaRX\\Google2FA\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antonio Carlos Ribeiro",
"email": "acr@antoniocarlosribeiro.com",
"role": "Creator & Designer"
}
],
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
"keywords": [
"2fa",
"Authentication",
"Two Factor Authentication",
"google2fa"
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa/issues",
"source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0"
},
"time": "2025-09-19T22:51:08+00:00"
},
{ {
"name": "psr/clock", "name": "psr/clock",
"version": "1.0.0", "version": "1.0.0",

View File

@ -0,0 +1,140 @@
@extends('admin.layouts.app')
@section('title', '보안 설정 (2차 인증)')
@section('content')
<div class="a-panel">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
<div>
<div style="font-weight:900; font-size:16px;">2 인증 (Google OTP)</div>
<div class="a-muted" style="font-size:12px; margin-top:4px;">
SMS 또는 Google OTP(TOTP) 2 인증을 진행합니다.
</div>
</div>
<a class="a-btn a-btn--ghost a-btn--sm" href="{{ route('admin.me') }}" style="width:auto;">뒤로가기</a>
</div>
</div>
<div style="height:12px;"></div>
{{-- 이용 안내 --}}
<div class="a-panel">
<div style="font-weight:900; margin-bottom:6px;">이용 안내</div>
<div class="a-muted" style="font-size:13px; line-height:1.55;">
1) Google Authenticator / Microsoft Authenticator 설치<br>
2) 등록 시작 QR 스캔(또는 시크릿 수동 입력)<br>
3) 앱에 표시되는 6자리 코드를 입력해 등록 완료<br>
4) 필요 재등록( 시크릿 발급) 또는 삭제 가능
</div>
</div>
<div style="height:12px;"></div>
{{-- 상태/모드 --}}
<div class="a-panel">
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; align-items:center;">
<div>
<div style="font-weight:900;">현재 상태</div>
<div style="margin-top:6px;">
@if($isRegistered)
<span class="a-pill a-pill--ok">Google OTP 등록됨</span>
<span class="a-muted" style="font-size:12px; margin-left:6px;">({{ $admin->totp_verified_at }})</span>
@elseif($isPending)
<span class="a-pill a-pill--warn">등록 진행 </span>
@else
<span class="a-pill a-pill--muted">미등록</span>
@endif
</div>
</div>
<form method="POST" action="{{ route('admin.totp.mode') }}" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
@csrf
<label class="a-muted" style="font-size:13px;">2 인증방법</label>
<select class="a-input a-input--sm" name="totp_enabled" style="width:180px;">
<option value="0" {{ (int)($admin->totp_enabled ?? 0) === 0 ? 'selected' : '' }}>SMS 인증</option>
<option value="1"
{{ (int)($admin->totp_enabled ?? 0) === 1 ? 'selected' : '' }}
{{ !$isRegistered ? 'disabled' : '' }}
>Google OTP 인증</option>
</select>
<button class="a-btn a-btn--primary a-btn--sm" type="submit" style="width:auto;">저장</button>
@if(!$isRegistered)
<div class="a-muted" style="font-size:12px; width:100%;">
Google OTP 미등록 상태에서는 OTP 인증으로 전환할 없습니다.
</div>
@endif
</form>
</div>
</div>
<div style="height:12px;"></div>
{{-- 등록/확인 UI --}}
@if($isPending)
<div class="a-panel">
<div style="font-weight:900; margin-bottom:10px;">등록 진행</div>
<div class="a-grid2">
<div class="a-card" style="padding:14px;">
<div class="a-muted" style="font-size:12px; margin-bottom:8px;">QR 코드 스캔</div>
<div style="background:#fff; border-radius:14px; padding:12px; display:inline-block;">
{!! $qrSvg !!}
</div>
</div>
<div class="a-card" style="padding:14px;">
<div class="a-muted" style="font-size:12px; margin-bottom:6px;">시크릿(수동 입력용)</div>
<div class="a-mono" style="font-weight:900; font-size:16px; letter-spacing:.08em;">
{{ $secret }}
</div>
<div style="height:10px;"></div>
<form method="POST" action="{{ route('admin.totp.confirm') }}">
@csrf
<label class="a-label">앱에 표시된 6자리 인증코드</label>
<input class="a-input a-otp-input" name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="123456">
@error('code') <div class="a-error">{{ $message }}</div> @enderror
<button class="a-btn a-btn--primary" type="submit" style="margin-top:12px;">
등록 완료
</button>
</form>
<form method="POST" action="{{ route('admin.totp.disable') }}" style="margin-top:10px;"
data-confirm="등록을 취소하고 OTP 정보를 삭제할까요?">
@csrf
<button class="a-btn a-btn--danger" type="submit">등록 취소(삭제)</button>
</form>
</div>
</div>
</div>
@else
{{-- 미등록 / 등록됨 --}}
<div class="a-panel">
<div style="display:flex; gap:8px; flex-wrap:wrap;">
@if(!$isRegistered)
<form method="POST" action="{{ route('admin.totp.start') }}"
data-confirm="Google OTP 등록을 시작할까요?\nQR 스캔 후 6자리 코드를 입력해야 완료됩니다.">
@csrf
<button class="a-btn a-btn--primary" type="submit" style="width:auto;">등록 시작</button>
</form>
@else
<form method="POST" action="{{ route('admin.totp.reset') }}"
data-confirm="Google OTP를 재등록할까요?\n새 시크릿이 발급되며 기존 등록은 무효화됩니다.">
@csrf
<button class="a-btn a-btn--primary" type="submit" style="width:auto;">재등록(수정)</button>
</form>
<form method="POST" action="{{ route('admin.totp.disable') }}"
data-confirm="Google OTP를 삭제할까요?\n삭제 후에는 SMS 인증을 사용합니다.">
@csrf
<button class="a-btn a-btn--danger" type="submit" style="width:auto;">삭제</button>
</form>
@endif
</div>
</div>
@endif
@endsection

View File

@ -67,25 +67,45 @@
</div> </div>
</div> </div>
@php
$hasSecret = !empty($me->totp_secret_enc);
$isVerified = !empty($me->totp_verified_at);
$isEnabled = (int)($me->totp_enabled ?? 0) === 1;
@endphp
<div class="a-meinfo"> <div class="a-meinfo">
<div class="a-meinfo__row"> <div class="a-meinfo__row">
<div class="a-meinfo__k">2FA 모드</div> <div class="a-meinfo__k">2FA 모드</div>
<div class="a-meinfo__v"> <div class="a-meinfo__v">
<span class="a-pill">{{ $me->two_factor_mode ?? 'sms' }}</span> <span class="a-pill">{{ ($isEnabled=='1') ? 'google TOPT' : 'SMS' }}</span>
</div> </div>
</div> </div>
<div class="a-meinfo__row"> <div class="a-meinfo__row">
<div class="a-meinfo__k">TOTP</div> <div class="a-meinfo__k">TOTP</div>
<div class="a-meinfo__v"> <div class="a-meinfo__v">
@if((int)($me->totp_enabled ?? 0) === 1) @php
<span class="a-pill a-pill--ok">Enabled</span> $hasSecret = !empty($me->totp_secret_enc); // 버튼 기준 (등록 플로우 진입 여부)
$isVerified = !empty($me->totp_verified_at); // 등록 완료 여부
$isModeOtp = (int)($me->totp_enabled ?? 0) === 1; // 현재 로그인 인증 방식 표시용(조건 X)
@endphp
{{-- 1) 등록 상태 표시 (등록/미등록/진행중) --}}
@if($hasSecret && $isVerified)
<span class="a-pill a-pill--ok">TOTP 등록됨</span>
<span class="a-muted" style="margin-left:8px; font-size:12px;">
<BR>({{ $me->totp_verified_at }})
</span>
@elseif($hasSecret && !$isVerified)
<span class="a-pill a-pill--warn">등록 진행중</span>
<span class="a-muted" style="margin-left:8px; font-size:12px;">
(인증코드 확인 필요)
</span>
@else @else
<span class="a-pill a-pill--muted">Disabled</span> <span class="a-pill a-pill--muted">미등록</span>
@endif @endif
</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">
@ -127,8 +147,21 @@
비밀번호 변경 비밀번호 변경
</a> </a>
<div class="a-muted" style="font-size:12px; margin-top:10px;"> <div class="a-muted" style="font-size:12px; margin-top:10px;">
TOTP 설정/리셋은 다음 단계(권한/역할) 작업 함께 붙이는 안전합니다. <div style="margin-top:10px;">
@if(!$hasSecret)
<a class="a-btn a-btn--primary a-btn--sm" style="width:auto;"
href="{{ route('admin.security') }}">
Google OTP 등록
</a>
@else
<a class="a-btn a-btn--ghost a-btn--sm" style="width:auto;"
href="{{ route('admin.security') }}">
2FA 인증방법 변경 / Google OTP 관리
</a>
@endif
</div>
</div> </div>
</article> </article>

View File

@ -42,8 +42,15 @@ Route::middleware(['web'])->group(function () {
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');
Route::post('/logout', [AdminAuthController::class, 'logout']) Route::get('/security', [AdminAuthController::class, 'security'])->name('admin.security');
->name('admin.logout');
Route::post('/totp/start', [AdminAuthController::class, 'totpStart'])->name('admin.totp.start');
Route::post('/totp/confirm', [AdminAuthController::class, 'totpConfirm'])->name('admin.totp.confirm');
Route::post('/totp/disable', [AdminAuthController::class, 'totpDisable'])->name('admin.totp.disable');
Route::post('/totp/reset', [AdminAuthController::class, 'totpReset'])->name('admin.totp.reset'); // 재등록(새 시크릿)
Route::post('/totp/mode', [AdminAuthController::class, 'totpMode'])->name('admin.totp.mode');
Route::post('/logout', [AdminAuthController::class, 'logout'])->name('admin.logout');
Route::prefix('/admins')->name('admin.admins.')->group(function () { Route::prefix('/admins')->name('admin.admins.')->group(function () {
Route::get('/', [AdminAdminsController::class, 'index'])->name('index'); Route::get('/', [AdminAdminsController::class, 'index'])->name('index');