Springboot 3 버전을 기반으로 권한을 JWT 토큰에 저장하고 각 컨트롤러에서 사용할 수 있도록 처리하는 간단한 샘플 코드를 작성했다.

아래와 같이 각 컨트롤러 또는 특정 메소드에 대한 접근 권한이 토큰에 부여된다.

{
  "authorities": "ORDER,ITEM",
  "username": "kim",
  "iat": 1692493809,
  "exp": 1692580209
}

ERD

멤버십 및 멤버십 권한 부여는 일대다 방식으로 이루어진다. 즉, 회원 한 명이 여러 인증 정보를 가질 수 있다.

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는 다음 프로세스를 수행한다.

  1. 경로가 /member/* 에 접근하는 경우 필터를 적용하지 않는다.
  2. Bearer 토큰(JWT)을 파싱하고 유효성을 확인한다.
  3. 토큰 정보에서 사용자명을 가져와 DB에서 회원을 검색한다
  4. 회원 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 토큰을 통한 스프링 시큐리티 인증 및 권한 부여 처리가 구현되었다.