관리 메뉴

bright jazz music

blog37 : JWT를 이용한 인증 - 암호화 키 분리 및 개선 본문

Projects/blog

blog37 : JWT를 이용한 인증 - 암호화 키 분리 및 개선

bright jazz music 2023. 2. 23. 07:52

지난 번에는 application.yml에 정보를 넣어두고 그 정보를 AppConfig라는 클래스에 바인딩 시키는 과정을 실습하였다.

yml의 문법을 사용하여 문자열, 숫자, 해쉬맵 뿐만 아니라 클래스 인스턴스에 바인딩하였다.

 

  • yml에 정보 기재
  • AppConfig에 @ConfigurationProperties() +@Data
  • 메인 클래스에 @EnableConfigurationProperties(바인딩 시킬 클래스)

 

이번에는 지난 번 포스팅의 내용을 참고하여 JWT를 생성하기 위한 key를 application.yml에 작성하고 그것을 AppConfig.java에 바인딩하여 사용하는 과정을 진행한다.

 

private final String KEY = "Kz06PMZdP03FQVS3m8Jg9gKSEQjV4/NePMOq1F0GNH4=";

 

이 키는 랜덤생성한 것이 아니라 자바에서 아래의 과정을 통해 만들어 준 것이다,

SecreteKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);

이렇게 하면 키가 내부적으로 생성된다. 이를 스트링으로 알아볼 수는 없기 때문에 아래의 코드를 통해 바이트로 가져온다.

key.getEncoded();

이를 Base64.getEncoder().encode(key.getEncoded()); 로 스트링으로 만든 값이 위의 String key 이다. 이 키의 값을 최초에 한 번 만드는 작업이 필요하다.

 

jwt를 만들어 서명값에 들어가는 key는 바이트 값으로 들어간다. 따라서 아래의 과정을 통해서 만들어진 키를 다시 바이트 값으로 만들어 준다음에 jwt의 서명값에 넣어준다.

 

SecretKey key = keys.hmacShakeyFor(Base64.getDecoder().decode(KEY));

 

jwt만들기

String jws = Jwts.builder().setSubject(String.valueOf(userId)).signWith(key).compact();

지금까지의 과정은 AuthController에서 jwt를 만들어서 키를 발급하는 과정이었다. 즉, 암호화의 과정이었다.

 

 AuthResolver에서는 복호화 작업을 진행한다. 동일한 스트링 키 값을

byte[] decodedKey = Base64.decodeBase64(KEY);

를 통해서 바이트로 만들어 준 뒤, 그것을 사용하여 jwt를 복호화 해준다. 그리고 그 내부의 값들을 사용한다.

 

이렇게 사용하면 AuthController와 AuthResolver에서 똑같은 값의 상수를 가지고 있어야 한다. 이 키를 application.yml에 가지고 있는 것도 보안적으로 훌륭한 것은 아니지만 일단 해당 방식으로 실습한다.

 

 

1. yml에 키값 넣어주기

#application.yaml
#jwt 설정
catnails:
  jwt-key : Kz06PMZdP03FQVS3m8Jg9gKSEQjV4/NePMOq1F0GNH4=

 

2. AppConfig.java 변경


//AppConfig.java

//@Configuration
@Data
@ConfigurationProperties(prefix = "catnails") //application.yml
public class AppConfig {

   public String jwtKey;
}

 

 

3. AuthController에 AppConfig 주입

//AuthController.java

package com.endofma.blog.controller;

import com.endofma.blog.config.AppConfig;
import com.endofma.blog.request.Login;
import com.endofma.blog.response.SessionResponse;
import com.endofma.blog.service.AuthService;
import io.jsonwebtoken.Jwts;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
//import org.springframework.http.HttpHeaders;
//import org.springframework.http.ResponseCookie;
//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.RestController;

//jwt
import java.security.Key;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.SignatureAlgorithm;


import javax.crypto.SecretKey;
import java.time.Duration;
import java.util.Base64;


@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {
    
    private final AuthService authService;
    private final AppConfig appConfig;

//AuthController.java
    @PostMapping("/auth/login")
//    public ResponseEntity<Object> login(@RequestBody Login login){
    public SessionResponse login(@RequestBody Login login){

        Long userId = authService.signin(login);

        SecretKey key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(appConfig.jwtKey));

        String jws = Jwts.builder()
                .setSubject(String.valueOf(userId))
                .signWith(key)
                .compact();

        return new SessionResponse(jws);

    }
}

key를 appConfig.jwtKey로 변경하였다.

이제 http 요청을 날려보자.

### auth.http

POST http://localhost:8080/auth/login
Content-Type: application/json

{
  "email" : "catnails@gmail.com",
  "password" : "1234"
}

 

값이 들어왔다.
accessToken 반환 역시 성공하였다.

 

4. AuthResolver에 AppConfig 주입

//AuthResolver.java

package com.endofma.blog.config;

import com.endofma.blog.config.data.UserSession;
import com.endofma.blog.domain.Session;
import com.endofma.blog.exception.Unauthorized;
import com.endofma.blog.repository.SessionRepository;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.tomcat.util.codec.binary.Base64;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.util.Optional;


@Slf4j
@RequiredArgsConstructor
public class AuthResolver implements HandlerMethodArgumentResolver {

    //주입
    private final SessionRepository sessionRepository;
    private final AppConfig appConfig;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        //컨트롤러에서 사용할 어노테이션이나 DTO가 사용자가 사용하려는 것이 맞는지, 지원하는지 체크한다.
        return parameter.getParameterType().equals(UserSession.class); //UserSession 클래스를 사용하는 것이 맞는지 확인. 맞으면 컨트롤러에 값 할당
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        log.info(">>> {}", appConfig.toString()); //추가

        //실제로 해당 DTO에 값을 세팅해준다.

        String jws = webRequest.getHeader("Authorization"); //헤더의 값으로 확인해준다.
        if (jws == null || jws.equals("")){
            throw new Unauthorized();
        }
        
        //appConfig.jwtKey사용
        byte[] decodedKey = Base64.decodeBase64(appConfig.jwtKey); //스트링 값이었던 것을 바이트 값으로 변환한다. setSigningKey(String)이 폐기되어서.

        try {
            Jws<Claims> claims = Jwts.parserBuilder()
//                    .setSigningKey(KEY) //사인값 추가 deprecated
                    .setSigningKey(decodedKey) //파라미터로 바이트 값이 들어간다.
                    .build()
                    .parseClaimsJws(jws);

            String userId = claims.getBody().getSubject();
            return new UserSession(Long.parseLong(userId));

            //OK, we can trust this JWT
        } catch (JwtException e) {
            throw new Unauthorized();
            //don't trust the JWT!
        }

    }
}

 

###post.http
### 주석 #3개

GET http://localhost:8080/foo
Content-Type: application/json
Authorization: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.MFZVLmx0p7Y-P16K7gXRVQaaC3ghTpCqaivUoBEX0vM

근데 토큰값이 같다.  어쨌든 일단 보낸다.

 

현재는 요청과 응답이 성공적으로 전송된다.

사실 인증을 여러 차례 보내도 현재는 동일한 토큰 값이 반환된다. 이는 jwt 생성 과정에서 해싱을 할 때 동일한 값이 들어가기 때문이다.

String jws = Jwts.builder()
        .setSubject(String.valueOf(userId))
        .signWith(key)
        .compact();

이는 보안적 측면에서 권장되지 않는다. 만료되지 않는 토큰이 탈취되는 경우 계속해서 재사용될 가능성이 존재하기 때문이다.

 

 

5. jwt 생성 시 동일한 값이 생성되지 않도록 코드 변경

//AuthController.java

package com.endofma.blog.controller;

import com.endofma.blog.config.AppConfig;
import com.endofma.blog.request.Login;
import com.endofma.blog.response.SessionResponse;
import com.endofma.blog.service.AuthService;
import io.jsonwebtoken.Jwts;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
//import org.springframework.http.HttpHeaders;
//import org.springframework.http.ResponseCookie;
//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.RestController;

//jwt
import java.security.Key;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.SignatureAlgorithm;


import javax.crypto.SecretKey;
import java.time.Duration;
import java.util.Base64;
import java.util.Date;


@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;
    private final AppConfig appConfig;

//AuthController.java
    @PostMapping("/auth/login")
//    public ResponseEntity<Object> login(@RequestBody Login login){
    public SessionResponse login(@RequestBody Login login){

        Long userId = authService.signin(login);

        SecretKey key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(appConfig.jwtKey));

        String jws = Jwts.builder()
                .setSubject(String.valueOf(userId))
                .signWith(key)
                .setIssuedAt(new Date()) //생성 시마다 값이 달라지도록 .setIssuedAt()을 추가하였다.
                .compact();

        return new SessionResponse(jws);

    }
}

만료시간도 설정해 줄 수 있지만 .setIssuedAt()을 추가한 것만으로도 매번 값이 바뀔 것이므로 현재는 이렇게만 적용한다.

나중에 .setExpiration()을 추가해 줄 수 있다. 이를 추가하면 AuthResolver에서 만료 여부를 확인할 수 있다.

 

이전과 다른 토큰값이 반환되었다. 이는 일회성이 아니며 요청 시마다 변경된다.

AuthResolver.java

정상적으로 인증을 거쳐 /foo 에 접근한 것을 확인할 수 있다.

 

 

6. 중복 코드 제거

AuthController와 AuthResolver에 스트링 값을 바이트 배열 값으로 변환해 주는 코드가 존재하였다. 따라서 이를 AppConfig클래스에서 아예 바이트 배열을 하도록 setJwtKey와 getJwtKey 메서드를 생성하여 해결하였다.

이제 AuthController와 AuthResover에서 바이트값을 복호화 하는 코드를 삭제하고 AppConig.getJwtKey()를 통해서 값을 가져온다.

 

AuthController의

SecretKey key = Keys.hmacShakeyFor(appConfig.getJwtKey())

코드를 AppConfig 클래스 내부에 만들어 줄 수도 있겠다. 이번에는 x

 

//AppConfig.java
package com.endofma.blog.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import java.util.Base64;
import java.util.List;
import java.util.Map;

//AppConfig.java

//@Configuration
@Data
@ConfigurationProperties(prefix = "catnails") //application.yml
public class AppConfig {

//   private String jwtKey;
   private byte[] jwtKey;

   public void setJwtKey(String jwtKey) {
      this.jwtKey = Base64.getDecoder().decode(jwtKey);
   }
   
   public byte[] getJwtKey(){
      return jwtKey;
   }
}

 

//AuthController.java

package com.endofma.blog.controller;

import com.endofma.blog.config.AppConfig;
import com.endofma.blog.request.Login;
import com.endofma.blog.response.SessionResponse;
import com.endofma.blog.service.AuthService;
import io.jsonwebtoken.Jwts;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
//import org.springframework.http.HttpHeaders;
//import org.springframework.http.ResponseCookie;
//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.RestController;

//jwt
import java.security.Key;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.SignatureAlgorithm;


import javax.crypto.SecretKey;
import java.time.Duration;
import java.util.Base64;
import java.util.Date;


@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;
    private final AppConfig appConfig;

//AuthController.java
    @PostMapping("/auth/login")
//    public ResponseEntity<Object> login(@RequestBody Login login){
    public SessionResponse login(@RequestBody Login login){

        Long userId = authService.signin(login);

//        SecretKey key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(appConfig.jwtKey));
        SecretKey key = Keys.hmacShaKeyFor(appConfig.getJwtKey());

        String jws = Jwts.builder()
                .setSubject(String.valueOf(userId))
                .signWith(key)
                .setIssuedAt(new Date()) //생성 시마다 값이 달라지도록 .setIssuedAt()을 추가하였다.
                .compact();

        return new SessionResponse(jws);

    }
}

 

//AuthResolver.java

package com.endofma.blog.config;

import com.endofma.blog.config.data.UserSession;
import com.endofma.blog.domain.Session;
import com.endofma.blog.exception.Unauthorized;
import com.endofma.blog.repository.SessionRepository;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.tomcat.util.codec.binary.Base64;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.util.Optional;


@Slf4j
@RequiredArgsConstructor
public class AuthResolver implements HandlerMethodArgumentResolver {

    //주입
    private final SessionRepository sessionRepository;
    private final AppConfig appConfig;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        //컨트롤러에서 사용할 어노테이션이나 DTO가 사용자가 사용하려는 것이 맞는지, 지원하는지 체크한다.
        return parameter.getParameterType().equals(UserSession.class); //UserSession 클래스를 사용하는 것이 맞는지 확인. 맞으면 컨트롤러에 값 할당
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        log.info(">>> {}", appConfig.toString()); //추가

        //실제로 해당 DTO에 값을 세팅해준다.

        String jws = webRequest.getHeader("Authorization"); //헤더의 값으로 확인해준다.
        if (jws == null || jws.equals("")){
            throw new Unauthorized();
        }

        //appConfig.jwtKey사용
//        byte[] decodedKey = Base64.decodeBase64(appConfig.jwtKey); //스트링 값이었던 것을 바이트 값으로 변환한다. setSigningKey(String)이 폐기되어서.

        try {
            Jws<Claims> claims = Jwts.parserBuilder()
//                    .setSigningKey(KEY) //사인값 추가 deprecated
                    .setSigningKey(appConfig.getJwtKey()) //파라미터로 바이트 값이 들어간다.
                    .build()
                    .parseClaimsJws(jws);

            String userId = claims.getBody().getSubject();
            return new UserSession(Long.parseLong(userId));

            //OK, we can trust this JWT
        } catch (JwtException e) {
            throw new Unauthorized();
            //don't trust the JWT!
        }

    }
}

 

 

 

 

 

 

 

 

 

 

 

 

 

Comments