관리자 회원 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)
|
## 문서(Documentation)
|
||||||
|
|
||||||
|
|||||||
@ -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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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차 인증방법이 저장되었습니다.'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,4 +2,5 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Providers\FortifyServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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
291
composer.lock
generated
@ -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",
|
||||||
|
|||||||
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>
|
||||||
</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>
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user