elevne's Study Note

Spring Security : JWT 적용해보기 (2) 본문

Backend/Spring

Spring Security : JWT 적용해보기 (2)

elevne 2023. 6. 11. 17:10

JWT, JSON Web Token 에 대해서 우선 간단하게 알아보았다. JWT 를 많이 사용하기 이전에는 쿠키 또는 세션을 활용하여 인증/인가를 구현하였다. 하지만 쿠키는 클라이언트 측에서 정보 위변조가 가능하다는 단점을, 세션은 사용자가 로그인할 때마다 사용자 세션 정보를 담은 객체를 생성하게 되고 매 요청마다 DB 에 접근하기 때문에 서버에 부담이 크다는 단점을 가지고 있다. JWT 는 이러한 문제점들을 해결해준다.

 

 

JWT 는 말그대로 JSON 형태를 가진 일종의 토큰으로, 해당 토큰을 가진 사용자는 권한 증명을 할 수 있는 것이다. JWT 는 상대적으로 서버의 부담을 줄여주며, 암호화된 토큰을 사용하여 안전한 통신이 가능하다. 또, 토큰 발행 전용 서버를 개설하여 서버의 부담을 완화해줄 수 있으며, 토큰에 특정 권한을 심어줄 수도 있다. 이러한 JWT 토큰은 Access TokenRefresh Token 으로 구분될 수 있다. Access Token 은 사용자 정보에 접근하기 위해 사용되는 토큰이고, Refresh Token 은 Access Token 을 갱신하기 위해 사용하는 토큰이다.

 

 

JWT 는 헤더, 페이로드, 시그니처 세 부분으로 이루어진다. 헤더에는 토큰 타입, 알고리즘을 명시하고 페이로드에서는 토큰에 권한을 부여, 사용자 정보를 선택해 담을 수 있다. 시그니처는 일련의 문자열로, 이를 통해 토큰이 위조되었는지 확인할 수 있다. 

 

 

JWT

 

 

사용자가 서버에 로그인 요청을 보내게 되면, 서버에서는 암호화된 토큰을 생성해서 반환한다. 사용자는 토큰을 쿠키, state 등에 저장해두고, 이후 모든 요청에 토큰을 태워 요청을 보내게 된다. 서버는 그러면 그 토큰을 가지고 사용자를 식별하여 요청에 대한 데이터를 반환해줄 수 있는 것이다.

 

 

JWT 를 받는 Spring Boot 서버에서는, 클라이언트가 요청을 보내게 될 때 Filter 가 동작하게 된다. 필터는 애플리케이션에서 요청을 받을 때 가장 먼저 처리되는 일로, 이 때 토큰을 검증하는 절차를 밟는 것이다. 토큰이 없다면 클라이언트에 토큰이 없다고 바로 반환하고, 토큰이 있다면 해당 토큰에 대한 검증으로 넘어간다. 필터는 이 때 먼저, UserDetailsService 라는 것을 사용한다. 이는 유저 정보를 데이터베이스에서 가져와서, 요청에서 받은 토큰의 정보와 비교하는데 사용한다. 토큰에 대한 검증이 통과된다면, SecurityContextHolder 를 업데이트하고, DispatcherServletDispatch 한다. 이는 Controller 로 보내져서 각종 로직을 처리하고, 다시 클라이언트로 데이터를 보내게 되는 것이다.

 

 

 

여기서 SecurityContextHolder 에 대해 다시 알아볼 필요가 있었다. 우선 SecurityContext 라는 것이 사용되는데, 이는 Authentication 객체가 저장되는 보관소로, 일반적으로 ThreadLocal (스레드마다 갖는 고유한 저장공간) 에 저장된다. SecurityContextHolderSecuirtyContext 를 감싸는 객체로 SecurityContext 객체의 저장 방식, 전략을 지정해줄 수 있다. 클라이언트가 로그인 요청을 보냈을 때 인증에 실패하면 SecurityContextHolder.clearContext() 메소드로 기존 SecurityContext 정보를 초기화해주고, 성공하면 SecurityContext인증 토큰을 저장, HttpSession 에도 SecurityContext 를 저장해주게 된다.

 

 

SecurityContextHolder

 

 

 

JWT 인증 구현을 저번에 만들던 게시판 프로젝트에 적용해보기로 하였다. 우선  그 때 미리 만들어두었던 User Entity 에 스프링 시큐리티를 적용하기 위해 UserDetails 를 구현하도록 만든다. (오버라이드 해야할 메소드들도 전부 불러온다) UserDetails스프링 시큐리티에서 사용자의 정보를 담는 인터페이스로, 사용자의 정보를 불러오기 위해 각종 메소드들을 오버라이드 해줘야한다. 또, 스프링 시큐리티에서는 UserDetailsService 라는, 유저의 정보를 가져오는 인터페이스를 제공한다. 이는 loadUserByUsername 이라는 이름의 UserDetails 타입의 오브젝트를 찾아 반환하는 메소드를 제공한다.

 

 

@Entity
@Table(name = "USER")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User extends TimeEntity implements UserDetails {

    @Id
    @Column(name = "USER_UUID")
    @GeneratedValue(strategy = GenerationType.UUID)
    private String userUUID;

    @Column(name = "USER_ID", nullable = false, updatable = false, length = 20)
    private String userId;

    @Column(name = "PASSWORD", length = 255)
    private String password;

    @Column(name = "EMAIL", length = 100)
    private String email;

    @Column(name = "USER_ROLE")
    @Enumerated(EnumType.STRING)
    private UserRole userRole;


    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(userRole.name()));
    }

    @Override
    public String getUsername() {
        return this.userId;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }




    @RequiredArgsConstructor
    public enum UserRole {
        USER("USER"), ADMIN("ADMIN");
        private final String role;
    }

}

 

 

위 메소드들에 조금 알아보자면, getAuthoritiesGrantedAuthorities 의 콜렉션을 반환하는 메소드이다. 이를 사용하기 위해서는 User Entity 에 권한관련 필드를 추가해줘야 하며, 위에서는 UserRole enum 클래스를 사용한다. 지금은 유저 권한이 복수로 들어가지 않기 때문에 SimpleGrantedAuthority 객체에 UserRole 의 정보를 담아 List 에 넣어주어 반환한다. SimpleGrantedAuthorityGrantedAuthority 의 구현체로, 부여된 권한의 String representation 을 저장하는 오브젝트이다. getUserName() 메소드는 해당 유저의 아이디를 반환하게끔 하고, 그 밑의 boolean 값을 반환하는 메소드들은 우선 전부 true 를 반환하도록 바꿔둔다. (원래는 getPassword 메소드도 오버라이드 해야하지만 롬복의 @Getter 어노테이션을 통해 생성된 상태)

 

 

 

위 작업을 처리해준 후, JWT 토큰을 처리하는 필터를 작성한다. JwtAuthenticationFilter 이라는 이름의 클래스를 만들고, OncePerRequestFilter 을 상속받게끔 한다. (이 대신에 Filter 인터페이스를 구현하도록 할 수도 있다.) OncePerRequestFilter 은 어느 서블릿 컨테이너에서든 요청당 한 번의 실행을 보장하는 것을 목표로 하며, doFilterInternal 메소드와 HttpServletRequest, HttpServletResponse 인자를 제공한다. 요청당 한 번의 필터링만을 보장한다는 것은, 만약 인가를 거치고 url 을 포워딩한다면 포워딩 요청의 인증 인가 필터를 다시 거치지 않고 다음 로직을 실행하게끔 해준다는 뜻이다.

 

 

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain) throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String username;
        if (authHeader == null || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); return; }
        jwt = authHeader.substring(7);
        username = jwtService.extractUsername(jwt);
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
            if (jwtService.isTokenValid(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities()
                );
                authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                );
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        filterChain.doFilter(request, response);
    }

}

 

 

doFilterInternal 메소드 내부에서 HttpServletRequest, Response 객체를 이용하여 인증, 인가 로직을 지난 후 filterChain.doFilter() 메소드를 사용하여 다음 filter 를 호출해주면 된다. 

 

 

doFilterInternal 에서는 우선 HttpServletRequest 의 헤더에서 Authorization 부분을 가져오게 된다. Authorization 부분에서 토큰을 추출해내고, 그 안에서 Username 정보를 또다시 추출해낸다. 이를 사용하여 UserDetailsServiceloadUserByUsername 메소드를 호출하여 UserDetails 오브젝트를 불러온다. 

 

 

위 코드에서는 JWT 가 유효할 경우 UsernamePasswordAuthenticationToken 을 생성한다. 사용된 UsernamePasswordAuthenticatinoToken 은 Authentication 인터페이스의 구현체로, 사용자의 인증저보를 나타내는 객체이다. 이 클래스는 사용자의 이름과 비밀번호를 포함한 인증 정보를 캡슐화하고, 스프링 시큐리티에서 인증작업을 수행하는데 사용된다. 그 다음으로 authToken.setDetails 내부에 인자로 들어간 WebAuthenticationDetailsSource() 는 스프링 시큐리티에서 사용되는 AuthenticationDetails 를 생성하는 역할을 수행하는 클래스이다. 이 클래스는 사용자의 웹 요청을 기반으로 인증 세부 정보를 생성한다. 위 코드를 보게되면 buildDetails 메소드 내에 HttpServletRequest 가 들어가있다. HttpServletRequest 에서 IP 주소를 추출하여 인증 세부 정보에 포함시키고, 사용자 에이전트 정보를 추출하여 인증 세부 정보에 포함시키기도 한다. WebAuthenticatinoDetailsSourceUsernamePasswordAuthenticationToken 에 주입되어 사용된다. SecurityContextHolder.getContext().setAuthentication(authToken) 은 authToken 을 사용하여 현재 스레드의 SecurityContext 에 인증정보를 설정하고 저장한다. 이렇게 인증정보가 저장되면, 스프링 시큐리티의 다른 부분에서도 해당 인증 정보를 활용할 수 있는 것이다.

 

 

위에서 사용된 JwtService 내부 코드에 대해서는 다음에 이어서 정리할 예정이다.

 

 

 

 

 

 

 

Reference: 

https://www.youtube.com/watch?v=KxqlJblhzfI&list=LL&index=1 

'Backend > Spring' 카테고리의 다른 글

Spring Security : JWT 적용해보기 (3)  (0) 2023.06.29
Spring WebSocket  (0) 2023.06.28
Spring Security : JWT 적용해보기 (1)  (0) 2023.06.10
Spring Boot Project (1)  (0) 2023.06.06
Spring Boot 복습 (6)  (0) 2023.04.10