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 기반으로 로그인, 로그아웃, 토큰 재발급, 로그아웃, 간단한 회원 정보 조회까지 해보겠습니다!
안녕하세요 해당 포스팅의 깃허브 주소가 있을까요?