spring

[Spring Boot] Swagger, Bearer + JWT(JSON Web Token) 적용

박진만 2023. 3. 22. 11:45
반응형

- Spring Boot 3.0.4, springdoc 2.0.4 기준

- JWT(JSON Web Token)
JSON 웹 토큰(JSON Web Token, JWT)은 선택적 서명 및 선택적 암호화를 사용하여 데이터를 만들기 위한 인터넷 표준으로 페이로드는 클레임(claim), 표명(assert)을 처리하는 JSON을 보관하고 있다.
전달하고자하는 정보를 안전하게 전송하기 위핸 웹표준(RFC 7519) 방식으로, 인증에 필요한 중요정보(api key, api secret)부터, 만료일, 발행자, 암호화 알고리즘과 같은 기본 정보까지 포함.
JWT 토큰 내에 만료일이나 인증정보를 가지고 있기 때문에, 서버에서 인증을 위한 별도의 세션 처리를 할 필요가 없다.

3가지(header, payload, signatue) 정보가 . 으로 구분되어 합쳐진 형태.

1. build.gradle
- implementation 추가

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5', 'io.jsonwebtoken:jjwt-jackson:0.11.5'

2. application.properties
- jwt 설정 내용 추가 : apiKey, secretKey 에 설정한 키 값을 입력한다.

#jwt
jwt.token.typ=JWT
jwt.token.alg=HS256
jwt.token.apiKey=xxxxxxxxx
jwt.token.secretKey=xxxxxxxx

3. SwaggerConfig.java
- swagger API 설정 : 기본 정보 설정 및 JWT 설정

import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import javax.crypto.SecretKey;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.propertyeditors.StringTrimmerEditor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
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;

import com.test.common.request.TokenRequest;
import com.test.common.response.ErrorResponse;
import com.test.common.response.TokenResponse;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.Setter;

@RequestMapping(value = "/token")
@Tag(name = "token", description = "Bearer JWT Token API")
@ConfigurationProperties(prefix = "jwt.token")
@RequiredArgsConstructor
@RestController
public class TokenController {
	
    private Logger logger = LoggerFactory.getLogger(this.getClass());

    @Setter 
    private String typ;
    @Setter 
    private String alg;
    @Setter 
    private String apiKey;
    @Setter 
    private String secretKey;
	
    // input 스트링으로 들어오는 String 데이터들의 white space를 trim 해주는 역할을 한다.
    // 모든 요청이 들어올 때마다 해당 method를 거침 (node의 middleware 같은 것)
    @InitBinder
    public void initBinder(WebDataBinder dataBinder) {
        StringTrimmerEditor stringTrimmerEditor = new StringTrimmerEditor(true);
        dataBinder.registerCustomEditor(String.class, stringTrimmerEditor);
    }

    @Operation(description = "JWT token 생성")
    @Parameters({
        @Parameter(name = "apikey", description = "apikey", required = true),
        @Parameter(name = "nonce", description = "현재시각(단위: millisecond)", required = true)
    })
    @PostMapping(value = "")
    public ResponseEntity<? extends Object> createToken(
            @Parameter(hidden = true) @RequestBody TokenRequest tokenRequest
            , HttpServletRequest request
            ) throws Exception {
		
        ServletInputStream inputStream = request.getInputStream();
        String requestBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
        logger.debug("TokenController callApiJsonRequest requestBody : " + requestBody);

        logger.debug("TokenController createToken tokenRequest.toString() : " + tokenRequest.toString());
        logger.debug("TokenController createToken tokenRequest.getApiKey() : " + tokenRequest.getApiKey());
        logger.debug("TokenController createToken tokenRequest.getNonce() : " + tokenRequest.getNonce());

        if(StringUtils.isBlank(tokenRequest.getApiKey()) || tokenRequest.getNonce() == null) {
            return ResponseEntity.badRequest().body(new ErrorResponse("400", "apiKey 또는 nonce가 없습니다.", null));
        } else if(!apiKey.equals(tokenRequest.getApiKey())) {
            return ResponseEntity.badRequest().body(new ErrorResponse("400", "apiKey가 일치하지 않습니다.", null));
        }

        Date now = new Date();
        long nonce = tokenRequest.getNonce();
        long expTime = 1800000L; // 유효시간 : 30분

        try {

            if(nonce < now.getTime() - expTime) {
                return ResponseEntity.badRequest().body(new ErrorResponse("400", "nonce값이 유효하지 않습니다.", null));
            }

        } catch(Exception e) {
            return ResponseEntity.badRequest().body(new ErrorResponse("400", "nonce값은 millisecond값으로 설정해야 합니다.", null));
        }

        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("testApp")
            .setIssuedAt(now)
            .setExpiration(new Date(nonce + expTime))
            .claim("apiKey", apiKey)
            .signWith(key)
            .compact();

        return ResponseEntity.ok().body(new TokenResponse(jwt));
		
    }

}

5. TokenRequest.java
- 요청 값 관리 : 요청 항목 정의 

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter @Setter @ToString
@Schema(name = "TokenRequest")
public class TokenRequest {
	
    private String apiKey;

    @Schema(description = "현재시각(단위: millisecond)")
    private Long nonce;
	
}

6. TokenResponse.java
- 응답 값 관리 : 응답 항목 정의

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;

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

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

7. AuthorizationAspect.java
- @Aspect 를 이용한 token 유효성 검사
- @Before 를 통해서 TokenController 를 제외한 모든 Controller 요청에 JWT 인증 적용

import java.io.UnsupportedEncodingException;
import java.util.Date;
import java.util.regex.Pattern;

import javax.crypto.SecretKey;

import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import com.test.common.exception.AuthorizationHeaderNotExistsException;
import com.test.common.exception.InvalidTokenException;
import com.test.common.exception.TokenExpiredException;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.WeakKeyException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.Setter;

@ConfigurationProperties(prefix = "jwt.token")
@Aspect
@Component
public class AuthorizationAspect {
	
    private Logger logger = LoggerFactory.getLogger(this.getClass());

    @Setter 
    private String apiKey;

    @Setter 
    private String secretKey;
    
    @Before("execution(* com.test..*Controller.*(..)) && !target(com.test.common.web.TokenController)")
    public void checkToken(JoinPoint joinPoint) throws WeakKeyException, UnsupportedEncodingException, TokenExpiredException {

        logger.debug("AuthorizationAspect checkToken");

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

                logger.debug("AuthorizationAspect checkToken claims : " + claims);

                if(!claims.containsKey("apiKey") || !apiKey.equals(claims.get("apiKey").toString())
                        || claims.getExpiration() == null) {
                    throw new InvalidTokenException();
                }

                long exp = claims.getExpiration().getTime();

                logger.debug("AuthorizationAspect checkToken exp : " + exp);
                logger.debug("AuthorizationAspect checkToken new Date().getTime() : " + new Date().getTime());

                if(exp < new Date().getTime()) {
                    throw new TokenExpiredException();
                }

            }

        } else {
            throw new InvalidTokenException();
        }

    }
	
}

8. AuthorizationHeaderNotExistsException.java
- authorization 정보가 없을 때 발생 할 Exception

public class AuthorizationHeaderNotExistsException extends RuntimeException {

    private static final long serialVersionUID = -1013888520992595369L;

    public AuthorizationHeaderNotExistsException() {
        super("Authorization 헤더가 없습니다.");
    }
	
}

9. InvalidTokenException.java
- token 값이 유효하지 않을 때 발생 할 Exception

public class InvalidTokenException extends RuntimeException {

    private static final long serialVersionUID = 346116252681253824L;

    public InvalidTokenException() {
        super("token 값이 유효하지 않습니다.");
    }
	
}

10. TokenExpiredException.java
- token 유효기간이 만료 되었을 때 발생 할 Exception

public class TokenExpiredException extends RuntimeException {

    private static final long serialVersionUID = -3644870070470966659L;

    public TokenExpiredException() {
        super("토큰이 만료되었습니다.");
    }
	
}

11. Swagger ui 화면 접속

- Authorize 버튼과 API 목록 우측에 자물쇠 모양이 열려있는 모양으로 생성됨

- API 요청 시 인증 오류 발생

- token API 정보 열기 → Try it out 버튼 클릭 → apiKey, nonce 입력 후 Execute 버튼 클릭 → token 생성

- 생성된 token 값 복사

- Authorize 버튼 클릭

- 복사한 token 값 입력 후 Authorize 클릭

- Close 버튼 클릭

- API 목록 우측에 자물쇠 모양이 닫힌 모양으로 변경됨

- header 에 authorization: Bearer [생성한 token] 포함하여 API 요청 → 정상 응답

반응형