콘텐츠로 이동

5. 인증 API#

이 절에서 설명하는 API는 LOCAL 모드(auth.provider=LOCAL)에서만 지원된다. auth.provider=IDP인 경우 Access Token은 외부 IdP에서 발급받아 사용하며, Refresh/Logout 정책도 IdP 설정을 따른다.

5.1 JWT 인증#

Altibase 의 REST API는 JWT(JSON Web Token) 기반 인증을 사용하며, 모든 API 요청 시 Authorization 헤더에 다음과 같이 Bearer 토큰을 포함해야 한다.

Authorization 헤더 형식

Authorization: Bearer <access_token>

예시

curl -X GET http://localhost:8080/api/collections \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

인증 절차

인증의 절차는 다음 단계로 구성된다.

  1. 클라이언트는 clientId와 apiKey 를 사용하여 토큰을 요청한다.
  2. 서버는 Access Token과 Refresh Token을 반환한다.
  3. Access Token은 API 요청 인증에 사용된다.
  4. Access Token이 만료되면 Refresh Token을 사용하여 새 토큰을 발급받는다.
  5. 로그아웃 후 Refresh Token은 무효화 된다.

5.2 토큰 구조#

토큰의 구조#

토큰 유형 만료 시간 용도
Access Token 1시간 (설정 가능) API 인증
Refresh Token 30일 (설정 가능) Access Token 갱신(재발급)

토큰 만료 시간 설정 (관리자 용)#

application.yml#

jwt:
  secret: ${JWT_SECRET}
  expiration: ${JWT_EXPIRATION:3600000}          # Access Token: 1시간
  refresh-expiration: ${JWT_REFRESH_EXPIRATION:2592000000}  # Refresh Token: 30일

환경변수#

# Access Token: 30분
export JWT_EXPIRATION=1800000

# Refresh Token: 7일
export JWT_REFRESH_EXPIRATION=604800000

5.3 토큰 발급#

클라이언트 ID(clientId)와 API 키(apiKey)로 JWT 토큰을 발급 받는다.

엔드포인트 :

POST /api/auth/token

요청 헤더 :

Content-Type: application/json

요청 바디 :

필드 타입 필수 여부 설명
clientId string 필수 클라이언트 ID
apiKey string 필수 발급된 API 키

예제#

curl -X POST http://localhost:8080/api/auth/token \
  -H "Content-Type: application/json" \
  -d '{"clientId": "mobileApp", 
       "apiKey": "abc123def456"
  }'

응답 (200 OK):

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refreshToken": "refresh_token_value...",
  "type": "Bearer",
  "expiresIn": 3600000
}
  • expiresIn : 토큰의 만료 시간 (millisecond)

에러 응답 (401 Unauthorized):

{
  "error": {
    "code": "Unauthorized.",
    "message": "Invalid client ID or API key.",
    "details": {}
  }
}

5.4 토큰 갱신(재발급)#

Access Token이 만료되었거나 만료가 임박한 경우, Refresh Token을 사용하여 새로운 토큰을 발급받을 수 있다. Refresh Token을 이용한 토큰 갱신은 자동으로 수행되지 않는다. 클라이언트는 Access Token이 만료되었거나 만료가 임박한 경우 Refresh Token 재발급 API를 호출하여 새로운 토큰을 발급받아야 한다. 해당 API는 클라이언트 프로그램에서 직접 구현하거나 REST API를 직접 호출하여 사용할 수 있다. 클라이언트 구현 예제는 5.6 클라이언트 구현 예제를 참고한다.

엔드포인트 :

POST /api/auth/refresh

요청 헤더 :

Content-Type: application/json

요청 바디 :

필드 타입 필수 여부 설명
refreshToken string 필수 기존 Refresh Token

예제 :

  • 요청
curl -X POST http://localhost:8080/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}'

Important

갱신 시 Access Token과 Refresh Token 모두 새로 발급된다. 클라이언트는 둘 다 업데이트해야 한다.

  • 응답 (200 OK):
{
  "type": "Bearer",
  "token": "new_access_token...",
  "refreshToken": "new_refresh_token...",
  "expiresIn": 3600000
} 
  • 에러 응답:

Refresh Token 갱신 실패 시 응답은 실패 원인에 따라 400 Bad Request 또는 401 Unauthorized로 나뉜다.

  • 400 Bad Request

  • Refresh Token 형식이 올바르지 않은 경우

  • 서버에 저장된 Refresh Token과 일치하지 않는 경우
  • 토큰에 포함된 clientId를 찾을 수 없는 경우

  • 401 Unauthorized

  • Refresh Token이 만료된 경우
  • Refresh Token 서명 검증에 실패한 경우
{
  "error": {
    "code": "Unauthorized.",
    "message": "Invalid or expired refresh token.",
    "details": {}
  }
}

5.5 로그아웃#

로그 아웃 시 현재 Access Token은 더 이상 사용할 수 없으며, Refresh Token도 함께 무효화 된다.

엔드포인트 :

POST /api/auth/logout

요청 헤더 :

Authorization: Bearer ${JWT_TOKEN} 

예제 :

curl -X POST http://localhost:8080/api/auth/logout \
  -H "Authorization: Bearer ${TOKEN}"

응답 (200 OK)

5.6 클라이언트 구현 예제#

아래의 예제는 API 호출 중 401 에러(토큰 만료)가 발생할 경우, 자동으로 토큰을 갱신하고 원래 요청을 재시도하도록 구현한 예제 프로그램이다.

JavaScript (Axios Interceptor)#

axios.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config;

    // 401 에러이고, 재시도하지 않은 요청인 경우
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        // Refresh Token으로 새 토큰 발급
        const refreshToken = localStorage.getItem('refreshToken');
        const response = await axios.post('/api/auth/refresh', { refreshToken });

        // 새 토큰 저장 (둘 다 업데이트!)
        localStorage.setItem('accessToken', response.data.token);
        localStorage.setItem('refreshToken', response.data.refreshToken);

        // 원래 요청 재시도
        originalRequest.headers.Authorization = `Bearer ${response.data.token}`;
        return axios.request(originalRequest);
      } catch (refreshError) {
        // Refresh Token도 만료 → 로그인 페이지로 이동
        localStorage.removeItem('accessToken');
        localStorage.removeItem('refreshToken');
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }
    return Promise.reject(error);
  }
);

Java (Spring RestClient Interceptor)#

@Configuration
public class RestClientConfig {

    @Bean
    public RestClient restClient(RestClient.Builder builder) {
        return builder
            .baseUrl("http://localhost:8080")
            .requestInterceptor((request, body, execution) -> {
                // 1. Access Token 주입
                String accessToken = TokenStorage.getAccessToken();
                if (accessToken != null) {
                    request.getHeaders().setBearerAuth(accessToken);
                }

                // 2. 요청 실행
                ClientHttpResponse response = execution.execute(request, body);

                // 3. 401 에러 시 토큰 갱신
                if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) {
                    String refreshToken = TokenStorage.getRefreshToken();
                    if (refreshToken == null) {
                        return response;
                    }

                    synchronized (this) {
                        // 다른 스레드가 이미 갱신했는지 확인
                        if (!refreshToken.equals(TokenStorage.getRefreshToken())) {
                            request.getHeaders().setBearerAuth(TokenStorage.getAccessToken());
                            return execution.execute(request, body);
                        }

                        try {
                            // 새 토큰 요청
                            AuthResponse newTokens = RestClient.builder()
                                .baseUrl("http://localhost:8080")
                                .build()
                                .post().uri("/api/auth/refresh")
                                .contentType(MediaType.APPLICATION_JSON)
                                .body(new RefreshRequest(refreshToken))
                                .retrieve().body(AuthResponse.class);

                            // 토큰 저장 및 재시도
                            TokenStorage.save(newTokens.token(), newTokens.refreshToken());
                            request.getHeaders().setBearerAuth(newTokens.token());
                            return execution.execute(request, body);
                        } catch (Exception e) {
                            TokenStorage.clear();
                        }
                    }
                }
                return response;
            })
            .build();
    }

    // 토큰 저장소 (실제 구현에 맞게 대체)
    static class TokenStorage {
        private static String accessToken;
        private static String refreshToken;
        public static String getAccessToken() { return accessToken; }
        public static String getRefreshToken() { return refreshToken; }
        public static void save(String access, String refresh) {
            accessToken = access;
            refreshToken = refresh;
        }
        public static void clear() { accessToken = null; refreshToken = null; }
    }

    record RefreshRequest(String refreshToken) {}
    record AuthResponse(String token, String refreshToken) {}
}