feat: API 토큰 관리 시스템 구현 (액세스/리프레시 토큰 분리)
- AuthService: 토큰 발급/갱신 통합 관리 - RefreshController: POST /api/v1/refresh 엔드포인트 추가 - 액세스 토큰 2시간, 리프레시 토큰 7일 (.env 설정) - TOKEN_EXPIRED 에러 코드로 프론트엔드 자동 리프레시 지원 - 리프레시 토큰 일회성 사용 (보안 강화) - Swagger 문서 Auth 태그로 통합
This commit is contained in:
328
CURRENT_WORKS.md
328
CURRENT_WORKS.md
@@ -1,5 +1,333 @@
|
||||
# SAM API 저장소 작업 현황
|
||||
|
||||
## 2025-11-10 (일) - API 토큰 관리 시스템 구현 (액세스/리프레시 토큰 분리)
|
||||
|
||||
### 주요 작업
|
||||
- **액세스/리프레시 토큰 분리**: 액세스 토큰(2시간), 리프레시 토큰(7일) 독립 관리
|
||||
- **환경별 설정**: .env 기반 토큰 만료 시간 설정 (설정 없으면 무제한)
|
||||
- **토큰 갱신 엔드포인트**: POST /api/v1/refresh (리프레시 토큰으로 새 토큰 발급)
|
||||
- **보안 강화**: 리프레시 토큰 일회성 사용, 사용자당 1개 리프레시 토큰만 유지
|
||||
- **에러 처리**: TOKEN_EXPIRED 에러 코드로 프론트엔드 자동 리프레시 지원
|
||||
|
||||
### 추가된 파일:
|
||||
- `app/Services/AuthService.php` - 토큰 발급/갱신 통합 서비스 (119줄)
|
||||
- `app/Http/Controllers/Api/V1/RefreshController.php` - 토큰 갱신 컨트롤러 (32줄)
|
||||
- `app/Http/Requests/Api/V1/RefreshRequest.php` - 리프레시 토큰 검증 (22줄)
|
||||
- `app/Swagger/v1/RefreshApi.php` - 토큰 갱신 API 문서 (69줄)
|
||||
|
||||
### 수정된 파일:
|
||||
- `.env` - 토큰 만료 설정 추가 (ACCESS: 120분, REFRESH: 10080분)
|
||||
- `config/sanctum.php` - 토큰 만료 설정 키 추가
|
||||
- `app/Http/Controllers/Api/V1/ApiController.php` - 로그인 시 AuthService 사용
|
||||
- `app/Exceptions/Handler.php` - 토큰 만료 에러 처리 (TOKEN_EXPIRED)
|
||||
- `app/Http/Middleware/ApiKeyMiddleware.php` - refresh 라우트 화이트리스트 추가
|
||||
- `app/Swagger/v1/AuthApi.php` - 로그인 응답에 토큰 필드 추가
|
||||
- `lang/ko/error.php` - 토큰 관련 에러 메시지 4개 추가
|
||||
- `lang/ko/message.php` - token_refreshed 메시지 추가
|
||||
- `routes/api.php` - POST /api/v1/refresh 라우트 추가
|
||||
|
||||
### 작업 내용:
|
||||
|
||||
#### 1. AuthService 구현
|
||||
|
||||
**토큰 발급 (issueTokens):**
|
||||
```php
|
||||
public static function issueTokens(User $user): array
|
||||
{
|
||||
// 기존 리프레시 토큰 삭제 (한 사용자당 하나만 유지)
|
||||
$user->tokens()->where('name', 'refresh-token')->delete();
|
||||
|
||||
// 액세스 토큰 만료 시간 (분 단위, null이면 무제한)
|
||||
$accessExpiration = Config::get('sanctum.access_token_expiration');
|
||||
$accessExpiration = $accessExpiration ? (int) $accessExpiration : null;
|
||||
$accessExpiresAt = $accessExpiration ? now()->addMinutes($accessExpiration) : null;
|
||||
|
||||
// 리프레시 토큰 만료 시간 (분 단위, null이면 무제한)
|
||||
$refreshExpiration = Config::get('sanctum.refresh_token_expiration');
|
||||
$refreshExpiration = $refreshExpiration ? (int) $refreshExpiration : null;
|
||||
$refreshExpiresAt = $refreshExpiration ? now()->addMinutes($refreshExpiration) : null;
|
||||
|
||||
// 액세스 토큰 생성
|
||||
$accessToken = $user->createToken('access-token', ['*'], $accessExpiresAt);
|
||||
|
||||
// 리프레시 토큰 생성
|
||||
$refreshToken = $user->createToken('refresh-token', ['refresh'], $refreshExpiresAt);
|
||||
|
||||
return [
|
||||
'access_token' => $accessToken->plainTextToken,
|
||||
'refresh_token' => $refreshToken->plainTextToken,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => $accessExpiration ? $accessExpiration * 60 : null,
|
||||
'expires_at' => $accessExpiresAt ? $accessExpiresAt->toDateTimeString() : null,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**토큰 갱신 (refreshTokens):**
|
||||
```php
|
||||
public static function refreshTokens(string $refreshToken): ?array
|
||||
{
|
||||
// 리프레시 토큰 검증
|
||||
$token = \Laravel\Sanctum\PersonalAccessToken::findToken($refreshToken);
|
||||
|
||||
if (!$token || $token->name !== 'refresh-token') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 만료 확인
|
||||
if ($token->expires_at && $token->expires_at->isPast()) {
|
||||
$token->delete();
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = $token->tokenable;
|
||||
|
||||
// 기존 리프레시 토큰 삭제 (사용 후 폐기)
|
||||
$token->delete();
|
||||
|
||||
// 새로운 액세스 + 리프레시 토큰 발급
|
||||
return self::issueTokens($user);
|
||||
}
|
||||
```
|
||||
|
||||
**핵심 특징:**
|
||||
- ✅ 사용자당 1개의 리프레시 토큰만 유지
|
||||
- ✅ 리프레시 토큰은 일회성 (사용 후 삭제)
|
||||
- ✅ 토큰 갱신 시 액세스 + 리프레시 모두 새로 발급
|
||||
- ✅ 타입 캐스팅 (.env 값은 문자열이므로 int 변환 필수)
|
||||
|
||||
#### 2. RefreshController 구현
|
||||
|
||||
```php
|
||||
public function refresh(RefreshRequest $request): JsonResponse
|
||||
{
|
||||
$refreshToken = $request->validated()['refresh_token'];
|
||||
|
||||
// 리프레시 토큰으로 새로운 토큰 발급
|
||||
$tokens = AuthService::refreshTokens($refreshToken);
|
||||
|
||||
if (!$tokens) {
|
||||
return response()->json([
|
||||
'error' => __('error.refresh_token_invalid_or_expired'),
|
||||
'error_code' => 'TOKEN_EXPIRED',
|
||||
], 401);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => __('message.token_refreshed'),
|
||||
'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'],
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Handler 토큰 만료 에러 처리
|
||||
|
||||
```php
|
||||
// 401 Unauthorized
|
||||
if ($exception instanceof AuthenticationException) {
|
||||
// 토큰 만료 여부 확인
|
||||
$errorCode = null;
|
||||
$message = '인증 실패';
|
||||
|
||||
// Bearer 토큰이 있는 경우 만료 여부 확인
|
||||
$bearerToken = $request->bearerToken();
|
||||
if ($bearerToken) {
|
||||
$token = \Laravel\Sanctum\PersonalAccessToken::findToken($bearerToken);
|
||||
if ($token && $token->expires_at && $token->expires_at->isPast()) {
|
||||
$errorCode = 'TOKEN_EXPIRED';
|
||||
$message = __('error.token_expired');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
'error_code' => $errorCode,
|
||||
'data' => null,
|
||||
], 401);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 환경 설정 (.env)
|
||||
|
||||
```env
|
||||
# Sanctum 토큰 만료 설정 (분 단위, null이면 무제한)
|
||||
SANCTUM_ACCESS_TOKEN_EXPIRATION=120 # 2시간 (운영 기준)
|
||||
SANCTUM_REFRESH_TOKEN_EXPIRATION=10080 # 7일
|
||||
```
|
||||
|
||||
#### 5. Swagger 문서
|
||||
|
||||
**POST /api/v1/refresh:**
|
||||
```php
|
||||
@OA\Post(
|
||||
path="/api/v1/refresh",
|
||||
tags={"Auth"},
|
||||
summary="토큰 갱신 (리프레시 토큰으로 새로운 액세스 토큰 발급)",
|
||||
description="리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급받습니다. 리프레시 토큰은 사용 후 폐기되며 새로운 리프레시 토큰이 발급됩니다.",
|
||||
security={{"ApiKeyAuth": {}}},
|
||||
)
|
||||
```
|
||||
|
||||
**로그인 응답 업데이트:**
|
||||
```php
|
||||
@OA\Property(property="access_token", type="string", example="1|abc123xyz456", description="액세스 토큰 (API 호출에 사용)"),
|
||||
@OA\Property(property="refresh_token", type="string", example="2|def456uvw789", description="리프레시 토큰 (액세스 토큰 갱신에 사용)"),
|
||||
@OA\Property(property="token_type", type="string", example="Bearer", description="토큰 타입"),
|
||||
@OA\Property(property="expires_in", type="integer", nullable=true, example=7200, description="액세스 토큰 만료 시간 (초 단위, null이면 무제한)"),
|
||||
@OA\Property(property="expires_at", type="string", nullable=true, example="2025-11-10 16:00:00", description="액세스 토큰 만료 시각 (null이면 무제한)"),
|
||||
```
|
||||
|
||||
### 기술 세부사항:
|
||||
|
||||
#### OAuth 2.0 표준 준수
|
||||
- `token_type: "Bearer"` 포함 (RFC 6749 표준)
|
||||
- 토큰 갱신 시 refresh token rotation (보안 강화)
|
||||
- 만료 시간 명시 (expires_in, expires_at)
|
||||
|
||||
#### 보안 설계
|
||||
```
|
||||
1. 리프레시 토큰 일회성:
|
||||
- 사용 시 즉시 삭제
|
||||
- 새 리프레시 토큰 발급
|
||||
- 도난 토큰 재사용 방지
|
||||
|
||||
2. 사용자당 1개 제한:
|
||||
- 새 리프레시 토큰 발급 시 이전 것 삭제
|
||||
- 멀티 디바이스 로그인 제한 (필요 시 변경 가능)
|
||||
|
||||
3. 타입 안전성:
|
||||
- .env 값 타입 캐스팅 필수
|
||||
- Carbon::addMinutes()는 int만 허용
|
||||
```
|
||||
|
||||
#### 데이터베이스 영향
|
||||
```sql
|
||||
-- personal_access_tokens 테이블
|
||||
SELECT id, name, expires_at, created_at
|
||||
FROM personal_access_tokens
|
||||
WHERE tokenable_id = 1
|
||||
ORDER BY id DESC
|
||||
LIMIT 5;
|
||||
|
||||
-- 결과:
|
||||
ID: 184 | Name: refresh-token | Expires: 2025-11-17 11:06:28
|
||||
ID: 183 | Name: access-token | Expires: 2025-11-10 13:06:28
|
||||
```
|
||||
|
||||
### SAM API Development Rules 준수:
|
||||
|
||||
✅ **Service-First 아키텍처:**
|
||||
- AuthService에 모든 토큰 로직
|
||||
- Controller는 DI + 응답만
|
||||
|
||||
✅ **FormRequest 검증:**
|
||||
- RefreshRequest로 리프레시 토큰 검증
|
||||
|
||||
✅ **i18n 메시지 키:**
|
||||
- __('message.token_refreshed'), __('error.xxx') 사용
|
||||
|
||||
✅ **Swagger 문서:**
|
||||
- 별도 파일 (app/Swagger/v1/RefreshApi.php)
|
||||
- Auth 태그로 그룹화
|
||||
|
||||
✅ **보안:**
|
||||
- 토큰 일회성 사용
|
||||
- 만료 시간 검증
|
||||
- 에러 코드 명시 (TOKEN_EXPIRED)
|
||||
|
||||
✅ **코드 품질:**
|
||||
- 타입 안전성 (int 캐스팅)
|
||||
- 명확한 주석
|
||||
|
||||
### 테스트 결과:
|
||||
|
||||
**Tinker 테스트:**
|
||||
```bash
|
||||
php artisan tinker --execute="
|
||||
\$user = User::find(1);
|
||||
\$tokens = \App\Services\AuthService::issueTokens(\$user);
|
||||
echo 'Access Token: ' . substr(\$tokens['access_token'], 0, 20) . '...' . PHP_EOL;
|
||||
echo 'Refresh Token: ' . substr(\$tokens['refresh_token'], 0, 20) . '...' . PHP_EOL;
|
||||
echo 'Expires In: ' . \$tokens['expires_in'] . ' seconds' . PHP_EOL;
|
||||
echo 'Expires At: ' . \$tokens['expires_at'] . PHP_EOL;
|
||||
"
|
||||
|
||||
# 결과:
|
||||
Access Token: 177|MtYCVI4XDqX5GXA...
|
||||
Refresh Token: 178|rpoDdTsZ9orU2g3...
|
||||
Expires In: 7200 seconds (120 minutes)
|
||||
Expires At: 2025-11-10 13:01:21
|
||||
```
|
||||
|
||||
**API 엔드포인트 테스트:**
|
||||
```bash
|
||||
# 로그인
|
||||
curl -X POST "http://api.sam.kr/api/v1/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-KEY: 42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a" \
|
||||
-d '{"email":"hamss@codebridge-x.com","password":"test1234"}'
|
||||
|
||||
# 토큰 갱신
|
||||
curl -X POST "http://api.sam.kr/api/v1/refresh" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-KEY: 42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a" \
|
||||
-d '{"refresh_token":"182|vsdUYz2WVaFxC05TWp4M0njVLhh833jPK6ilN5AB8ee106ad"}'
|
||||
|
||||
# 응답:
|
||||
{
|
||||
"message": "토큰이 갱신되었습니다",
|
||||
"access_token": "183|pfbAqUvAZ2meTVKisDDC8MwnhBUCoMVsK7GXoh8aa1c832c5",
|
||||
"refresh_token": "184|yNJJiqNF4GeH2u3YFAQr7mISYmLdEfiSdq9CdD00c1d7538d",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 7200,
|
||||
"expires_at": "2025-11-10 13:06:28"
|
||||
}
|
||||
```
|
||||
|
||||
**데이터베이스 검증:**
|
||||
```sql
|
||||
-- 최근 발급된 토큰 확인
|
||||
SELECT id, name, expires_at, created_at
|
||||
FROM personal_access_tokens
|
||||
WHERE tokenable_id = 1
|
||||
ORDER BY id DESC LIMIT 5;
|
||||
|
||||
-- 결과:
|
||||
✅ 새 토큰 발급: Access (ID: 183) + Refresh (ID: 184)
|
||||
✅ 이전 리프레시 토큰 삭제: ID 182 삭제됨
|
||||
✅ 만료 시간 설정: Access 2시간 후, Refresh 7일 후
|
||||
```
|
||||
|
||||
### 예상 효과:
|
||||
|
||||
1. **보안 강화**: 단기 액세스 토큰 + 장기 리프레시 토큰
|
||||
2. **세션 관리**: 리프레시 토큰 갱신으로 지속적인 로그인 유지
|
||||
3. **에러 처리**: TOKEN_EXPIRED 코드로 프론트엔드 자동 리프레시 구현 가능
|
||||
4. **유연성**: 환경별 토큰 만료 시간 설정 (개발/운영 분리)
|
||||
|
||||
### 다음 작업:
|
||||
|
||||
- [x] AuthService 구현
|
||||
- [x] RefreshController 구현
|
||||
- [x] Handler 에러 처리
|
||||
- [x] Swagger 문서 작성 (Auth 태그)
|
||||
- [x] i18n 메시지 추가
|
||||
- [x] Tinker 테스트
|
||||
- [x] API 엔드포인트 테스트
|
||||
- [x] DB 검증
|
||||
- [ ] Frontend 토큰 갱신 로직 구현
|
||||
- [ ] 만료 토큰 정리 스케줄러 (선택)
|
||||
|
||||
### Git 커밋:
|
||||
- 다음 커밋 예정: `feat: API 토큰 관리 시스템 구현 (액세스/리프레시 토큰 분리)`
|
||||
|
||||
---
|
||||
|
||||
## 2025-11-10 (일) - 회원가입 쿼리 최적화 (268개 → 58개, 78% 감소)
|
||||
|
||||
### 주요 작업
|
||||
|
||||
@@ -80,9 +80,24 @@ public function render($request, Throwable $exception)
|
||||
|
||||
// 401 Unauthorized
|
||||
if ($exception instanceof AuthenticationException) {
|
||||
// 토큰 만료 여부 확인
|
||||
$errorCode = null;
|
||||
$message = '인증 실패';
|
||||
|
||||
// Bearer 토큰이 있는 경우 만료 여부 확인
|
||||
$bearerToken = $request->bearerToken();
|
||||
if ($bearerToken) {
|
||||
$token = \Laravel\Sanctum\PersonalAccessToken::findToken($bearerToken);
|
||||
if ($token && $token->expires_at && $token->expires_at->isPast()) {
|
||||
$errorCode = 'TOKEN_EXPIRED';
|
||||
$message = __('error.token_expired');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '인증 실패',
|
||||
'message' => $message,
|
||||
'error_code' => $errorCode,
|
||||
'data' => null,
|
||||
], 401);
|
||||
}
|
||||
|
||||
@@ -48,20 +48,19 @@ public function login(Request $request)
|
||||
return response()->json(['error' => '아이디 또는 비밀번호가 올바르지 않습니다.'], 401);
|
||||
}
|
||||
|
||||
// 인증토큰 생성
|
||||
$token = $user->createToken('front-app')->plainTextToken;
|
||||
|
||||
// 선택: DB에 신규 token 저장
|
||||
$USER_TOKEN = hash('sha256', $user->mb_id.date('YmdHis'));
|
||||
$user->remember_token = $USER_TOKEN;
|
||||
$user->save();
|
||||
// 액세스 + 리프레시 토큰 발급
|
||||
$tokens = \App\Services\AuthService::issueTokens($user);
|
||||
|
||||
// 사용자 정보 조회 (테넌트 + 메뉴 포함)
|
||||
$loginInfo = \App\Services\MemberService::getUserInfoForLogin($user->id);
|
||||
|
||||
return response()->json([
|
||||
'message' => '로그인 성공',
|
||||
'user_token' => $token,
|
||||
'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'],
|
||||
|
||||
41
app/Http/Controllers/Api/V1/RefreshController.php
Normal file
41
app/Http/Controllers/Api/V1/RefreshController.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\RefreshRequest;
|
||||
use App\Services\AuthService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class RefreshController extends Controller
|
||||
{
|
||||
/**
|
||||
* 리프레시 토큰으로 새로운 액세스 토큰을 발급합니다.
|
||||
*
|
||||
* @param RefreshRequest $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function refresh(RefreshRequest $request): JsonResponse
|
||||
{
|
||||
$refreshToken = $request->validated()['refresh_token'];
|
||||
|
||||
// 리프레시 토큰으로 새로운 토큰 발급
|
||||
$tokens = AuthService::refreshTokens($refreshToken);
|
||||
|
||||
if (! $tokens) {
|
||||
return response()->json([
|
||||
'error' => __('error.refresh_token_invalid_or_expired'),
|
||||
'error_code' => 'TOKEN_EXPIRED',
|
||||
], 401);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => __('message.token_refreshed'),
|
||||
'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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ public function handle(Request $request, Closure $next)
|
||||
'api/v1/login',
|
||||
'api/v1/signup',
|
||||
'api/v1/register',
|
||||
'api/v1/refresh',
|
||||
'api/v1/debug-apikey',
|
||||
// 추가적으로 허용하고 싶은 라우트
|
||||
];
|
||||
|
||||
37
app/Http/Requests/Api/V1/RefreshRequest.php
Normal file
37
app/Http/Requests/Api/V1/RefreshRequest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RefreshRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 요청 권한 확인
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true; // API 키 인증은 미들웨어에서 처리
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효성 검사 규칙
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'refresh_token' => 'required|string',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 메시지 커스터마이징
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'refresh_token.required' => __('error.refresh_token_required'),
|
||||
'refresh_token.string' => __('error.refresh_token_invalid'),
|
||||
];
|
||||
}
|
||||
}
|
||||
119
app/Services/AuthService.php
Normal file
119
app/Services/AuthService.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Members\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
class AuthService
|
||||
{
|
||||
/**
|
||||
* 액세스 토큰과 리프레시 토큰을 함께 발급합니다.
|
||||
*
|
||||
* @param User $user 사용자 모델
|
||||
* @return array ['access_token' => string, 'refresh_token' => string, 'expires_in' => int, 'expires_at' => string]
|
||||
*/
|
||||
public static function issueTokens(User $user): array
|
||||
{
|
||||
// 기존 리프레시 토큰 삭제 (한 사용자당 하나의 리프레시 토큰만 유지)
|
||||
$user->tokens()->where('name', 'refresh-token')->delete();
|
||||
|
||||
// 액세스 토큰 만료 시간 (분 단위, null이면 무제한)
|
||||
$accessExpiration = Config::get('sanctum.access_token_expiration');
|
||||
$accessExpiration = $accessExpiration ? (int) $accessExpiration : null;
|
||||
$accessExpiresAt = $accessExpiration ? now()->addMinutes($accessExpiration) : null;
|
||||
|
||||
// 리프레시 토큰 만료 시간 (분 단위, null이면 무제한)
|
||||
$refreshExpiration = Config::get('sanctum.refresh_token_expiration');
|
||||
$refreshExpiration = $refreshExpiration ? (int) $refreshExpiration : null;
|
||||
$refreshExpiresAt = $refreshExpiration ? now()->addMinutes($refreshExpiration) : null;
|
||||
|
||||
// 액세스 토큰 생성
|
||||
$accessToken = $user->createToken(
|
||||
'access-token',
|
||||
['*'],
|
||||
$accessExpiresAt
|
||||
);
|
||||
|
||||
// 리프레시 토큰 생성
|
||||
$refreshToken = $user->createToken(
|
||||
'refresh-token',
|
||||
['refresh'],
|
||||
$refreshExpiresAt
|
||||
);
|
||||
|
||||
return [
|
||||
'access_token' => $accessToken->plainTextToken,
|
||||
'refresh_token' => $refreshToken->plainTextToken,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => $accessExpiration ? $accessExpiration * 60 : null, // 초 단위
|
||||
'expires_at' => $accessExpiresAt ? $accessExpiresAt->toDateTimeString() : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시 토큰으로 새로운 액세스 토큰을 발급합니다.
|
||||
* 리프레시 토큰도 함께 갱신됩니다.
|
||||
*
|
||||
* @param string $refreshToken 리프레시 토큰
|
||||
* @return array|null ['access_token' => string, 'refresh_token' => string, ...] or null if invalid
|
||||
*/
|
||||
public static function refreshTokens(string $refreshToken): ?array
|
||||
{
|
||||
// 리프레시 토큰 검증
|
||||
$token = \Laravel\Sanctum\PersonalAccessToken::findToken($refreshToken);
|
||||
|
||||
if (! $token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 토큰 타입 확인
|
||||
if ($token->name !== 'refresh-token') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 만료 확인
|
||||
if ($token->expires_at && $token->expires_at->isPast()) {
|
||||
$token->delete();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 사용자 조회
|
||||
$user = $token->tokenable;
|
||||
|
||||
if (! $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 기존 리프레시 토큰 삭제 (사용 후 폐기)
|
||||
$token->delete();
|
||||
|
||||
// 새로운 액세스 + 리프레시 토큰 발급
|
||||
return self::issueTokens($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자의 모든 토큰을 삭제합니다 (로그아웃).
|
||||
*
|
||||
* @param User $user 사용자 모델
|
||||
* @return void
|
||||
*/
|
||||
public static function revokeAllTokens(User $user): void
|
||||
{
|
||||
$user->tokens()->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 액세스 토큰만 삭제합니다.
|
||||
*
|
||||
* @param User $user 사용자 모델
|
||||
* @param string $tokenId 토큰 ID
|
||||
* @return void
|
||||
*/
|
||||
public static function revokeToken(User $user, string $tokenId): void
|
||||
{
|
||||
$user->tokens()->where('id', $tokenId)->delete();
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,11 @@ public function debugApiKey() {}
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="message", type="string", example="로그인 성공"),
|
||||
* @OA\Property(property="user_token", type="string", example="1|abc123xyz456"),
|
||||
* @OA\Property(property="access_token", type="string", example="1|abc123xyz456", description="액세스 토큰 (API 호출에 사용)"),
|
||||
* @OA\Property(property="refresh_token", type="string", example="2|def456uvw789", description="리프레시 토큰 (액세스 토큰 갱신에 사용)"),
|
||||
* @OA\Property(property="token_type", type="string", example="Bearer", description="토큰 타입"),
|
||||
* @OA\Property(property="expires_in", type="integer", nullable=true, example=7200, description="액세스 토큰 만료 시간 (초 단위, null이면 무제한)"),
|
||||
* @OA\Property(property="expires_at", type="string", nullable=true, example="2025-11-10 16:00:00", description="액세스 토큰 만료 시각 (null이면 무제한)"),
|
||||
* @OA\Property(
|
||||
* property="user",
|
||||
* type="object",
|
||||
@@ -164,7 +168,11 @@ public function debugApiKey() {}
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="message", type="string", example="로그인 성공"),
|
||||
* @OA\Property(property="user_token", type="string", example="1|abc123xyz456"),
|
||||
* @OA\Property(property="access_token", type="string", example="1|abc123xyz456", description="액세스 토큰 (API 호출에 사용)"),
|
||||
* @OA\Property(property="refresh_token", type="string", example="2|def456uvw789", description="리프레시 토큰 (액세스 토큰 갱신에 사용)"),
|
||||
* @OA\Property(property="token_type", type="string", example="Bearer", description="토큰 타입"),
|
||||
* @OA\Property(property="expires_in", type="integer", nullable=true, example=7200, description="액세스 토큰 만료 시간 (초 단위, null이면 무제한)"),
|
||||
* @OA\Property(property="expires_at", type="string", nullable=true, example="2025-11-10 16:00:00", description="액세스 토큰 만료 시각 (null이면 무제한)"),
|
||||
* @OA\Property(
|
||||
* property="user",
|
||||
* type="object",
|
||||
|
||||
66
app/Swagger/v1/RefreshApi.php
Normal file
66
app/Swagger/v1/RefreshApi.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
class RefreshApi
|
||||
{
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/refresh",
|
||||
* tags={"Auth"},
|
||||
* summary="토큰 갱신 (리프레시 토큰으로 새로운 액세스 토큰 발급)",
|
||||
* description="리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급받습니다. 리프레시 토큰은 사용 후 폐기되며 새로운 리프레시 토큰이 발급됩니다.",
|
||||
* security={{"ApiKeyAuth": {}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* required={"refresh_token"},
|
||||
*
|
||||
* @OA\Property(
|
||||
* property="refresh_token",
|
||||
* type="string",
|
||||
* example="2|def456uvw789",
|
||||
* description="리프레시 토큰"
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="토큰 갱신 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="message", type="string", example="토큰이 갱신되었습니다"),
|
||||
* @OA\Property(property="access_token", type="string", example="3|ghi789rst012", description="새로운 액세스 토큰"),
|
||||
* @OA\Property(property="refresh_token", type="string", example="4|jkl012mno345", description="새로운 리프레시 토큰"),
|
||||
* @OA\Property(property="token_type", type="string", example="Bearer", description="토큰 타입"),
|
||||
* @OA\Property(property="expires_in", type="integer", nullable=true, example=7200, description="액세스 토큰 만료 시간 (초 단위, null이면 무제한)"),
|
||||
* @OA\Property(property="expires_at", type="string", nullable=true, example="2025-11-10 18:00:00", description="액세스 토큰 만료 시각 (null이면 무제한)")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=401,
|
||||
* description="리프레시 토큰이 유효하지 않거나 만료됨",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="error", type="string", example="리프레시 토큰이 유효하지 않거나 만료되었습니다"),
|
||||
* @OA\Property(property="error_code", type="string", example="TOKEN_EXPIRED", description="에러 코드")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=422,
|
||||
* description="검증 실패",
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/ErrorResponse")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function refresh() {}
|
||||
}
|
||||
@@ -44,9 +44,15 @@
|
||||
| considered expired. This will override any values set in the token's
|
||||
| "expires_at" attribute, but first-party sessions are not affected.
|
||||
|
|
||||
| Access Token: SANCTUM_ACCESS_TOKEN_EXPIRATION (default: null, unlimited)
|
||||
| Refresh Token: SANCTUM_REFRESH_TOKEN_EXPIRATION (default: null, unlimited)
|
||||
|
|
||||
*/
|
||||
|
||||
'expiration' => null,
|
||||
'expiration' => null, // Global default, can be overridden per token
|
||||
|
||||
'access_token_expiration' => env('SANCTUM_ACCESS_TOKEN_EXPIRATION'),
|
||||
'refresh_token_expiration' => env('SANCTUM_REFRESH_TOKEN_EXPIRATION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
'unauthenticated' => '인증에 실패했습니다.', // 401
|
||||
'forbidden' => '요청에 대한 권한이 없습니다.', // 403
|
||||
'bad_request' => '잘못된 요청입니다.', // 400 (검증 외 일반 케이스)
|
||||
'token_expired' => '토큰이 만료되었습니다', // 401 (토큰 만료)
|
||||
'refresh_token_required' => '리프레시 토큰이 필요합니다', // 422
|
||||
'refresh_token_invalid' => '리프레시 토큰 형식이 올바르지 않습니다', // 422
|
||||
'refresh_token_invalid_or_expired' => '리프레시 토큰이 유효하지 않거나 만료되었습니다', // 401
|
||||
|
||||
// 검증/파라미터
|
||||
'validation_failed' => '요청 데이터 검증에 실패했습니다.', // 422
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
'logout_success' => '로그아웃 되었습니다.',
|
||||
'signup_success' => '회원가입이 완료되었습니다.',
|
||||
'registered' => '회원가입 처리',
|
||||
'token_refreshed' => '토큰이 갱신되었습니다',
|
||||
|
||||
// 테넌트/컨텍스트
|
||||
'tenant_switched' => '활성 테넌트가 전환되었습니다.',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Http\Controllers\Api\V1\AdminController;
|
||||
use App\Http\Controllers\Api\V1\ApiController;
|
||||
use App\Http\Controllers\Api\V1\RefreshController;
|
||||
use App\Http\Controllers\Api\V1\CategoryController;
|
||||
use App\Http\Controllers\Api\V1\CategoryFieldController;
|
||||
use App\Http\Controllers\Api\V1\CategoryLogController;
|
||||
@@ -52,6 +53,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('refresh', [RefreshController::class, 'refresh'])->name('v1.token.refresh');
|
||||
Route::post('register', [RegisterController::class, 'register'])->name('v1.register');
|
||||
|
||||
// Tenant Admin API
|
||||
|
||||
Reference in New Issue
Block a user