feat: API 토큰 관리 시스템 구현 (액세스/리프레시 토큰 분리)

- AuthService: 토큰 발급/갱신 통합 관리
- RefreshController: POST /api/v1/refresh 엔드포인트 추가
- 액세스 토큰 2시간, 리프레시 토큰 7일 (.env 설정)
- TOKEN_EXPIRED 에러 코드로 프론트엔드 자동 리프레시 지원
- 리프레시 토큰 일회성 사용 (보안 강화)
- Swagger 문서 Auth 태그로 통합
This commit is contained in:
2025-11-10 11:17:32 +09:00
parent 657623fef5
commit 798d5149ea
13 changed files with 639 additions and 12 deletions

View File

@@ -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% 감소)
### 주요 작업 ### 주요 작업

View File

@@ -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);
} }

View File

@@ -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'],

View 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'],
]);
}
}

View File

@@ -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',
// 추가적으로 허용하고 싶은 라우트 // 추가적으로 허용하고 싶은 라우트
]; ];

View 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'),
];
}
}

View 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();
}
}

View File

@@ -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",

View 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() {}
}

View File

@@ -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'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -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

View File

@@ -24,6 +24,7 @@
'logout_success' => '로그아웃 되었습니다.', 'logout_success' => '로그아웃 되었습니다.',
'signup_success' => '회원가입이 완료되었습니다.', 'signup_success' => '회원가입이 완료되었습니다.',
'registered' => '회원가입 처리', 'registered' => '회원가입 처리',
'token_refreshed' => '토큰이 갱신되었습니다',
// 테넌트/컨텍스트 // 테넌트/컨텍스트
'tenant_switched' => '활성 테넌트가 전환되었습니다.', 'tenant_switched' => '활성 테넌트가 전환되었습니다.',

View File

@@ -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