Study/Java

[Java] 62. Spring boot에서 Web-Filter를 설정하는 방법(Spring Security)

v명월v 2022. 3. 15. 22:14

안녕하세요. 명월입니다.


이 글은 Java의 Spring boot에서 Web-Filter를 설정하는 방법(Spring Security)에 대한 글입니다.


저도 사실 Spring Security에 대해서는 자세히 모릅니다. 관련 사양서와 도큐멘트를 봤는데도 양도 엄청나고 전부 활용할 수 있을까 하는 의문도 있습니다. 단지 「브라우저로부터 요청(Request)가 있을 때, Controller에서 처리가 되기 전에 Filter로써 호출이 되는 것」 정도로 알고 있습니다. Web Framework 시절에도 그냥 세션 확인해서 User 인스턴스가 있으면 인증이고 아니면 인증 실패 정도로 설정한 게 전부였던 것으로 기억합니다.

그래서 이번에 Spring boot를 전체적으로 정리하는 중에 있어서 Web-Filter에 대해서 어떻게 정리하는 게 좋을까 고민을 정말 많이 했던 것 같습니다.


이번에 제가 Web framework에서 Spring boot로 옮기는 작업을 하면서 또 하나의 목표를 설정한 것이 프론트엔드(front-end)와 백엔드(back-end)의 완전한 분리를 목표로 작업하고 있습니다.

그러면서 적용을 해야 하는 부분이 세션을 이용한 로그인 인증이 아닌 JWT(Json web token)을 이용한 로그인 관리가 되어야 합니다.

링크 - [Java] JWT(Json Web Token)을 발행, 확인하는 방법

이전부터 제가 프론트엔드의 프레임워크(Angular, Vue, React)를 사용하고 싶었으나, 언제나 세션을 완전히 분리하지 않는 이상 한계가 존재하게 되네요..

SPA(Single page application)의 환경에서는 메인 페이지의 Page request가 최초 한번만 발생하고, Javascript를 이용해서 동적으로 DOM과 해더를 설정하는 환경이 됩니다만, 그럴 경우 페이지에서와 쿠키와 세션, 변수 데이터 관리, 그리고 비동기로 요청되는 ajax의 쿠키와 세션, 변수 데이터 관리가 꽤나 복잡하게 되더군요.

저만 그런지도 모르겠습니다. 이 부분은 각자의 경험에 따라 느끼는 부분이 차이가 있을 것 같습니다.


일단 이번 글에서의 목표는 Spring boot에서 Web-Filter를 사용해 JWT(Json web token)을 이용해서 인증하는 프로그램을 만들 생각입니다.

JWT 인증에 관해서는 「그랩의 블로그」의 블로그를 많이 참조했습니다. 감사합니다.

링크 - https://tansfil.tistory.com/59


먼저 제가 JWT 인증을 사용하기 위해서는 Access Token과 Refresh Token을 구현해야 했습니다.

먼저 이전의 프로젝트에서 pom.xml에 JWT 라이브러리를 추가하겠습니다.

레포지토리 - https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt/0.9.1

<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt</artifactId>
  <version>0.9.1</version>
</dependency>

그리고 filter 패키지를 생성하고 JWT(Json web token)을 다루는 Provider 클래스를 생성합니다.

package com.example.demo.filter;

import java.util.Base64;
import java.util.Date;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.SignatureException;

// Autowired의 의존성 주입이 가능하게 Component 어트리뷰트를 설정
@Component
public class JwtProvider {
  // Refresh-Token의 보안 키를 application.properties에서 설정할 수 있게 설정
  @Value("spring.jwt.secret")
  private String SECRET_KEY = "secret";
  // Access-Token의 보안 키를 application.properties에서 설정할 수 있게 설정
  @Value("spring.jwt.access")
  private String ACCESS_KEY = "access";
  // tick 기준 - 24시간
  private final int TICK_24HOUR = 1000 * 60 * 60 * 24;
  // tick 기준 - 10분
  private final int TICK_10MIN = 1000 * 60 * 10;
  // Cookie의 Key 명
  private final String X_AUTH_TOKEN_REFRESH = "X-AUTH-TOKEN-REFRESH";
  private final String X_AUTH_TOKEN_ACCESS = "X-AUTH-TOKEN-ACCESS";
  // 생성자
  public JwtProvider() {
    // 키를 Base64 암호화하여 복잡도를 높힌다.
    SECRET_KEY = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
    ACCESS_KEY = Base64.getEncoder().encodeToString(ACCESS_KEY.getBytes());
  }
  // Refresh-Token 생성 함수
  public String createRefreshToken(String id, HttpServletRequest req, HttpServletResponse res) {
    // token을 생성한다. 유효기간 2주
    var token = createToken(id, TICK_24HOUR * 14, SECRET_KEY, req);
    // 쿠키를 생성한다.
    var cookie = createCookie(token, X_AUTH_TOKEN_REFRESH, TICK_24HOUR * 14 / 1000);
    // 해더에 쿠키를 추가한다. Set-Cookie
    res.addCookie(cookie);
    // 토큰 String 값을 리턴
    return token;
  }
  // Access-Token 생성 함수
  public String createAccessToken(String id, HttpServletRequest req, HttpServletResponse res) {
    // token을 생성한다. 유효기간 10분
    var token = createToken(id, TICK_10MIN, ACCESS_KEY, req);
    // 쿠키를 생성한다.
    var cookie = createCookie(token, X_AUTH_TOKEN_ACCESS, TICK_10MIN / 1000);
    // 해더에 쿠키를 추가한다. Set-Cookie
    res.addCookie(cookie);
    // 토큰 String 값을 리턴
    return token;
  }
  // 해더로 쿠키를 만료시킨다.
  public void clearToken(HttpServletRequest req, HttpServletResponse res) {
    // 쿠키 데이터가 없으면 종료
    if (req.getCookies() == null) {
      return;
    }
    // Refresh-Token 쿠키 만료
    res.addCookie(this.createCookie(null, X_AUTH_TOKEN_REFRESH, 0));
    // Access-Token 쿠키 만료
    res.addCookie(this.createCookie(null, X_AUTH_TOKEN_ACCESS, 0));
  }
  // 토큰 생성 함수
  public String createToken(String id, long milisecond, String signature, HttpServletRequest req) {
    // Claims을 생성
    var claims = Jwts.claims().setId(id);
    // 현재 시간
    var now = new Date();
    // JWT 토큰을 만드는데, Payload 정보와 생성시간, 만료시간, 알고리즘 종류와 암호화 키를 넣어 암호화 함.
    return Jwts.builder()
               .setClaims(claims)
               .setIssuedAt(now)
               .setExpiration(new Date(now.getTime() + milisecond))
               .signWith(SignatureAlgorithm.HS256, signature).compact();
  }
  // String으로 된 토큰을 Jws<Claims> 타입으로 변환
  public Jws<Claims> parseToken(String jwt, String signature) {
    try {
      // 암호화 키로 복호화한다.
      // 암호화 키나 만료되었으면 에러가 발생
      return Jwts.parser()
                 .setSigningKey(signature)
                 .parseClaimsJws(jwt);
    } catch (SignatureException | ExpiredJwtException e) {
      // 에러가 발생하면 null을 리턴
      return null;
    }
  }
  // 토큰에서 id 값을 리턴
  public String getId(Jws<Claims> token) {
    return token.getBody().getId();
  }
  // 쿠키에서 Refresh-Token을 취득하는 함수
  public Jws<Claims> getRefreshToken(HttpServletRequest req) {
    // 쿠키에서 Refresh-Token을 취득한다.
    var cookie = this.getCookie(req, X_AUTH_TOKEN_REFRESH);
    // 쿠키에 데이터가 없으면
    if (cookie != null) {
      // Jws<Claims> 타입으로 변환
      var token = parseToken(cookie, SECRET_KEY);
      // 복호화가 되면
      if (token != null) {
        // 토큰 반환
        return token;
      }
    }
    // 그 외는 모두 null
    return null;
  }
  // 쿠키에서 Access-Token을 취득하는 함수
  public Jws<Claims> getAccessToken(HttpServletRequest req) {
    // 쿠키에서 Access-Token을 취득한다.
    var cookie = this.getCookie(req, X_AUTH_TOKEN_ACCESS);
    // 쿠키에 데이터가 없으면
    if (cookie != null) {
      // Jws<Claims> 타입으로 변환
      var token = parseToken(cookie, ACCESS_KEY);
      // 복호화가 되면
      if (token != null) {
        // 토큰 반환
        return token;
      }
    }
    // 그 외는 모두 null
    return null;
  }
  // key 명으로 쿠키 값을 취득
  private String getCookie(HttpServletRequest req, String key) {
    // 쿠키 값이 없으면 null
    if (req.getCookies() == null) {
      return null;
    }
    // 반복문으로
    for (var c : req.getCookies()) {
      // 키를 찾는다.
      if (key.equals(c.getName())) {
        // 쿠키 값 반환
        return c.getValue();
      }
    }
    // 없으면 null
    return null;
  }
  // 쿠키 생성합니다.
  private Cookie createCookie(String token, String key, int expire) {
    // 쿠키 생성 key-value
    Cookie cookie = new Cookie(key, token);
    // 쿠키 경로
    cookie.setPath("/");
    // Javascript에서는 읽을 수 없다.
    cookie.setHttpOnly(true);
    cookie.setSecure(true);
    // 만료 시간 설정
    cookie.setMaxAge(expire);
    // 쿠키 리턴
    return cookie;
  }
}

여기까지 JWT(Json web token)을 발행하고 값을 취득합니다. 그리고 쿠키에 등록, 삭제하는 클래스를 만들었습니다.


이걸로 Spring security 라이브러리에서 필터(filter) 설정 하겠습니다.

이 부분도 아래의 블로그님들이 글을 많이 참조했습니다. 감사합니다.

참조 - https://kimchanjung.github.io/programming/2020/07/02/spring-security-02/

참조 - https://qiita.com/nyasba/items/

참조 - https://www.baeldung.com/spring-security-login

참조 - https://catsbi.oopy.io/

참조 - https://tmdrl5779.tistory.com/78

참조 - https://fenderist.tistory.com/342


먼저 pom.xml에 라이브러리를 추가합니다.

레포지토리 - https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security/2.6.4

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
  <version>2.6.4</version>
</dependency>
package com.example.demo.filter;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

// 어트리뷰트 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  // JWT 프로바이더를 의존성 주입으로 받는다.
  @Autowired
  private JwtProvider jwtProvider;
  // WebFilter 예외 패턴
  private String[] passUrl = new String[] { "/login.html(.*)", "/logout.html(.*)", "/refresh.html(.*)" };

  // 설정
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    // 라이브러리의 기본 /login 페이지와 csrf 기능 끄기
    http.httpBasic().disable()
        .csrf().disable()
        
        // 세션 설정하고
        // Token 형식에서는 SessionCreationPolicy.STATELESS를 설정해 세션을 생성하지 않는다.
        .sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
        // 인증 처리
        // 예외 패턴 이외는 모두 인증 요구
        .authorizeRequests()
        .antMatchers(passUrl).permitAll()
        .anyRequest().authenticated()
        .and()
        // 필터 적용
        .addFilterAfter(new WebFilter(jwtProvider, passUrl), UsernamePasswordAuthenticationFilter.class);
  }
}

위에 소스를 보면 제가 passUrl를 사용했습니다.

이게 제가 예전부터 사용하던 방법인데... 다른 블로그를 보니 HttpSecurity 인스턴스에서 깔끔하게 예외처리를 하던데.. 저는 어떻게 설정해도 필터가 깔끔하게 되지를 않네요..

아무리 짱구를 굴려봐도 어떤 형식으로 필터가 걸리는 지 이해를 못하는 것 같네요.. 그래서 저는 위 방식으로 사용했습니다.

package com.example.demo.filter;

import java.io.IOException;
import java.util.ArrayList;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;
// WebFilter 클래스
public class WebFilter extends GenericFilterBean {

  // SecurityConfig 클래스에서 JWT 프로바이더와 passUrl 정보를 받는다.
  private JwtProvider jwtProvider;
  private String[] passUrl;
  // 생성자
  public WebFilter(JwtProvider jwtProvider, String[] passUrl) {
    this.jwtProvider = jwtProvider;
    this.passUrl = passUrl;
  }
  // 필터링
  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
      throws IOException, ServletException {

    var req = (HttpServletRequest) request;
    var res = (HttpServletResponse) response;
    // passUrl 정보로 인증이 필요없는 페이지는 넘긴다.
    String url = req.getRequestURI();
    for (String buf : passUrl) {
        if (url.matches(buf)) {
            chain.doFilter(req, res);
            return;
        }
    }
    // JWT 프로바이더에서 Access-Token을 취득한다.
    var access = jwtProvider.getAccessToken(req);
    // 만료가 되었거나 인증 정보가 맞지 않으면 null이 발생하기 때문에 인증이 되지 않는다.
    if (access != null) {
      // 인증이 되었으면 필터를 넘어갈 수 있게 인증 처리한다.
      SecurityContextHolder.getContext().setAuthentication(
          new UsernamePasswordAuthenticationToken(jwtProvider.getId(access), null, new ArrayList<>()));
    } else {
      // 인증 실패 코드 - 403 권한 없음
      res.setStatus(403);  
    }
    chain.doFilter(request, response);
  }
}

여기까지가 JWT 인증 처리입니다. 저도 Spring security 사양을 정확하게 잘 몰라서 많은 블로그와 글을 참조해서 작성했습니다. 근데 저는 꽤 심플하게 나왔네요...


실제 Controller에서 어떻게 움직이는지 확인해 봅시다.

package com.example.demo.controller;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.Base64;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import com.example.demo.filter.JwtProvider;

// Session처럼 유저 정보 취득할 때 디비 접속을 줄이기 위해 유저 정보 클래스를 생성
class User implements Serializable {
  // 직렬화
  private static final long serialVersionUID = 1L;
  // 맴버 변수
  private String id;
  // getter
  public String getId() {
    return id;
  }
  // setter
  public void setId(String id) {
    this.id = id;
  }
  // User 클래스를 직렬화한다.
  public static String convertSerializable(User user) {
    try (var baos = new ByteArrayOutputStream()) {
      try (var oos = new ObjectOutputStream(baos)) {
        oos.writeObject(user);
        var data = baos.toByteArray();
        // byte 형식을 base64로 압축
        return Base64.getEncoder().encodeToString(data);
      }
    } catch (Throwable e) {
      e.printStackTrace();
      return null;
    }
  }
  // User 클래스를 역직렬화한다.
  public static User convertData(String code) {
    // base64 형식을 byte 형식으로 변환
    var data = Base64.getDecoder().decode(code);
    try (var bais = new ByteArrayInputStream(data)) {
      try (var ois = new ObjectInputStream(bais)) {
        Object objectMember = ois.readObject();
        return (User) objectMember;
      }
    } catch (Throwable e) {
      e.printStackTrace();
      return null;
    }
  }
}

// 컨트럴러 어트리뷰트
@Controller
// Controller 클래스
public class HomeController {
  // JWT 토큰 프로바이더
  @Autowired
  private JwtProvider jwtProvider;

  // 매핑 주소
  @RequestMapping(value = { "/", "/index.html" })
  @ResponseBody // 예제를 위해 template를 사용하지 않는다.
  public String index(Model model, HttpServletRequest req, HttpServletResponse res) {
    // Access 토큰 취득
    var access = jwtProvider.getAccessToken(req);
    // user 직렬화 코드 취득
    var code = jwtProvider.getId(access);
    // 역직렬화
    var user = User.convertData(code);
    // id 값 출력
    return user.getId();
  }
  // 매핑 주소
  @RequestMapping(value = "/login.html")
  @ResponseBody // 예제를 위해 template를 사용하지 않는다.
  public String login(Model model, HttpServletRequest req, HttpServletResponse res) {
    // Refresh 토큰을 생성
    jwtProvider.createRefreshToken("nowonbun", req, res);
    // User 인스턴스를 생성
    var user = new User();
    // id 설정
    user.setId("nowonbun");
    // 인스턴스를 직렬화 하여 Access 토큰을 생성
    jwtProvider.createAccessToken(User.convertSerializable(user), req, res);
    // 응답 코드 - 정상 200
    res.setStatus(200);
    return "login";
  }
  // 매핑 주소
  @RequestMapping(value = "/refresh.html")
  public void refresh(Model model, HttpServletRequest req, HttpServletResponse res) throws IOException {
    // Refresh 토큰을 취득
    var refresh = jwtProvider.getRefreshToken(req);
    if (refresh != null) {
      // User 인스턴스를 생성
      var user = new User();
      // Refresh 토큰에서 유저 id 취득
      user.setId(jwtProvider.getId(refresh));
      // Access 토큰 생성
      jwtProvider.createAccessToken(User.convertSerializable(user), req, res);
      // 응답 코드 - 정상 200
      res.setStatus(200);
      return;
    }
    // refresh 토큰이 없으면 에러! - 403 권한 없음
    res.setStatus(403);
  }
  // 매핑 주소
  @RequestMapping(value = "logout.html")
  public void logout(Model model, HttpServletRequest req, HttpServletResponse res) throws IOException {
    // 쿠키 모두 삭제
    jwtProvider.clearToken(req, res);
    // 응답 코드 - 정상 200
    res.setStatus(200);
  }
}

저는 Access 토큰에 User 클래스를 직렬화해서 넣었습니다. 이유는 사실 예전에 Session을 쓸 때도 클래스를 직렬화하여 넣은 다음, 유저 정보를 디비 접속 없이 세션에서 가져다 쓰곤 했습니다.

그런데 JWT를 사용하면 id로 디비를 검색해야 하는 번거로움이 발생하기 떄문에 그냥 Session처럼 직렬화해서 넣었습니다. 저는 예제를 위해 직렬화만 했지만 실제로 사용하면 직렬화 코드를 한번 변조하는 것이 보안상 좋겠네요..

이제 실행해 보도록 하겠습니다.

맨 처음 루트 페이지나 index.html를 접속하면 에러가 발생합니다.. 인증이 되지 않은 것입니다.

로그인을 합니다.

정상 코드 200이 나왔고 Response에는 Set-Cookie 해더로 쿠기가 브라우저의 데이터에 입력되는 것을 확인할 수 있습니다.


다시 루트 페이지나 index.html로 접속을 합니다.

이번에는 정상 인증이 되어서 페이지가 나왔습니다.


다시 Access-Token를 삭제해 봅시다.

Refresh-Token이 있으나 루트 페이지는 인증 실패가 나옵니다.


다시 Refresh 페이지로 가서 Access-Token을 갱신합니다.

Access-Token이 갱신되는 것을 확인할 수 있습니다.

다시 루트 페이지가 열리는 것을 확인 할 수 있네요..


이번에는 로그아웃입니다.

쿠키 삭제되었습니다. Refresh페이지를 열어봅니다.

Refresh-Token이 없으니 에러가 발생합니다.


여기까지 제가 구상한 대로 작성이 된 것 같네요.

좀 이해하기 쉽게 모두 쿠키에서 작업을 했습니다만, 좀 더 보안을 엄격하게 하기 위해서 Access-Token의 경우는 해더로 데이터를 받고 Request는 form-data로 주고 받게 해서 브라우저 메모리에 남지 않게 하는 방법도 좋을 듯 싶네요.

아니면 토큰에 좀 더 암호화 된 데이터를 넣어서 validate(검증)을 더 복잡하게 하는 방법도 있겠네요..


여기까지 Java의 Spring boot에서 Web-Filter를 설정하는 방법(Spring Security)에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.