[Spring Boot Tutorial] 17. Bearer JWT를 이용한 api 인증 (Swagger v3)

2020. 11. 18. 13:14Spring/Spring Boot Tutorial

반응형

[Spring Boot Tutorial] 16. Swagger v3에 HTTP 기본인증(Basic Authentication) 설정하기 과정을 먼저 선행한 후 진행해주세요.

  1. Bearer 기본
  2. Bearer + JWT?
    1. JWT?
    2. JWT 라이브러리 추가
    3. JwtBuilder 구조
  3. Bearer + JWT api에 적용
    1. 프로퍼티에 jwt 설정 추가
    2. token 생성 api
    3. @Aspect를 이용한 token 유효성 검사
  4. Swagger 테스트
    1. Access-Control-Allow-Headers 설정
    2. swagger 테스트

1. Bearer 기본

OpenAPI config Swagger Info에 인증 헤더 설정을 추가합니다.

@Component
public class OpenApiConfig {

	@Bean
	public OpenAPI openAPI(@Value("${demo.version}") String appVersion,
			@Value("${demo.url}") String url, @Value("${spring.profiles.active}") String active) {
		Info info = new Info().title("Demo API - " + active).version(appVersion)
				.description("Spring Boot를 이용한 Demo 웹 애플리케이션 API입니다.")
				.termsOfService("http://swagger.io/terms/")
				.contact(new Contact().name("jini").url("https://blog.jiniworld.me/").email("jini@jiniworld.me"))
				.license(new License().name("Apache License Version 2.0").url("http://www.apache.org/licenses/LICENSE-2.0"));

		List<Server> servers = Arrays.asList(new Server().url(url).description("demo (" + active +")"));

		SecurityScheme securityScheme = new SecurityScheme()
				.type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT")
				.in(SecurityScheme.In.HEADER).name("Authorization");		
		SecurityRequirement schemaRequirement = new SecurityRequirement().addList("bearerAuth");

		return new OpenAPI()
				.components(new Components().addSecuritySchemes("bearerAuth", securityScheme))
				.security(Arrays.asList(schemaRequirement))
				.info(info)
				.servers(servers);
	}

}

OpenAPI 빈에 securityScheme 설정을 마친 후 다시 Swagger 창을 열어봅니다.
Authorize 버튼을 누른 후 토큰으로 사용할 값을 넣습니다.

112

bearer 토큰을 설정 한 후, api를 테스트 해봅니다.

113
Curl 명령어의 맨 뒤에 -H "Authorization: Bearer 123"이 추가된 것을 확인할 수 있습니다.

Bearer 토큰은 설정한 값을 그대로 헤더로 전송합니다.
그러나, 헤더에 전송할 토큰이 그대로 노출되는 이런 형태는 Basic 인증방식과 마찬가지로 보안에 매우 취약합니다.

따라서, 우리는 Bearer 방식으로 인증 처리를 하되, 전송할 토큰을 JWT를 이용하여 포맷 변환하여 보내어 보안을 높이도록 할 것입니다.


2. Bearer + JWT

Bearer Auth는 설정한 토큰 값을 그대로 헤더에 포함시킵니다.
만일 사용자 인증에 필요한 정보를 Bearer에 그대로 넣는다면 정보가 쉽게 노출되겠죠?

우리는 보안을 높이기 위해 인증에 필요한 정보를 암호화를 거친 후 토큰으로 만들어, 이 토큰을 헤더에 설정할 것입니다.

이번 시간에는 JWT Format을 이용하여 토큰을 암호화 하는 방법을 알아볼 것입니다.

2-1) JWT?

JSON Web Token

전달하고자하는 정보를 안전하게 전송하기 위핸 웹표준(RFC 7519) 방식으로, 인증에 필요한 중요정보(api key, api secret)부터, 만료일, 발행자, 암호화 알고리즘과 같은 기본 정보까지 포함하고 있습니다.

JWT 토큰 내에 만료일이나 인증정보를 가지고 있기 때문에, 서버에서 인증을 위한 별도의 세션 처리를 할 필요가 없습니다.

JWT는 3가지(header, payload, signatue) 정보가 .로 구분되어 합쳐진 형태를 가지고 있습니다.

122

header에는 암호화 알고리즘과 토큰 타입 정보가 들어가고,

{
	"alg": "HS256",
	"typ": "JWT"
}

payload에는 토큰에 대한 다양한 정보들이 들어갑니다.
이때, header와 payload는 json문자열을 base64로 변환하여 jwt에 포함됩니다.

base64는 디코딩하면 바로 정보가 노출되기 때문에, header와 payload에는 기밀정보는 넣으면 됩니다.

{
	"iss": "demoApp",
	"iat": 1605429551,
	"exp": 1605429572,
	"apiKey": "lf2McyT3V5gDu2pNNm4VxmX3C2mezX3s"
}

payload에는 iss(issuer, 발급자), iat(issue at, 발급일), exp(expiration time 만료일시) 의 정보를 넣을 수 있습니다.

각 정보들은 옵션으로, 토큰에 넣고 싶은 정보들만 넣으면 됩니다.
그리고 apiKey와 같은 custom 키도 넣어도 됩니다.

마지막으로 verify signature에는 header와 payload. 그리고 secret-key(비밀키)를 이용하여 헤더에서 설정한 암호화 알고리즘 방식(HS256)을 이용하여 암호화를 하여 만듭니다.

HMACSHA256(
	base64UrlEncode(header) + "." +
	base64UrlEncode(payload),
	your-256-bit-secret
)

※ HS256는 해시 알고리즘의 일종으로, base64와 같이 임의로 디코딩을 할 수 없습니다.

2-2) JWT 라이브러리 추가

JWT 공식 홈페이지로 가면, 검증된 JWT token 라이브러리들을 확인할 수 있습니다.
직접 JWT 토큰을 처음부터 생성해도 되지만, 편리한 라이브러리들이 이미 제공되고 있으니 이 라이브러리들을 이용하는 것을 권장합니다.

각 라이브러리마다 지원하는 키와 암호화 목록도 나와있으니 확인해보고 프로젝트에 적합한 라이브러리를 사용하면 됩니다.

121

저는 io.jsonwebtoken 라이브러리를 이용하였습니다.

pom.xml에 dependency 정보를 추가해줍니다.

<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.11.2</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-jackson</artifactId>
  <version>0.11.2</version>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-impl</artifactId>
  <version>0.11.2</version>
  <scope>runtime</scope>
</dependency>

2-3) JwtBuilder 구조

인증에 이용할 JWT 토큰을 생성하는 api를 만들어 봅시다.

api를 만들기 전에, JWT 토큰을 만드는 JwtBuilder에 대해 간단한 설명을 하겠습니다.
io.jsonwebtoken.Jwts의 builder를 이용하여 만드는 이 토큰은 최종 반환형태는 String입니다.
위에서 설명했던 header와 payload(claim), signature를 설정할 수 있습니다.

import io.jsonwebtoken.Jwts;

SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes("UTF-8"));

Map<String, Object> header = new HashMap<>();
header.put("typ", "JWT");
header.put("alg", "HS256");

String jwt = Jwts.builder()
	.setHeader(header)
	.setIssuer("demoApp")
	.setIssuedAt(new Date())
	.setExpiration(new Date() + 1800000L))
	.claim("apiKey", apiKey)
	.signWith(key)  
	.compact();

header map에 사용할 알고리즘과 토큰타입을 넣습니다.
payload에 설정할 수 있는 예약된 키 중, io.jsonwebtoken 에서 사용가능한 키는 setExpiration 같이 메서드 형태로 제공됩니다.
설정하고자 하는 payload 키값들을 위와같이 설정해줍니다.

그리고, apiKey 같이 custom 키를 설정하고 싶다면, claim 메서드를 이용하여 설정하면 됩니다.

signature를 만들때 사용되는 secretKey는 signWith에 설정하면 됩니다.

그 밖에 JwtBuilder에서 제공하고 있는 메서드들입니다.
123


3. Bearer + JWT api에 적용

Jwt 토큰 생성방법을 알아봤으니, 이제 demo 애플리케이션에 적용해봅시다.

3-1) 프로퍼티에 jwt 설정 추가

토큰 타입(JWT)나 signature 암호화 알고리즘(HS256), 그리고 토큰 생성에 필요한 기타 정보를 프로퍼티에 설정합니다.

demo:
  api: /api/v1
  url: 'http://localhost:${server.port}'
  version: '@project.version@'
  token:
    typ: JWT
    alg: HS256
    api-key: lf2McyT3V5gDu2pNNm4VxmX3C2mezX3s
    secret-key: GYghbwpVZ4tZtbHu4Bdh8EBhAQj8EKax

저의 경우, payload 구성 중 apiKey라는 값을 별도로 추가하여, 토큰의 유효성 검사에 이용하고자 합니다.

프로퍼티에 apiKey와 signature 생성에 필요한 secretKey도 함께 추가하였습니다.
(apiKey와 secretKey는 32bit 난수값으로 설정하였습니다.)

3-2) token 생성 api

토큰 생성을 위한 메서드를 추가합니다.

@RequestMapping(value = "/token")
@Tag(name = "token", description = "Bearer JWT Token API")
@ConfigurationProperties(prefix = "demo.token")
@RequiredArgsConstructor
@RestController
public class TokenController {

	@Setter private String typ;
	@Setter private String alg;
	@Setter private String apiKey;
	@Setter private String secretKey;

	@Operation(description = "JWT token 생성")
	@PostMapping(value = "")
	public ResponseEntity<? extends BasicResponse> createToken(@RequestBody TokenRequest tokenRequest) throws InvalidKeyException, UnsupportedEncodingException {
		if(StringUtils.isBlank(tokenRequest.getApikey()) || StringUtils.isBlank(tokenRequest.getNonce())) {
			return ResponseEntity.badRequest().body(new ErrorResponse("apikey 또는 nonce가 없습니다.", "400"));
		} else if(!apiKey.equals(tokenRequest.getApikey())) {
			return ResponseEntity.badRequest().body(new ErrorResponse("apikey가 일치하지 않습니다.", "400"));
		}

		Date now = new Date();
		long nonce = Long.valueOf(tokenRequest.getNonce()) , expTime = 1800000L;	// 유효시간 : 30m
		try {
			if(nonce < now.getTime() - expTime)
				return ResponseEntity.badRequest().body(new ErrorResponse("nonce값이 유효하지 않습니다.", "400"));
		} catch(Exception e) {
			return ResponseEntity.badRequest().body(new ErrorResponse("nonce값은 millisecond값으로 설정해야 합니다.", "400"));
		}

		SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes("UTF-8"));

		Map<String, Object> header = new HashMap<>();
		header.put("typ", typ);
		header.put("alg", alg);

		String jwt = Jwts.builder()
			.setHeader(header)
			.setIssuer("demoApp")
			.setIssuedAt(now)
			.setExpiration(new Date(nonce + expTime))
			.claim("apiKey", apiKey)
			.signWith(key)  
			.compact();
		return ResponseEntity.ok().body(new TokenResponse(jwt));
    }

	@Getter @Setter
	@Schema(name = "TokenRequest")
	public static class TokenRequest {
		private String apikey;
		@Schema(description = "현재시각(단위: millisecond)") private String nonce;
	}

	@Getter
	public class TokenResponse extends BasicResponse {
		@Schema(description = "JWT 토큰") private String token;

		public TokenResponse(String token) {
			this.token = token;
		}
	}
}

typ, alg, apiKey, secretKey는 demo.token 프로퍼티에 설정된 값을 가져옵니다. (ln 3, ln 8-11)

Token 발급은 POST 메서드 방식을 이용하고, 현재 시각에 대한 millisecond값과 apiKey를 입력 받습니다.(ln 15)

토큰의 유효시간은 nonce + 30분으로 설정하였습니다. (ln 23, 41)
때문에, nonce값이 현재 시각-30분 보다 작을 경우 400 에러를 발생시켰습니다. (ln 25-26)

apiKey는 custom데이터이기 때문에 .claim 메서드를 이용하여 JWT 토큰에 설정합니다. (ln 42)

새로 추가한 token 발급 api를 Swagger v3(OpenAPI 3) 문서에 표현하기 위해 springdoc.paths-to-match/token path를 추가합니다.

springdoc:
  api-docs:
    path: /api-docs
  default-consumes-media-type: application/json
  default-produces-media-type: application/json
  swagger-ui:
    operations-sorter: method
    tags-sorter: alpha
    path: /swagger-ui.html
    disable-swagger-default-url: true
    display-query-params-without-oauth2: true
  paths-to-match:
  - /api/v1/**
  - /test/**
  - /token

3-3) @Aspect를 이용한 token 유효성 검사

이전시간에서 @Asepect를 이용하여 Basic Authentication 헤더 검사를 했었습니다.
이제 이 토큰 유형을 Bearer로 바꾸고, 토큰의 유효성을 검사하여, token이 invalid할 경우, Exception을 발생시키도록 합니다.
※ 관련 : [Spring Boot Tutorial] 16. Swagger v3에 HTTP 기본인증(Basic Authentication) 설정하기 - 4

@ConfigurationProperties(prefix = "demo.token")
@Aspect
@Component
public class AuthorizationAspect {

	@Setter private String apiKey;
	@Setter private String secretKey;

	@Before("execution(public * me.jiniworld.demo.controllers.api.v1..*Controller.*(..)) ")
	public void insertAdminLog(JoinPoint joinPoint) throws WeakKeyException, UnsupportedEncodingException, TokenExpiredException {
		SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes("UTF-8"));

		HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();		
		String authorization = request.getHeader("Authorization");
		if(StringUtils.isBlank(authorization)){
			throw new AuthorizationHeaderNotExistsException();
		}
		if(Pattern.matches("^Bearer .*", authorization)) {
			authorization = authorization.replaceAll("^Bearer( )*", "");
			Jws<Claims> jwsClaims = Jwts.parserBuilder()
					.setSigningKey(key)
					.build()
					.parseClaimsJws(authorization);

			if(jwsClaims.getBody() != null) {
				Claims claims = jwsClaims.getBody();
				if(!claims.containsKey("apikey") || !apiKey.equals(claims.get("apikey").toString())
						|| claims.getExpiration() == null) {
					throw new InvalidTokenException();
				}
				long exp = claims.getExpiration().getTime();
				if(exp < new Date().getTime()) {
					throw new TokenExpiredException();
				}
			}
		} else {
			throw new InvalidTokenException();
		}
	}
}

java.util.regex.Pattern 을 이용하여 Authorization 헤더의 prefix를 체크하고, (ln 18)
secretKey을 이용하여 토큰을 복호화합니다.(ln 20-23)

만일, 토큰 정보에 apiKey나 nonce가 없다면 InvalidTokenException 익셉션을 발생시키고, (ln 27-30)
토큰의 유효시간이 초과했을 경우에는 TokenExpiredException 익셉션을 발생시킵니다. (ln 31-34)


4. Swagger 테스트

Swagger를 열어 token 생성 및 api 테스트를 해봅니다.

115

apiKey와 nonce를 넣어 token을 발급받고,

124
Authorize 버튼을 눌러 발급받은 토큰값을 넣어줍니다.

4.1. Access-Control-Allow-Headers 설정2021.09.15 수정

Swagger에서 user 조회 api 를 실행해 봅니다.

126-2

그런데, 위와같이 response가 내려오지 않고 있습니다.

로그에는 아래와같이, GET 메서드가 아닌 OPTIONS만 호출되고 있네요.

2021-09-15 15:12:25.895 -DEBUG [ http-nio-8989-exec-7] o.s.w.f.CommonsRequestLoggingFilter      : Before request [OPTIONS /api/v1/users/2, client=127.0.0.1, headers=[host:"127.0.0.1:8989", connection:"keep-alive", accept:"*/*", access-control-request-method:"GET", access-control-request-headers:"authorization", origin:"http://localhost:8989", user-agent:"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36", sec-fetch-mode:"cors", sec-fetch-site:"cross-site", sec-fetch-dest:"empty", referer:"http://localhost:8989/", accept-encoding:"gzip, deflate, br", accept-language:"ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7"]]
2021-09-15 15:12:25.895 -DEBUG [ http-nio-8989-exec-7] o.s.web.servlet.DispatcherServlet        : OPTIONS "/api/v1/users/2", parameters={}
2021-09-15 15:12:25.896 -DEBUG [ http-nio-8989-exec-7] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to me.jiniworld.demo.controllers.api.v1.UserController#select(long)
2021-09-15 15:12:25.896 -DEBUG [ http-nio-8989-exec-7] o.s.web.servlet.DispatcherServlet        : Completed 200 OK
2021-09-15 15:12:25.896 -DEBUG [ http-nio-8989-exec-7] o.s.w.f.CommonsRequestLoggingFilter      : After request [OPTIONS /api/v1/users/2, client=127.0.0.1, headers=[host:"127.0.0.1:8989", connection:"keep-alive", accept:"*/*", access-control-request-method:"GET", access-control-request-headers:"authorization", origin:"http://localhost:8989", user-agent:"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36", sec-fetch-mode:"cors", sec-fetch-site:"cross-site", sec-fetch-dest:"empty", referer:"http://localhost:8989/", accept-encoding:"gzip, deflate, br", accept-language:"ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7"]]

이는 ResponseHeader에 허용된 header 목록에 Authorization 헤더를 포함하지 않아서 발생된 문제입니다.

Access-Control-Allow-HeadersAuthorization를 추가해줍니다.

@Component
public class HttpsFilter implements Filter {

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		HttpServletResponse res = (HttpServletResponse) response;
		res.setHeader("Access-Control-Allow-Origin", "*");
		res.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, PUT, PATCH, OPTIONS");
		res.setHeader("Access-Control-Max-Age", "3600");
		res.setHeader("Access-Control-Allow-Headers", "X-Requested-With, Origin, Content-Type, Accept, X-XSRF-TOKEN, Authorization");
        chain.doFilter(request, response);
	}

}

4.2. swagger 테스트

다시 user 조회 api를 실행해봅니다.

126
Authorization 헤더에 Bearer JWT 토큰이 들어있으며, 사용자 조회도 정상적으로 이뤄졌습니다.

만일 유효기간이 지난 토큰을 사용한 채, /api 를 조회할 경우 아래와 401 응답을 발생시킵니다.

127


GitHub에서 demo 프로젝트를 다운받아 볼 수 있습니다.

728x90
반응형