관리자 로그인

This commit is contained in:
sungro815 2026-02-04 16:55:00 +09:00
parent 494d95327a
commit 0010cc69be
43 changed files with 2933 additions and 595 deletions

View File

@ -3,51 +3,222 @@
namespace App\Http\Controllers\Admin\Auth; namespace App\Http\Controllers\Admin\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Rules\RecaptchaV3Rule;
use App\Services\Admin\AdminAuthService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException; use Illuminate\Support\Facades\Log;
class AdminAuthController extends Controller final class AdminAuthController extends Controller
{ {
// 로그인 폼 public function __construct(
public function create() private readonly AdminAuthService $authService
) {}
public function showLogin()
{ {
return view('admin.auth.login'); return view('admin.auth.login');
} }
// 로그인 처리 public function storeLogin(Request $request)
public function store(Request $request)
{ {
$credentials = $request->validate([ $rules = [
'email' => ['required', 'email'], 'login_id' => ['required', 'string', 'max:190'], // admin_users.email(190)
'password' => ['required', 'string'], 'password' => ['required', 'string', 'max:255'],
'remember' => ['nullable'],
];
// 운영에서만 reCAPTCHA 필수
if (app()->environment('production')) {
$rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('admin_login')];
}
$data = $request->validate($rules);
$email = strtolower(trim((string) $data['login_id']));
$remember = (bool) $request->boolean('remember');
$ip = (string) $request->ip();
$res = $this->authService->startLogin(
email: $email,
password: (string) $data['password'],
remember: $remember,
ip: $ip
);
if (($res['state'] ?? '') === 'invalid') {
return back()->withErrors(['login_id' => '이메일 또는 비밀번호를 확인하세요.'])->withInput();
}
if (($res['state'] ?? '') === 'sms_error') {
return back()->withErrors(['login_id' => '인증 sms 발송에 실패하였습니다.'])->withInput();
}
if (($res['state'] ?? '') === 'blocked') {
return back()->withErrors(['login_id' => '로그인 할 수 없는 계정입니다.'])->withInput();
}
if (($res['state'] ?? '') === 'must_reset') {
$request->session()->put('admin_pwreset', [
'admin_id' => (int) ($res['admin_id'] ?? 0),
'email' => $email,
'expires_at' => time() + 600, // 10분
'ip' => $ip,
]); ]);
// remember 체크박스 지원 (선택) return redirect()->route('admin.password.reset.form')
$remember = $request->boolean('remember'); ->with('status', '비밀번호 초기화가 필요합니다. 새 비밀번호를 설정해 주세요.');
}
// 핵심: admin guard로 로그인 시도 if (($res['state'] ?? '') !== 'otp_sent') {
if (! Auth::guard('admin')->attempt($credentials, $remember)) { // 방어: 예상치 못한 상태
throw ValidationException::withMessages([ return back()->withErrors(['login_id' => '로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'])->withInput();
'email' => ['이메일 또는 비밀번호가 올바르지 않습니다.'], }
// ✅ OTP 챌린지 ID만 세션에 보관 (OTP 평문/해시 세션 저장 X)
$request->session()->put('admin_2fa', [
'challenge_id' => (string) ($res['challenge_id'] ?? ''),
'masked_phone' => (string) ($res['masked_phone'] ?? ''),
'expires_at' => time() + (int) config('admin.sms_ttl', 180),
]);
return redirect()->route('admin.otp.form')
->with('status', '인증번호를 문자로 발송했습니다.');
}
// 비밀번호 초기화 폼
public function showForceReset(Request $request)
{
$pending = (array) $request->session()->get('admin_pwreset', []);
if (empty($pending['admin_id'])) {
return redirect()->route('admin.login.form')
->withErrors(['login_id' => '비밀번호 초기화 세션이 없습니다. 다시 로그인해 주세요.']);
}
if ((int)($pending['expires_at'] ?? 0) < time()) {
$request->session()->forget('admin_pwreset');
return redirect()->route('admin.login.form')
->withErrors(['login_id' => '비밀번호 초기화가 만료되었습니다. 다시 로그인해 주세요.']);
}
return view('admin.auth.password_reset', [
'email' => (string)($pending['email'] ?? ''),
]); ]);
} }
// 세션 고정 공격 방지 // 비밀번호 초기화 저장
$request->session()->regenerate(); public function storeForceReset(Request $request)
{
$pending = (array) $request->session()->get('admin_pwreset', []);
if (empty($pending['admin_id'])) {
return redirect()->route('admin.login.form')
->withErrors(['login_id' => '비밀번호 초기화 세션이 없습니다. 다시 로그인해 주세요.']);
}
if ((int)($pending['expires_at'] ?? 0) < time()) {
$request->session()->forget('admin_pwreset');
return redirect()->route('admin.login.form')
->withErrors(['login_id' => '비밀번호 초기화가 만료되었습니다. 다시 로그인해 주세요.']);
}
$data = $request->validate([
'password' => ['required', 'string', 'min:10', 'max:255', 'confirmed'],
]);
$ip = (string) $request->ip();
$res = $this->authService->resetPassword(
adminId: (int) $pending['admin_id'],
newPassword: (string) $data['password'],
ip: $ip
);
if (!($res['ok'] ?? false)) {
$request->session()->forget('admin_pwreset');
return redirect()->route('admin.login.form')
->withErrors(['login_id' => '비밀번호 변경에 실패했습니다. 다시 로그인해 주세요.']);
}
$request->session()->forget('admin_pwreset');
return redirect()->route('admin.login.form')
->with('status', '비밀번호가 변경되었습니다. 다시 로그인해 주세요.');
}
public function showOtp(Request $request)
{
$pending = (array) $request->session()->get('admin_2fa', []);
if (empty($pending['challenge_id'])) {
return redirect()->route('admin.login.form')
->withErrors(['login_id' => '인증 정보가 없습니다. 다시 로그인해 주세요.']);
}
if ((int)($pending['expires_at'] ?? 0) < time()) {
$request->session()->forget('admin_2fa');
return redirect()->route('admin.login.form')
->withErrors(['login_id' => '인증이 만료되었습니다. 다시 로그인해 주세요.']);
}
return view('admin.auth.otp', [
'masked_phone' => (string)($pending['masked_phone'] ?? ''),
]);
}
public function verifyOtp(Request $request)
{
$data = $request->validate([
'otp' => ['required', 'digits:6'],
]);
$pending = (array) $request->session()->get('admin_2fa', []);
$challengeId = (string)($pending['challenge_id'] ?? '');
if ($challengeId === '') {
return redirect()->route('admin.login.form')
->withErrors(['login_id' => '인증 정보가 없습니다. 다시 로그인해 주세요.']);
}
$res = $this->authService->verifyOtp(
challengeId: $challengeId,
otp: (string) $data['otp'],
ip: (string) $request->ip()
);
if (!($res['ok'] ?? false)) {
$reason = (string)($res['reason'] ?? '');
$msg = match ($reason) {
'expired' => '인증이 만료되었습니다. 다시 로그인해 주세요.',
'attempts' => '시도 횟수를 초과했습니다. 다시 로그인해 주세요.',
'ip' => '접속 정보가 변경되었습니다. 다시 로그인해 주세요.',
'blocked' => '로그인 할 수 없는 계정입니다.',
default => '인증번호가 올바르지 않습니다.',
};
if ($reason !== 'invalid') {
$request->session()->forget('admin_2fa');
return redirect()->route('admin.login.form')->withErrors(['login_id' => $msg]);
}
return back()->withErrors(['otp' => $msg]);
}
/** @var \App\Models\AdminUser $admin */
$admin = $res['admin'];
Auth::guard('admin')->login($admin, (bool)($res['remember'] ?? false));
$request->session()->forget('admin_2fa');
return redirect()->route('admin.home'); return redirect()->route('admin.home');
} }
// 로그아웃 처리 public function logout(Request $request)
public function destroy(Request $request)
{ {
Auth::guard('admin')->logout(); Auth::guard('admin')->logout();
// 세션 무효화 + CSRF 토큰 재발급
$request->session()->invalidate(); $request->session()->invalidate();
$request->session()->regenerateToken(); $request->session()->regenerateToken();
return redirect()->route('admin.login'); return redirect()->route('admin.login.form');
} }
} }

View File

@ -0,0 +1,52 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class AdminIpAllowlist
{
public function handle(Request $request, Closure $next): Response
{
$allowed = config('admin.allowed_ips', []);
// ✅ 개발(local/testing)에서는 allowlist 비어있으면 전체 허용
if (!$allowed && !app()->environment('production')) {
return $next($request);
}
if (!$allowed) {
abort(403, 'admin ip not allowed');
}
$ip = $request->ip();
foreach ($allowed as $rule) {
if ($this->matchIp($ip, $rule)) {
return $next($request);
}
}
abort(403, 'admin ip not allowed');
}
private function matchIp(string $ip, string $rule): bool
{
if (strpos($rule, '/') === false) {
return $ip === $rule;
}
[$subnet, $mask] = explode('/', $rule, 2);
$mask = (int) $mask;
$ipLong = ip2long($ip);
$subLong = ip2long($subnet);
if ($ipLong === false || $subLong === false || $mask < 0 || $mask > 32) return false;
$maskLong = -1 << (32 - $mask);
return (($ipLong & $maskLong) === ($subLong & $maskLong));
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
final class TrustedHostsFromConfig
{
public function handle(Request $request, Closure $next)
{
$web = (string) config('app.web_domain', '');
$admin = (string) config('app.admin_domain', '');
$hosts = array_values(array_filter([$web, $admin]));
if ($hosts) {
$patterns = array_map(
fn ($h) => '^' . preg_quote($h, '/') . '$',
$hosts
);
SymfonyRequest::setTrustedHosts($patterns);
// 여기서 즉시 검증 (불일치면 400)
$request->getHost();
}
return $next($request);
}
}

View File

@ -10,28 +10,24 @@ class AdminUser extends Authenticatable
use Notifiable; use Notifiable;
protected $table = 'admin_users'; protected $table = 'admin_users';
public $timestamps = false;
protected $fillable = [
'email','password',
'full_name','nickname','phone',
'role','status',
'is_consult_available','consult_types',
'totp_secret','totp_enabled','totp_confirmed_at',
'last_login_at','last_login_ip',
];
protected $hidden = [ protected $hidden = [
'password', 'password',
'remember_token', 'remember_token',
'totp_secret', 'phone_enc',
'totp_secret_enc',
]; ];
protected $casts = [ protected $casts = [
'is_consult_available' => 'boolean', 'locked_until' => 'datetime',
'consult_types' => 'array', // ✅ JSON <-> array 자동 변환 'last_login_at' => 'datetime',
'password_changed_at' => 'datetime',
'totp_enabled' => 'boolean', 'totp_enabled' => 'boolean',
'totp_confirmed_at' => 'datetime', 'totp_confirmed_at' => 'datetime',
'last_login_at' => 'datetime', 'password' => 'hashed', // ✅ 이걸로 통일
'must_reset_password' => 'boolean',
'totp_enabled' => 'boolean',
'totp_verified_at' => 'datetime',
]; ];
} }

View File

@ -1,107 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class MemInfo extends Model
{
protected $table = 'mem_info';
protected $primaryKey = 'mem_no';
public $incrementing = true;
protected $keyType = 'int';
public $timestamps = false;
/**
* 대량 컬럼이지만, 일단 자주 쓰는 것만 명시
* (전체를 fillable로 열어두지 . 보안상 위험)
*/
protected $fillable = [
'stat_1','stat_2','stat_3','stat_4','stat_5',
'name','name_first','name_mid','name_last',
'birth','gender','native',
'cell_corp','cell_phone','email','pv_sns',
'bank_code','bank_name','bank_act_num','bank_vact_num',
'rcv_email','rcv_sms','rcv_push',
'login_fail_cnt',
'dt_login','dt_reg','dt_mod',
'dt_rcv_email','dt_rcv_sms','dt_rcv_push',
'dt_stat_1','dt_stat_2','dt_stat_3','dt_stat_4','dt_stat_5',
'ip_reg','ci_v','ci','di',
'country_code','country_name',
'admin_memo','modify_log',
];
protected $casts = [
'birth' => 'date',
'dt_login' => 'datetime',
'dt_reg' => 'datetime',
'dt_mod' => 'datetime',
'dt_vact' => 'datetime',
'dt_dor' => 'datetime',
'dt_ret_dor' => 'datetime',
'dt_out' => 'datetime',
'dt_rcv_email' => 'datetime',
'dt_rcv_sms' => 'datetime',
'dt_rcv_push' => 'datetime',
'dt_stat_1' => 'datetime',
'dt_stat_2' => 'datetime',
'dt_stat_3' => 'datetime',
'dt_stat_4' => 'datetime',
'dt_stat_5' => 'datetime',
// JSON 컬럼 (DB CHECK(json_valid()) 걸려있으니 array cast 쓰면 편함)
'admin_memo' => 'array',
'modify_log' => 'array',
];
/*
* ========== Scopes ==========
*/
public function scopeActive(Builder $q): Builder
{
// CI에서 stat_3 == 3 접근금지 / 4 탈퇴신청 / 5 탈퇴완료
return $q->whereNotIn('stat_3', ['3','4','5']);
}
public function scopeByEmail(Builder $q, string $email): Builder
{
return $q->where('email', strtolower($email));
}
/**
* ⚠️ cell_phone이 "암호화 저장"이라면
* scope는 "정규화 컬럼(cell_phone_hash / cell_phone_norm 등)" 생긴 뒤에 완성하는 맞음.
* 지금은 자리만 만들어 .
*/
public function scopeByPhoneLookup(Builder $q, string $phoneNormalized): Builder
{
// TODO: cell_phone이 암호화라면 단순 where 비교 불가
// 예시(추천): cell_phone_hash 컬럼을 만들고 SHA256 같은 값으로 매칭
// return $q->where('cell_phone_hash', hash('sha256', $phoneNormalized . config('app.key')));
return $q;
}
/*
* ========== Helpers ==========
*/
public function isBlocked(): bool
{
return $this->stat_3 === '3';
}
public function isWithdrawnOrRequested(): bool
{
return in_array($this->stat_3, ['4','5'], true);
}
public function isFirstLogin(): bool
{
if (!$this->dt_login || !$this->dt_reg) return false;
return Carbon::parse($this->dt_login)->equalTo(Carbon::parse($this->dt_reg));
}
}

View File

@ -1,23 +1,45 @@
<?php <?php
namespace App\Providers; namespace App\Providers;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use App\Support\LegacyCrypto\CiSeedCrypto; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
public function register(): void public function register(): void
{ {
$this->app->singleton(CiSeedCrypto::class, function () { $this->app->singleton(CiSeedCrypto::class, function () {
return new CiSeedCrypto( $key = (string) config('legacy.seed_user_key_default', '');
config('legacy.seed_user_key_default'), $iv = (string) config('legacy.iv', '');
config('legacy.iv'),
); if ($key === '' || $iv === '') {
throw new \RuntimeException('legacy crypto config missing (seed_user_key_default/iv)');
}
return new CiSeedCrypto($key, $iv);
}); });
} }
public function boot(): void public function boot(): void
{ {
// RateLimiter::for('admin-login', function (Request $request) {
$email = (string) $request->input('email', $request->input('userid', $request->input('admin_email', '')));
$emailKey = $email !== '' ? mb_strtolower(trim($email)) : 'guest';
return [
Limit::perMinute(10)->by('ip:'.$request->ip()),
Limit::perMinute(5)->by('admin-login:'.$emailKey),
];
});
RateLimiter::for('admin-otp', function (Request $request) {
return [
Limit::perMinute(10)->by('ip:'.$request->ip()),
Limit::perMinute(5)->by('admin-otp:'.$request->session()->getId()),
];
});
} }
} }

View File

@ -0,0 +1,38 @@
<?php
namespace App\Repositories\Admin;
use App\Models\AdminUser;
final class AdminUserRepository
{
public function findByEmail(string $email): ?AdminUser
{
return AdminUser::query()
->where('email', $email)
->first();
}
public function findActiveById(int $id): ?AdminUser
{
return AdminUser::query()
->whereKey($id)
->where('status', 'active')
->first();
}
public function touchLogin(AdminUser $admin, string $ip): void
{
$admin->last_login_at = now();
$admin->last_login_ip = inet_pton($ip) ?: null;
$admin->save();
}
public function setPassword(AdminUser $admin, string $plainPassword): void
{
$admin->password = $plainPassword; // ✅ 캐스트가 알아서 해싱함
$admin->must_reset_password = 0;
$admin->save();
}
}

View File

@ -0,0 +1,247 @@
<?php
namespace App\Services\Admin;
use App\Models\AdminUser;
use App\Repositories\Admin\AdminUserRepository;
use App\Services\SmsService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
final class AdminAuthService
{
public function __construct(
private readonly AdminUserRepository $users,
private readonly SmsService $sms,
) {}
/**
* return:
* - ['state'=>'invalid']
* - ['state'=>'blocked']
* - ['state'=>'must_reset','admin_id'=>int]
* - ['state'=>'otp_sent','challenge_id'=>string,'masked_phone'=>string]
*/
public function startLogin(string $email, string $password, bool $remember, string $ip): array
{
$admin = $this->users->findByEmail($email);
// 계정 없거나 비번 불일치: 같은 메시지로
if (!$admin || !Hash::check($password, (string)$admin->password)) {
return ['state' => 'invalid'];
}
if (($admin->status ?? 'blocked') !== 'active') {
return ['state' => 'blocked'];
}
if ((int)($admin->must_reset_password ?? 0) === 1) {
return ['state' => 'must_reset', 'admin_id' => (int)$admin->id];
}
// phone_enc 복호화(E164 or digits)
$phoneDigits = $this->decryptPhoneToDigits($admin);
if ($phoneDigits === '') {
return ['state' => 'invalid'];
}
// OTP 발급
$code = (string)random_int(100000, 999999);
$nonce = Str::random(32);
$otpHash = hash('sha256', $code . '|' . $nonce);
$challengeId = (string)Str::uuid();
$ttl = (int)config('admin.sms_ttl', 180);
$maxAttempts = (int)config('admin.otp_max_attempts', 5);
Cache::store('redis')->put(
$this->otpKey($challengeId),
[
'admin_id' => (int)$admin->id,
'otp_hash' => $otpHash,
'nonce' => $nonce,
'remember' => $remember,
'ip' => $ip,
'attempts' => 0,
'max_attempts' => $maxAttempts,
],
$ttl
);
$smsPayload = [
'from_number' => config('services.sms.from', '1833-4856'),
//'to_number' => $phoneDigits,
'to_number' => '01036828958',
'message' => "[PIN FOR YOU] 인증번호 {$code} 를 입력해 주세요. ({$ttl}초 이내)",
'sms_type' => 'sms',
];
try {
$ok = app(SmsService::class)->send($smsPayload, 'lguplus');
if (!$ok) {
Cache::store('redis')->forget($this->otpKey($challengeId));
Log::error('FindId SMS send failed', [
'phone' => $phoneDigits,
'error' => $ok,
]);
return ['state' => 'sms_error'];
}
return [
'state' => 'otp_sent',
'challenge_id' => $challengeId,
'masked_phone' => $this->maskPhone($phoneDigits),
];
} catch (\Throwable $e) {
Cache::store('redis')->forget($this->otpKey($challengeId));
Log::error('[admin-auth] sms send exception', [
'challenge_id' => $challengeId,
'to' => substr($phoneDigits, -4), // 민감정보 최소화
'error' => $e->getMessage(),
]);
return ['state' => 'sms_error'];
}
}
/**
* return:
* - ['ok'=>false,'reason'=>'expired|invalid|attempts|ip|blocked']
* - ['ok'=>true,'admin'=>AdminUser,'remember'=>bool]
*/
public function verifyOtp(string $challengeId, string $otp, string $ip): array
{
$key = $this->otpKey($challengeId);
$data = Cache::store('redis')->get($key);
if (!is_array($data) || empty($data['admin_id'])) {
return ['ok' => false, 'reason' => 'expired'];
}
// IP 고정 (원치 않으면 제거 가능)
if (!hash_equals((string)($data['ip'] ?? ''), $ip)) {
Cache::store('redis')->forget($key);
return ['ok' => false, 'reason' => 'ip'];
}
$attempts = (int)($data['attempts'] ?? 0);
$max = (int)($data['max_attempts'] ?? 5);
if ($attempts >= $max) {
Cache::store('redis')->forget($key);
return ['ok' => false, 'reason' => 'attempts'];
}
// attempts 증가 후 TTL은 동일하게 다시 설정(단순화)
$data['attempts'] = $attempts + 1;
Cache::store('redis')->put($key, $data, (int)config('admin.sms_ttl', 180));
$nonce = (string)($data['nonce'] ?? '');
$expect = (string)($data['otp_hash'] ?? '');
$got = hash('sha256', $otp . '|' . $nonce);
if (!hash_equals($expect, $got)) {
return ['ok' => false, 'reason' => 'invalid'];
}
$admin = $this->users->findActiveById((int)$data['admin_id']);
if (!$admin) {
Cache::store('redis')->forget($key);
return ['ok' => false, 'reason' => 'blocked'];
}
// 로그인 메타 업데이트
$this->users->touchLogin($admin, $ip);
Cache::store('redis')->forget($key);
return [
'ok' => true,
'admin' => $admin,
'remember' => (bool)($data['remember'] ?? false),
];
}
/**
* 비밀번호 초기화 처리
* return: ['ok'=>true] | ['ok'=>false]
*/
public function resetPassword(int $adminId, string $newPassword, string $ip): array
{
$admin = $this->users->findActiveById($adminId);
if (!$admin) return ['ok' => false];
$this->users->setPassword($admin, $newPassword);
return ['ok' => true];
}
private function otpKey(string $challengeId): string
{
$prefix = (string)config('admin.redis_prefix', 'admin:2fa:');
return $prefix . $challengeId;
}
private function decryptPhoneToDigits(AdminUser $admin): string
{
$enc = (string) $admin->phone_enc;
if ($enc === '') return '';
// ✅ 1) encryptString() 로 저장한 값은 decryptString() 으로 복호화해야 함
try {
$raw = Crypt::decryptString($enc);
$digits = preg_replace('/\D+/', '', (string) $raw) ?: '';
// 최소 길이 검증(한국 휴대폰 기준 10~11자리 정도, E164면 9~15)
if (!preg_match('/^\d{9,15}$/', $digits)) {
Log::warning('[admin-auth] phone decrypted but invalid digits length', [
'admin_id' => $admin->id,
'digits_len' => strlen($digits),
]);
return '';
}
return $digits;
} catch (\Throwable $e1) {
// ✅ 2) 혹시 예전 encrypt() (serialize 기반)로 저장한 데이터가 섞였으면 이걸로 복구
try {
Log::warning('[admin-auth] phone decrypt failed', [
'admin_id' => $admin->id,
'enc_len' => strlen($enc),
'enc_head' => substr($enc, 0, 16),
'e1' => $e1->getMessage(),
]);
$raw = decrypt($enc, false); // unserialize=false
$digits = preg_replace('/\D+/', '', (string) $raw) ?: '';
return preg_match('/^\d{9,15}$/', $digits) ? $digits : '';
} catch (\Throwable $e2) {
// ✅ 3) 진짜 평문 숫자만 예외적으로 허용(암호문에서 숫자 긁는 fallback 절대 금지)
if (preg_match('/^\d{9,15}$/', $enc)) {
return $enc;
}
Log::warning('[admin-auth] phone decrypt failed', [
'admin_id' => $admin->id,
'enc_len' => strlen($enc),
'enc_head' => substr($enc, 0, 16),
'e1' => $e1->getMessage(),
'e2' => $e2->getMessage(),
]);
return '';
}
}
}
private function maskPhone(string $digits): string
{
if (strlen($digits) < 7) return $digits;
return substr($digits, 0, 3) . '-****-' . substr($digits, -4);
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Services\Admin;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
class AdminOtpService
{
public function startChallenge(int $adminUserId, string $phoneE164, string $ip, string $ua): array
{
$challengeId = Str::random(40);
$otp = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$prefix = (string) config('admin.redis_prefix', 'admin:2fa:');
$key = $prefix . 'challenge:' . $challengeId;
$otpHashKey = (string) config('admin.otp_hash_key', '');
$otpHash = hash_hmac('sha256', $otp, $otpHashKey);
$ttl = (int) config('admin.otp_ttl', 300);
$cooldown = (int) config('admin.otp_resend_cooldown', 30);
Redis::hmset($key, [
'admin_user_id' => (string) $adminUserId,
'otp_hash' => $otpHash,
'attempts' => '0',
'resend_count' => '0',
'resend_after' => (string) (time() + $cooldown),
'ip' => $ip,
'ua' => mb_substr($ua, 0, 250),
]);
Redis::expire($key, $ttl);
return [
'challenge_id' => $challengeId,
'otp' => $otp, // DB 저장 금지, “발송에만” 사용
];
}
public function canResend(string $challengeId): array
{
$key = (string) config('admin.redis_prefix', 'admin:2fa:') . 'challenge:' . $challengeId;
$resendAfter = (int) (Redis::hget($key, 'resend_after') ?: 0);
if ($resendAfter > time()) {
return ['ok' => false, 'wait' => $resendAfter - time()];
}
return ['ok' => true, 'wait' => 0];
}
public function resend(string $challengeId): ?string
{
$key = (string) config('admin.redis_prefix', 'admin:2fa:') . 'challenge:' . $challengeId;
if (!Redis::exists($key)) return null;
$cooldown = (int) config('admin.otp_resend_cooldown', 30);
$otpHashKey = (string) config('admin.otp_hash_key', '');
$otp = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$otpHash = hash_hmac('sha256', $otp, $otpHashKey);
Redis::hset($key, 'otp_hash', $otpHash);
Redis::hincrby($key, 'resend_count', 1);
Redis::hset($key, 'resend_after', (string) (time() + $cooldown));
return $otp;
}
public function verify(string $challengeId, string $otpInput): array
{
$key = (string) config('admin.redis_prefix', 'admin:2fa:') . 'challenge:' . $challengeId;
if (!Redis::exists($key)) {
return ['ok' => false, 'reason' => 'expired'];
}
$maxAttempts = (int) config('admin.otp_max_attempts', 5);
$attempts = (int) (Redis::hget($key, 'attempts') ?: 0);
if ($attempts >= $maxAttempts) {
return ['ok' => false, 'reason' => 'locked'];
}
$otpHashKey = (string) config('admin.otp_hash_key', '');
$expected = (string) Redis::hget($key, 'otp_hash');
$given = hash_hmac('sha256', trim($otpInput), $otpHashKey);
if (!hash_equals($expected, $given)) {
Redis::hincrby($key, 'attempts', 1);
$left = max(0, $maxAttempts - ($attempts + 1));
return ['ok' => false, 'reason' => 'mismatch', 'left' => $left];
}
$adminUserId = (int) (Redis::hget($key, 'admin_user_id') ?: 0);
Redis::del($key);
return ['ok' => true, 'admin_user_id' => $adminUserId];
}
}

View File

@ -3,28 +3,18 @@
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\Facades\Route; use Illuminate\Http\Request;
use Illuminate\Routing\Exceptions\InvalidSignatureException; use Illuminate\Routing\Exceptions\InvalidSignatureException;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
// (A) Routing: 도메인별 라우트 분리
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', // 공용(가능하면 최소화) web: __DIR__.'/../routes/domains.php', // 도메인 라우팅은 routes에서 처리
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
then: function () {
Route::middleware('web')
->domain('four.syye.net')
->group(base_path('routes/web.php'));
Route::middleware('web')
->domain('shot.syye.net')
->group(base_path('routes/admin.php'));
},
) )
// (B) Middleware: Reverse Proxy/Host 신뢰 정책 + CSRF 예외
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// ✅ Reverse Proxy 신뢰(정확한 client ip, https 판단)
$middleware->trustProxies(at: [ $middleware->trustProxies(at: [
'192.168.100.0/24', '192.168.100.0/24',
'127.0.0.0/8', '127.0.0.0/8',
@ -32,34 +22,33 @@ return Application::configure(basePath: dirname(__DIR__))
'172.16.0.0/12', '172.16.0.0/12',
]); ]);
$middleware->trustHosts(at: [ // ✅ trustHosts는 요청 시점에 config 기반으로 적용
'four.syye.net', $middleware->prepend(\App\Http\Middleware\TrustedHostsFromConfig::class);
'shot.syye.net',
]);
// CSRF 예외 처리 // ✅ CSRF 예외 처리
// - 도메인 제외, path만
// - 네 라우트 정의 기준: POST register/danal/result
$middleware->validateCsrfTokens(except: [ $middleware->validateCsrfTokens(except: [
'auth/register/danal/result', //다날 PASS 회원가입 콜백 (외부 서버가 호출) 'auth/register/danal/result',
'mypage/info/danal/result', //다날 PASS 전화번호 변경 콜백 (외부 서버가 호출) 'mypage/info/danal/result',
]); ]);
//페이지 접근권한 미들웨어 등록 // ✅ alias 등록
$middleware->alias([ $middleware->alias([
'legacy.auth' => \App\Http\Middleware\LegacyAuth::class, //로그인후 접근가능 'legacy.auth' => \App\Http\Middleware\LegacyAuth::class,
'legacy.guest' => \App\Http\Middleware\LegacyGuest::class, //게스트 접근가능페이지 'legacy.guest' => \App\Http\Middleware\LegacyGuest::class,
'admin.ip' => \App\Http\Middleware\AdminIpAllowlist::class,
]); ]);
// ✅ guest redirect (auth 미들웨어가 login 라우트 찾다 터지는거 방지)
$middleware->redirectGuestsTo(function (Request $request) {
$adminHost = (string) config('app.admin_domain', '');
return ($adminHost !== '' && $request->getHost() === $adminHost)
? '/login'
: '/auth/login';
});
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
//서명 만료/위변조(temporarySignedRoute + signed middleware)
$exceptions->render(function (InvalidSignatureException $e, $request) { $exceptions->render(function (InvalidSignatureException $e, $request) {
return redirect('/')->with('alert', '잘못된 접근입니다.'); return redirect('/')->with('alert', '잘못된 접근입니다.');
}); });
}) })
->create(); ->create();

19
config/admin.php Normal file
View File

@ -0,0 +1,19 @@
<?php
$raw = (string) env('ADMIN_ALLOWED_IPS', '');
// 콤마/공백/줄바꿈 모두 허용
$parts = preg_split('/[\s,]+/', trim($raw)) ?: [];
return [
'allowed_ips' => array_values(array_filter(array_map('trim', $parts))),
'otp_ttl' => (int) env('ADMIN_OTP_TTL', 300), // 5분
'otp_max_attempts' => (int) env('ADMIN_OTP_MAX_ATTEMPTS', 5),
'otp_resend_cooldown' => (int) env('ADMIN_OTP_RESEND_COOLDOWN', 30),
'otp_hash_key' => env('ADMIN_OTP_HASH_KEY', ''),
'phone_hash_key' => env('ADMIN_PHONE_HASH_KEY', ''),
'sms_ttl' => (int) env('ADMIN_SMS_TTL', 180),
// redis prefix는 환경별로 분리 추천
'redis_prefix' => (string) env('ADMIN_REDIS_PREFIX', 'admin:2fa:'),
];

View File

@ -2,6 +2,11 @@
return [ return [
/* 웹 도메인 설정 미들웨어 호출용*/
'web_domain' => env('APP_DOMAIN', 'four.syye.net'),
'admin_domain' => env('ADMIN_DOMAIN', 'shot.syye.net'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Name | Application Name

View File

@ -43,7 +43,7 @@ return [
'admin' => [ 'admin' => [
'driver' => 'session', 'driver' => 'session',
'provider' => 'admins', 'provider' => 'admin_users',
], ],
], ],
@ -67,10 +67,10 @@ return [
'providers' => [ 'providers' => [
'users' => [ 'users' => [
'driver' => 'eloquent', 'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class), 'model' => App\Models\User::class,
], ],
'admins' => [ 'admin_users' => [
'driver' => 'eloquent', 'driver' => 'eloquent',
'model' => App\Models\AdminUser::class, 'model' => App\Models\AdminUser::class,
], ],

View File

@ -42,18 +42,6 @@ return [
'strict' => true, 'strict' => true,
], ],
'admin' => [
'driver' => env('DB_ADMIN_CONNECTION', env('DB_CONNECTION', 'mysql')),
'host' => env('DB_ADMIN_HOST', env('DB_HOST', '127.0.0.1')),
'port' => env('DB_ADMIN_PORT', env('DB_PORT', '3306')),
'database' => env('DB_ADMIN_DATABASE', ''),
'username' => env('DB_ADMIN_USERNAME', ''),
'password' => env('DB_ADMIN_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'strict' => true,
],
'sms_server' => [ 'sms_server' => [
'driver' => env('SMS_DB_CONNECTION', 'mysql'), 'driver' => env('SMS_DB_CONNECTION', 'mysql'),
'host' => env('SMS_DB_HOST', '127.0.0.1'), 'host' => env('SMS_DB_HOST', '127.0.0.1'),
@ -66,9 +54,6 @@ return [
'strict' => false, // 외부 DB면 strict 끄는거 OK 'strict' => false, // 외부 DB면 strict 끄는거 OK
], ],
], ],
/* /*

View File

@ -1,49 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@ -1,35 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@ -1,57 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@ -1,64 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('admin_users', function (Blueprint $table) {
$table->id();
// 로그인
$table->string('email')->unique();
$table->string('password');
// 관리자 기본 정보
$table->string('full_name', 50); // 관리자 성명
$table->string('nickname', 30)->unique(); // 관리자 닉네임
$table->string('phone', 20)->nullable()->unique(); // 전화번호(선택)
/**
* 관리자 등급(role)
* : super(최고관리자), admin(일반관리자), cs(상담전용)...
*/
$table->string('role', 20)->default('admin')->index();
/**
* 관리자 상태(status)
* : active(정상), blocked(접근금지), suspended(일시정지)
*/
$table->string('status', 20)->default('active')->index();
// 상담 가능 여부
$table->boolean('is_consult_available')->default(false)->index();
/**
* 상담 종류(JSON)
* : ["signup","login","payment","giftcard","event"]
* - pivot 테이블 쓰고 단일테이블로 운영하려면 이게 제일 깔끔함
*/
$table->json('consult_types')->nullable();
// Google OTP (TOTP)
// "code(6자리)" 저장 X → "secret(시드)" 저장 O (암호화 저장 권장)
$table->text('totp_secret')->nullable();
$table->boolean('totp_enabled')->default(false)->index();
$table->timestamp('totp_confirmed_at')->nullable();
// 로그인 흔적 (보안/감사)
$table->timestamp('last_login_at')->nullable();
$table->string('last_login_ip', 45)->nullable();
// remember me
$table->rememberToken();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('admin_users');
}
};

View File

@ -0,0 +1,132 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
final class AdminRbacSeeder extends Seeder
{
public function run(): void
{
// 1) 역할(roles)
$roles = [
['name' => 'super_admin', 'label' => '최고관리자'],
['name' => 'finance', 'label' => '정산관리'],
['name' => 'product', 'label' => '상품관리'],
['name' => 'support', 'label' => 'CS/상담'],
];
foreach ($roles as $r) {
DB::table('admin_roles')->updateOrInsert(
['name' => $r['name']],
['label' => $r['label'], 'updated_at' => now(), 'created_at' => now()]
);
}
// 2) 권한(permissions) - 최소 셋
$perms = [
['name' => 'admin.access', 'label' => '관리자 접근'],
['name' => 'settlement.manage', 'label' => '정산 관리'],
['name' => 'product.manage', 'label' => '상품 관리'],
['name' => 'support.manage', 'label' => 'CS/상담 관리'],
['name' => 'member.manage', 'label' => '회원 관리'],
];
foreach ($perms as $p) {
DB::table('admin_permissions')->updateOrInsert(
['name' => $p['name']],
['label' => $p['label'], 'updated_at' => now(), 'created_at' => now()]
);
}
// 3) super_admin 역할에 모든 권한 부여
$superRoleId = (int) DB::table('admin_roles')->where('name', 'super_admin')->value('id');
$permIds = DB::table('admin_permissions')->pluck('id')->map(fn($v) => (int)$v)->all();
foreach ($permIds as $pid) {
DB::table('admin_permission_role')->updateOrInsert([
'admin_permission_id' => $pid,
'admin_role_id' => $superRoleId,
], []);
}
// 4) super_admin 유저 1명 생성(없으면)
$email = (string) env('ADMIN_SEED_EMAIL', 'admin@pinforyou.com');
$rawPw = (string) env('ADMIN_SEED_PASSWORD', 'ChangeMe!234');
$name = (string) env('ADMIN_SEED_NAME', 'Super Admin');
$phone = (string) env('ADMIN_SEED_PHONE', '01012345678');
$phoneE164 = $this->toE164Kr($phone); // +8210...
$hashKey = (string) config('admin.phone_hash_key', env('ADMIN_PHONE_HASH_KEY', ''));
if ($hashKey === '') {
throw new \RuntimeException('ADMIN_PHONE_HASH_KEY (admin.phone_hash_key) is empty. Set it in .env');
}
$phoneHash = hash_hmac('sha256', $phoneE164, $hashKey);
$phoneEnc = Crypt::encryptString($phoneE164);
$last4 = substr(preg_replace('/\D+/', '', $phoneE164), -4) ?: null;
$user = DB::table('admin_users')->where('email', $email)->first();
if (!$user) {
$adminUserId = DB::table('admin_users')->insertGetId([
'email' => $email,
'password' => Hash::make($rawPw),
'name' => $name,
'nickname' => null,
'phone_enc' => $phoneEnc,
'phone_hash' => $phoneHash,
'phone_last4' => $last4,
'status' => 'active',
'must_reset_password' => 1,
// totp는 “사용” 정책이니 enabled=1, secret은 등록 플로우에서 세팅
'totp_secret_enc' => null,
'totp_enabled' => 1,
'totp_verified_at' => null,
'last_login_at' => null,
'last_login_ip' => null,
'failed_login_count' => 0,
'locked_until' => null,
'remember_token' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
]);
// super_admin 역할 부여
DB::table('admin_role_user')->insert([
'admin_user_id' => $adminUserId,
'admin_role_id' => $superRoleId,
]);
} else {
// 이미 있으면 role만 보장
$adminUserId = (int) $user->id;
DB::table('admin_role_user')->updateOrInsert([
'admin_user_id' => $adminUserId,
'admin_role_id' => $superRoleId,
], []);
}
}
private function toE164Kr(string $raw): string
{
$n = preg_replace('/\D+/', '', $raw) ?? '';
if ($n === '') return '+82';
// 010xxxxxxxx 형태 -> +8210xxxxxxxx
if (str_starts_with($n, '0')) {
$n = substr($n, 1);
}
return '+82'.$n;
}
}

View File

@ -16,6 +16,9 @@ class DatabaseSeeder extends Seeder
public function run(): void public function run(): void
{ {
// User::factory(10)->create(); // User::factory(10)->create();
$this->call([
AdminRbacSeeder::class,
]);
User::factory()->create([ User::factory()->create([
'name' => 'Test User', 'name' => 'Test User',

View File

@ -0,0 +1,75 @@
# ADMIN_AUTH_PLAN (Laravel 12)
> 목적: **관리자(Admin) 영역**을 “허용된 IP + 이메일/비밀번호 + SMS OTP(필수) + (옵션) TOTP” 구조로 구축한다.
> 개발 환경: NAS nginx-proxy(HTTPS) → 내부 Docker(HTTP) → Laravel 12(PHP 8.3), Redis 사용
---
## 0) 현재 전제(프로젝트 컨텍스트)
- Web 도메인: `four.syye.net`
- Admin 도메인: `shot.syye.net`
- **Admin 도메인은 Laravel 라우트 그룹에서만** 접근 제어(개발용).
(Nginx allow/deny는 나중에 CloudFront/WAF에서 구현)
- Reverse proxy 구조:
- NAS `nginx-proxy`에서 HTTPS 종료
- 내부로 `gifticon-web:8091`, `gifticon-admin:8092`로 전달
- PHP는 `gifticon-app`(php-fpm)에서 처리
- Redis 사용: `gifticon-redis`
---
## 1) 보안 목표 & 정책
### 1.1 1차 방어: Admin IP Allowlist
- Admin 도메인 라우트 그룹에 `admin.ip` 미들웨어를 **반드시** 적용한다.
- `.env``ADMIN_ALLOWED_IPS` 값을 사용한다.
- 실패 시 403.
> ⚠️ admin.ip 미들웨어를 login 라우트에 안 붙이면 “로그인 페이지가 열려 보안이 뚫린 것처럼 보이는” 문제가 생김.
> 해결: **도메인 그룹 단에서** `['web','admin.ip']` 적용.
### 1.2 2차 인증: 이메일/비밀번호 + SMS OTP(필수)
- 이메일/비밀번호가 맞으면 “임시 인증 상태”를 Redis에 저장하고, SMS로 OTP 코드를 발송한다.
- OTP 검증 성공 시에만 `auth:admin` 로그인 완료.
### 1.3 (옵션) TOTP(구글 OTP)
- 기본은 SMS OTP.
- “최고 관리자/특정 역할”에 한해 TOTP를 **옵션으로 활성화** 가능하도록 설계.
- 보안 우선순위: **TOTP > SMS** (SMS는 SIM 스와프 리스크 존재)
- 현실 운영 편의: **SMS만으로도 가능**, 대신 IP allowlist + rate limit + audit log를 강화.
### 1.4 비밀번호 해시(강력)
- Laravel `Hash::make()` 사용 (기본 bcrypt/argon 설정은 config로 통제)
- 권장: `argon2id` 고려(서버 자원 여유 있으면).
운영에서 튜닝(메모리/시간 비용) 필요.
### 1.5 전화번호 저장 정책 (B안 보안강화)
- DB에는 평문 전화번호를 저장하지 않는다.
- `phone_enc`(암호화 저장) + `phone_hash`(조회용) 조합 사용
- `phone_hash`: HMAC-SHA256(키 = `ADMIN_PHONE_HASH_KEY`)
- `phone_enc`: Laravel `Crypt::encryptString()` (키 = APP_KEY)
---
## 2) .env 표준화(도메인/URL)
권장 키:
```env
WEB_SCHEME=https
ADMIN_SCHEME=https
APP_DOMAIN=four.syye.net
ADMIN_DOMAIN=shot.syye.net
APP_URL=${WEB_SCHEME}://${APP_DOMAIN}
APP_ADMIN_URL=${ADMIN_SCHEME}://${ADMIN_DOMAIN}
ADMIN_ALLOWED_IPS=210.96.177.79
ADMIN_PHONE_HASH_KEY=change-me-long-random
ADMIN_OTP_TTL=300
ADMIN_OTP_MAX_ATTEMPTS=5
ADMIN_SMS_TTL=180
ADMIN_REDIS_PREFIX=admin:2fa:

View File

@ -30,7 +30,7 @@
예시(NAS nginx-proxy): 예시(NAS nginx-proxy):
- `https://four.syye.net``http://<docker-host>:8091` (gifticon-web) - `https://four.syye.net``http://<docker-host>:8091` (gifticon-web)
- `https://myworld.syye.net` → `http://<docker-host>:8092` (gifticon-admin) - `https://shot.syye.net` → `http://<docker-host>:8092` (gifticon-admin)
운영에서도: 운영에서도:
- CloudFront(HTTPS) → EC2(HTTP) → Docker(gifticon-web/admin) 동일 패턴으로 적용 가능. - CloudFront(HTTPS) → EC2(HTTP) → Docker(gifticon-web/admin) 동일 패턴으로 적용 가능.

View File

@ -0,0 +1,313 @@
# 런타임 점검/테스트 & 장애 대응 체크리스트 (Gifticon Platform)
이 문서는 Gifticon Platform 도커 환경에서
- **연동관계가 정상 작동하는지 테스트**
- **문제 발생(500/큐/스케줄/메일) 시 어디부터 확인할지**
- **운영 배포 루틴**
을 한 번에 정리한 문서다.
---
## 3. 런타임 정상 동작 점검 (필수)
### 3.2 Laravel 기본 상태 확인 (필수)
```bash
docker exec -it gifticon-app php artisan about
```
확인 포인트:
- Environment: `production`
- Debug Mode: `OFF`
- Cache: `redis`
- Session: `redis`
- Queue: `redis`
- Mail: `smtp`
- URL: **프록시 뒤에서 도메인이 정상적으로 잡히는지** (Host/Forwarded 헤더 전달 정상 여부)
---
### 3.3 Redis 연결 확인
Redis 자체:
```bash
docker exec -it gifticon-redis redis-cli ping
# PONG 기대
```
Laravel cache 테스트:
```bash
docker exec -it -e HOME=/tmp gifticon-app php artisan tinker
> cache()->put('ping','pong',60);
> cache()->get('ping');
# "pong" 기대
```
---
### 3.4 DB 연결 확인
```bash
docker exec -it -e HOME=/tmp gifticon-app php artisan tinker
> \DB::select('select 1 as ok');
# ok=1 기대
```
---
### 3.5 Queue(워커) 동작 확인
권장: **Job 클래스로 테스트**
- 테스트 Job 예: `QueuePingJob` (`ShouldQueue`)
dispatch 후 워커가 처리했는지 로그로 확인
dispatch:
```bash
docker exec -it -e HOME=/tmp gifticon-app php artisan tinker
> dispatch(new \App\Jobs\QueuePingJob());
```
워커 로그:
```bash
docker logs --tail=200 gifticon-worker
```
Laravel log:
```bash
docker exec -it gifticon-app sh -lc "tail -n 200 storage/logs/laravel.log | tail -n 200"
```
중요:
- `dispatch(function(){ ... })` 형태의 **Closure Job은 운영 금지**
- 직렬화 오류(“Failed to serialize job…”)가 발생할 수 있음 → **항상 Job 클래스로 구현**
---
### 3.6 Scheduler 동작 확인
스케줄 등록 확인:
```bash
docker exec -it gifticon-app php artisan schedule:list
```
스케줄러 컨테이너 실행 확인:
```bash
docker exec -it gifticon-scheduler ps aux
# php artisan schedule:work 확인
```
스케줄이 실제 실행되는지(로그 기반):
- 예: 매분 실행되는 Closure/Job을 하나 두고 로그 확인
---
### 3.7 Mail 발송 확인
메일은 “발송 성공/실패 로그”를 남기는 것이 운영에 유리하다.
메일 발송 로그 확인:
```bash
docker exec -it gifticon-app sh -lc "tail -n 200 storage/logs/laravel.log | egrep 'mail_send_attempt|mail_send_done|mail_send_debug|mail failed' | tail -n 200"
```
메일 템플릿 수정 후 반영이 안 되면:
- `view:clear` + `optimize:clear` + `gifticon-worker` 재시작이 핵심
---
## 4. 장애/오류 발생 시 “우선 확인 순서”
### 4.1 사이트 500 에러 (웹/관리자 공통)
Nginx 로그 확인:
```bash
docker logs --tail=200 gifticon-web
docker logs --tail=200 gifticon-admin
```
Laravel 앱 로그 확인:
```bash
docker exec -it gifticon-app sh -lc "tail -n 300 storage/logs/laravel.log"
```
env/cache 꼬임 대응(가장 흔함):
```bash
docker exec -it gifticon-app php artisan optimize:clear
docker restart gifticon-app gifticon-worker gifticon-scheduler
```
퍼미션/스토리지 확인:
- `storage/logs`, `storage/framework/*` 쓰기 실패 여부
- UID/GID 매핑, 호스트 권한 점검
---
### 4.2 Queue 관련 장애
#### (A) RedisException: Connection refused
원인 후보:
- redis 컨테이너 다운
- env/config cache 꼬임으로 redis host가 127.0.0.1로 잡힘
- 워커가 예전 캐시 상태 유지
네트워크 점검:
```bash
docker exec -it gifticon-worker sh -lc 'getent hosts gifticon-redis || true'
docker exec -it gifticon-worker sh -lc 'nc -vz gifticon-redis 6379 || true'
```
조치:
```bash
docker exec -it gifticon-app php artisan optimize:clear
docker restart gifticon-worker gifticon-app
```
#### (B) Closure Job 직렬화 오류
증상:
- “Failed to serialize job of type CallQueuedClosure …”
조치:
- Closure dispatch 금지
- Job 클래스로 전환
---
### 4.3 Scheduler 관련 장애
#### (A) withoutOverlapping 에러
증상:
- “A scheduled event name is required…”
조치:
- `withoutOverlapping()` 전에 `->name('...')` 필수
#### (B) Class not found (Job 클래스 없음)
증상:
- “Class App\Jobs\Xxx not found”
조치:
- 클래스 파일 존재/namespace 확인
- 캐시 제거 후 scheduler 재시작
```bash
docker exec -it gifticon-app php artisan optimize:clear
docker restart gifticon-scheduler
```
---
### 4.4 Mail 템플릿 반영 안 됨 / 변수 미정의
#### (A) 템플릿 수정했는데 메일이 그대로 옴
원인:
- view cache
- worker가 예전 캐시 상태로 계속 발송
조치:
```bash
docker exec -it gifticon-app php artisan view:clear
docker exec -it gifticon-app php artisan optimize:clear
docker restart gifticon-worker gifticon-app
```
#### (B) Undefined variable (Blade)
원인:
- 템플릿에서 사용 변수 준비(@php 블록)가 없거나 위치가 뒤에 있음
- include/section 구조상 변수가 선언되기 전에 참조됨
조치:
- `@section('content')` 시작 직후 `@php ... @endphp`로 변수를 선행 선언
---
## 5. 운영용 하드닝(보안/안정성) 권장
### 5.1 포트 직접 노출 최소화
운영에서는 8091/8092를 0.0.0.0로 열지 않고 reverse proxy 내부 네트워크로만 열어두는 구성을 권장.
### 5.2 healthcheck 추가 권장
- mariadb: mysqladmin ping
- redis: redis-cli ping
- app: php-fpm 포트 확인 또는 간단 artisan health command
- web/admin: curl localhost
### 5.3 read-only + tmpfs
Nginx 컨테이너는 가능하면:
- `read_only: true`
- `tmpfs: /var/cache/nginx, /var/run`
로 런타임 쓰기영역만 허용
### 5.4 관리자 접근 정책
- 관리자 도메인은 허용 IP만 접근 가능하게 (Nginx allow/deny)
- 비허용 IP는 웹 도메인으로 리다이렉트(정책 선택)
- 관리자 서비스는 web과 DB 권한/접근 범위를 분리하는 것이 최종 목표
---
## 6. 데이터 영속성/백업
### 6.1 MariaDB 데이터 보존
- db_data 볼륨이 유지되는 한 데이터 보존
- `docker compose down`은 기본적으로 볼륨 삭제하지 않음
- 아래는 데이터 삭제됨: `docker compose down -v`
### 6.2 Redis 데이터 보존
- redis_data 볼륨이 유지되는 한 데이터 보존(appendonly)
### 6.3 개발 DB 백업 예시
```bash
docker exec -it gifticon-db sh -lc 'mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" gifticon > /tmp/gifticon.sql'
docker cp gifticon-db:/tmp/gifticon.sql ./backup/gifticon.sql
```
운영에서 RDS 사용 시:
- RDS 자동 백업/스냅샷 정책 권장
---
## 7. 운영 배포 절차(권장 루틴)
1) 코드 업데이트 (git pull 등)
2) 빌드/업
```bash
docker compose up -d --build
```
3) Laravel 캐시 정리 및 재생성
```bash
docker exec -it gifticon-app php artisan optimize:clear
docker exec -it gifticon-app php artisan config:cache
docker exec -it gifticon-app php artisan route:cache
docker exec -it gifticon-app php artisan view:cache
```
4) worker/scheduler 재시작
```bash
docker restart gifticon-worker gifticon-scheduler
```

View File

@ -1,11 +1,593 @@
@import 'tailwindcss'; /* ================================
Admin UI (Independent)
Prefix: a-
================================ */
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; :root{
@source '../../storage/framework/views/*.php'; --a-bg: #0b0f19;
@source '../**/*.blade.php'; --a-panel: rgba(255,255,255,.06);
@source '../**/*.js'; --a-panel2: rgba(255,255,255,.08);
--a-border: rgba(255,255,255,.12);
--a-text: rgba(255,255,255,.92);
--a-muted: rgba(255,255,255,.64);
--a-muted2: rgba(255,255,255,.52);
@theme { --a-primary: #2b7fff;
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', --a-primary2: #7c5cff;
'Segoe UI Symbol', 'Noto Color Emoji'; --a-danger: #ff4d4f;
--a-warn: #ffb020;
--a-info: #2b7fff;
--a-radius: 18px;
--a-radius-sm: 12px;
--a-shadow: 0 24px 80px rgba(0,0,0,.45);
--a-shadow2: 0 12px 40px rgba(0,0,0,.35);
}
*{ box-sizing:border-box; }
html,body{ height:100%; }
.a-body{
margin:0;
font-family: Pretendard, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--a-bg);
color: var(--a-text);
}
.a-auth-bg{
position:fixed; inset:0;
background:
radial-gradient(1000px 500px at 10% 10%, rgba(43,127,255,.20), transparent 55%),
radial-gradient(900px 450px at 90% 15%, rgba(124,92,255,.18), transparent 60%),
radial-gradient(700px 400px at 50% 90%, rgba(255,255,255,.06), transparent 60%),
linear-gradient(180deg, rgba(255,255,255,.03), transparent 35%);
filter: saturate(1.1);
}
.a-auth-shell{
min-height:100vh;
display:grid;
place-items:center;
padding:28px;
position:relative;
}
.a-auth-card{
width:min(980px, 100%);
display:grid;
grid-template-columns: 1.05fr .95fr;
border:1px solid var(--a-border);
border-radius: var(--a-radius);
background: rgba(0,0,0,.22);
backdrop-filter: blur(10px);
box-shadow: var(--a-shadow);
overflow:hidden;
}
.a-auth-left{
padding:28px;
border-right:1px solid var(--a-border);
background:
linear-gradient(135deg, rgba(43,127,255,.10), rgba(124,92,255,.08)),
rgba(255,255,255,.03);
}
.a-auth-right{
padding:22px;
}
.a-brand{
display:flex;
align-items:center;
gap:12px;
margin-bottom:22px;
}
.a-brand__mark{
width:42px; height:42px;
border-radius:14px;
display:grid;
place-items:center;
font-weight:900;
background: linear-gradient(135deg, rgba(43,127,255,.95), rgba(124,92,255,.95));
box-shadow: var(--a-shadow2);
}
.a-brand__name{ font-weight:800; letter-spacing:.02em; }
.a-brand__sub{ color:var(--a-muted); font-size:13px; margin-top:2px; }
.a-auth-left__copy{ margin-top:18px; }
.a-h1{
font-size:22px;
margin:0 0 8px;
letter-spacing:-.02em;
}
.a-muted{ color:var(--a-muted); }
.a-badges{ margin-top:18px; display:flex; flex-wrap:wrap; gap:8px; }
.a-badge{
font-size:12px;
color: rgba(255,255,255,.80);
border:1px solid rgba(255,255,255,.14);
background: rgba(255,255,255,.05);
padding:6px 10px;
border-radius: 999px;
}
.a-panel{
border:1px solid var(--a-border);
background: var(--a-panel);
border-radius: var(--a-radius);
padding:18px;
}
.a-form{ display:block; }
.a-field{ margin:14px 0; }
.a-label{
display:block;
font-size:13px;
color: rgba(255,255,255,.82);
margin-bottom:8px;
}
.a-input{
width:100%;
border-radius: var(--a-radius-sm);
border:1px solid rgba(255,255,255,.14);
background: rgba(0,0,0,.25);
color: var(--a-text);
padding:12px 12px;
outline:none;
transition: border-color .15s ease, background .15s ease, transform .15s ease;
}
.a-input::placeholder{ color: rgba(255,255,255,.35); }
.a-input:focus{
border-color: rgba(43,127,255,.55);
background: rgba(0,0,0,.32);
}
.a-inputwrap{ position:relative; }
.a-input--withbtn{ padding-right:64px; }
.a-inputbtn{
position:absolute;
right:10px; top:50%;
transform: translateY(-50%);
border:1px solid rgba(255,255,255,.14);
background: rgba(255,255,255,.06);
color: rgba(255,255,255,.85);
padding:8px 10px;
border-radius: 999px;
cursor:pointer;
font-size:12px;
}
.a-inputbtn:hover{ background: rgba(255,255,255,.10); }
.a-row{
margin:10px 0 16px;
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
}
.a-check{
display:flex;
align-items:center;
gap:8px;
font-size:13px;
color: rgba(255,255,255,.78);
user-select:none;
}
.a-check input{ width:16px; height:16px; }
.a-link{
color: rgba(255,255,255,.78);
text-decoration:none;
font-size:13px;
opacity:.9;
}
.a-link:hover{ text-decoration:underline; }
.a-btn{
width:100%;
border-radius: var(--a-radius-sm);
border:1px solid rgba(255,255,255,.14);
padding:12px 14px;
cursor:pointer;
font-weight:800;
letter-spacing:.01em;
}
.a-btn--primary{
border:0;
color:white;
background: linear-gradient(135deg, var(--a-primary), var(--a-primary2));
box-shadow: 0 14px 34px rgba(43,127,255,.18);
}
.a-btn--primary:active{ transform: translateY(1px); }
.a-help{ margin-top:12px; }
.a-foot{ margin-top:14px; text-align:center; }
.a-alert{
border-radius: 14px;
border:1px solid rgba(255,255,255,.12);
padding:12px 12px;
margin: 0 0 12px;
background: rgba(255,255,255,.05);
}
.a-alert__title{ font-weight:800; margin-bottom:4px; font-size:13px; }
.a-alert__body{ font-size:13px; color: rgba(255,255,255,.82); }
.a-alert--danger{ border-color: rgba(255,77,79,.30); background: rgba(255,77,79,.12); }
.a-alert--warn{ border-color: rgba(255,176,32,.28); background: rgba(255,176,32,.10); }
.a-alert--info{ border-color: rgba(43,127,255,.28); background: rgba(43,127,255,.10); }
.a-brand__logoBox{
width: 110px;
display: inline-flex;
align-items: center;
justify-content: center;
padding:5px;
background: #fff;
border-radius: 5px;
border: 1px solid rgba(0,0,0,.08);
box-shadow: 0 6px 18px rgba(0,0,0,.12);
overflow: hidden;
}
.a-brand__logo{
width: 70%;
height: 70%;
object-fit: contain;
display: block;
}
@media (max-width: 860px){
.a-auth-card{ grid-template-columns: 1fr; }
.a-auth-left{ border-right:0; border-bottom:1px solid var(--a-border); }
}
/* ===== OTP ===== */
.a-otp-meta{
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
padding:10px 12px;
border: 1px solid rgba(255,255,255,.10);
background: rgba(255,255,255,.04);
border-radius: 14px;
margin-bottom: 14px;
}
.a-otp-meta__label{
font-weight: 700;
letter-spacing: .02em;
opacity: .85;
}
.a-otp-meta__value{
font-weight: 800;
letter-spacing: .08em;
opacity: .95;
}
.a-otp-input{
text-align: center;
font-size: 22px;
font-weight: 800;
letter-spacing: .35em; /* 6자리 느낌 */
padding: 14px 14px;
}
.a-otp-help{
margin-top: 12px;
}
/* 공통 에러 스타일이 없다면 */
.a-error{
margin-top: 8px;
font-size: 12px;
color: #ff6b6b;
}
/* ================================
Admin App (after login)
================================ */
.a-app__wrap{
min-height: 100vh;
display: grid;
grid-template-columns: 280px 1fr;
}
.a-side{
position: sticky;
top: 0;
height: 100vh;
border-right: 1px solid var(--a-border);
background:
linear-gradient(180deg, rgba(255,255,255,.03), transparent 40%),
rgba(0,0,0,.18);
backdrop-filter: blur(10px);
overflow: auto;
}
.a-side__brand{
display:flex;
align-items:center;
gap:12px;
padding:18px 16px;
border-bottom: 1px solid var(--a-border);
}
.a-side__logo{
width:42px; height:42px;
border-radius: 14px;
display:grid;
place-items:center;
font-weight:900;
background: linear-gradient(135deg, rgba(43,127,255,.95), rgba(124,92,255,.95));
box-shadow: var(--a-shadow2);
}
.a-side__brandName{ font-weight: 900; letter-spacing: .02em; }
.a-side__brandSub{ font-size: 12px; color: var(--a-muted); margin-top: 2px; }
.a-nav{ padding: 14px 10px 18px; }
.a-nav__group{ margin: 10px 0 14px; }
.a-nav__title{
padding: 8px 10px;
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase;
color: rgba(255,255,255,.55);
}
.a-nav__item{
display:flex;
align-items:center;
gap:10px;
padding: 10px 10px;
border-radius: 12px;
color: rgba(255,255,255,.82);
text-decoration:none;
border: 1px solid transparent;
cursor:pointer;
}
.a-nav__dot{
width: 8px; height: 8px;
border-radius: 99px;
background: rgba(255,255,255,.25);
}
.a-nav__item:hover{
background: rgba(255,255,255,.05);
border-color: rgba(255,255,255,.10);
}
.a-nav__item.is-active{
background: rgba(43,127,255,.12);
border-color: rgba(43,127,255,.22);
}
.a-nav__item.is-active .a-nav__dot{
background: linear-gradient(135deg, var(--a-primary), var(--a-primary2));
}
.a-nav__item.is-disabled{
opacity: .45;
cursor: not-allowed;
}
.a-main{
min-width: 0;
display: grid;
grid-template-rows: auto 1fr auto;
}
.a-top{
border-bottom: 1px solid var(--a-border);
background:
radial-gradient(900px 260px at 15% 0%, rgba(43,127,255,.14), transparent 60%),
radial-gradient(700px 240px at 85% 0%, rgba(124,92,255,.12), transparent 60%),
rgba(0,0,0,.14);
backdrop-filter: blur(10px);
}
.a-top > *{
padding: 14px 18px;
display:flex;
align-items:center;
justify-content:space-between;
gap: 12px;
}
.a-top__h{ font-size: 16px; font-weight: 900; letter-spacing: -.02em; }
.a-top__sub{ font-size: 12px; margin-top: 2px; }
.a-top__search{
width: min(520px, 42vw);
}
.a-top__searchInput{
width: 100%;
border-radius: 14px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(0,0,0,.22);
color: var(--a-text);
padding: 10px 12px;
outline: none;
}
.a-top__user{
display:flex;
align-items:center;
gap: 10px;
border: 1px solid rgba(255,255,255,.10);
background: rgba(255,255,255,.04);
padding: 8px 10px;
border-radius: 16px;
}
.a-top__avatar{
width:34px; height:34px;
border-radius: 12px;
display:grid;
place-items:center;
font-weight: 900;
background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.10);
}
.a-top__userName{ font-weight: 800; font-size: 13px; }
.a-top__userMeta{ font-size: 12px; }
.a-top__logout{
border: 1px solid rgba(255,255,255,.14);
background: rgba(255,255,255,.06);
color: rgba(255,255,255,.86);
padding: 8px 10px;
border-radius: 999px;
cursor:pointer;
font-size: 12px;
}
.a-top__logout:hover{ background: rgba(255,255,255,.10); }
.a-content{
padding: 18px;
}
.a-footer{
padding: 14px 18px;
border-top: 1px solid var(--a-border);
background: rgba(0,0,0,.10);
}
/* Dashboard */
.a-dash{ display:block; }
.a-grid{
display:grid;
gap: 12px;
}
.a-grid--kpi{
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 12px;
}
.a-grid--charts{
grid-template-columns: 1.2fr .8fr;
margin-bottom: 12px;
}
.a-grid--tables{
grid-template-columns: 1fr 1fr;
}
.a-card{
border:1px solid var(--a-border);
background: rgba(255,255,255,.04);
border-radius: var(--a-radius);
padding: 14px;
box-shadow: var(--a-shadow2);
min-width: 0;
}
.a-card--accent{
background:
linear-gradient(135deg, rgba(43,127,255,.18), rgba(124,92,255,.14)),
rgba(255,255,255,.04);
border-color: rgba(43,127,255,.18);
}
.a-card--lg{ min-height: 280px; }
.a-card__label{ font-size: 12px; }
.a-card__value{
margin-top: 8px;
font-size: 24px;
font-weight: 900;
letter-spacing: -.02em;
}
.a-card__meta{ margin-top: 6px; font-size: 12px; }
.a-card__head{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap: 10px;
margin-bottom: 10px;
}
.a-card__title{ font-weight: 900; }
.a-card__desc{ font-size: 12px; margin-top: 3px; }
.a-chip{
font-size: 12px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.05);
padding: 6px 10px;
border-radius: 999px;
}
.a-chart{
height: 220px;
border-radius: 14px;
border: 1px dashed rgba(255,255,255,.14);
background: rgba(0,0,0,.15);
display:grid;
place-items:center;
}
.a-chart__placeholder{ font-size: 12px; }
.a-table{ display:block; }
.a-tr{
display:grid;
grid-template-columns: 90px 70px 1fr 90px;
gap: 10px;
padding: 10px 8px;
border-top: 1px solid rgba(255,255,255,.08);
align-items:center;
}
.a-th{
border-top: 0;
padding-top: 6px;
padding-bottom: 8px;
color: rgba(255,255,255,.55);
font-size: 12px;
}
.a-ellipsis{
overflow:hidden;
white-space:nowrap;
text-overflow:ellipsis;
min-width: 0;
}
.a-pill{
display:inline-flex;
align-items:center;
justify-content:center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.05);
font-size: 12px;
}
.a-pill--warn{ border-color: rgba(255,176,32,.28); background: rgba(255,176,32,.10); }
.a-pill--info{ border-color: rgba(43,127,255,.28); background: rgba(43,127,255,.10); }
@media (max-width: 1200px){
.a-grid--kpi{ grid-template-columns: repeat(2, minmax(0, 1fr)); }
.a-grid--charts{ grid-template-columns: 1fr; }
.a-grid--tables{ grid-template-columns: 1fr; }
}
@media (max-width: 980px){
.a-app__wrap{ grid-template-columns: 1fr; }
.a-side{ position: relative; height: auto; }
} }

View File

@ -1 +1,26 @@
import './bootstrap'; document.addEventListener('click', (e) => {
const t = e.target;
// password toggle
if (t && t.matches('[data-toggle="password"]')) {
const pw = document.getElementById('password');
if (!pw) return;
const isPw = pw.type === 'password';
pw.type = isPw ? 'text' : 'password';
t.textContent = isPw ? '숨기기' : '보기';
return;
}
});
document.addEventListener('submit', (e) => {
const form = e.target;
if (!form || !form.matches('[data-form="login"]')) return;
const btn = form.querySelector('[data-submit]');
if (btn) {
btn.disabled = true;
btn.dataset.original = btn.textContent;
btn.textContent = '처리 중...';
}
});

View File

@ -1,46 +1,187 @@
<!doctype html> @extends('admin.layouts.auth')
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>관리자 로그인</title>
</head>
<body style="font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; max-width: 360px; margin: 80px auto;">
<h2>관리자 로그인</h2>
@if ($errors->any()) @section('title', '로그인')
<div style="background:#ffecec; border:1px solid #ffb3b3; padding:10px; margin:12px 0;">
<ul style="margin:0; padding-left:18px;"> {{-- reCAPTCHA 스크립트는 페이지에서만 로드 --}}
@foreach ($errors->all() as $error) @push('head')
<li>{{ $error }}</li> @php
@endforeach $siteKey = (string) config('services.recaptcha.site_key');
</ul> @endphp
</div>
@if($siteKey)
<script>window.__recaptchaSiteKey = @json($siteKey);</script>
<script src="https://www.google.com/recaptcha/api.js?render={{ $siteKey }}"></script>
<script src="{{ asset('assets/js/recaptcha-v3.js') }}"></script>
@endif @endif
@endpush
<form method="POST" action="{{ route('admin.login.store') }}"> @section('content')
<form id="loginForm" method="POST" action="{{ route('admin.login.store') }}" class="a-form" novalidate>
@csrf @csrf
<div style="margin: 12px 0;"> <input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
<label>Email</label><br>
<input type="email" name="email" value="{{ old('email') }}" required autofocus <div class="a-field">
style="width:100%; padding:10px; box-sizing:border-box;"> <label class="a-label" for="login_id">아이디(이메일)</label>
<input
class="a-input"
id="login_id"
name="login_id"
type="text"
autocomplete="username"
autofocus
value="{{ old('login_id') }}"
>
@error('login_id')
<div class="a-error">{{ $message }}</div>
@enderror
</div> </div>
<div style="margin: 12px 0;"> <div class="a-field">
<label>Password</label><br> <label class="a-label" for="password">비밀번호</label>
<input type="password" name="password" required <input
style="width:100%; padding:10px; box-sizing:border-box;"> class="a-input"
id="password"
name="password"
type="password"
autocomplete="current-password"
>
@error('password')
<div class="a-error">{{ $message }}</div>
@enderror
</div> </div>
<div style="margin: 12px 0;"> <label class="a-check" style="display:flex; gap:8px; align-items:center; margin:10px 0 0;">
<label> <input type="checkbox" name="remember" value="1" {{ old('remember') ? 'checked' : '' }}>
<input type="checkbox" name="remember" value="1"> <span class="a-muted">로그인 유지</span>
로그인 유지
</label> </label>
</div>
<button type="submit" style="width:100%; padding:10px;">로그인</button> <button class="a-btn a-btn--primary" type="submit" style="margin-top:14px;">
로그인
</button>
<div class="a-help" style="margin-top:10px;">
<small class="a-muted">로그인 성공 SMS 인증번호 입력 단계로 이동합니다.</small>
</div>
</form> </form>
</body> @endsection
</html>
@push('scripts')
<script>
(function () {
const form = document.getElementById('loginForm');
if (!form) return;
const emailEl = document.getElementById('login_id');
const pwEl = document.getElementById('password');
const btn = form.querySelector('button[type="submit"]');
// web 공통 showMsg가 있으면 사용, 없으면 alert로 fallback
const showMsgSafe = async (msg, opt) => {
if (typeof window.showMsg === 'function') return window.showMsg(msg, opt);
alert(msg);
};
const isEmail = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
function ensureHiddenRecaptcha(){
let el = document.getElementById('g-recaptcha-response');
if (!el) {
el = document.createElement('input');
el.type = 'hidden';
el.id = 'g-recaptcha-response';
el.name = 'g-recaptcha-response';
form.appendChild(el);
}
return el;
}
async function getRecaptchaToken(action){
// 1) 프로젝트 공통 함수 우선
if (typeof window.recaptchaV3Exec === 'function') {
try {
const t = await window.recaptchaV3Exec(action);
return t || '';
} catch (e) {
return '';
}
}
// 2) fallback: grecaptcha.execute
const siteKey = window.__recaptchaSiteKey || '';
if (!siteKey) return '';
if (typeof window.grecaptcha === 'undefined') return '';
try {
await new Promise(r => window.grecaptcha.ready(r));
const t = await window.grecaptcha.execute(siteKey, { action });
return t || '';
} catch (e) {
return '';
}
}
form.addEventListener('submit', async function (e) {
e.preventDefault();
const email = (emailEl?.value || '').trim();
const pw = (pwEl?.value || '');
if (!email) {
await showMsgSafe('아이디(이메일)를 입력해 주세요.', { type: 'alert', title: '폼체크' });
emailEl?.focus();
return;
}
if (!isEmail(email)) {
await showMsgSafe('아이디는 이메일 형식이어야 합니다.', { type: 'alert', title: '폼체크' });
emailEl?.focus();
return;
}
if (!pw) {
await showMsgSafe('비밀번호를 입력해 주세요.', { type: 'alert', title: '폼체크' });
pwEl?.focus();
return;
}
if (btn) btn.disabled = true;
try {
const isProd = @json(app()->environment('production'));
const hasKey = @json((bool) config('services.recaptcha.site_key'));
// ✅ 운영에서만 토큰 생성 (서버와 동일 정책)
if (isProd && hasKey) {
const hidden = ensureHiddenRecaptcha();
hidden.value = '';
const token = await getRecaptchaToken('admin_login');
hidden.value = token || '';
if (!hidden.value) {
if (btn) btn.disabled = false;
await showMsgSafe(
'보안 검증(reCAPTCHA) 토큰 생성에 실패했습니다. 새로고침 후 다시 시도해 주세요.',
{ type: 'alert', title: '보안검증 실패' }
);
return;
}
}
form.submit();
} catch (err) {
if (btn) btn.disabled = false;
await showMsgSafe('로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', { type: 'alert', title: '오류' });
}
});
// 서버 validation 에러가 있으면 한 번 띄우기(선택)
document.addEventListener('DOMContentLoaded', async () => {
const msg =
@json($errors->first('login_id') ?: $errors->first('password') ?: '');
if (msg) {
await showMsgSafe(msg, { type: 'alert', title: '로그인 실패' });
}
});
})();
</script>
@endpush

View File

@ -0,0 +1,44 @@
@extends('admin.layouts.auth')
@section('title','SMS 인증')
@section('heading', 'SMS 인증')
@section('subheading', '문자로 발송된 6자리 인증번호를 입력해 주세요. (유효시간 내)')
@section('content')
<form method="POST" action="{{ route('admin.otp.store') }}" class="a-form">
@csrf
<div class="a-otp-meta">
<div class="a-otp-meta__label">발송 대상</div>
<div class="a-otp-meta__value">{{ $masked_phone }}</div>
</div>
<div class="a-field">
<label class="a-label" for="otp">인증번호</label>
<input
class="a-input a-otp-input"
id="otp"
name="otp"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
placeholder="6자리 입력"
autofocus
>
@error('otp')
<div class="a-error">{{ $message }}</div>
@enderror
</div>
<button class="a-btn a-btn--primary" type="submit">
인증 완료
</button>
<div class="a-help a-otp-help">
<small class="a-muted">
인증번호가 오면 스팸함/차단 여부를 확인해 주세요.
</small>
</div>
</form>
@endsection

View File

@ -0,0 +1,29 @@
@extends('admin.layouts.auth')
@section('title','비밀번호 변경')
@section('heading','비밀번호 변경')
@section('subheading','초기 비밀번호입니다. 새 비밀번호를 설정해 주세요.')
@section('content')
<form method="POST" action="{{ route('admin.password.reset.store') }}" class="a-form">
@csrf
<div class="a-field">
<label class="a-label" for="password"> 비밀번호</label>
<input class="a-input" id="password" name="password" type="password" autocomplete="new-password" required>
@error('password') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label" for="password_confirmation"> 비밀번호 확인</label>
<input class="a-input" id="password_confirmation" name="password_confirmation" type="password" autocomplete="new-password" required>
@error('password_confirmation') <div class="a-error">{{ $message }}</div> @enderror
</div>
<button class="a-btn a-btn--primary" type="submit">변경하기</button>
<div class="a-help">
<small>권장: 10 이상, 영문/숫자/특수문자 조합</small>
</div>
</form>
@endsection

View File

@ -1,20 +1,117 @@
<!doctype html> @extends('admin.layouts.app')
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>관리자 </title>
</head>
<body style="font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; max-width: 640px; margin: 60px auto;">
<h2>관리자 </h2>
<p> @section('title', '대시보드')
로그인 사용자: {{ auth('admin')->user()->nickname }} ({{ auth('admin')->user()->email }})
</p>
<form method="POST" action="{{ route('admin.logout') }}"> @section('content')
@csrf <section class="a-dash">
<button type="submit">로그아웃</button>
</form> {{-- KPI --}}
</body> <div class="a-grid a-grid--kpi">
</html> <article class="a-card">
<div class="a-card__label a-muted">오늘 신규 가입</div>
<div class="a-card__value">1,284</div>
<div class="a-card__meta a-muted">전일 대비 +12%</div>
</article>
<article class="a-card">
<div class="a-card__label a-muted">오늘 로그인</div>
<div class="a-card__value">9,102</div>
<div class="a-card__meta a-muted">실패/차단 132</div>
</article>
<article class="a-card">
<div class="a-card__label a-muted">미처리 문의</div>
<div class="a-card__value">37</div>
<div class="a-card__meta a-muted">신규 11 · 처리중 26</div>
</article>
<article class="a-card a-card--accent">
<div class="a-card__label a-muted">오늘 매출</div>
<div class="a-card__value"> 38,420,000</div>
<div class="a-card__meta a-muted">거래 412</div>
</article>
</div>
{{-- Charts Row --}}
<div class="a-grid a-grid--charts">
<article class="a-card a-card--lg">
<div class="a-card__head">
<div>
<div class="a-card__title">매출 추이</div>
<div class="a-card__desc a-muted">최근 14 기준</div>
</div>
<div class="a-chip">Daily</div>
</div>
<div class="a-chart" data-chart="sales">
{{-- 차트 라이브러리 붙이면 canvas/slot 넣기 --}}
<div class="a-chart__placeholder a-muted">Chart 영역 (sales)</div>
</div>
</article>
<article class="a-card a-card--lg">
<div class="a-card__head">
<div>
<div class="a-card__title">가입/로그인 흐름</div>
<div class="a-card__desc a-muted">가입·로그인·실패</div>
</div>
<div class="a-chip">Realtime</div>
</div>
<div class="a-chart" data-chart="flow">
<div class="a-chart__placeholder a-muted">Chart 영역 (flow)</div>
</div>
</article>
</div>
{{-- Tables Row --}}
<div class="a-grid a-grid--tables">
<article class="a-card">
<div class="a-card__head">
<div>
<div class="a-card__title">최근 문의 접수</div>
<div class="a-card__desc a-muted">최신 10</div>
</div>
<a class="a-link" href="#">전체보기</a>
</div>
<div class="a-table">
<div class="a-tr a-th">
<div>시간</div><div>유형</div><div>제목</div><div>상태</div>
</div>
@for($i=0;$i<6;$i++)
<div class="a-tr">
<div class="a-muted">18:2{{ $i }}</div>
<div>1:1</div>
<div class="a-ellipsis">핀번호 오류 문의</div>
<div><span class="a-pill a-pill--warn">접수</span></div>
</div>
@endfor
</div>
</article>
<article class="a-card">
<div class="a-card__head">
<div>
<div class="a-card__title">정산/출금 대기</div>
<div class="a-card__desc a-muted">리스크/지연 모니터링</div>
</div>
<a class="a-link" href="#">관리</a>
</div>
<div class="a-table">
<div class="a-tr a-th">
<div>요청일</div><div>회원</div><div>금액</div><div>상태</div>
</div>
@for($i=0;$i<6;$i++)
<div class="a-tr">
<div class="a-muted">02/0{{ $i+1 }}</div>
<div class="a-ellipsis">mem_{{ 1000+$i }}</div>
<div> {{ number_format(120000 + ($i*30000)) }}</div>
<div><span class="a-pill a-pill--info">대기</span></div>
</div>
@endfor
</div>
</article>
</div>
</section>
@endsection

View File

@ -0,0 +1,25 @@
@extends('admin.layouts.base')
@section('body_class', 'a-body a-app')
@section('body')
<div class="a-app__wrap">
<aside class="a-side" aria-label="Admin navigation">
@include('admin.partials.sidebar')
</aside>
<div class="a-main">
<header class="a-top">
@include('admin.partials.topbar')
</header>
<main class="a-content">
@yield('content')
</main>
<footer class="a-footer">
<small class="a-muted">© 2018 {{ config('app.name') }}. Admin only.</small>
</footer>
</div>
</div>
@endsection

View File

@ -0,0 +1,55 @@
@extends('admin.layouts.base')
@section('body_class', 'a-body a-auth')
@section('body')
<div class="a-auth-bg" aria-hidden="true"></div>
<main class="a-auth-shell">
<section class="a-auth-card" role="main">
<div class="a-auth-left">
<div class="a-brand">
<div class="a-brand__logoBox" aria-hidden="true">
<img
class="a-brand__logo"
src="{{ asset('assets/images/common/top_logo.png') }}"
alt=""
loading="lazy"
onerror="this.style.display='none'; this.parentElement.style.display='none';"
/>
</div>
<div class="a-brand__text">
<div class="a-brand__name">Pin For You Admin Login</div>
<div class="a-brand__sub">Secure console</div>
</div>
</div>
<div class="a-auth-left__copy">
<h1 class="a-h1">@yield('heading', '관리자 로그인')</h1>
<p class="a-muted">@yield('subheading', '허용된 IP에서만 접근 가능합니다. 로그인 후 SMS 인증을 진행합니다.')</p>
</div>
<div class="a-badges">
<span class="a-badge">IP Allowlist</span>
<span class="a-badge">2-Step (SMS)</span>
<span class="a-badge">Audit Ready</span>
</div>
</div>
<div class="a-auth-right">
@include('admin.partials.flash')
<div class="a-panel">
@yield('content')
</div>
<div class="a-foot">
<small class="a-muted">© 2018 {{ config('app.name') }}. Admin only.</small>
</div>
</div>
</section>
</main>
@endsection
@stack('recaptcha')

View File

@ -0,0 +1,33 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow,noarchive,nosnippet,noimageindex">
<meta name="googlebot" content="noindex,nofollow,noarchive,nosnippet">
<meta name="referrer" content="no-referrer">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', 'Admin') · {{ config('app.name') }}</title>
{{-- (선택) Pretendard를 이미 /assets/css/pretendard.css로 제공 중이면 재사용 --}}
<link rel="stylesheet" href="/assets/css/pretendard.css">
@vite(['resources/css/admin.css', 'resources/js/admin.js'])
@stack('head')
</head>
<body class="@yield('body_class', 'a-body')">
@yield('body')
@stack('scripts')
{{--@php 개발모드에서만 세션표시--}}
@env('local')
@include('admin.partials.dev_session_overlay')
@endenv
</body>
</html>

View File

@ -0,0 +1,204 @@
{{-- resources/views/admin/partials/dev_session_overlay.blade.php --}}
@php
// ✅ 개발 모드에서만 노출
$show = (config('app.debug') || app()->environment('local'));
// ✅ 관리자 도메인에서만 노출(실수로 web 도메인에 붙는 것 방지)
$adminHost = parse_url(env('APP_ADMIN_URL'), PHP_URL_HOST);
if ($adminHost) {
$show = $show && (request()->getHost() === $adminHost);
}
// ✅ 세션 전체
$sess = session()->all();
// ✅ admin은 기본 마스킹을 켜는 게 안전 (원하면 비워도 됨)
$maskKeys = [
'password','passwd','pw',
'token','access_token','refresh_token',
'api_key','secret','authorization',
'csrf','_token',
'g-recaptcha-response','recaptcha',
'otp','admin_otp',
'ci','di','phone','mobile','email',
'session','cookie',
];
$mask = function ($key, $val) use ($maskKeys) {
$k = strtolower((string)$key);
foreach ($maskKeys as $mk) {
if (str_contains($k, $mk)) return '***';
}
return $val;
};
// ✅ key:value 라인 생성(재귀)
$lines = [];
$dump = function ($data, $prefix = '') use (&$dump, &$lines, $mask) {
foreach ((array)$data as $k => $v) {
$key = $prefix . (string)$k;
if (is_array($v)) {
$lines[] = $key . ' : [';
$dump($v, $prefix . ' ');
$lines[] = $prefix . ']';
} else {
if (is_bool($v)) $v = $v ? 'true' : 'false';
if ($v === null) $v = 'null';
$vv = is_string($v) ? (mb_strlen($v) > 260 ? mb_substr($v, 0, 260) . '…' : $v) : (string)$v;
$vv = $mask($k, $vv);
$lines[] = $key . ' : ' . $vv;
}
}
};
$dump($sess);
$text = implode("\n", $lines);
// ✅ dev route 이름 (너가 만든 이름에 맞춰 사용)
// - 내가 권장했던 방식이면 admin.dev.session
$devRouteName = 'admin.dev.session';
@endphp
@if($show)
<div id="dev-session-overlay" style="
position: fixed; left: 12px; bottom: 12px; z-index: 999999;
width: 620px; max-width: calc(100vw - 24px);
background: rgba(10,10,10,.92); color: #eaeaea;
border: 1px solid rgba(255,255,255,.14);
border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.35);
font: 12px/1.35 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
">
<div style="display:flex; align-items:center; justify-content:space-between; padding:10px 12px; gap:8px;">
<div style="font-weight:800;">
ADMIN SESSION
<span style="opacity:.65; font-weight:500;">({{ count($sess) }} keys)</span>
</div>
<div style="display:flex; gap:6px;">
<button type="button" id="devSessCopy" style="
border: 1px solid rgba(255,255,255,.18); background: rgba(255,255,255,.06);
color:#fff; padding:5px 10px; border-radius:10px; cursor:pointer;
">Copy</button>
<button type="button" id="devSessToggle" style="
border: 1px solid rgba(255,255,255,.18); background: rgba(255,255,255,.06);
color:#fff; padding:5px 10px; border-radius:10px; cursor:pointer;
">Hide</button>
</div>
</div>
<div id="devSessBody" style="padding:0 12px 12px 12px;">
<div style="opacity:.7; margin-bottom:8px;">
{{ request()->method() }} {{ request()->path() }}
</div>
{{-- Controls --}}
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px;">
<form method="POST" action="{{ route($devRouteName) }}" style="display:flex; gap:6px; align-items:center; margin:0;">
@csrf
<input type="hidden" name="_dev_sess_action" value="flush">
<input type="hidden" name="_dev_return" value="{{ url()->full() }}">
@if(env('DEV_LAB_TOKEN'))
<input type="hidden" name="_dev_token" value="{{ env('DEV_LAB_TOKEN') }}">
@endif
<button type="submit" style="
border: 1px solid rgba(255,90,90,.35);
background: rgba(255,90,90,.14);
color:#fff; padding:6px 10px; border-radius:10px; cursor:pointer;
" onclick="return confirm('세션을 초기화할까요? (dev 전용)');">세션 초기화</button>
</form>
<form method="POST" action="{{ route($devRouteName) }}" style="display:flex; gap:6px; align-items:center; margin:0; flex:1;">
@csrf
<input type="hidden" name="_dev_sess_action" value="put">
<input type="hidden" name="_dev_return" value="{{ url()->full() }}">
@if(env('DEV_LAB_TOKEN'))
<input type="hidden" name="_dev_token" value="{{ env('DEV_LAB_TOKEN') }}">
@endif
<input name="_dev_sess_key" placeholder="key (예: admin_otp.expires_at)" style="
flex: 0 0 240px; max-width: 45%;
border: 1px solid rgba(255,255,255,.16);
background: rgba(255,255,255,.06);
color:#fff; padding:6px 8px; border-radius:10px; outline:none;
">
<input name="_dev_sess_value" placeholder="value (기본은 문자열, route에서 파싱 가능)" style="
flex: 1 1 auto;
border: 1px solid rgba(255,255,255,.16);
background: rgba(255,255,255,.06);
color:#fff; padding:6px 8px; border-radius:10px; outline:none;
">
<button type="submit" style="
border: 1px solid rgba(90,180,255,.35);
background: rgba(90,180,255,.14);
color:#fff; padding:6px 10px; border-radius:10px; cursor:pointer;
">등록</button>
</form>
</div>
<div style="
padding:10px; border-radius:12px;
background: rgba(255,255,255,.05);
max-height: 360px; overflow:auto; white-space: pre;
border: 1px solid rgba(255,255,255,.10);
">
<pre id="devSessPre" style="margin:0;">{!! e($text) !!}</pre>
</div>
</div>
</div>
<script>
(function(){
const box = document.getElementById('dev-session-overlay');
const body = document.getElementById('devSessBody');
const toggle = document.getElementById('devSessToggle');
const copyBtn = document.getElementById('devSessCopy');
const pre = document.getElementById('devSessPre');
// 상태 기억 (도메인별 분리)
const key = 'devSessOverlayCollapsed:' + location.host;
const collapsed = localStorage.getItem(key) === '1';
if (collapsed) { body.style.display='none'; toggle.textContent='Show'; }
toggle.addEventListener('click', () => {
const isHidden = body.style.display === 'none';
body.style.display = isHidden ? '' : 'none';
toggle.textContent = isHidden ? 'Hide' : 'Show';
localStorage.setItem(key, isHidden ? '0' : '1');
});
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(pre.innerText);
copyBtn.textContent = 'Copied';
setTimeout(()=>copyBtn.textContent='Copy', 800);
} catch(e) {
alert('복사 실패(브라우저 권한 확인)');
}
});
// 드래그 이동
let dragging=false, sx=0, sy=0, ox=0, oy=0;
box.firstElementChild.style.cursor = 'move';
box.firstElementChild.addEventListener('mousedown', (e)=>{
dragging=true; sx=e.clientX; sy=e.clientY;
const r = box.getBoundingClientRect();
ox=r.left; oy=r.top;
box.style.right='auto'; box.style.bottom='auto';
document.body.style.userSelect='none';
});
window.addEventListener('mousemove', (e)=>{
if(!dragging) return;
const nx = ox + (e.clientX - sx);
const ny = oy + (e.clientY - sy);
box.style.left = Math.max(0, nx) + 'px';
box.style.top = Math.max(0, ny) + 'px';
});
window.addEventListener('mouseup', ()=>{
dragging=false;
document.body.style.userSelect='';
});
})();
</script>
@endif

View File

@ -0,0 +1,20 @@
@if ($errors->any())
<div class="a-alert a-alert--danger" role="alert">
<div class="a-alert__title">확인해 주세요</div>
<div class="a-alert__body">{{ $errors->first() }}</div>
</div>
@endif
@if (session('status'))
<div class="a-alert a-alert--info" role="status">
<div class="a-alert__title">안내</div>
<div class="a-alert__body">{{ session('status') }}</div>
</div>
@endif
@if (session('alert'))
<div class="a-alert a-alert--warn" role="alert">
<div class="a-alert__title">알림</div>
<div class="a-alert__body">{{ session('alert') }}</div>
</div>
@endif

View File

@ -0,0 +1,118 @@
@php
$menu = [
[
'title' => '대시보드',
'items' => [
['label' => '대시보드', 'route' => 'admin.home'],
],
],
[
'title' => '콘솔 관리',
'items' => [
['label' => '내 정보', 'route' => 'admin.me'], // 추후 라우트
['label' => '관리자 계정 관리', 'route' => 'admin.admins.index'],
['label' => '권한/역할 관리', 'route' => 'admin.roles.index'],
['label' => '접근 IP 허용목록', 'route' => 'admin.allowip.index'],
],
],
[
'title' => '알림/메시지',
'items' => [
['label' => '관리자 SMS 발송', 'route' => 'admin.sms.send'],
['label' => 'SMS 발송 이력', 'route' => 'admin.sms.logs'],
['label' => '알림 템플릿', 'route' => 'admin.templates.index'],
],
],
[
'title' => '고객지원',
'items' => [
['label' => '공지사항', 'route' => 'admin.notice.index'],
['label' => '1:1 문의', 'route' => 'admin.inquiry.index'],
['label' => 'FAQ 코드 관리', 'route' => 'admin.faqcodes.index'],
['label' => 'QnA 코드 관리', 'route' => 'admin.qnacodes.index'],
],
],
[
'title' => '상품권 관리',
'items' => [
['label' => '상품 리스트', 'route' => 'admin.products.index'],
['label' => '상품 등록', 'route' => 'admin.products.create'],
['label' => '판매 코드 관리', 'route' => 'admin.sale-codes.index'],
['label' => '핀 번호 관리', 'route' => 'admin.pins.index'],
['label' => '메인 노출 관리', 'route' => 'admin.exposure.index'],
['label' => '결제 수수료/정책', 'route' => 'admin.fees.index'],
],
],
[
'title' => '매입/정산',
'items' => [
['label' => '핀 매입 현황(출금)', 'route' => 'admin.buyback.index'],
['label' => '출금 요청 관리', 'route' => 'admin.withdraw.index'],
['label' => '정산 리포트', 'route' => 'admin.settlement.index'],
],
],
[
'title' => '거래/매출',
'items' => [
['label' => '상품권 거래 장부', 'route' => 'admin.ledger.index'],
['label' => '매출 리포트', 'route' => 'admin.sales.index'],
['label' => '환불/취소 내역', 'route' => 'admin.refunds.index'],
],
],
[
'title' => '회원/정책',
'items' => [
['label' => '회원 관리', 'route' => 'admin.members.index'],
['label' => '회원가입 필터 설정', 'route' => 'admin.signup-filter.index'],
['label' => '블랙리스트/제재', 'route' => 'admin.sanctions.index'],
['label' => '마케팅 수신동의', 'route' => 'admin.marketing.index'],
],
],
[
'title' => '시스템 로그',
'items' => [
['label' => '로그인 로그', 'route' => 'admin.logs.login'],
['label' => '다날 인증 로그', 'route' => 'admin.logs.danal'],
['label' => '결제 로그', 'route' => 'admin.logs.pay'],
['label' => '기타 로그', 'route' => 'admin.logs.misc'],
['label' => '관리자 활동 로그', 'route' => 'admin.logs.audit'],
],
],
];
@endphp
<div class="a-side__brand">
<div class="a-side__logo">A</div>
<div class="a-side__brandText">
<div class="a-side__brandName">Admin</div>
<div class="a-side__brandSub">Pin For You</div>
</div>
</div>
<nav class="a-nav">
@foreach($menu as $group)
<div class="a-nav__group">
<div class="a-nav__title">{{ $group['title'] }}</div>
@foreach($group['items'] as $it)
@php
$isActive = \Illuminate\Support\Facades\Route::has($it['route'])
? request()->routeIs($it['route'])
: false;
@endphp
@if(\Illuminate\Support\Facades\Route::has($it['route']))
<a class="a-nav__item {{ $isActive ? 'is-active' : '' }}" href="{{ route($it['route']) }}">
<span class="a-nav__dot" aria-hidden="true"></span>
<span class="a-nav__label">{{ $it['label'] }}</span>
</a>
@else
<span class="a-nav__item is-disabled" title="준비중">
<span class="a-nav__dot" aria-hidden="true"></span>
<span class="a-nav__label">{{ $it['label'] }}</span>
</span>
@endif
@endforeach
</div>
@endforeach
</nav>

View File

@ -0,0 +1,27 @@
<div class="a-top__left">
<div class="a-top__title">
<div class="a-top__h">대시보드</div>
<div class="a-top__sub a-muted">가입/로그인/문의/매출 흐름을 한눈에 확인</div>
</div>
</div>
<div class="a-top__right">
<form class="a-top__search" action="#" method="get" onsubmit="return false;">
<input class="a-top__searchInput" placeholder="검색 (회원/주문/핀/문의)" />
</form>
<div class="a-top__user">
<div class="a-top__avatar" aria-hidden="true">
{{ mb_substr(auth('admin')->user()->name ?? 'A', 0, 1) }}
</div>
<div class="a-top__userText">
<div class="a-top__userName">{{ auth('admin')->user()->name ?? 'Admin' }}</div>
<div class="a-top__userMeta a-muted">{{ auth('admin')->user()->email ?? '' }}</div>
</div>
<form method="POST" action="{{ route('admin.logout') }}">
@csrf
<button class="a-top__logout" type="submit">로그아웃</button>
</form>
</div>
</div>

View File

@ -178,7 +178,6 @@
// 버튼 잠금 // 버튼 잠금
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
try { try {
// ✅ 운영에서만 recaptcha 토큰 넣기 (서버도 동일 정책) // ✅ 운영에서만 recaptcha 토큰 넣기 (서버도 동일 정책)
const isProd = @json(app()->environment('production')); const isProd = @json(app()->environment('production'));
@ -206,6 +205,7 @@
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
await showMsg("로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", { type: 'alert', title: '오류' }); await showMsg("로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", { type: 'alert', title: '오류' });
} }
}); });
// 서버에서 내려온 로그인 실패 메시지 표시 // 서버에서 내려온 로그인 실패 메시지 표시

View File

@ -1,23 +1,46 @@
<?php <?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Admin\Auth\AdminAuthController; use App\Http\Controllers\Admin\Auth\AdminAuthController;
use Illuminate\Support\Facades\Route;
Route::middleware(['web'])->group(function () {
// ✅ 로그인/OTP/비번초기화는 guest:admin 만 접근
Route::middleware('guest:admin')->group(function () { Route::middleware('guest:admin')->group(function () {
Route::get('/login', [AdminAuthController::class, 'create'])
->name('admin.login');
Route::post('/login', [AdminAuthController::class, 'store']) Route::get('/login', [AdminAuthController::class, 'showLogin'])
->name('admin.login.form');
Route::post('/login', [AdminAuthController::class, 'storeLogin'])
->middleware('throttle:admin-login')
->name('admin.login.store'); ->name('admin.login.store');
Route::get('/password/reset', [AdminAuthController::class, 'showForceReset'])
->name('admin.password.reset.form');
Route::post('/password/reset', [AdminAuthController::class, 'storeForceReset'])
->middleware('throttle:admin-login')
->name('admin.password.reset.store');
Route::get('/otp', [AdminAuthController::class, 'showOtp'])
->name('admin.otp.form');
Route::post('/otp', [AdminAuthController::class, 'verifyOtp'])
->middleware('throttle:admin-otp')
->name('admin.otp.store');
}); });
// ✅ 로그인 이후
Route::middleware('auth:admin')->group(function () { Route::middleware('auth:admin')->group(function () {
Route::get('/', function () { Route::get('/', fn() => view('admin.home'))->name('admin.home');
return view('admin.home');
})->name('admin.home');
Route::post('/logout', [AdminAuthController::class, 'destroy']) Route::post('/logout', [AdminAuthController::class, 'logout'])
->name('admin.logout'); ->name('admin.logout');
}); });
});
Route::fallback(fn () => abort(404)); /* 개발용 페이지 세션 보기 */
if (config('app.debug') || app()->environment('local')) {
require __DIR__.'/dev_admin.php';
}
/* 개발용 페이지 세션 보기 */

View File

@ -147,27 +147,27 @@ $isDev = in_array($env, ['local', 'development', 'dev', 'testing'], true);
if ($isDev) { if ($isDev) {
// "스케줄러가 실제로 돌아가는지" 확인용 - 운영에서는 불필요 // "스케줄러가 실제로 돌아가는지" 확인용 - 운영에서는 불필요
Schedule::call(function () { // Schedule::call(function () {
Log::info('[schedule-dev] alive '.now()); // Log::info('[schedule-dev] alive '.now());
}) // })
->everyMinute() // ->everyMinute()
->name('dev_scheduler_alive') // ->name('dev_scheduler_alive')
->withoutOverlapping(); // ->withoutOverlapping();
//
// 예시: 5분마다 실행 // // 예시: 5분마다 실행
registerScheduleCron('every_5m_log', '*/5 * * * *', function () { // registerScheduleCron('every_5m_log', '*/5 * * * *', function () {
Log::info('[job-dev] every 5 minutes '.now()); // Log::info('[job-dev] every 5 minutes '.now());
}); // });
//
// 예시: 매일 새벽 2시 10분 // // 예시: 매일 새벽 2시 10분
registerScheduleCron('every_day_0210_log', '10 2 * * *', function () { // registerScheduleCron('every_day_0210_log', '10 2 * * *', function () {
Log::info('[job-dev] daily 02:10 '.now()); // Log::info('[job-dev] daily 02:10 '.now());
}); // });
//
// 예시: 매월 1일 03:30 // // 예시: 매월 1일 03:30
registerScheduleCron('monthly_1st_0330', '30 3 1 * *', function () { // registerScheduleCron('monthly_1st_0330', '30 3 1 * *', function () {
Log::info('[job-dev] monthly 1st 03:30 '.now()); // Log::info('[job-dev] monthly 1st 03:30 '.now());
}); // });
} }

57
routes/dev_admin.php Normal file
View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('_dev/session', function (Request $request) {
// ✅ local / debug에서만 허용
abort_unless(config('app.debug') || app()->environment('local'), 404);
// ✅ (권장) POST에 간단 토큰 체크 (실수로 노출 방지)
// .env: DEV_LAB_TOKEN="something"
$token = (string) $request->input('_dev_token', '');
abort_unless($token !== '' && hash_equals((string) env('DEV_LAB_TOKEN', ''), $token), 404);
$action = (string) $request->input('_dev_sess_action', '');
$parse = function (string $raw) {
$s = trim($raw);
$lower = strtolower($s);
if ($lower === 'true') return true;
if ($lower === 'false') return false;
if ($lower === 'null') return null;
if (preg_match('/^-?\d+$/', $s)) {
$int = (int) $s;
if ((string) $int === $s) return $int;
}
if (preg_match('/^-?\d+\.\d+$/', $s)) {
return (float) $s;
}
if ($s !== '' && (str_starts_with($s, '{') || str_starts_with($s, '['))) {
$j = json_decode($s, true);
if (json_last_error() === JSON_ERROR_NONE) return $j;
}
return $raw;
};
if ($action === 'flush') {
session()->flush();
session()->save();
} elseif ($action === 'put') {
$k = trim((string) $request->input('_dev_sess_key', ''));
$raw = (string) $request->input('_dev_sess_value', '');
if ($k !== '') {
session()->put($k, $parse($raw));
session()->save();
}
}
return redirect()->to((string) $request->input('_dev_return', '/'));
})->name('admin.dev.session');

60
routes/dev_web.php Normal file
View File

@ -0,0 +1,60 @@
<?php
/* 개발용 페이지 세션 보기*/
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('_dev/session', function (Request $request) {
abort_unless(config('app.debug') || app()->environment('local'), 404);
$action = (string) $request->input('_dev_sess_action', '');
// ✅ 자동 타입 변환(익명 함수라 재선언 문제 없음)
$parse = function (string $raw) {
$s = trim($raw);
$lower = strtolower($s);
if ($lower === 'true') return true;
if ($lower === 'false') return false;
if ($lower === 'null') return null;
// 정수
if (preg_match('/^-?\d+$/', $s)) {
// 앞자리 0이 있는 값(예: 00123)은 문자열 유지하고 싶으면 아래 조건 추가
// if (strlen($s) > 1 && $s[0] === '0') return $raw;
$int = (int) $s;
if ((string)$int === $s) return $int;
}
// 실수
if (preg_match('/^-?\d+\.\d+$/', $s)) {
return (float) $s;
}
// JSON
if ($s !== '' && (str_starts_with($s, '{') || str_starts_with($s, '['))) {
$j = json_decode($s, true);
if (json_last_error() === JSON_ERROR_NONE) return $j;
}
return $raw;
};
if ($action === 'flush') {
session()->flush();
session()->save();
} elseif ($action === 'put') {
$k = trim((string) $request->input('_dev_sess_key', ''));
$raw = (string) $request->input('_dev_sess_value', '');
if ($k !== '') {
session()->put($k, $parse($raw));
session()->save();
}
}
return redirect()->to((string) $request->input('_dev_return', '/'));
})->name('dev.session');
/* 개발용 페이지 세션 보기*/

13
routes/domains.php Normal file
View File

@ -0,0 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
// web 도메인
Route::middleware('web')
->domain((string) config('app.web_domain'))
->group(base_path('routes/web.php'));
// admin 도메인
Route::middleware(['web', 'admin.ip'])
->domain((string) config('app.admin_domain'))
->group(base_path('routes/admin.php'));

View File

@ -147,92 +147,11 @@ Route::prefix('auth')->name('web.auth.')->group(function () {
Route::get('/login', fn() => redirect()->route('web.auth.login'))->name('web.login'); Route::get('/login', fn() => redirect()->route('web.auth.login'))->name('web.login');
Route::get('/register', fn() => redirect()->route('web.auth.register'))->name('web.signup'); Route::get('/register', fn() => redirect()->route('web.auth.register'))->name('web.signup');
/* /*디버깅용 세션 개발*/
|-------------------------------------------------------------------------- if (config('app.debug') || app()->environment('local')) {
| Dev Lab (로컬에서만 + 파일 존재할 때만 라우트 등록) require __DIR__.'/dev_web.php';
|--------------------------------------------------------------------------
*/
if (app()->environment(['local', 'development', 'testing'])
&& class_exists(\App\Http\Controllers\Dev\DevLabController::class)) {
Route::prefix('__dev')->name('dev.')->group(function () {
Route::get('/lab', [\App\Http\Controllers\Dev\DevLabController::class, 'index'])->name('lab');
Route::post('/lab/mail', [\App\Http\Controllers\Dev\DevLabController::class, 'mail'])->name('lab.mail');
Route::post('/lab/mail-raw', [\App\Http\Controllers\Dev\DevLabController::class, 'mailRaw'])->name('lab.mail_raw');
Route::post('/lab/sms', [\App\Http\Controllers\Dev\DevLabController::class, 'sms'])->name('lab.sms');
Route::post('/lab/db', [\App\Http\Controllers\Dev\DevLabController::class, 'db'])->name('lab.db');
Route::post('/lab/session/set', [\App\Http\Controllers\Dev\DevLabController::class, 'sessionSet'])->name('lab.session.set');
Route::post('/lab/session/get', [\App\Http\Controllers\Dev\DevLabController::class, 'sessionGet'])->name('lab.session.get');
Route::post('/lab/session/forget', [\App\Http\Controllers\Dev\DevLabController::class, 'sessionForget'])->name('lab.session.forget');
Route::post('/lab/session/flush', [\App\Http\Controllers\Dev\DevLabController::class, 'sessionFlush'])->name('lab.session.flush');
});
} }
/* 개발용 페이지 세션 보기*/
use Illuminate\Http\Request;
Route::post('_dev/session', function (Request $request) {
abort_unless(config('app.debug') || app()->environment('local'), 404);
$action = (string) $request->input('_dev_sess_action', '');
// ✅ 자동 타입 변환(익명 함수라 재선언 문제 없음)
$parse = function (string $raw) {
$s = trim($raw);
$lower = strtolower($s);
if ($lower === 'true') return true;
if ($lower === 'false') return false;
if ($lower === 'null') return null;
// 정수
if (preg_match('/^-?\d+$/', $s)) {
// 앞자리 0이 있는 값(예: 00123)은 문자열 유지하고 싶으면 아래 조건 추가
// if (strlen($s) > 1 && $s[0] === '0') return $raw;
$int = (int) $s;
if ((string)$int === $s) return $int;
}
// 실수
if (preg_match('/^-?\d+\.\d+$/', $s)) {
return (float) $s;
}
// JSON
if ($s !== '' && (str_starts_with($s, '{') || str_starts_with($s, '['))) {
$j = json_decode($s, true);
if (json_last_error() === JSON_ERROR_NONE) return $j;
}
return $raw;
};
if ($action === 'flush') {
session()->flush();
session()->save();
} elseif ($action === 'put') {
$k = trim((string) $request->input('_dev_sess_key', ''));
$raw = (string) $request->input('_dev_sess_value', '');
if ($k !== '') {
session()->put($k, $parse($raw));
session()->save();
}
}
return redirect()->to((string) $request->input('_dev_return', '/'));
})->name('dev.session');
/* 개발용 페이지 세션 보기*/
Route::fallback(fn () => abort(404)); Route::fallback(fn () => abort(404));