Spring Security + JWT 토큰을 통한 로그인 구현
현재 앱 개발을 위해 REST API를 사용 중인데 로그인한 사용자 정보를 Session에 저장하는 방식은
REST의 stateless를 위반하기 때문에 JWT를 선택하였다.
JWT
JWT(Json Web Token)은 일반적으로 클라이언트와 서버 통신 시 권한 인가(Authorization)을 위해 사용하는 토큰이다.
기본 동작 원리는 간단하다.
- 클라이언트에서 ID/PW를 통해 로그인 요청
- 서버에서 DB에 해당 ID/PW를 가진 User가 있다면, Access Token과 Refresh Token을 발급해준다.
- 클라이언트는 발급받은 Access Token을 헤더에 담아서 서버가 허용한 API를 사용할 수 있게 된다.
Access Token & Refresh Token에 관한 정보는 여기를 클릭
만약 Refresh Token이 유출되어서 다른 사용자가 이를 통해 새로운 Access Token을 발급받았다면?
이 경우, Access Token의 충돌이 발생하기 때문에, 서버 측에서는 두 토큰을 모두 폐기시켜야 한다.
국제 인터넷 표준화 기구(IETF)에서는 이를 방지하기 위해 Refresh Token도 Access Token과 같은 유효 기간을 가지도록 하여,
사용자가 한 번 Refresh Token으로 Access Token을 발급 받았으면, Refresh Token도 다시 발급 받도록 하는 것을 권장하고 있다.
새로운 Access Token + Refresh Token에 대한 재발급 원리는 다음과 같다.
코드 작성
1. 라이브러리 설정
Spring Security와 JWT를 사용하기 위해 다음 라이브러리들을 추가해준다.
build.gradle
dependencies {
...
//security
implementation 'org.springframework.boot:spring-boot-starter-security'
// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
2. JWT DTO 생성
클라이언트에 토큰을 보내기 위해 JwtDto를 생성한다.
JwtDto.java
@Builder
@Data
@AllArgsConstructor
public class JwtDto {
private String grantType;
private String accessToken;
private String refreshToken;
}
JwtDto 필드에 있는 grantType은 JWT에 대한 인증 타입이다.
이 글에서는 OAuth2.0에 널리 사용되는 Bearer 인증 방식을 사용한다.
3. 암호 키 설정
터미널에 다음 명령어를 작성하여 랜덤으로 암호 키를 생성한다.
해당 키를 application-jwt.yaml에 설정한다.
키는 토큰의 암호화와 복호화에 사용될 것이고, HS256 알고리즘을 사용하기 위해 32글자 이상으로 설정한다.
터미널
openssl rand -hex 32
application-jwt.yaml
jwt:
secret: 랜덤 암호 키
4. JwtProvider 구현
Spring Security와 JWT를 사용하여 인증과 권한 부여를 처리하는 클래스이다.
이 클래스에서 JWT 토큰의 생성, 복호화, 검증 기능을 구현하였다.
JwtProvider.java
@Slf4j
@Component
public class JwtProvider {
private final Key key;
// application-jwt.yaml에서 secret 값 가져와서 key에 저장
public JwtProvider(@Value("${jwt.secret}") String secretKey){
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// Member 정보를 가지고 AccessToken, RefreshToken을 생성하는 메서드
public Jwt generateToken(Authentication authentication){
// 권한 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성 -> (현재시간 + 30분(밀리초))
Date accessTokenExpiration = new Date(now + 1000 * 60 * 30);
String accessToken = Jwts.builder()
.subject(authentication.getName())
.claim("auth", authorities)
.expiration(accessTokenExpiration)
.signWith(key)
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.expiration(new Date(now + 1000 * 60 * 60 * 24))
.signWith(key)
.compact();
return Jwt.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
// Jwt를 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(String accessToken){
// Jwt 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.toList();
// UserDetails 객체를 만들어서 Authentication return
// UserDetails: interface, User: UserDetails 를 구현한 class
UserDetails principal = new User(claims.getSubject(),"",authorities);
return new UsernamePasswordAuthenticationToken(principal,"",authorities);
}
// 토큰 정보를 검증하는 메서드
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith((SecretKey) key)
.build()
.parseSignedClaims(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.info("invalid JWT", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty", e);
}
return false;
}
// accessToken
private Claims parseClaims(String accessToken){
try {
return Jwts.parser()
.verifyWith((SecretKey) key)
.build()
.parseSignedClaims(accessToken)
.getPayload();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
generateToken()
- 인증(Authentication) 객체를 기반으로 Access Token과 Refresh Token 생성
- Access Token: 인증된 사용자의 권한 정보와 만료 시간을 담고 있음
- Refresh Token: Access Token의 갱신을 위해 사용 됨
getAuthentication()
- 주어진 Access token을 복호화하여 사용자의 인증 정보(Authentication)를 생성
- 토큰의 Claims에서 권한 정보를 추출하고, User 객체를 생성하여 Authentification 객체로 반환
- Collection<? extends GrantedAuthority> 로 리턴받는 이유
→ 권한 정보를 다양한 타입의 객체로 처리할 수 있고, 더 큰 유연성과 확장성을 가질 수 있음
[Authentification 객체 생성 과정]
- 토큰의 클레임에서 권한 정보를 가져옴. "auth" 클레임은 토큰에 저장된 권한 정보를 나타냄
- 가져온 권한 정보를 SimpleGrantedAuthority 객체를 변환하여 컬렉션에 추가
- UserDetails 객체를 생성하여 주체(subject)와 권한 정보, 기타 필요한 정보를 설정
- UsernamePasswordAuthentificationToken 객체를 생성하여 주체와 권한 정보를 포함한 인증(Authentication) 객체를 생성
validateToken()
- 주어진 토큰을 검증하여 유효성을 확인
- Jwts.parser()를 사용하여 토큰의 서명 키를 설정하고, 예외 처리를 통해 토큰의 유효성 여부를 판단
- IllegalArgumentExeception 발생하는 경우
→ 토큰이 올바른 형식이 아니거나 클레임이 비어있는 경우 등에 발생 - claim.getSubject()는 주어진 토큰의 클레임에서 "sub" 클레임의 값을 반환
→ 토큰의 주체를 나타냄 ex) 사용자의 식별자나 이메일 주소
parseClaims()
- 클레임(Claims): 토큰에서 사용할 정보의 조각
- 주어진 Access Token을 복호화하고, 만료된 토큰인 경우에도 Claims 반환
- parseSignedClaims() 메서드가 JWT 토큰의 검증과 파싱을 모두 수행
5. JwtAuthenticationFilter 구현
클라이언트 요청 시 JWT 인증을 하기 위해 설치하는 커스텀 필터로, UsernamePasswordAuthenticationFilter 이전에
실행할 것이다. 클라이언트로부터 들어오는 요청에서 JWT 토큰을 처리하고, 유효한 토큰인 경우 해당 토큰의 인증 정보(Authentication)를 SecurityContext에 저장하여 인증된 요청을 처리할 수 있도록 한다.
JWT를 통해 username + password 인증을 수행한다는 뜻
JwtAuthenticationFilter.java
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtProvider jwtProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
// 1. Request Header에서 JWT 토큰 추출
String token = resolveToken((HttpServletRequest) request);
// 2. validateToken으로 토큰 유효성 검사
if (token != null && jwtProvider.validateToken(token)) {
// 토큰이 유효할 겨우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
// Request Header에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authentication");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7);
}
return null;
}
}
resolveToken()
- 주어진 HttpServletRequest에서 토큰 정보를 추출하는 역할
- "Authorization"헤더에서 "Bearer" 접두사로 시작하는 토큰을 추출하여 반환
doFilter()
- resovleToken() 메서드를 사용하여 요청 헤더에서 JWT를 추출
- JwtProvider의 validateToken() 메서드로 JWT의 유효성 검증
- 토큰이 유효하면 JwtProvider의 getAuthentication() 메서드로 인증 객체 가져와서 SecurityContext에 저장
→ 요청을 처리하는 동안 인증 정보가 유지된다. - chain.doFilter()를 호출하여 다음 필터로 요청을 전달
6. SecurityConfig 설정
Spring Security의 설정을 담당하는 SecurityConfig를 작성한다.
SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtProvider jwtProvider;
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
//권한 확인을 하지 않는 uri
private static final String[] PERMIT_ALL_PATTERNS = new String[] {
"/h2-console/**", "/test",
"/v1/api/member/**",
"/v1/api/message/**",
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception{
return httpSecurity
// REST API이므로 basic auth 및 csrf 보안을 사용하지 않음
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable) // 1번
.cors(AbstractHttpConfigurer::disable) // 2번
.headers((headerConfig) -> headerConfig.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) // 3번
// JWT를 사용하기 때문에 세션을 사용하지 않음
.sessionManagement(configurer -> configurer
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(PERMIT_ALL_PATTERNS).permitAll()
)
// JWT 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider),
UsernamePasswordAuthenticationFilter.class)
.exceptionHandling((exceptionConfig) ->
exceptionConfig.authenticationEntryPoint(unauthorizedEntryPoint).accessDeniedHandler(accessDeniedHandler)
).getOrBuild();// 401 403 관련 예외처리
}
private final AuthenticationEntryPoint unauthorizedEntryPoint =
(request, response, authException) -> {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
String json = new ObjectMapper().writeValueAsString(ErrorCode.UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
PrintWriter writer = response.getWriter();
writer.write(json);
writer.flush();
};
private final AccessDeniedHandler accessDeniedHandler =
(request, response, accessDeniedException) -> {
response.setStatus(HttpStatus.FORBIDDEN.value());
String json = new ObjectMapper().writeValueAsString(ErrorCode.FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
PrintWriter writer = response.getWriter();
writer.write(json);
writer.flush();
};
}
SecurityFilterChain() → HttpSecurity를 구성하여 보안 설정을 정의
- HTTP Basic 인증 비활성화:
REST API에서는 HTTP Basic 인증을 사용하지 않기 때문에 이를 비활성화합니다. - CSRF 보호 비활성화:
REST API는 주로 stateless한 특성을 가지므로 CSRF 보호가 필요하지 않습니다. - CORS 보호 비활성화:
CORS 설정을 비활성화합니다. 필요에 따라 CORS 설정을 추가할 수 있습니다. - X-Frame-Options 헤더 비활성화:
X-Frame-Options 헤더를 비활성화하여 iframe 내에서 페이지가 로드될 수 있도록 합니다. - 세션 관리 설정:
JWT를 사용하기 때문에 서버에서 세션을 생성하지 않도록 설정합니다.
SessionCreationPolicy.STATELESS는 세션을 사용하지 않음을 의미합니다. - 허용된 요청 패턴 설정:
PERMIT_ALL_PATTERNS 배열에 정의된 URL 패턴에 대해 인증 없이 접근을 허용합니다. - JWT 인증 필터 추가:
JWT 인증을 위해 직접 구현한 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가합니다. - 예외 처리 설정:
인증 실패 및 접근 거부 시의 예외 처리를 설정합니다. unauthorizedEntryPoint와 accessDeniedHandler는
각각 401 Unauthorized와 403 Forbidden 응답을 처리합니다.
BCryptPasswordEncoder()
- BCryptPasswordEncoder를 생성하여 반환
- BCryptPasswordEncoder는 Spring Security에서 제공하는 비밀번호 암호화 클래스이다.
이 클래스는 비밀번호를 안전하게 저장하기 위해 해시 알고리즘을 사용한다.
7. 인증을 위한 Domain, Repository 설정
자신의 프로젝트에 맞는 요구사항을 설정해주고, UserDetails interface를 구현한다.
인증은 phoneNumber와 password로 진행할 것이다.
UserPrivacy.java
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"phoneNumber"}, name = "PHONE_NUMBER_UNIQUE")})
public class UserPrivacy extends BaseEntity implements UserDetails {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id", updatable = false,
unique = true, nullable = false)
private Integer id;
@Column(nullable = false)
private String phoneNumber;
@Column(nullable = false)
private String password;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")
private LocalDateTime birthDay;
private String email;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getUsername() {
// TODO: UsernamePasswordAuthenticationToken 말고 커스텀 Token으로 변경
return this.phoneNumber;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
getAuthorites()
- 유저가 가지고 있는 권한(authority) 목록을 SimpleGrantedAuthority로 변환하여 반환
- 나머지 Override 메서드들은 전부 true로 반환하도록 설정
UserRepository.java
public interface UserRepository extends JpaRepository<UserPrivacy,Integer> {
Optional<UserPrivacy> findByPhoneNumber(String phoneNumber);
boolean existsByPhoneNumber(String phoneNumber);
}
8. 인증을 위한 Service 구현
UserService.java
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class UserService {
private final UserRepository userRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtProvider jwtProvider;
private final BCryptPasswordEncoder passwordEncoder;
public UserPrivacy save(SignUpRequestDto requestDto){
List<String> roles = new ArrayList<>();
roles.add("USER");
try{
return userRepository.save(requestDto.toEntity(
passwordEncoder.encode(requestDto.password()),roles)
);
} catch (DataIntegrityViolationException e){
if(e.getMessage().toUpperCase().contains("PHONE_NUMBER_UNIQUE")){
throw new OccupiedException(ErrorCode.EXISTS_MEMBER);
}
throw e;
}
}
@Transactional(readOnly = true)
public Jwt signIn(LoginRequestDto requestDto){
// 1. phoneNumber + password 를 기반으로 Authentication 객체 생성
// 이때 authentication 은 인증 여부를 확인하는 authentication 값이 false
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(requestDto.phoneNumber(), requestDto.password());
// 2. 실제 검증 authentication() 메서드를 통해 요청된 User 에 대한 검증 진행
// authenticate 메서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByPhoneNumber 메서드 실행
Authentication authentication =
authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 생성 후 반환
return jwtProvider.generateToken(authentication);
}
}
save()
- 회원 가입 요청으로 들어온 SignRequestDto에서 비밀번호 암호화와 기본 역할인 USER를 가진 사용자 정보 저장
- 만약 이미 존재하는 회원일 경우 OccupiedException(커스텀) 던져주기
try catch 문을 통해 회원가입 동시성 문제 해결
SignIn()
- 로그인 요청으로 들어온 phoneNumber + password를 기반으로 Authentication 객체 생성
- authenticate() 메서드를 통해 요청된 Member에 대한 검증 진행
➡︎ loadUserByUsername 메서드가 실행됨 (어떤 객체를 검증할 것인지에 대해 직접 구현해야 함) - 검증에 성공하면 인증된 Authentication 객체를 기반으로 JWT 토큰 생성
9. CutomUserDetailsService 구현
CustomUserDetailsService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String phoneNumber) throws UsernameNotFoundException {
return userRepository.findByPhoneNumber(phoneNumber)
.map(this::createUserDetails)
.orElseThrow(() ->
new UsernameNotFoundException("해당하는 회원을 찾을 수 없습니다."));
}
// 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 return
private UserDetails createUserDetails(UserPrivacy userPrivacy) {
List<GrantedAuthority> authorities = userPrivacy.getRoles().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return User.builder()
.username(userPrivacy.getUsername())
.password(userPrivacy.getPassword())
.authorities(authorities)
.build();
}
}
Postman을 사용한 회원가입 및 로그인 기능 자동 테스트