관리자 로그인
This commit is contained in:
parent
494d95327a
commit
0010cc69be
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
app/Http/Middleware/AdminIpAllowlist.php
Normal file
52
app/Http/Middleware/AdminIpAllowlist.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Middleware/TrustedHostsFromConfig.php
Normal file
32
app/Http/Middleware/TrustedHostsFromConfig.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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',
|
||||||
];
|
];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()),
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
app/Repositories/Admin/AdminUserRepository.php
Normal file
38
app/Repositories/Admin/AdminUserRepository.php
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
247
app/Services/Admin/AdminAuthService.php
Normal file
247
app/Services/Admin/AdminAuthService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/Services/Admin/AdminOtpService.php
Normal file
99
app/Services/Admin/AdminOtpService.php
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
19
config/admin.php
Normal 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:'),
|
||||||
|
];
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
132
database/seeders/AdminRbacSeeder.php
Normal file
132
database/seeders/AdminRbacSeeder.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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',
|
||||||
|
|||||||
75
docs/ADMIN/ADMIN_AUTH_PLAN.md
Normal file
75
docs/ADMIN/ADMIN_AUTH_PLAN.md
Normal 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:
|
||||||
@ -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) 동일 패턴으로 적용 가능.
|
||||||
|
|||||||
313
docs/ops/runtime-checklist-and-troubleshooting.md
Normal file
313
docs/ops/runtime-checklist-and-troubleshooting.md
Normal 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
|
||||||
|
```
|
||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = '처리 중...';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
44
resources/views/admin/auth/otp.blade.php
Normal file
44
resources/views/admin/auth/otp.blade.php
Normal 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
|
||||||
29
resources/views/admin/auth/password_reset.blade.php
Normal file
29
resources/views/admin/auth/password_reset.blade.php
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
25
resources/views/admin/layouts/app.blade.php
Normal file
25
resources/views/admin/layouts/app.blade.php
Normal 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
|
||||||
55
resources/views/admin/layouts/auth.blade.php
Normal file
55
resources/views/admin/layouts/auth.blade.php
Normal 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')
|
||||||
33
resources/views/admin/layouts/base.blade.php
Normal file
33
resources/views/admin/layouts/base.blade.php
Normal 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>
|
||||||
204
resources/views/admin/partials/dev_session_overlay.blade.php
Normal file
204
resources/views/admin/partials/dev_session_overlay.blade.php
Normal 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
|
||||||
20
resources/views/admin/partials/flash.blade.php
Normal file
20
resources/views/admin/partials/flash.blade.php
Normal 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
|
||||||
118
resources/views/admin/partials/sidebar.blade.php
Normal file
118
resources/views/admin/partials/sidebar.blade.php
Normal 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>
|
||||||
27
resources/views/admin/partials/topbar.blade.php
Normal file
27
resources/views/admin/partials/topbar.blade.php
Normal 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>
|
||||||
@ -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: '오류' });
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 서버에서 내려온 로그인 실패 메시지 표시
|
// 서버에서 내려온 로그인 실패 메시지 표시
|
||||||
|
|||||||
@ -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';
|
||||||
|
}
|
||||||
|
/* 개발용 페이지 세션 보기 */
|
||||||
|
|||||||
@ -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
57
routes/dev_admin.php
Normal 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
60
routes/dev_web.php
Normal 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
13
routes/domains.php
Normal 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'));
|
||||||
@ -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));
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user