diff --git a/README.md b/README.md index 613aaa4..343e4fd 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ PIN FOR YOU는 **상품권/모바일 교환권(기프티콘) 거래/구매**를 보안(핀번호/민감정보), 결제 연동, 회원 인증, 운영 안정성을 최우선으로 설계합니다. --- +컨테이너 안에서 tinker 사용방법 +HOME=/tmp XDG_CONFIG_HOME=/tmp php artisan tinker ## 문서(Documentation) diff --git a/app/Http/Controllers/Admin/Auth/AdminAuthController.php b/app/Http/Controllers/Admin/Auth/AdminAuthController.php index d954277..d8f2356 100644 --- a/app/Http/Controllers/Admin/Auth/AdminAuthController.php +++ b/app/Http/Controllers/Admin/Auth/AdminAuthController.php @@ -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']); + } } diff --git a/app/Repositories/Admin/AdminUserRepository.php b/app/Repositories/Admin/AdminUserRepository.php index b9cb79a..c7334cc 100644 --- a/app/Repositories/Admin/AdminUserRepository.php +++ b/app/Repositories/Admin/AdminUserRepository.php @@ -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; + } } diff --git a/app/Services/Admin/AdminAuthService.php b/app/Services/Admin/AdminAuthService.php index d9ae049..a6c7471 100644 --- a/app/Services/Admin/AdminAuthService.php +++ b/app/Services/Admin/AdminAuthService.php @@ -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차 인증방법이 저장되었습니다.']; + } } diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d..0ad9c57 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,4 +2,5 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\FortifyServiceProvider::class, ]; diff --git a/composer.json b/composer.json index 44c6054..d783f8f 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.2", + "laravel/fortify": "^1.34", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1" }, diff --git a/composer.lock b/composer.lock index 70ec0d2..d75dd93 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/resources/views/admin/me/security.blade.php b/resources/views/admin/me/security.blade.php new file mode 100644 index 0000000..e528311 --- /dev/null +++ b/resources/views/admin/me/security.blade.php @@ -0,0 +1,140 @@ +@extends('admin.layouts.app') + +@section('title', '보안 설정 (2차 인증)') + +@section('content') +