시큐리티 + 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 에러가 발생했다.
✨ 해결
아무리 서버를 재시작하여 돌려봐도 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();
💡 이후의 고찰…
그런데 왜 대괄호는 인식하지 못하는 것일까? 궁금해서 검색해봤는데 스프링에서는 JWT 에서 auth 항목이 대괄호로 감싸인 배열 형식이더라도 이를 문자열로 처리할 수 있다고 한다. 하지만 권한 정보를 올바르게 인식하기 위해서는 Collection
이런 식으로… 대괄호와 따옴표를 제거할 수 있도록 코드를 수정하는 것도 하나의 방법이었을 수 있다. 다만 내 경우에는 어차피 권한 롤은 한 개 뿐이기 때문에 토큰에도 문자열 하나만 들어가는 것이 깔끔할 듯 하여 아래 방식을 시도해보지는 않았다.
@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 로그인을 구현해보러 가야겠다.