From 7bea6f2debeca9a4bf9dd32e1053cbc174fafc27 Mon Sep 17 00:00:00 2001 From: kent Date: Sat, 20 Dec 2025 13:43:04 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20MNG=20=E2=86=92=20DEV=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - login_tokens 테이블 마이그레이션 생성 - LoginToken 모델 생성 (One-Time Token 관리) - POST /api/v1/token-login 엔드포인트 추가 - 토큰 검증 후 access_token 발급, 1회용 토큰 삭제 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Http/Controllers/Api/V1/ApiController.php | 42 +++++++++ app/Models/LoginToken.php | 89 +++++++++++++++++++ ...12_20_132721_create_login_tokens_table.php | 42 +++++++++ routes/api.php | 1 + 4 files changed, 174 insertions(+) create mode 100644 app/Models/LoginToken.php create mode 100644 database/migrations/2025_12_20_132721_create_login_tokens_table.php diff --git a/app/Http/Controllers/Api/V1/ApiController.php b/app/Http/Controllers/Api/V1/ApiController.php index 9cf18c5..ba8df58 100644 --- a/app/Http/Controllers/Api/V1/ApiController.php +++ b/app/Http/Controllers/Api/V1/ApiController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; +use App\Models\LoginToken; use App\Models\Members\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -110,4 +111,45 @@ public function signup(Request $request) return ['user' => $user->only(['id', 'user_id', 'name', 'email', 'phone'])]; }); } + + /** + * One-Time Token을 사용한 자동 로그인 (MNG → DEV) + */ + public function tokenLogin(Request $request) + { + $token = $request->input('token'); + + if (! $token) { + return response()->json(['error' => '토큰이 필요합니다.'], 400); + } + + // 토큰 검증 + $loginToken = LoginToken::findValidToken($token); + + if (! $loginToken) { + return response()->json(['error' => '유효하지 않거나 만료된 토큰입니다.'], 401); + } + + // 토큰 사용 (1회용 - 사용 후 삭제) + $user = $loginToken->consume(); + + // 액세스 + 리프레시 토큰 발급 + $tokens = \App\Services\AuthService::issueTokens($user); + + // 사용자 정보 조회 (테넌트 + 메뉴 포함) + $loginInfo = \App\Services\MemberService::getUserInfoForLogin($user->id); + + return response()->json([ + 'message' => '로그인 성공', + 'access_token' => $tokens['access_token'], + 'refresh_token' => $tokens['refresh_token'], + 'token_type' => $tokens['token_type'], + 'expires_in' => $tokens['expires_in'], + 'expires_at' => $tokens['expires_at'], + 'user' => $loginInfo['user'], + 'tenant' => $loginInfo['tenant'], + 'menus' => $loginInfo['menus'], + 'roles' => $loginInfo['roles'], + ]); + } } diff --git a/app/Models/LoginToken.php b/app/Models/LoginToken.php new file mode 100644 index 0000000..afea3af --- /dev/null +++ b/app/Models/LoginToken.php @@ -0,0 +1,89 @@ + 'datetime', + ]; + + /** + * 토큰 만료 시간 (분) + */ + public const EXPIRES_IN_MINUTES = 5; + + /** + * 사용자 관계 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 새 토큰 생성 + */ + public static function createForUser(int $userId): self + { + // 기존 토큰 삭제 (해당 사용자) + self::where('user_id', $userId)->delete(); + + return self::create([ + 'user_id' => $userId, + 'token' => Str::random(64), + 'expires_at' => now()->addMinutes(self::EXPIRES_IN_MINUTES), + ]); + } + + /** + * 토큰으로 조회 및 검증 + */ + public static function findValidToken(string $token): ?self + { + return self::where('token', $token) + ->where('expires_at', '>', now()) + ->first(); + } + + /** + * 만료 여부 확인 + */ + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + /** + * 토큰 사용 (1회용 - 사용 후 삭제) + */ + public function consume(): User + { + $user = $this->user; + $this->delete(); + + return $user; + } + + /** + * 만료된 토큰 정리 + */ + public static function cleanupExpired(): int + { + return self::where('expires_at', '<', now())->delete(); + } +} diff --git a/database/migrations/2025_12_20_132721_create_login_tokens_table.php b/database/migrations/2025_12_20_132721_create_login_tokens_table.php new file mode 100644 index 0000000..e93e016 --- /dev/null +++ b/database/migrations/2025_12_20_132721_create_login_tokens_table.php @@ -0,0 +1,42 @@ +id(); + $table->unsignedBigInteger('user_id')->comment('사용자 ID'); + $table->string('token', 64)->unique()->comment('One-Time Token (64자)'); + $table->timestamp('expires_at')->comment('만료 시간'); + $table->timestamps(); + + // 인덱스 + $table->index('token'); + $table->index('expires_at'); + + // 외래키 (users 테이블과 연결) + $table->foreign('user_id') + ->references('id') + ->on('users') + ->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('login_tokens'); + } +}; diff --git a/routes/api.php b/routes/api.php index 65d30df..71ab878 100644 --- a/routes/api.php +++ b/routes/api.php @@ -107,6 +107,7 @@ Route::post('login', [ApiController::class, 'login'])->name('v1.users.login'); Route::middleware('auth:sanctum')->post('logout', [ApiController::class, 'logout'])->name('v1.users.logout'); Route::post('signup', [ApiController::class, 'signup'])->name('v1.users.signup'); + Route::post('token-login', [ApiController::class, 'tokenLogin'])->name('v1.auth.token-login'); // MNG → DEV 자동 로그인 Route::post('refresh', [RefreshController::class, 'refresh'])->name('v1.token.refresh'); Route::post('register', [RegisterController::class, 'register'])->name('v1.register');