Springboot3.x에서 JWT에 권한 적용하기
Springboot 3 버전을 기반으로 권한을 JWT 토큰에 저장하고 각 컨트롤러에서 사용할 수 있도록 처리하는 간단한 샘플 코드를 작성했다.
아래와 같이 각 컨트롤러 또는 특정 메소드에 대한 접근 권한이 토큰에 부여된다.
{
"authorities": "ORDER,ITEM",
"username": "kim",
"iat": 1692493809,
"exp": 1692580209
}
ERD
멤버십 및 멤버십 권한 부여는 일대다 방식으로 이루어진다. 즉, 회원 한 명이 여러 인증 정보를 가질 수 있다.
Entitiy Setting
Member Entity 는 아래와 같다.
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "MEMBER")
public class Member extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
private Long memberId;
@Column(name = "USER_ID", nullable = false, length = 20)
private String userId;
@Column(name = "PASSWORD", nullable = false, length = 100)
private String password;
@Column(name = "NAME", nullable = false, length = 20)
private String name;
@Column(name = "GENDER", nullable = false, length = 20)
@Enumerated(value = EnumType.STRING)
private Gender gender;
@Default
@OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true,
fetch = FetchType.LAZY)
private List<MemberAuthority> memberAuthorityList = new ArrayList<>();
public void addAuthorities(List<MemberAuthorityDto> memberAuthorityDtoList) {
memberAuthorityDtoList.forEach(memberAuthorityDto -> {
memberAuthorityDto.setMember(this);
memberAuthorityList.add(memberAuthorityDto.toEntity());
});
}
public List<Authority> getAuthorities() {
return memberAuthorityList.stream()
.map(MemberAuthority::getAuthority).collect(Collectors.toList());
}
}
MemberAuthority Entity 는 아래와 같다.
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
@Table(name = "MEMBER_AUTHORITY")
public class MemberAuthority extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_AUTHORITY_ID")
private Long userAuthorityId;
@Column(name = "AUTHORITY", nullable = false, length = 20)
@Enumerated(value = EnumType.STRING)
private Authority authority;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "MEMBER_ID")
private Member member;
}
Member 및 MemberAuthority 엔티티는 양방향 관계로 설정된다. 그래서 멤버를 입력할 때 권한이 함께 입력되고 함께 검색될 수 있도록 연관관계 편의 메서드(addAuthorities, getAuthorities)도 만든다.
Security Setting
스프링부트 3.x 버전부터는 보안 설정이 변경되었다. SecurityFilterChain를 통해 설정하도록 변경되었다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
private final UserDetailService userDetailService;
private final JwtRequestFilter jwtRequestFilter;
@Bean
public SecurityFilterChain filterChain(final HttpSecurity http) throws Exception {
http.httpBasic(HttpBasicConfigurer::disable)
.authenticationProvider(authenticationProvider())
.csrf(CsrfConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(configurer -> configurer.sessionCreationPolicy(
SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(
request -> request.requestMatchers("/member/**").permitAll()
.requestMatchers("/api/**").permitAll()
.anyRequest().authenticated()
).addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
}
member/**
로 시작하는 부분은 회원 가입/로그인이 필요하므로 permitAll()로 설정하여 누구나 접근이 가능해야 한다. /api/**
는 실제로 REST-API로 처리되는 Controller이므로 먼저 permitAll()로 설정하고 열어야 하지만, JWT 토큰으로만 인증이 이루어지도록 만들었다. 그리고 이 부분은 jwtRequestFilter가 처리하도록 한다.
그리고 각 컨트롤러 메서드에서 @PreAuthorize
를 통해 인증을 확인할 수 있도록 @EnableMethodSecurity(securedEnabled = true)
를 추가한다.
authenticationProvider
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
jwt 처리를 별도로 처리하기 위해 필터가 추가함. JwtRequestFilter
필터는 각 컨트롤러에 액세스할 때 헤더의 토큰 값을 검사하고 파싱한다. UserDetailsService를 구현하는 클래스를 생성하고, 여기서는 Member를 조회하기 위해 loadUserByusername을 구현한다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> optionalMember = memberRepository.findByUserId(username);
if (optionalMember.isEmpty()) {
throw new UsernameNotFoundException("UsernameNotFound [" + username + "]");
}
return new User(optionalMember.get());
}
static class User implements UserDetails {
private final String username;
private final String password;
private final List<String> authorities;
public User(Member member) {
this.username = member.getUserId();
this.password = member.getPassword();
authorities = Arrays.stream(Authority.values())
.map(Authority::name)
.collect(Collectors.toList());
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities.stream().map(SimpleGrantedAuthority::new).collect(
Collectors.toSet());
}
... 중략 ...
}
}
다음으로 필터를 설정한다.
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtRequestFilter extends OncePerRequestFilter {
private final JwtTokenHandler jwtTokenHandler;
private final UserDetailService userDetailService;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String[] excludePath = {"/member"};
String path = request.getRequestURI();
return Arrays.stream(excludePath).anyMatch(path::startsWith);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
final String authorizationHeader = request.getHeader("Authorization");
String username;
String token = null;
//HttpSession session = request.getSession();
//Parse the token attached below the Bearer part of the Header.
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
token = authorizationHeader.substring(7);
}
username = jwtTokenHandler.extractUsername(token);
if (username == null) {
ErrorResponse.exceptionCall(HttpStatus.UNAUTHORIZED, response);
return;
}
UserDetails userDetails = userDetailService.loadUserByUsername(username);
if (SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
= new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
//session.setAttribute("userId", username);
}
filterChain.doFilter(request, response);
}
}
JwtRequestFilter는 다음 프로세스를 수행한다.
- 경로가 /member/* 에 접근하는 경우 필터를 적용하지 않는다.
- Bearer 토큰(JWT)을 파싱하고 유효성을 확인한다.
- 토큰 정보에서 사용자명을 가져와 DB에서 회원을 검색한다
- 회원 DB에서 정보를 확인후에 DB에 저장된 권한 정보를 SecurityContextHolder에 저장한다.
다음으로 JWT 토큰을 생성하고 파싱하는 핸들러를 생성한다.
JWT Handler
@Component
@Slf4j
public class JwtTokenHandler {
@Value("${jwt.secret-key}")
private String jwtSecretKey;
private String createToken(Map<String, Object> claims) {
String secretKeyEncodeBase64 = Encoders.BASE64.encode(jwtSecretKey.getBytes());
byte[] keyBytes = Decoders.BASE64.decode(secretKeyEncodeBase64);
Key key = Keys.hmacShaKeyFor(keyBytes);
return Jwts.builder()
.signWith(key)
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24))
.compact();
}
private Claims extractAllClaims(String token) {
if (StringUtils.isEmpty(token)) return null;
SignatureAlgorithm sa = SignatureAlgorithm.HS256;
SecretKeySpec secretKeySpec = new SecretKeySpec(jwtSecretKey.getBytes(), sa.getJcaName());
Claims claims;
try {
claims = Jwts.parserBuilder().setSigningKey(secretKeySpec).build()
.parseClaimsJws(token).getBody();
} catch (JwtException e) {
log.error(e.toString());
claims = null;
}
return claims;
}
public String extractUsername(String token) {
final Claims claims = extractAllClaims(token);
if (claims == null) return null;
else return claims.get("username",String.class);
}
public String generateToken(Member member) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", member.getUserId());
String auths = member.getMemberAuthorityList().stream()
.map(MemberAuthority::getAuthority)
.collect(Collectors.joining(","));
claims.put("authorities", auths);
return createToken(claims);
}
}
생성 토큰 메서드를 통해 jwt 토큰을 생성한다. 여기서 회원 엔티티를 통해 권한을 검색하고 JWT 토큰 클레임에 추가한다.
다음으로 MemberService에서 가입 및 로그인 시 인증을 확인하여 토큰을 부여하는 방법을 처리하자
Service
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
private final JwtTokenHandler jwtTokenHandler;
private final PasswordEncoder passwordEncoder;
@Transactional
public MemberDto save(MemberDto memberDto) {
Member saveMember = memberRepository.save(memberDto.toEntity());
saveMember.addAuthorities(memberDto.getAuthorities());
return MemberDto.fromEntity(saveMember);
}
public LoginDto loginProcess(LoginDto loginDto) {
Optional<Member> optionalMember = memberRepository.findByUserId(loginDto.getUserId());
if (optionalMember.isEmpty() || !isMatchPassword(loginDto.getPassword(),
optionalMember.get().getPassword())) {
loginDto.setResult(false);
loginDto.setMsg("ID and password do not match.");
return loginDto;
}
loginDto.setAccessToken(jwtTokenHandler.generateToken(optionalMember.get()));
loginDto.setResult(true);
loginDto.setMsg("SUCCESS");
return loginDto;
}
private boolean isMatchPassword(String rawPassword, String dbPassword) {
return passwordEncoder.matches(rawPassword, dbPassword);
}
}
loginProcess 메서드에서는 회원이 등록된 회원인지 확인하고 JWT 토큰을 생성한다.
Controller
@RestController
@RequestMapping("/member")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
@PostMapping
public ResponseEntity<MemberDto> create(@RequestBody MemberDto memberDto) {
memberDto.setPassword(passwordEncoder.encode(memberDto.getPassword()));
return ResponseEntity.ok(memberService.save(memberDto));
}
@PostMapping("/login")
public ResponseEntity<LoginDto> login(@RequestBody LoginDto loginDto) {
return ResponseEntity.ok(memberService.loginProcess(loginDto));
}
@GetMapping
public String helloMember() {
return "Hello Member";
}
}
MemberController에서는 가입 및 로그인 구현 메서드를 구현한다. 멤버로 시작하는 모든 메서드는 위의 Security 및 JwtRequestFilter에서 예외를 처리했기 때문에 누구나 액세스할 수 있다.
다음은 kim이라는 회원으로 가입하기 위한 JSON이다. 현재 kim은 ORDER 권한만 가지고 있다.
{
"userId": "kim",
"password": "1234",
"name": "KIM",
"authorities": [
{
"authority": "ORDER"
}
]
}
가입하고 로그인하면 아래와 같이 토큰이 발급된다.
{
"authorities": "ORDER",
"username": "kim",
"iat": 1693003940,
"exp": 1693090340
}
다음으로 실제 권한 테스트를 수행할 수 있는 컨트롤러를 구현합니다.
@RestController
@RequestMapping("/api/access")
public class AccessTestController {
@GetMapping("/item")
@PreAuthorize("hasAuthority('ITEM')")
public String accessItem() {
return "Hello Item";
}
@GetMapping("/order")
@PreAuthorize("hasAuthority('ORDER')")
public String accessOrder() {
return "Hello Order";
}
@GetMapping("/item/order")
@PreAuthorize("hasAnyAuthority('ITEM','ORDER')")
public String accessOrderOrItem() {
return "Hello Item and Order";
}
@GetMapping("/anyone")
public String accessAnyone() {
return "Hello Anyone";
}
}
위의 Controller가 JwtTokenkim이라는 멤버로 로그인하여 토큰과 연결하면 인증 확인 없이 accessOrder() 메서드와 accessAnyone() 메서드만 접근할 수 있다.
이렇게 최종적으로 JWT 토큰을 통한 스프링 시큐리티 인증 및 권한 부여 처리가 구현되었다.