REST API에서 사용자는 로그인을 통해 Access Token을 발행받고 이후 해당 토큰을 서비스 요청할 때 같이 보냄으로 인증을 받아 서비스를 이용할 수 있다.
1. Login Flow
2. Service Request Flow
3. Logout Flow
리포 참고
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
testImplementation 'org.springframework.security:spring-security-test'
}
public String login(RequestUserLoginDto requestDto) {
// 로그인 검사 : 유저 정보 존재 여부, 비밀번호 일치 여부
User findUser = userRepository.findById(requestDto.getId()).orElseThrow(() -> new UserNotFoundException(""));
if (!passwordEncoder.matches(requestDto.getPassword(), findUser.getPassword()))
throw new PasswordMismatchException("");
// 토큰 생성
String token = jwtTokenProvider.createToken(findUser.getId());
// 레디스에 토큰 저장
redisTemplate.opsForValue().set("JWT_TOKEN:" + requestDto.getId(), token);
return token;
}
로그인 요청 시
1. 해당 정보의 유저가 존재하는지, 비밀번호가 일치하는지 확인한다.
2. 유효하다면 유저의 id 정보를 담은 토큰을 생성한다.
3. 로그인 처리된 유저의 토큰을 기억하기 위해 레디스에 저장한다.
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
@Value("${spring.jwt.key}")
private String SECRET_KEY;
private final long tokenValidTime = 30 * 60 * 1000L; // 토큰 유효시간 = 30분
private final CustumUserDetailService custumUserDetailService;
// 객체 초기화, SecretKey를 Base64로 인코딩
@PostConstruct
protected void init() {
SECRET_KEY = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
}
// 토큰 생성
public String createToken(String userId) {
return Jwts.builder()
.setClaims(Jwts.claims().setSubject(userId)) // 정보 저장
.setIssuedAt(new Date()) // 토큰 발행시간
.setExpiration(new Date(new Date().getTime() + tokenValidTime)) // 토큰 유효시간
.signWith(SignatureAlgorithm.HS512, SECRET_KEY) // 암호화 알고리즘, secret 값
.compact();
}
...
}
필요한 정보를 설정하여 토큰을 생성한다.
@Value("${spring.jwt.key}")
로 접근spring:
jwt:
key:
헤더에 발급받은 토큰을 담아 요청합니다. 가장 먼저 Spring Filter를 거쳐 로그인 인증, 사용자 권한 인증 등의 과정을 수행합니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // rest api : csrf, httpBasic, formLogin 미사용
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // jwt 인증 : session 미사용
.authorizeHttpRequests((authz) -> authz // 권한 설정
.requestMatchers("/auth/**").permitAll() // 로그인, 로그아웃 관련
.requestMatchers("/register/**").permitAll() // 회원가입 관련
.requestMatchers("/swagger-ui/**", "/v3/**").permitAll() // api 명세 관련
.requestMatchers("/admin/**").hasRole("ADMIN") // admin 관련
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
return http.build();
}
}
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
@Autowired
private final JwtTokenProvider jwtTokenProvider;
private final RedisTemplate<String, String> redisTemplate;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request); // 헤더에서 토큰 받기
if (token != null && jwtTokenProvider.validateToken(token)) { // 토큰이 유효하다면
String key = "JWT_TOKEN:" + jwtTokenProvider.getUserId(token);
if (redisTemplate.hasKey(key) && redisTemplate.opsForValue().get(key) != null) { // 로그인 여부 체크
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response); // 다음 Filter 실행
}
}
서비스 요청 시
1. 헤더에서 토큰을 추출한다.
2. 토큰이 유효한지 검사한다.
3. 토큰이 로그인 상태인지 검사한다.
4. 유효하고 로그인 상태라면 Spring Security 상에서 인증 처리한다.
인증 처리되면 인정된 유저의 권한에 따라 요청 처리 후 값을 반환합니다.
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
@Value("${spring.jwt.key}")
private String SECRET_KEY;
private final long tokenValidTime = 30 * 60 * 1000L; // 토큰 유효시간 = 30분
private final CustumUserDetailService custumUserDetailService;
// 객체 초기화, SecretKey를 Base64로 인코딩
@PostConstruct
protected void init() {
SECRET_KEY = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
}
// 토큰 생성
public String createToken(String userId) {
return Jwts.builder()
.setClaims(Jwts.claims().setSubject(userId)) // 정보 저장
.setIssuedAt(new Date()) // 토큰 발행시간
.setExpiration(new Date(new Date().getTime() + tokenValidTime)) // 토큰 유효시간
.signWith(SignatureAlgorithm.HS512, SECRET_KEY) // 암호화 알고리즘, secret 값
.compact();
}
// 인증 정보 조회
public Authentication getAuthentication(String token) {
CustomUserDetails customUserDetails = (CustomUserDetails) custumUserDetailService.loadUserByUsername(getUserId(token));
return new UsernamePasswordAuthenticationToken(customUserDetails, "", customUserDetails.getAuthorities());
}
// 토큰에서 User Id 추출
public String getUserId(String token) {
try {
return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody().getSubject();
} catch (ExpiredJwtException e) {
return e.getClaims().getSubject();
}
}
// 토큰 유효성, 만료일자 확인
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
return true;
} catch (SecurityException e) {
throw new JwtException("잘못된 JWT 시그니처입니다.");
} catch (MalformedJwtException ex) {
throw new JwtException("유효하지 않은 토큰입니다.");
} catch (ExpiredJwtException ex) {
throw new JwtException("만료된 토큰입니다.");
} catch (UnsupportedJwtException ex) {
throw new JwtException("지원되지 않는 토큰입니다.");
} catch (IllegalArgumentException ex) {
throw new JwtException("토큰에 저장된 정보가 없습니다.");
}
}
// Request의 Header에서 token 값 가져오기
public String resolveToken(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
}
public class CustomUserDetails implements UserDetails {
private User user;
public CustomUserDetails(User user) {
this.user = user;
}
@Override
public List<GrantedAuthority> getAuthorities() {
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getUserRole().name()));
return authorities;
}
// get Password 메서드
@Override
public String getPassword() {
return user.getPassword();
}
// get Username 메서드 (생성한 User은 id 사용)
@Override
public String getUsername() {
return user.getId();
}
// 계정이 만료 되었는지 (true: 만료X)
@Override
public boolean isAccountNonExpired() {
return true;
}
// 계정이 잠겼는지 (true: 잠기지 않음)
@Override
public boolean isAccountNonLocked() {
return true;
}
// 비밀번호가 만료되었는지 (true: 만료X)
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 계정이 활성화(사용가능)인지 (true: 활성화)
@Override
public boolean isEnabled() {
return true;
}
}
@Service
@RequiredArgsConstructor
public class CustumUserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
User user = userRepository.findById(id).orElseThrow(() -> new UserNotFoundException(""));
return new CustomUserDetails(user);
}
}
public void logout() {
// spring security 상 인증된 user detail의 username과 일치하는 jwt를 삭제
CustomUserDetails customUserDetails = (CustomUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
redisTemplate.delete("JWT_TOKEN:" + customUserDetails.getUsername());
}
로그아웃 요청 시
1. 현재 Spring Security에서 인증된 유저 정보를 받는다.
2. 레디스에서 유저 정보와 일치하는 JWT를 찾아 삭제한다.