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');