시큐리티 + JWT 토큰을 결합하여 구현하는 과정에서 발생한 에러. 토큰 발급은 잘 되는데 권한 인증이 필요한 요청을 보냈을 때 계속 403 에러가 발생했다.

🚀 사전 정리

  • 토큰 인증 테스트를 위해 ROLE_ADMIN 권한을 가진 사용자만 “/admin/**” 경로로 요청할 수 있도록 설정했다.

  • ADMIN 권한을 가진 계정으로 로그인하여 “/admin/test” 경로로 POST 요청하면 “login success” 라는 문자열이 반환되면 성공이다.

🔥 문제 상황

DB에서 권한 정보는 varchar 로 저장되기 때문에 User 도메인에서도 String 형으로 작성했다. 우리 서비스는 USER, ADMIN 권한 두 종류로만 분류하고 있기 때문에 한 유저가 여러 개의 권한을 가질 필요가 없어서 문자열로 처리하는 것이 적절했으나, Spring Security 의 시스템상 권한 정보를 Collection 형태로 반환해야 했다.

package kr.kh.backend.domain;

@Builder
@Getter @Setter
@ToString(exclude = "password")
public class User implements UserDetails {

    private int id;
    private String email;
    private String nickname;
    private String password;
    private String platform;
    private String roles;


    @Override
    public String getUsername() {
        return nickname;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles != null && !roles.isEmpty() ?
                List.of(roles.split(",")).stream()
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList()) :
                new ArrayList<>();
    }

    // 이하 생략...

}

권한 정보는 그렇게 리스트 형태로 저장되었고, 토큰 발급 과정에서 권한 정보를 가져올 때 리스트 형태 그대로 받아서 그대로 토큰에 넣어주었다.

// (수정 전) 유저 권한 가져오기
List<String> authoritiesList = authentication.getAuthorities().stream()
        .map(GrantedAuthority::getAuthority)
        .collect(Collectors.toList());

// access token 생성 : 인증된 사용자의 권한 정보와 만료 시간을 담는다. (1시간)
Date expiration = new Date(now + 1000 * 60 * 60);
String accessToken = Jwts.builder()
        .setSubject(authentication.getName())
        .claim("auth", authoritiesList)
        .setExpiration(expiration)
        .signWith(key, SignatureAlgorithm.HS256)
        .compact();

그 결과, 아래와 같은 페이로드 형태가 되었고, 토큰 발급 과정에서는 문제가 없었기에 그대로 진행했다. 그러나 권한 인증 과정에서 403 에러가 발생했다.

스크린샷 2024-10-06 오후 2 35 08

✨ 해결

아무리 서버를 재시작하여 돌려봐도 JWTAuthFilter 를 문제 없이 통과하는 것으로 보였다. 인증된 객체가 어떤 식으로 저장되어 있나 확인해 보기 위해 SecurityContextHolder 를 로그로 찍어보았는데 권한은 ROLE_ADMIN 으로 들어가 있는 게 맞았고, Authenticated = true 로 권한 인증에 성공한 것이 확인되었다.

2024-10-06T14:38:02.034+09:00  INFO 91285 --- [backend] [nio-8080-exec-2] k.kh.backend.security.jwt.JwtAuthFilter  : success JWT Filter ! SecurityContextHolder = SecurityContextImpl [Authentication=UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=tester7, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_ADMIN]], Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_ADMIN]]]

원인을 찾지 못하던 와중에 페이로드의 대괄호[] 가 신경쓰이기 시작했다. 이전에 두 차례 프로젝트를 진행하면서 JWT 토큰을 사용했을 때, 페이로드는 전부 문자열이었던 것으로 기억을 해서 혹시 몰라 String 형으로 다시 변환하여 토큰에 저장해주었다.

이 방식으로 실행해본 결과 원하던 결과를 얻을 수 있었다.

// (수정 후) 유저 권한 가져오기
String authorities = authentication.getAuthorities().stream()
        .map(GrantedAuthority::getAuthority)
        .collect(Collectors.joining(","));

// access token 생성 : 인증된 사용자의 권한 정보와 만료 시간을 담는다. (1시간)
Date expiration = new Date(now + 1000 * 60 * 60);
String accessToken = Jwts.builder()
        .setSubject(authentication.getName())
        .claim("auth", authorities)
        .setExpiration(expiration)
        .signWith(key, SignatureAlgorithm.HS256)
        .compact();

스크린샷 2024-10-06 오후 2 50 11

스크린샷 2024-10-06 오후 2 21 17

💡 이후의 고찰…

그런데 왜 대괄호는 인식하지 못하는 것일까? 궁금해서 검색해봤는데 스프링에서는 JWT 에서 auth 항목이 대괄호로 감싸인 배열 형식이더라도 이를 문자열로 처리할 수 있다고 한다. 하지만 권한 정보를 올바르게 인식하기 위해서는 Collection 형태로 반환해야 한다는데, 아마 기존에 내가 작성했던 코드로는 auth 항목을 파싱하는 과정에서 오류가 있었던 듯 하다.

이런 식으로… 대괄호와 따옴표를 제거할 수 있도록 코드를 수정하는 것도 하나의 방법이었을 수 있다. 다만 내 경우에는 어차피 권한 롤은 한 개 뿐이기 때문에 토큰에도 문자열 하나만 들어가는 것이 깔끔할 듯 하여 아래 방식을 시도해보지는 않았다.

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    if (roles != null && !roles.isEmpty()) {
        return Arrays.stream(roles.replaceAll("[\\[\\]\"]", "").split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }
    return new ArrayList<>();
}

트러블 슈팅 끝 ! 이제 oauth 로그인을 구현해보러 가야겠다.