스프링 시큐리티 3. 인가( JWT 기반 Token 인가 구현 )

2024. 3. 17. 20:10Spring/Spring Security

[ 인증과 인가의 차이 ]

 2편이었던 인증 글에서는 로그인을 한 뒤 서버 내에 저장된 정보가 일치하는 것을 확인하는 작업을 진행하였다. 이렇게 인증은 프로그램 자체에 접근 권한이 있는 사용자를 가려내는 과정이다. 반면 인가는 프로그램 자체에 접근 권한이 있는 사람이 인증을 받고, 내부 리소스에 접근을 할 때 그 내부 리소스에 접근할 수 있는 권한 또는 역할에 포함되는가를 살펴보는 과정이다. 그래서 인증은 인가보다 먼저 일어나며 주요 특징을 표로 정리해보면 아래와 같다.

Authentication Authorization
시스템에 권한이 있는지 유저의 신원을 확인함 리소스 접근 시 유저에게 권한 또는 역할이 있는지 확인함
인증은 인가보다 우선적으로 일어남 인가는 인증 이후에 일어남
로그인 정보가 필요함 권한과 역할 정보가 필요함
인증 실패시엔 401 에러를 응답으로 내놓음 인가 실패시엔 403 에러를 응답으로 내놓음

[ 권한과 역할의 차이 ]

역할(ROLE)

 역할은 특정한 그룹이 가진 특권과 행동을 의미한다. 권한보다는 덜 자세한 접근으 위해서 사용한다. 예를 들어 네이버 카페를 생각해보자. 네이버 카페에선 회원가입한 유저에게만 게시글을 볼 수 있게 하는 설정이 있다. 이런 경우 OO카페에 대한 역할 접근 제한의 예시로 볼 수 있다.

 

권한(AUTHORITY)

 권한은 개인의 특권 혹은 행동이 가능한 권한을 의미한다. 권한은 역할보다 자세한 접근을 위해서 사용한다. 예를 들어 동일한 네이버 카페에서 회원가입한 유저 중 신규회원은 게시판 1, 게시판 2, 게시판 3에 관한 접근 권한만 있고 게시판 4나 게시판 5에 대한 권한이 없다. 이렇게 역할 내에서 조금 더 자세한 분류를 하고 싶을 때 권한을 사용한다. 

 

스프링 시큐리티의 권한과 역할 처리

 스프링 시큐리티는 권한과 역할 모두를 지원한다. 또한 hasRole(), hasAuthority() 같은 함수를 requestsMatchers에 등록함으로써 인가 시 이를 확인하고 맞지 않는 경우 권한이 없음을 응답으로 내보낼 수도 있게 지원한다. 여기서 주의할 게 있는데 AUTHORITY는 VIEWBOALD1으로 이름을 작성해도 되지만 ROLE은 반드시 ADMIN으로 하고 싶다면 ROLE_ADMIN과 같은 방식으로 저장을 해주어야 한다. 프레임 워크 내의 규칙 같은 것이다.


[ JWT 토큰 ]

 인증과 인가가 뭔지도 알겠고 권한과 역할이 뭔지도 알겠는데, 그러면 인가는 어떤 방식으로 해주어야 하나? 의문이 들 수 있다. 기본적인 스프링 시큐리티 내의 인증 결과부터 한번 알아보자.

 

 스프링시큐리티에서 기본적으로 제공하는 옵션에서는 인증이 되면 브라우저로 JSESSIONID라는 쿠키를 보내 로그인이 되었음을 확인하는 세션을 제공한다. 이를 통해 클라이언트 측에서는 사용자의 요청에 해당 세션 쿠키를 추가적으로 같이 보낸다. 그 이유는 매번 로그인처럼 DB에서 username으로 정보를 찾아오고 입력 받은 비밀번호를 암호화해서 PasswordEncoder에서 확인하면 매 요청마다 암호화 1초, DB 커넥션 1개 소모를 반드시 해야한다는 것을 의미하며 이는 자원낭비다.

 

 그런데 JSESSIONID는 부족하다. 의미있는 정보도 담고 있지 않고 기한도 없고 딱히 암호화도 되어있지 않다. 따라서 의미있는 정보도 담고 기한도 정하고 암호화도 하기 위해서 JWT 토큰 방식을 많이들 사용한다. JWT 토큰을 방식을 사용해 인가를 구현하면 얻는 장점은 크게 두 가지라고 생각한다.

 

JWT 토큰의 장점

1. JWT 토큰을 사용하면 서버 내에 세션 정보를 보관할 필요가 없다. 인증 후 JWT 토큰에는 정보 발급하면 필요한 정보를 넣고 이후 받을 때마다 꺼내서 쓰기만 하면 된다. 이는 서버내의 자원을 아끼는 결과로 이어진다.

2. JWT 토큰을 사용하면 토큰이 변경됐는지를 감지할 수 있고  만약 갈취당하더라도 시간이 설정이 가능하기 때문에 안전성이 올라간다.

 

JWT 토큰의 구성과 역할

 그럼 JWT 토큰은 어떻게 정보를 저장하고 토큰을 조작했는지 확인할 수 있는걸까?JWT토큰은 크게 3가지로 나뉘어져있다. 

 

header: 헤더는 JWT를 암호화한 방식과 어떤 타입인지에 관한 정보를 담고 있다.

 

payload: 페이로드엔 JWT에 담고 싶은 내용이 JSON 형태로 담겨져 있다.

 

signature: 서명엔 JWT 토큰 정보를 서버 내부에 있는 key와 함께 암호화한다. 암호화 알고리즘은 아래와 같다. 

 

 기본적으로 header와 payload를 자세히 보면 알겠지만 header와 payload는 단순히 인코딩된 내용이다. 누구든지 토큰만 있다면 그 정보를 해석할 수 있다. 그렇기에 아무런 검증 과정이 없다면 누군가가 서버 내의 알고 있는 권한 등을 부여해 payload부분을 바꿔치기한 토큰을 서버로 보내 권한을 얻어갈 수 있다. 따라서 토큰이 서버에서 보낸 내용을 그대로 가져온 것인지 아니면 조작된 내용인지 서버는 판별을 해야한다. 그 방법으로 서명을 사용하는데 서명은 header, payload, secretKey를 통해서 암호화한다.  그러면 토큰이 발급되고 클라이언트에게 넘어간다. 만약 payload를 조작한다면 서버 측에서 다시 header와 payload, secretKey를 이용해서 암호화하고 서명과 동일한지 확인하여 검증한다. 이 때 서명도 조작할 수 있지 않나 생각할 수 있다. 이는 공격자가 secretKey를 모르는 한 서명 조작을 해도 이 검증에서 걸린다. 따라서 secretKey는 보호돼야하는 내용이며 비밀번호처럼 무차별 대입에 뚫리지 않도록 복잡하게 설정을 해야 한다. 이를 기억하고 이제 구현으로 넘어가보자.


[ JWT 토큰 구현 ]

JWT 토큰을 스프링에서 사용하기 위해서는 build.gradle에 의존성을 추가해주어야 한다. 스프링 부트 3.2.3의 설정으로는 아래와 같은데, 버전이 다르다면 하단의 사이트를 참고해서 확인하기 바란다.

https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api 

 

build.gradle

// JWT 토큰 관련 의존관계
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'

 

그리고 Configuration에서의 SecurityFilterChain 부분을 바꿔주어야 한다. JWT 토큰을 사용하면 더이상 JSESSIONID로 관리될 필요가 없으므로 SessionCreationPolicy를 STATELESS로 설정해놓자. 만약 다른 React나 Vue 같은 프레임워크로 만들어진 클라이언트와 통신해야 한다면 cors에 exposed header 부분을 추가해주어야 한다. 여기선 그러지 않을 것이니 아래처럼만 설정하면 된다.

 

SecurityConfig.java

@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf((csrf) -> csrf.disable())
            .authorizeHttpRequests((requests) -> requests
                    .requestMatchers("/security").authenticated()
                    .requestMatchers("/register").permitAll())
            .formLogin(Customizer.withDefaults())
            .httpBasic(Customizer.withDefaults());
    return http.build();
}

 

 이제 JWT 토큰을 보낼 필터를 작성해야 한다. 예전엔 몰라서 컨트롤러에 작성을 했는데 필터를 사용하면 인증이 되면 HttpServletResponse에 JWT 토큰을 생성해서 채우는 과정을 컨트롤러에 작성할 필요 없이 추가해줄 수 있다. 이를 작성하기 이전에 JWT 토큰의 서명에는 secretKey가 필요하다고 했었다. 이 부분은 application.properties, yml에 넣어도 되고 이렇게 따로 파일로 만들어서 저장해두자. 만약 깃허브에 올릴거라면 이 부분은 반드시 .gitignore에 포함시키자.


SecurityConstants.java

public interface SecurityConstants {

    public static final String JWT_KEY = "jxgEQeXHuPq8VdbyYFNkANdudQ53YUn4";
    public static final String JWT_HEADER = "Authorization";

}

 

 이제 Authentication을 통해 유저 이름, 패스워드, 권한 정보를 가져오고 이를 통해 JWT 토큰을 만들어주면 된다. 또한 이 때 마감시간도 정해줄 수 있다.

 

JWTTokenGeneratorFilter.java

public class JWTTokenGeneratorFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (null != authentication) {
            SecretKey key = Keys.hmacShaKeyFor(SecurityConstants.JWT_KEY.getBytes(StandardCharsets.UTF_8));
            String jwt = Jwts.builder().issuer("Recfli").subject("JWT Token")
                    .claim("username", authentication.getName())
                    .claim("authorities", populateAuthorities(authentication.getAuthorities()))
                    .issuedAt(new Date())
                    .expiration(new Date((new Date()).getTime() + 30000000))
                    .signWith(key).compact();
            response.setHeader(SecurityConstants.JWT_HEADER, jwt);
        }

        filterChain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return !request.getServletPath().equals("/user");
    }

    private String populateAuthorities(Collection<? extends GrantedAuthority> collection) {
        Set<String> authoritiesSet = new HashSet<>();
        for (GrantedAuthority authority : collection) {
            authoritiesSet.add(authority.getAuthority());
        }
        return String.join(",", authoritiesSet);
    }

}

 

 이게 어떻게 가능한 일인가는 스프링 시큐리티의 아키텍처 부분에 가면 있는 SecurityContext Holder라는 객체에 관해서 알고 있어야 한다. 아래의 URL에서 자세한 내용을 확인할 수 있다. 

https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html#servlet-authentication-securitycontextholder

 

 이해한 바와 내가 작성했던 코드와의 연관을 생각해보면 앞에서 AuthenticationProvider를 작성하며 다음과 같은 메서드에서 Authentication을 리턴할 때 UsernamePasswordAuthentication Token이라는 객체를 리턴해주고 거기에 List<GrantedAuthority>를 넘겨주었다. 여기서 쓰는 SimpleGrantAuthority는 내부에 들어가보면 알겠지만 그냥 role을 담는 껍데기 객체라고 생각하면 된다.

 

StudyAuthenticationProvider.java

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password = authentication.getCredentials().toString();
        Optional<Member> member = memberRepository.findByUserEmailWithAuthorities(username);
        if(member.isPresent()){
            Member findMember = member.get();
            if(passwordEncoder.matches(password, findMember.getPassword())){
                return new UsernamePasswordAuthenticationToken(username, password, getGrantedAuthorities(findMember.getAuthorities()));
            } else{
                throw new BadCredentialsException("Invalid password!");
            }
        }
        else{
            throw new BadCredentialsException("No user registered with this details!");
        }
    } 

 

 그러면 스프링 시큐리티 프레임워크에서는 쓰레드 로컬로 작동하는 SecurityContextHolder에 Authentication으로 저장을 한다. 공식문서에서 그려진 그림은 다음과 같은데 SecurityContextHolder는 인증과정에서 만든 Authentication를 보관한다. 코드를 따라왔다면 그 때의 Authentication은 우리가 AuthenticationProvider를 정의하면서 username, password, authorties 정보를 담아 객체를 생성했었던 그 UserPasswordAuthenticationToken이다. 스프링 프레임워크 자체는 쓰레드 풀에서 HTTP 요청이 올 때마다 꺼내서 사용하는데 요청이 끝나기 전까지 계속 같은 쓰레드 내에서 작동한다. 그리고 SecurityContextHolder는 쓰레드 로컬로 만들어졌으니 동일한 HTTP 요청 내에서는 SecurityContextHolder 내부에 Authentication 객체를 담은 이후라면 어디서든 그 정보를 꺼내올 수 있다. 그러니 따로 뭘 불러오지 않아도 SecurityContextHo lder에서 Authentication 객체를 가져올 수 있었던 것이다.

 

 인증은 Filter 중에서도 BasicAuthenticationFilter에서 이루어지며 인증이 일어난 뒤에 토큰이 생성되려면  BasicAuthenticationFilter 이후에 Filter를 세워놓아야한다. 또한 만약 인증이 안된 경우에는 만들어서 보내면 안되므로 Authentication이 null인지를 체크하는 것이다. 이 부분이 이해가 됐다면 좋겠다. Servlet으로 요청을 받고 응답해본 경험이 있다면 이에 관해 쉽게 이해할 수 있을텐데 dispatcherServlet이 워낙 잘해줘서 잘 모를 수도 있다.

 

 그리고 응답에 JWT 토큰을 보냈으면 이제는 클라이언트 측에서 보낸 JWT 토큰을 수신하는 것도 해야한다. 따라서 JWT 토큰을 검증하는 필터 또한 작성해야 한다. 이번에는 이를 BasicAuthenticationFilter 앞에 세워놓아야 한다. 그 이유는 인증 절차가 일어나기 전에 JWT로 유저의 신원을 확인해야 인증 필터가 먼저 작동해 잘못된 요청이라는 응답을 내보내지 않기 때문이다.

 

 이 검증 필터의 로직은 HttpServletRequest에서 클라이언트가 Authorization 헤더에 담아서 보낸 JWT 토큰을 읽고 서버 내부에 있는 secretKey를 이용해서 체크하는 과정이다. 만약 시간이 지났거나 누가 조작을 해서 보낸 것이면 예외를 터뜨리고 403 에러를 내보낸다.

 

JWTTokenValidatorFilter.java

public class JWTTokenValidatorFilter  extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String jwt = request.getHeader(SecurityConstants.JWT_HEADER);
        if (null != jwt) {
            try {
                SecretKey key = Keys.hmacShaKeyFor(
                        SecurityConstants.JWT_KEY.getBytes(StandardCharsets.UTF_8));

                Claims claims = Jwts.parser()
                        .verifyWith(key)
                        .build()
                        .parseSignedClaims(jwt)
                        .getPayload();
                String username = String.valueOf(claims.get("username"));
                String authorities = (String) claims.get("authorities");
                Authentication auth = new UsernamePasswordAuthenticationToken(username, null,
                        AuthorityUtils.commaSeparatedStringToAuthorityList(authorities));
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (Exception e) {
                throw new BadCredentialsException("Invalid Token received!");
            }

        }
        filterChain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return request.getServletPath().equals("/user");
    }

}

 

 그럼 이제 Filter를 세워주어야 한다. 다시 SecurityConfig.java에 돌아와서 SecurityFilterChain을 사용하면 되고 아래 코드처럼 작성해주면 된다. 필터 위치에 관해서는 위에서 충분히 설명했으므로 이해가 어렵지 않을 거라 믿는다.


SecurityConfig.java

@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf((csrf) -> csrf.disable())
            .addFilterAfter(new JWTTokenGeneratorFilter(), BasicAuthenticationFilter.class)
            .addFilterBefore(new JWTTokenValidatorFilter(), BasicAuthenticationFilter.class)
            .authorizeHttpRequests((requests) -> requests
                    .requestMatchers("/security").authenticated()
                    .requestMatchers("/register").permitAll())
            .formLogin(Customizer.withDefaults())
            .httpBasic(Customizer.withDefaults());
    return http.build();
}

[ 테스트 ]

 스프링 시큐리티에서 httpBasic를 enable해놓으면 BasicAuthentication을 사용할 수 있다. 일단 아무 더미 API를 만들자 필자는 /user로 두었다.


LoginController.java

@RequestMapping("/user")
public Member getUserDetailsAfterLogin(Authentication authentication) {
    System.out.println("user method operated");
    Member findMember = memberRepository.findByUserEmail(authentication.getName());
    if (findMember != null) {
        return findMember;
    } else {
        return null;
    }
}

 

 이제 Basic Auth를 Postman에서 사용하면 되는데, 다음처럼 username, password 형태로 로그인을 해주면 Athorization으로 정보가 BASE64 형태로 인코딩돼서 넘어온다.

 

 인코딩된 데이터는 다음처럼 스프링 시큐리티 디버거를 사용하면 확인할 수 있다. 그런데 여기서 주의할 건 프론트 엔드와 함께 작업을 할 때 이 방법이 쓰이기는 하나 BASE 64인코딩은 암호화라고 보기가 사실은 어렵다. 그냥 정보를 HTTP 프로토콜에서 전송하고 해석하기 편한 값 정도라 실제 서버에서는 이를 HTTPS로 패킷을 한 번 암호화해서 감싸는 형태로 사용해야 한다.

 

 또한 받은 JWT 토큰을 활용해서 인증해보는 건 자율에 맞기도록 하겠다. 어차피 요청 때 Authorization 헤더가 있으면 JWTTokenValidationFilter에서 멈추니 그렇게 어렵지는 않을 것이다.

[ 참고 자료 ]

https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html#servlet-authentication-securitycontextholder

https://ugo04.tistory.com/162

Spring Security 6 초보에서 마스터되기 최신 강의! - Eazy Bytes