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 저장소 작업 현황
|
# 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% 감소)
|
## 2025-11-10 (일) - 회원가입 쿼리 최적화 (268개 → 58개, 78% 감소)
|
||||||
|
|
||||||
### 주요 작업
|
### 주요 작업
|
||||||
|
|||||||
@@ -80,9 +80,24 @@ public function render($request, Throwable $exception)
|
|||||||
|
|
||||||
// 401 Unauthorized
|
// 401 Unauthorized
|
||||||
if ($exception instanceof AuthenticationException) {
|
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([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => '인증 실패',
|
'message' => $message,
|
||||||
|
'error_code' => $errorCode,
|
||||||
'data' => null,
|
'data' => null,
|
||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,20 +48,19 @@ public function login(Request $request)
|
|||||||
return response()->json(['error' => '아이디 또는 비밀번호가 올바르지 않습니다.'], 401);
|
return response()->json(['error' => '아이디 또는 비밀번호가 올바르지 않습니다.'], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인증토큰 생성
|
// 액세스 + 리프레시 토큰 발급
|
||||||
$token = $user->createToken('front-app')->plainTextToken;
|
$tokens = \App\Services\AuthService::issueTokens($user);
|
||||||
|
|
||||||
// 선택: DB에 신규 token 저장
|
|
||||||
$USER_TOKEN = hash('sha256', $user->mb_id.date('YmdHis'));
|
|
||||||
$user->remember_token = $USER_TOKEN;
|
|
||||||
$user->save();
|
|
||||||
|
|
||||||
// 사용자 정보 조회 (테넌트 + 메뉴 포함)
|
// 사용자 정보 조회 (테넌트 + 메뉴 포함)
|
||||||
$loginInfo = \App\Services\MemberService::getUserInfoForLogin($user->id);
|
$loginInfo = \App\Services\MemberService::getUserInfoForLogin($user->id);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => '로그인 성공',
|
'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'],
|
'user' => $loginInfo['user'],
|
||||||
'tenant' => $loginInfo['tenant'],
|
'tenant' => $loginInfo['tenant'],
|
||||||
'menus' => $loginInfo['menus'],
|
'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/login',
|
||||||
'api/v1/signup',
|
'api/v1/signup',
|
||||||
'api/v1/register',
|
'api/v1/register',
|
||||||
|
'api/v1/refresh',
|
||||||
'api/v1/debug-apikey',
|
'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",
|
* type="object",
|
||||||
*
|
*
|
||||||
* @OA\Property(property="message", type="string", example="로그인 성공"),
|
* @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(
|
* @OA\Property(
|
||||||
* property="user",
|
* property="user",
|
||||||
* type="object",
|
* type="object",
|
||||||
@@ -164,7 +168,11 @@ public function debugApiKey() {}
|
|||||||
* type="object",
|
* type="object",
|
||||||
*
|
*
|
||||||
* @OA\Property(property="message", type="string", example="로그인 성공"),
|
* @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(
|
* @OA\Property(
|
||||||
* property="user",
|
* property="user",
|
||||||
* type="object",
|
* 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
|
| considered expired. This will override any values set in the token's
|
||||||
| "expires_at" attribute, but first-party sessions are not affected.
|
| "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
|
'unauthenticated' => '인증에 실패했습니다.', // 401
|
||||||
'forbidden' => '요청에 대한 권한이 없습니다.', // 403
|
'forbidden' => '요청에 대한 권한이 없습니다.', // 403
|
||||||
'bad_request' => '잘못된 요청입니다.', // 400 (검증 외 일반 케이스)
|
'bad_request' => '잘못된 요청입니다.', // 400 (검증 외 일반 케이스)
|
||||||
|
'token_expired' => '토큰이 만료되었습니다', // 401 (토큰 만료)
|
||||||
|
'refresh_token_required' => '리프레시 토큰이 필요합니다', // 422
|
||||||
|
'refresh_token_invalid' => '리프레시 토큰 형식이 올바르지 않습니다', // 422
|
||||||
|
'refresh_token_invalid_or_expired' => '리프레시 토큰이 유효하지 않거나 만료되었습니다', // 401
|
||||||
|
|
||||||
// 검증/파라미터
|
// 검증/파라미터
|
||||||
'validation_failed' => '요청 데이터 검증에 실패했습니다.', // 422
|
'validation_failed' => '요청 데이터 검증에 실패했습니다.', // 422
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
'logout_success' => '로그아웃 되었습니다.',
|
'logout_success' => '로그아웃 되었습니다.',
|
||||||
'signup_success' => '회원가입이 완료되었습니다.',
|
'signup_success' => '회원가입이 완료되었습니다.',
|
||||||
'registered' => '회원가입 처리',
|
'registered' => '회원가입 처리',
|
||||||
|
'token_refreshed' => '토큰이 갱신되었습니다',
|
||||||
|
|
||||||
// 테넌트/컨텍스트
|
// 테넌트/컨텍스트
|
||||||
'tenant_switched' => '활성 테넌트가 전환되었습니다.',
|
'tenant_switched' => '활성 테넌트가 전환되었습니다.',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Api\V1\AdminController;
|
use App\Http\Controllers\Api\V1\AdminController;
|
||||||
use App\Http\Controllers\Api\V1\ApiController;
|
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\CategoryController;
|
||||||
use App\Http\Controllers\Api\V1\CategoryFieldController;
|
use App\Http\Controllers\Api\V1\CategoryFieldController;
|
||||||
use App\Http\Controllers\Api\V1\CategoryLogController;
|
use App\Http\Controllers\Api\V1\CategoryLogController;
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
Route::post('login', [ApiController::class, 'login'])->name('v1.users.login');
|
Route::post('login', [ApiController::class, 'login'])->name('v1.users.login');
|
||||||
Route::middleware('auth:sanctum')->post('logout', [ApiController::class, 'logout'])->name('v1.users.logout');
|
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('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');
|
Route::post('register', [RegisterController::class, 'register'])->name('v1.register');
|
||||||
|
|
||||||
// Tenant Admin API
|
// Tenant Admin API
|
||||||
|
|||||||
Reference in New Issue
Block a user