Json Web Token
src/com.example.chat
├─config
│  ├─auth
│  └─jwt
├─controller
├─domain
├─dto
├─repository
└─service
dependencies {
	...
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
} 
Alt + insert를 눌러서 add dependency를 통해 위와 같이 필요한 dependencey들을 추가할 수 있습니다.
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/chat?serverTimezone=UTC&characterEncoding=UTF-8
    username: root
    password:
  redis:
    port: 6379
    host: localhost
jwt:
  secret: ee.ff
spring:
  profiles:
    include:
      - db
  jpa:
    database: mysql
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
        show_sql: true
logging:
  level:
    com.example: debug
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final JwtEntryPoint jwtEntryPoint; // 1
    private final JwtAuthenticationFilter  jwtAuthenticationFilter; // 1
    private final PrincipalDetailsService principalDetailsService; // 2
    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManagerBean();
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); // 3
    }
    @Override
    public void configure(WebSecurity web) { // 4
        web.ignoring().antMatchers("/favicon.ico");
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .cors()
                .and()
                .csrf().disable()
                .authorizeRequests() // 5
                .antMatchers("/", "/join/**", "/login", "/chat").permitAll()
                .anyRequest().hasRole("USER")
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(jwtEntryPoint)
                .and()
                .logout().disable() // 6
                .sessionManagement().sessionCreationPolicy(STATELESS)
                .and() // 7
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
        ;
    }
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(principalDetailsService).passwordEncoder(passwordEncoder());
    }
}
antMatchers("/", "/join/**", "/login", "/chat").permitAll() 메서드를 통해 명시된 url은 권한에 제한 없이 요청할 수 있습니다.@Slf4j
@Component
public class JwtEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        log.error("Unauthorized error: {}", authException.getMessage());
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Error: Unauthorized");
    }
}
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenUtil jwtTokenUtil;
    private final PrincipalDetailsService principalDetailsService;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        String accessToken = getToken(request);
        if (accessToken != null) {
            checkLogout(accessToken);
            String username = jwtTokenUtil.getUsername(accessToken);
            if (username != null) {
                UserDetails userDetails = principalDetailsService.loadUserByUsername(username);
                equalsUsernameFromTokenAndUserDetails(userDetails.getUsername(), username);
                validateAccessToken(accessToken, userDetails);
                processSecurity(request, userDetails);
            }
        }
        filterChain.doFilter(request, response);
    }
    private String getToken(HttpServletRequest request) {
        String headerAuth = request.getHeader("Authorization");
        if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
            return headerAuth.substring(7);
        }
        return null;
    }
    private void checkLogout(String accessToken) {
        log.info("JwtAuthenticationFilter/checkLogout(): 로그이아웃 검증 로직 추가 필요");
    }
    private void equalsUsernameFromTokenAndUserDetails(String userDetailsUsername, String tokenUsername) {
        if (!userDetailsUsername.equals(tokenUsername)) {
            throw new IllegalArgumentException("username이 토큰과 맞지 않습니다.");
        }
    }
    private void validateAccessToken(String accessToken, UserDetails userDetails) {
        if (!jwtTokenUtil.validateToken(accessToken, userDetails)) {
            throw new IllegalArgumentException("토큰 검증 실패");
        }
    }
    private void processSecurity(HttpServletRequest request, UserDetails userDetails) {
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails,null, userDetails.getAuthorities());
        usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
    }
}
JwtTokenUtil에 선언된 메서드로 토큰에서 username을 가져옵니다.@EnableCaching // 추가!
@SpringBootApplication
public class ChatApplication {
	public static void main(String[] args) {
		SpringApplication.run(ChatApplication.class, args);
	}
}
Spring의 캐싱기능(한번 접근한 것을 다시 접근할 때 빠르게 접근할 수 있음)을 사용하기 위해서 main 메서드가 있는 클래스에
@EnableCaching 어노테이션을 추가함
@Service
@RequiredArgsConstructor
@Slf4j
public class PrincipalDetailsService implements UserDetailsService {
    private final UserRepository userRepository;
    @Override
    @Cacheable(key = "#username", unless = "#result == null")
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<User> findUser = userRepository.findByUsername(username);
        log.info("PrincipalDetailsService/loadUserByUsername: username에 해당하는 user없을 경우 예외처리 필요");
        return PrincipalDetails.of(findUser.get());
    }
}
@Cacheable 어노테이션은 토큰을 부여할 때 마다 DB를 거치는 cost를 줄이기 위해 설정해둔 것입니다. 자세한 내용은 2편에서 설명드리겠습니다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PrincipalDetails implements UserDetails {
    private String username;
    private String password;
    @Builder.Default
    private List<String> roles = new ArrayList<>();
    public static UserDetails of(User user) {
        return PrincipalDetails.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .roles(user.getRoles())
                .build();
    }
    @Override
    @JsonIgnore
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(toList());
    }
    @Override
    public String getPassword() {
        return password;
    }
    @Override
    public String getUsername() {
        return username;
    }
    @Override
    @JsonIgnore
    public boolean isAccountNonExpired() {
        return false;
    }
    @Override
    @JsonIgnore
    public boolean isAccountNonLocked() {
        return false;
    }
    @Override
    @JsonIgnore
    public boolean isCredentialsNonExpired() {
        return false;
    }
    @Override
    @JsonIgnore
    public boolean isEnabled() {
        return false;
    }
}
코드 참고해서 진행하실 때 SimpleGrantedAuthority가 import 잘 되지 않는다면 import org.springframework.security.core.authority.SimpleGrantedAuthority; 로 SimpleGrantedAuthority를 import 해오면 됩니다.
PrincipalDetails 클래스를 따로 만든 이유는 Redis에 캐싱할 때, 기본적인 UserDetails로 저장할 경우 역직렬화가 되지 않는 이슈를 확인했습니다. 인증과 권한체크를 위한 정보들을 필드에 설정하였고, 저장할 때 관련이 없는 나머지 메서드들은 @JsonIgnore로 처리했습니다.
@Slf4j
@Component
public class JwtTokenUtil {
    @Value("${jwt.secret}") 
    private String SECRET_KEY; // application-db.yml에서 설정한 ee.ff 값이 할당됨
    public Claims extractAllClaims(String token) { // 2
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey(SECRET_KEY))
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
    public String getUsername(String token) {
        return extractAllClaims(token).get("username", String.class);
    }
    private SecretKey getSigningKey(String secretKey) {
        byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }
    public Boolean isTokenExpired(String token) {
        Date expiration = extractAllClaims(token).getExpiration();
        return expiration.before(new Date());
    }
    public String generateAccessToken(String username) {
        return doGenerateToken(username, ACCESS_TOKEN_EXPIRATION_TIME.getValue());
    }
    public String generateRefreshToken(String username) {
        return doGenerateToken(username, REFRESH_TOKEN_EXPIRATION_TIME.getValue());
    }
    private String doGenerateToken(String username, long expireTime) { // 1
        Claims claims = Jwts.claims();
        claims.put("username", username);
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expireTime))
                .signWith(getSigningKey(SECRET_KEY), SignatureAlgorithm.HS256)
                .compact();
    }
    public Boolean validateToken(String token, UserDetails userDetails) {
        String username = getUsername(token);
        return username.equals(userDetails.getUsername())
                && !isTokenExpired(token);
    }
    public long getRemainMilliSeconds(String token) {
        Date expiration = extractAllClaims(token).getExpiration();
        Date now = new Date();
        return expiration.getTime() - now.getTime();
    }
}
@Getter @AllArgsConstructor
public enum JwtExpirationEnums {
    ACCESS_TOKEN_EXPIRATION_TIME("JWT 만료 시간 / 30분", 1000L * 60 * 30),
    REFRESH_TOKEN_EXPIRATION_TIME("Refresh 토큰 만료 시간 / 7일", 1000L * 60 * 60 * 24 * 7),
    REISSUE_EXPIRATION_TIME("Refresh 토큰 만료 시간 / 3일", 1000L * 60 * 60 * 24 * 3);
    private String description;
    private Long value;
}
class가 아니라 enum 타입으로 작성했습니다
@Getter @AllArgsConstructor
public enum JwtHeaderUtilEnums {
    GRANT_TYPE("JWT 타입 / Bearer ", "Bearer ");
    private String description;
    private String value;
}
회원가입 기능을 구현해보겠습니다.
@RestController
@RequiredArgsConstructor
public class UserApiController {
    private final UserService userService;
    private final JwtTokenUtil jwtTokenUtil;
    @GetMapping("/chat")
    public String chat() {
        return "OK";
    }
    @PostMapping("/join")
    public String join(@RequestBody UserJoinDto joinDto) {
        userService.join(joinDto);
        return "회원가입 완료";
    }
    @PostMapping("/join/admin")
    public String joinAdmin(@RequestBody UserJoinDto joinDto) {
        userService.joinAdmin(joinDto);
        return "어드민 회원 가입 완료";
    }
}
@Data
@Builder
public class UserJoinDto {
    private String email;
    private String password;
    private String nickname;
}
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    public void join(UserJoinDto joinDto) {
        joinDto.setPassword(passwordEncoder.encode(joinDto.getPassword()));
        userRepository.save(User.of(joinDto));
    }
    public void joinAdmin(UserJoinDto joinDto) {
        joinDto.setPassword(passwordEncoder.encode(joinDto.getPassword()));
        userRepository.save(User.ofAdmin(joinDto));
    }
}
사용자가 입력한 비밀번호를 그대로 DB에 저장하면 안되기 때문에 passwordEncoder를 통해 비밀번호를 암호화하여 user를 저장합니다. 또한 권한 처리를 위해 User 객체 생성시 일반 유저와 Admin을 구분하여 생성합니다.
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class User {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;
    @Column(unique = true)
    private String username;
    @Column(unique = true)
    private String email;
    private String password;
    @Column(unique = true)
    private String nickname;
    @OneToMany(mappedBy = "user", cascade = ALL, orphanRemoval = true)
    @Builder.Default
    private Set<Authority> authorities = new HashSet<>();
    public static User of(UserJoinDto joinDto) {
        User user = User.builder()
                .username(UUID.randomUUID().toString())
                .email(joinDto.getEmail())
                .password(joinDto.getPassword())
                .nickname(joinDto.getNickname())
                .build();
        user.addAuthority(Authority.of(user));
        return user;
    }
    public static User ofAdmin(UserJoinDto joinDto) {
        User user = User.builder()
                .username(UUID.randomUUID().toString())
                .email(joinDto.getEmail())
                .password(joinDto.getPassword())
                .nickname(joinDto.getNickname())
                .build();
        user.addAuthority(Authority.ofAdmin(user));
        return user;
    }
    private void addAuthority(Authority authority) {
        authorities.add(authority);
    }
    public List<String> getRoles() {
        return authorities.stream()
                .map(Authority::getRole)
                .collect(toList());
    }
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class Authority implements GrantedAuthority {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "user_id")
    private User user;
    private String role;
    public static Authority of(User user) {
        return Authority.builder()
                .role("ROLE_USER")
                .user(user)
                .build();
    }
    public static Authority ofAdmin(User user) {
        return Authority.builder()
                .role("ROLE_ADMIN")
                .user(user)
                .build();
    }
    @Override
    public String getAuthority() {
        return role;
    }
}
회원과 권한의 관계는 1:N 관계이며, Spring Security가 권한을 체크할 때 GrantedAuthority 타입으로 체크하기 때문에 구현체로 만들었습니다.

회원을 생성하기 위해 Body에 정보를 넣어서 요청하는 API이기 때문에 POST요청으로 보내어서
아래와 같이 새로운 사용자가 생성된 것을 볼 수 있습니다.

이상으로 시큐리티와 JWT 설정 및 회원가입 기능까지 마무리했습니다. 다음 편에서 Redis 기반으로 로그인, 로그아웃, 토큰 재발급, 로그아웃, 간단한 회원 정보 조회까지 해보겠습니다!
안녕하세요 해당 포스팅의 깃허브 주소가 있을까요?