관리자 회원 totp 인증 추가
This commit is contained in:
parent
722b1b8575
commit
416ca9f464
@ -4,6 +4,8 @@ PIN FOR YOU는 **상품권/모바일 교환권(기프티콘) 거래/구매**를
|
||||
보안(핀번호/민감정보), 결제 연동, 회원 인증, 운영 안정성을 최우선으로 설계합니다.
|
||||
|
||||
---
|
||||
컨테이너 안에서 tinker 사용방법
|
||||
HOME=/tmp XDG_CONFIG_HOME=/tmp php artisan tinker
|
||||
|
||||
## 문서(Documentation)
|
||||
|
||||
|
||||
@ -290,4 +290,65 @@ final class AdminAuthController extends Controller
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -486,4 +486,41 @@ final class AdminUserRepository
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,12 +10,18 @@ use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
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
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdminUserRepository $users,
|
||||
private readonly SmsService $sms,
|
||||
private readonly TwoFactorAuthenticationProvider $totpProvider,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -272,4 +278,133 @@ final class AdminAuthService
|
||||
if (strlen($digits) < 7) return $digits;
|
||||
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차 인증방법이 저장되었습니다.'];
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,4 +2,5 @@
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
];
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/fortify": "^1.34",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
},
|
||||
|
||||
291
composer.lock
generated
291
composer.lock
generated
@ -4,8 +4,63 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "c514d8f7b9fc5970bdd94287905ef584",
|
||||
"content-hash": "7dd899a9877c179228369caa0361b2cc",
|
||||
"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",
|
||||
"version": "0.14.1",
|
||||
@ -135,6 +190,56 @@
|
||||
],
|
||||
"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",
|
||||
"version": "v3.0.3",
|
||||
@ -1052,6 +1157,69 @@
|
||||
],
|
||||
"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",
|
||||
"version": "v12.45.1",
|
||||
@ -2526,6 +2694,75 @@
|
||||
],
|
||||
"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",
|
||||
"version": "1.9.5",
|
||||
@ -2601,6 +2838,58 @@
|
||||
],
|
||||
"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",
|
||||
"version": "1.0.0",
|
||||
|
||||
140
resources/views/admin/me/security.blade.php
Normal file
140
resources/views/admin/me/security.blade.php
Normal 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
|
||||
@ -67,25 +67,45 @@
|
||||
</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__row">
|
||||
<div class="a-meinfo__k">2FA 모드</div>
|
||||
<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 class="a-meinfo__row">
|
||||
<div class="a-meinfo__k">TOTP</div>
|
||||
<div class="a-meinfo__v">
|
||||
@if((int)($me->totp_enabled ?? 0) === 1)
|
||||
<span class="a-pill a-pill--ok">Enabled</span>
|
||||
@php
|
||||
$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
|
||||
<span class="a-pill a-pill--muted">Disabled</span>
|
||||
<span class="a-pill a-pill--muted">미등록</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a-meinfo__row">
|
||||
<div class="a-meinfo__k">내 역할</div>
|
||||
<div class="a-meinfo__v">
|
||||
@ -127,8 +147,21 @@
|
||||
비밀번호 변경
|
||||
</a>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
</article>
|
||||
|
||||
@ -42,8 +42,15 @@ Route::middleware(['web'])->group(function () {
|
||||
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('/logout', [AdminAuthController::class, 'logout'])
|
||||
->name('admin.logout');
|
||||
Route::get('/security', [AdminAuthController::class, 'security'])->name('admin.security');
|
||||
|
||||
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::get('/', [AdminAdminsController::class, 'index'])->name('index');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user