프로젝트를 진행하면서 swagger로 API 테스트를 많이 사용한다. JWT(JSON Web Token) 인증이 필요한 엔드포인트는 추가 설정 없이는 401 Unautherized 에러를 반환한다.
{
"message": "인증이 필요합니다."
}
이번 글에서는 Swagger UI에서 JWT 토큰을 입력하고 모든 API 요청을 자동으로 Authorization 헤더를 포함시키는 방법에 대해 알아보자.
JWT를 사용하는 API에서는 일반적으로 다음과 같은 형식으로 Access Token 을 전달한다.
Authorization Bearer {ACCESS_TOKEN}
각각의 의미
Authorizaion: 인증 정보를 전달하는 HTTP 헤더Bearer: Bearer Token 인증 방식{ACCESS_TOKEN} 로그인 후 서버로부터 발급받은 JWTBearer 인증이란
Bearer의 사전적 의미 : 소유자과거 금융권에서 쓰이던 '무기명 채권(Bearer Bond)'에서 개념이 유래했다.무기명 채권은 증서에 주인 이름이 적혀있지 않는다. 즉, "이 종이를 들고 있는(Bearing) 사람이 곧 주인"이며, 은행은 가져온 사람에게 돈을 지급한다.
이를 프로그래밍 보안에도 가져와, 서버는 토큰을 들고 온 클라이언트가 '진짜 로그인한 그 사람'인지 재차 확인하지 않는다. "이 토큰을 소지한(Bearer) 사람을 권한을 가진 자로 인정한다."는 의미에서 Bearer 토큰이라는 이름이 붙었다.
Bearer 토큰을 사용하기 이전에는 OAuth 1.0 방식을 사용하였다.
OAuth 1.0 방식은 '토큰만 보내면 탈취당했을 때 위험하다'라는 생각에, 요청을 보낼 때마다HTTP 메서드,URL 주소,보내는 시간,토큰등을 복잡한 암호화 알고리즘으로 섞어 '매번 새로운 디지털 서명(Signature)'을 만들어 같이 보내야 했다. 이를 개발자가 구현하기가 매우 어려웠고 오류도 잦았다.Bearer은 OAuth 2.0 방식으로, 네트워크 전체를 HTTPS(SSL/TLS)로 암호화하는 것이 표준이 되었다. 네트워크 파이프 자체가 암호화되었으니, 요청마다 서명을 계산할 필요가 없어졌고, 발급받은 Bearer 토큰 문자열을 헤더에 그대로 실어 보내는 것으로 단순화시켰다.
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerConfig {
private static final String SECURITY_SCHEME_NAME = "Bearer Authentication";
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(apiInfo())
.addSecurityItem(
new SecurityRequirement()
.addList(SECURITY_SCHEME_NAME)
)
.components(
new Components()
.addSecuritySchemes(
SECURITY_SCHEME_NAME,
createSecurityScheme()
)
);
}
private Info apiInfo() {
return new Info()
.title("Couplead API")
.description("Couplead 서비스 API 문서")
.version("1.0.0");
}
private SecurityScheme createSecurityScheme() {
return new SecurityScheme()
.name(SECURITY_SCHEME_NAME)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer") .bearerFormat("JWT")
.in(SecurityScheme.In.HEADER);
}
}
SecurityScheme
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
Swagger에 다음 내용을 알려준다.
SecurityRequirement
new SecurityRequirement()
.addList(SECURITY_SCHEME_NAME)
등록한 JWT 인증 방식을 API 문서 전체에 적용한다.
이 설정이 적용되면 Swagger UI 상단에 Authorize 버튼이 표시되고, 각 API 오른쪽에는 자물쇠 아이콘이 나타난다.
주의
.addList(SECURITY_SCHEME_NAME)
.addSecuritySchemes(
SECURITY_SCHEME_NAME,
createSecurityScheme()
)
해당 두 부분에서 사용하는 이름을 반드시 같아야 한다.
같지 않을 경우, Swagger가 보안 요구사항과 보안 스키마를 연결하지 못할 수 있다.
Swagger 설정을 추가했어도, Spring Security에서 Swagger 관련 URL을 차단하기 때문에 SecurityConfig에서 Swagger 경로를 인증 없이 접근할 수 있도록 허용해야 한다.
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private static final String[] SWAGGER_WHITELIST = {
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**"
};
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http
) throws Exception {
http .csrf(csrf -> csrf.disable())
.sessionManagement(
session -> session.sessionCreationPolicy(
SessionCreationPolicy.STATELESS
)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers(SWAGGER_WHITELIST).permitAll()
.requestMatchers(
"/api/auth/login",
"/api/auth/signup",
"/api/auth/refresh"
).permitAll()
.requestMatchers(
HttpMethod.GET,
"/uploads/**"
).permitAll()
.anyRequest()
.authenticated()
)
.exceptionHandling(exception ->
exception.authenticationEntryPoint(
jwtAuthenticationEntryPoint
)
)
.addFilterBefore(
jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
}
이 중에 핵심 설정은 아래 부분이다.
.requestMatchers(
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**"
).permitAll()
swagger-ui/**: Swagger UI 화면과 관련 리소스
/swagger-ui.html: Swagger UI 리다이렉트 경로
/v3/api-docs/**: OpenAPI JSON 문서
로그인이나 회원가입 등은 JWT가 없어도 호출할 수 있어야 한다.
Swagger 문서에서도 해당 API에 자물쇠 표시가 되지 않게 하려면 @SecurityRequirements를 사용할 수 있다.
import com.example.couplead.auth.dto.LoginRequest;
import com.example.couplead.auth.dto.TokenResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
@Operation(
summary = "로그인",
description = "이메일과 비밀번호로 로그인하고 JWT를 발급받습니다."
)
@SecurityRequirements
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(
@RequestBody LoginRequest request
) {
TokenResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
}
해당 어노테이션은 전역으로 적용된 보안 요구사항을 해당 API에서 제거한다.(Swagger 문서 표시만 해당, 실제 접근 허용 여부는 별도로 설정해야한다.)
전역으로 인증 적용을 하고싶지 않다면, OpenAPI 설정에서 SecurityRequirement를 제거할 수 있다.
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(apiInfo())
.components(
new Components()
.addSecuritySchemes(
SECURITY_SCHEME_NAME,
createSecurityScheme()
)
);
}
http://localhost:8080/swagger-ui/index.html하여, 로그인 API를 선택한 뒤 Try it out을 클릭한다.{
"email": "test@example.com",
"password": "password1234!@#$"
}
로그인 정보를 입력 후 Execute를 클릭하면 서버에서 토큰을 반환한다.
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9..."
}
여기서 swagger 인증에 사용하는 token은 accessToken이다.
Authorize 버튼을 클릭한다.Bearer Authentication (http, Bearer)
Value:
위와 같이 값을 입력하는 창이 나타난다.
Bearer 을 제외하고 토큰 값만을 넣는다.scheme("bearer")로 설정했기 때문에 Swagger UI가 요청을 보낼 때 자동으로 Bearer를 붙인다.)이후에는 인증이 필요한 API는 정상적으로 반환하게 된다.
필요하다면 Swagger 경로와 정렬 방식을 설정할 수 있다.
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
operations-sorter: method
tags-sorter: alpha
display-request-duration: true
설정 내용은 다음과 같다.
api-docs.path: OpenAPI JSON 문서 경로
swagger-ui.path:Swagger UI 접근 경로
operations-sorter: API를 HTTP Method 기준으로 정렬
tags-sorter: 태그를 알파벳순으로 정렬
display-request-duration: 요청 처리 시간을 화면에 표시
Authorize 버튼 없음
SecurityScheme나 SecurityRequirement를 작성하지 않았는지 확인한다.
전역 인증을 적용했다면 두 설정이 모두 필요하다.
혹은
특정 API만 적용한다면 해당 API에 다음 어노테이션이 있어야 한다.
@SecurityRequirement(name = "Bearer Authentication")
Swagger UI 접속 시 401 에러
Spring Security에서 Swagger 경로를 허용했는지 확인한다.
.requestMatchers(
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**"
).permitAll()
API 목록이 보이지 않는다.
/v3/api-docs/**가 차단된 경우일 것으로, 브라우저에서 주소를 확인해본다.
http://localhost:8080/v3/api-docs
해당 주소에서 401 또는 403이 반환된다면 SecurityConfig의 허용 경로를 확인해야 한다.
토큰을 입력했지만 계속 401 발생
다음 항목을 차례대로 확인한다.
Access Token이 만료되지 않았는가?
Refresh Token을 잘못 입력하지 않았는가?
Bearer 문자열을 중복해서 입력하지 않았는가?
JWT 필터가 Authorization 헤더를 읽고 있는가?
JWT 필터가 Spring Security 필터 체인에 등록되어 있는가?
토큰의 서명 검증에 사용하는 Secret Key가 발급 시점과 동일한가?
토큰에서 사용자 정보를 정상적으로 추출하는가?
필터를 제대로 등록했는가?.addFilterBefore( jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class )
토큰을 입력했는데, Authrization 헤더가 없다.
SecurityScheme의 타입과 스키마를 확인한다.
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
OpenAPI 3에서는 Bearer 인증을 단순한 API Key가 아니라 HTTP Bearer 방식으로 정의하는 것이 표준적인 설정이다.