Spring으로 Token 받기

2021. 4. 11. 18:40Spring

쿠키와 세션

들어가기전에

  • 인증을 먼저 알고가자!

    • 인증은 Front-end 관점에서 봤을 때 사용자의 로그인, 회원가입과 같이 사용자의 도입부분을 가리키곤한다. 반면 Back-end 관점에서 봤을 때는 모든 API 요청에 대해 사용자를 확인하는 작업이다.
    • 사용자A와 사용자 B가 앱을 사용한다고 가정하자. 두 사용자는 기본적을로 정보가 다르고 보유하고 있는 컨텐츠도 다르다. 따라서 서버에서는 A,B가 요청을 보냈을 때 누구의 요청인지를 정확히 알아야한다. 만일 그렇지 않다면 자신의 정보가 타인에게 유출되는 최악의 상황이 발생한다. 그렇기에 앱(Front-end) 에서는 자신이 누구인지를 알만한 단서를 서버에 보내야 하며, 서버(Back-end) 는 그 단서를 파악해 각 요청에 맞는 데이터를 뿌려주게 된다.
  • 쿠키와 세션 방식

    2021-03-04-10-38-05.png

    • Session/Cookie 방식의 인증은 기본적으로 세션 저장소를 필요로 합니다.(Redis를 많이 사용). 세션 저장소는 로그인을 했을 때 사용자의 정보를 저장하고 열쇠가 되는 세션 ID값을 만든다. 그리고 HTTP헤더에 실어 사용자에게 돌려보낸다.그러면 사용자는 쿠키로 보관하고 있다 인증이 필요한 요청에 쿠키(Session ID)를 넣어 보낸다. 웹 서버에서는 세션 저장소에 쿠키(Session ID)를 받고 저장되어 있는 정보와 매칭을 시켜 인증을 완료한다.
    • Session은 서버에서 가지고 있는 정보이다. Cookie는 사용자에게 발급된 세션을 열기 위한 열쇠(Session ID)를 의미한다. 만약 쿠키만으로 인증을 사용한다는 말은 서버의 지원은 사용 x , 이는 즉 Client가 인증 정보를 책임지게 된다. 그렇게 되면 HTTP요청을 탈취당할 경우 다 털리게 된다.따라서 보안과는 상관없는 단순한 장바구니나 자동로그인 설정은 유용하게 쓰인다.

JWT란?

  • Session/Cookie 와 함께 모바일과 웹의 인증을 책임지는 대표주자이다. JWT는 Json Web Token의 약자로 인증에 필요한 정보들을 암호화시킨 토큰을 뜻한다. 위의 Session/Cookie 방식과 유사하게 사용자는 Access Token(JWT 토큰)을 HTTP 헤더에 실어 서버로 보내게 된다.

    2021-03-04-11-00-42.png

  • 토큰을 만들기 위해서는 크게 3가지 Header, Payload, Verify Signature가 필요하다.

    • Header : 위 3가지 정보를 암호화할 방식(alg), 타입(type) 등이 들어간다.
    • Payload : 서버에서 보낼 데이터가 들어간다. 일반적으로 유저의 고유 ID값, 유효기간이 들어갑니다.
    • Verify Signature : Base64 방식으로 인코딩한 Header, Payload 그리고 SECRET KEY를 더한 후 서명된다.
    • 최종적인 결과 : Encoded Header + "." + Encoded Payload + "." + Verify Signature
      Header, Payload는 인코딩될 뿐(16진수로 변경), 따로 암호화되지 않습니다. 따라서 JWT 토큰에서 Header, Payload는 누구나 디코딩하여 확인할 수 있습니다. 여기서 누구나 디코딩할 수 있다는 말은 Payload에는 유저의 중요한 정보(비밀번호)가 들어가면 쉽게 노출될 수 있다는 말이 됩니다.
      하지만 Verify Signature는 SECRET KEY를 알지 못하면 복호화할 수 없습니다.
  • JWT 순서

2021-03-04-11-05-49.png

  • 저장된 토큰을 가지고 나의 정보 요청

    이제 로그인이 되었고, 지금의 상태는 Local Storage에 토큰이 저장되어 있는 상태입니다.

    이제 프런트엔드에서 토큰을 헤더에 담아 백엔드로 요청을 보내고(예시로 내 정보를 요청합니다), 백엔드에서는 토큰 안에 담긴 정보를 확인하여 그에 해당하는 응답을 내려줍니다.

    여기서 주목해야 할 점은 백엔드에서 토큰 안에 담긴 정보를 확인하는 것입니다.
    이 단계에서 Interceptor의 개념이 등장합니다.

    인터셉터는 요청이 Controller로 가기 전에 요청을 가로채 작업을 처리할 수 있습니다.
    여기서는 요청 안에 토큰이 있는지 확인하고, 토큰 안에 있는 내용을 디코딩하여 요청안에 다시 넣어주는 작업을 합니다.
    Interceptor와 대조되는 Filter라는 게 있는데, 자세한 설명은 생략하고 여기서는 Interceptor를 사용하도록 하겠습니다.


프로젝트에 적용하기

본 프로젝트는 React와 협업 프로젝트입니다.

  1. 로그인으로 토큰(Token) 발급

    @Entity
    @Getter
    @Setter
    @NoArgsConstructor
    @AllArgsConstructor
    public class User extends Timestamped {
        @Id
        @GeneratedValue(strategy = GenerationType.AUTO)
        @Column(name = "USER_ID")
        private Long id;
    
        @Column(nullable = false)
        private String username;
    
        @Column(nullable = false)
        private String password;
    
        @Column(nullable = false)
        private String email;
    
        @Lob
        private String profile_img;
    
        public User(UserLoginRequestDto requestDto) {
            this.username = requestDto.getUsername();
            this.password = requestDto.getPassword();
        }
    
        public User(String username,String password, String email) {
            this.username = username;
            this.password = password;
            this.email = email;
        }
    
    

}


   ### ex) Id = 1, name = "홍길동", pwd = "1234"의 유저가 현재 가입되어있는 상태라고 가정합니다.

   fetch API 로 사용자가 입력한 **홍길동/1234**를 보내면 백엔드에서 생성한 토큰을 응답받아

   > localStorage.setItem("jwt",token.accessToken)

   명령의 통해 localStorage에 저장하는 구조입니다.

   1.2 **Controller**

   ```java
    @PostMapping("/api/login")
       public Object userLogin(@Valid @RequestBody UserLoginRequestDto requestDto) {
           if(!userService.checkUsernameAndPassword(requestDto)) {
               return exceptionController.error("비밀번호가 일치하지 않습니다.");
           }else {
               String token = userService.createToken(requestDto);
               return ResponseEntity.ok().body(new TokenResponse(token, "bearer"));
           }
       }
  • LoginRequest라는name과 pwd를 필드 변수로 가지고 있는 객체를 만들어 프런트에서 보낸 정보를 받아,

    -> 일련의 과정(UserService.createToken)을 거쳐 토큰을 생성하고

    -> 생성된 토큰을 TokenResponse 객체로 감싸 프런트로 보내줍니다.

  • TokenResponse

    @Getter
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    public class TokenResponse {
        private String accessToken;
        private String tokenType;
    }

1.3 JwtTokenProvider

   @Component
   public class JwtTokenProvider {
       private String secretKey = "DANGGEON";
       private long validityInMilliseconds = 30 * 60 * 1000L;

   //    public JwtTokenProvider(@Value("${security.jwt.token.secret-key}") String secretKey, @Value("${security.jwt.token.expire-length}") long validityInMilliseconds) {
   //        this.secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
   //        this.validityInMilliseconds = validityInMilliseconds;
   //    }
       @PostConstruct
       protected void init() {
           secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
       }

       //토큰생성
       public String createToken(String subject) {
           Claims claims = Jwts.claims().setSubject(subject);
           Date now = new Date();

           Date validity = new Date(now.getTime()
                   + validityInMilliseconds);


           return Jwts.builder()
                   .setClaims(claims)//정보저장
                   .setIssuedAt(now) //토큰 발행 시간 정보
                   .setExpiration(validity)
                   .signWith(SignatureAlgorithm.HS512, secretKey.getBytes())// 사용할 암호화 알고리즘과 HS256
                                                                 // signature 에 들어갈 secret값 세팅
                   .compact();
       }

       //토큰에서 값 추출
       public String getSubject(String token) {
           return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
       }

       //유효한 토큰인지 확인
       public boolean validateToken(String token) {
   //        try {
   //            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
   //            if (claims.getBody().getExpiration().before(new Date())) {
   //                return false;
   //            }
   //            return true;
   //        } catch (JwtException | IllegalArgumentException e) {
   //            return false;
   //        }
           try {
               Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
               return !claims.getBody().getExpiration().before(new Date());
           } catch (Exception e) {
               return false;
           }


       }
   }
  • 여기서 핵심은 jwtTokenProvider.createToken입니다.

    jwt방식을 이용하여 토큰을 생성하려면 라이브러리를 추가해주어야 합니다.

    build.gradle dependencies에 추가

    dependencies {
        ...
        implementation 'io.jsonwebtoken:jjwt:0.9.1'
        }
  • 그리고 많은 분들이 secret키와 expire를 properties에 했지만 저는 토큰을 생성해주는곳에 하는게 더 직관적이어서 여기에 했습니다. 이건 개인 차입니다!

  • 2021-04-10-5-21-42.png

    이런식으로 token이 발급되는것을 볼 수 있습니다.!

  1. 토큰으로 내 정보 요청

    2.1 프런트에서 내정보 요청

     fetch("/info",{
            method: 'get',
            headers: {
                'content-type': 'application/json',
                'Authorization': 'Bearer ' + localStorage.getItem("jwt"),
            }
        }).then(res => res.json())
            .then(json => alert("이름 : " + json.name+", 비밀번호 : " + json.pwd))
    • 위의 코드는 /api/profile라는 주소로 요청을 보내고 있고, Controller의 구현의 다음과 같습니다.
   @GetMapping("/api/profile")
       public Object getUserFromToken(HttpServletRequest request) {
           String username = (String) request.getAttribute("username");
           User user = userImageService.findByName(username);
           return user;
       }
  • 여기서 주목할 부분은 fetch API의 headers 부분입니다.

    헤더에 'Authorization': 'Bearer ' + localStorage.getItem("jwt") 으로 토큰을 전달해 줍니다.

2.2 Interceptor

  • 앞서 전체적인 흐름을 다룰 때 언급한 것처럼 요청이 controller에 도달하기 전에 Interceoptor로 요청을 가로채 header에 포함된 토큰의 내용을 디코딩하여 그 내용을 다시 요청으로 담아 controller 전달해 주어야 합니다.

    그러기 위해서는 어떤 요청에 대해서 인터셉터 할지 interceptor를 등록을 해놓아야 합니다.

    이때 WebMvcConfigurer를 이용합니다.

 **WebMVCConfig.java**

 ```java
 @Configuration
 public class WebMVCConfig implements WebMvcConfigurer {
     private final BearerAuthInterceptor bearerAuthInterceptor;

     public WebMVCConfig(BearerAuthInterceptor bearerAuthInterceptor) {
         this.bearerAuthInterceptor = bearerAuthInterceptor;
     }

     public void addInterceptors(InterceptorRegistry registry){
         System.out.println(">>> 인터셉터 등록");
         registry.addInterceptor(bearerAuthInterceptor).addPathPatterns("/api/profile");
         registry.addInterceptor(bearerAuthInterceptor).addPathPatterns("/api/profile/update");
         registry.addInterceptor(bearerAuthInterceptor).addPathPatterns("/api/boards");
     }
 }
 ```

 WebMvcConfig.aaddInterceptors를 보면 '/info'라는 패턴으로 들어오는 요청에 대해서 bearerAuthInterceptor를 등록해주었습니다. 이렇게 하면 애플리케이션이 실행될 때 인터셉터를 등록하고 그 주소로 들어오는 요청을 기다리는 상태가 됩니다.

 [![2021-04-10-5-40-39.png](https://i.postimg.cc/J0y6JnPg/2021-04-10-5-40-39.png)](https://postimg.cc/zHrFYJmS)
  • HandlerInterceptor를 implements 하여 구현합니다.

    BearerAuthIntercepter.java

    @Component
    public class BearerAuthInterceptor implements HandlerInterceptor {
        private AuthorizationExtractor authExtractor;
        private JwtTokenProvider jwtTokenProvider;
    
        public BearerAuthInterceptor(AuthorizationExtractor authExtractor, JwtTokenProvider jwtTokenProvider) {
            this.authExtractor = authExtractor;
            this.jwtTokenProvider = jwtTokenProvider;
        }
    
        @Override
        public boolean preHandle(HttpServletRequest request,
                                 HttpServletResponse response, Object handler) {
            System.out.println(">>> interceptor.preHandle 호출");
            String token = authExtractor.extract(request, "Bearer");
            System.out.println(token);
            if (token == null || token.length() == 0) {
                return true;
            }
    
            if (!jwtTokenProvider.validateToken(token)) {
                throw new IllegalArgumentException("유효하지 않은 토큰");
            }
    
            String username = jwtTokenProvider.getSubject(token);
            request.setAttribute("username", username);
            return true;
        }
    }

    /api/profile 라는 주소로 요청을 보내서 interceptor 가 호출되면 interceptor는 preHandle 메서드를 호출합니다.

    preHandle 메서드는 크게 3가지 동작을 합니다.

    • request로부터 authExtractor.extract로 토큰을 추출
    • jwtTokenProvider.getSubject로 토큰을 디코딩 - 위의 JwtTokenProvider 참고
    • request.setAttribute로 요청에 디코딩한 값을 세팅

    2.3 헤더에서 토큰 추출하기

  • AuthorizationExtractor.java

    @Component
    public class AuthorizationExtractor {
        public static final String AUTHORIZATION = "Authorization";
        public static final String ACCESS_TOKEN_TYPE = AuthorizationExtractor.class.getSimpleName() + ".ACCESS_TOKEN_TYPE";
    
        public String extract(HttpServletRequest request, String type) {
            Enumeration<String> headers = request.getHeaders(AUTHORIZATION);
            while (headers.hasMoreElements()) {
                String value = headers.nextElement();
                if (value.toLowerCase().startsWith(type.toLowerCase())) {
                    return value.substring(type.length()).trim();
                }
            }
    
            return Strings.EMPTY;
        }
    }

    우리는 프런트에서 요청을 보낼 때 토큰을 headers의 'Authorization'이라는 Key로 담아 보냈습니다.

    위의 코드를 간단히 설명하면 request의 헤더 중에 'Authorization' 항목의 값을 가져와서 그 안에 토큰 타입을 제외한 토큰 자체를 가져오는 로직입니다.

    마지막...

  • 헤더에 토큰을 담아 요청을 보냈고, 우리는 요청을 가로채 토큰을 확인한 다음 요청에 그 토큰이 의미하는 값을 담아주었습니다.

    controller에서 그 값을 받아 응답을 내려주는 단계만 남았습니다.

    @GetMapping("/api/profile")
        public Object getUserFromToken(HttpServletRequest request) {
            String username = (String) request.getAttribute("username");
            User user = userImageService.findByName(username);
            return user;
        }

    BearerAuthInterceptor.preHandle 메서드를 보면 HttpServletRequest라는 request에 name을 담아주고 있습니다. 따라서 controller에서 메서드 인자에 동일하게 'HttpServletRequest request'를 선언하여 getAttribute로 name의 값을 가져옵니다.

'Spring' 카테고리의 다른 글

[Spring] @Valid  (0) 2021.04.11
[Spring] Bcrypt로 암호화하기  (0) 2021.04.11
spring jpa localtime between  (0) 2021.03.23
Spring-JPA(@MappedSuperClass,@EntityListener)  (0) 2021.03.23
Spring으로 간단한 게시판 만들기  (0) 2018.11.12