저번 포스팅에서는 쿠키, 세션, 그리고 JWT에 대해 살펴봤습니다. 이번 포스팅에서는 Spring Security와 JWT를 이용하여 로그인 및 회원가입을 어떻게 구현할 수 있는지 살펴보겠습니다.
dependencies {
...
// Spring 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'
}
토큰의 암호화 복호화에 사용하기 위해서 secret key를 application.yml에 설정합니다.
해당 키는 HS256 알고리즘을 사용하기 위해 32글자 이상으로 설정해 줍니다.
jwt:
secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWppd29vbi1zcHJpbmctYm9vdC1zZWN1cml0eS1qd3QtdHV0b3JpYWwK
클라이언트에게 토큰을 보내기 위해 JwtToken DTO를 생성합니다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TokenDTO {
private String grantType;
private String accessToken;
private String refreshToken;
private Long accessTokenExpiresIn;
}
grantType 필드는 JWT에 대한 인증 타입입니다. 이번 포스팅에선 Bearer 인증 방식을 사용할 것입니다.
Spring Security와 JWT 토큰을 사용하여 인증과 권한 부여를 처리하는 클래스입니다.
이 클래스에서 JWT 토큰의 생성, 복호화, 검증 기능을 구현하였습니다.
@Slf4j
@Component
public class TokenProvider {
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_TYPE = "Bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 30분
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 7일
private final Key key;
public TokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public TokenDTO generateTokenDTO(Authentication authentication) {
// 권한들 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName()) // payload "sub": "name"
.claim(AUTHORITIES_KEY, authorities) // payload "auth": "ROLE_USER"
.setExpiration(accessTokenExpiresIn) // payload "exp": 1516239022 (예시)
.signWith(key, SignatureAlgorithm.HS512) // header "alg": "HS512"
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return TokenDTO.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiresIn.getTime())
.refreshToken(refreshToken)
.build();
}
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final TokenProvider tokenProvider;
// JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
// 1. Request Header 에서 토큰을 꺼냄
String jwt = resolveToken(request);
// 2. validateToken 으로 토큰 유효성 검사
// 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// Request Header 에서 토큰 정보를 꺼내오기
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
}
Spring Security의 설정을 담당하는 SecurityConfig를 작성합니다.
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// CSRF 설정 Disable
http
.csrf((auth) -> auth.disable());
//From 로그인 방식 disable
http
.formLogin((auth) -> auth.disable());
//http basic 인증 방식 disable
http
.httpBasic((auth) -> auth.disable());
// 시큐리티는 기본적으로 세션을 사용
// JWT는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
//경로별 인가 작업
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated());
// JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
http
.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
Spring Security의 UserDetails 인터페이스를 구현하고 있는 Member Entity를 생성합니다. 이 Entity는 사용자 정보를 나타내며, Spring Security에서 제공하는 인증 및 권한 부여 기능을 활용하기 위해 구현합니다.
@Getter
@AllArgsConstructor
@RequiredArgsConstructor
@Builder
@Entity
public class Member implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
@Column(nullable = false)
private String username;
@Column(nullable = false)
private String password;
private String address; // 도로명 주소
private String phone;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@OneToMany(mappedBy = "member")
private List<Todo> todoList;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByUsername(String username);
boolean existsByUsername(String username);
}
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByUsername(username)
.map(this::createUserDetails)
.orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));
}
// DB 에 User 값이 존재한다면 UserDetails 객체로 만들어서 리턴
private UserDetails createUserDetails(Member member) {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getAuthorities().toString());
return new User(
String.valueOf(member.getId()),
member.getPassword(),
Collections.singleton(grantedAuthority)
);
}
}
loadUserByUsername 메서드는 주어진 사용자 이름을 이용하여 데이터베이스에서 해당 사용자 정보를 조회합니다. memberRepository.findByUsername(username)을 통해 사용자가 존재하면 createUserDetails 메서드를 호출하여 해당 사용자 정보를 UserDetails 객체로 변환하여 반환합니다.
@Service
@RequiredArgsConstructor
public class AuthService {
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Transactional
public MemberResponseDTO signup(MemberRequestDTO memberRequestDTO) {
if (memberRepository.existsByUsername(memberRequestDTO.getUsername())) {
throw new CustomException(ErrorCode.DUPLICATE_USER_ID);
}
Member member = memberRequestDTO.toMember(passwordEncoder);
return MemberResponseDTO.of(memberRepository.save(member));
}
@Transactional
public TokenDTO login(MemberRequestDTO memberRequestDTO) {
// 1. Login ID/PW 를 기반으로 AuthenticationToken 생성
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(memberRequestDTO.getUsername(), memberRequestDTO.getPassword());
// 2. 실제로 검증 (사용자 비밀번호 체크) 이 이루어지는 부분
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 토큰 생성
TokenDTO tokenDTO = tokenProvider.generateTokenDTO(authentication);
// 4. RefreshToken 저장
RefreshToken refreshToken = RefreshToken.builder()
.key(authentication.getName())
.value(tokenDTO.getRefreshToken())
.build();
refreshTokenRepository.save(refreshToken);
// 5. 토큰 발급
return tokenDTO;
}
@Transactional
public TokenDTO reissue(TokenRequestDTO tokenRequestDTO) {
// 1. Refresh Token 검증
if (!tokenProvider.validateToken(tokenRequestDTO.getRefreshToken())) {
throw new RuntimeException("Refresh Token 이 유효하지 않습니다.");
}
// 2. Access Token 에서 Member ID 가져오기
Authentication authentication = tokenProvider.getAuthentication(tokenRequestDTO.getAccessToken());
// 3. 저장소에서 Member ID 를 기반으로 Refresh Token 값 가져옴
RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName())
.orElseThrow(() -> new CustomException(ErrorCode.TOKEN_NOT_FOUND));
// 4. Refresh Token 일치하는지 검사
if (!refreshToken.getValue().equals(tokenRequestDTO.getRefreshToken())) {
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
// 5. 새로운 토큰 생성
TokenDTO tokenDTO = tokenProvider.generateTokenDTO(authentication);
// 6. 저장소 정보 업데이트
RefreshToken newRefreshToken = refreshToken.updateValue(tokenDTO.getRefreshToken());
refreshTokenRepository.save(newRefreshToken);
// 토큰 발급
return tokenDTO;
}
}
login()
사용자의 로그인 요청을 처리하고, 입력된 ID와 비밀번호로 UsernamePasswordAuthenticationToken을 생성하여 Spring Security의 AuthenticationManager를 통해 실제로 검증합니다.
authenticate 메서드가 실행이 될 때 CustomUserDetailsService에서 만들었던 loadUserByUsername 메서드가 실행되므로 검증이 성공하면 해당 인증 정보를 기반으로 JWT 토큰을 생성하고, Refresh Token을 생성하여 저장합니다.
reissue()
Refresh Token을 사용하여 Access Token을 재발급하는 메서드입니다.
먼저 Refresh Token의 유효성을 검증하고, Access Token에서 Member ID를 추출하고 추출한 Member ID를 이용하여 저장소에서 해당 Member의 Refresh Token을 가져오고, 요청된 Refresh Token과 일치하는지 검증합니다.
검증이 성공하면 새로운 토큰을 생성하고, 저장소의 Refresh Token 정보를 업데이트한 뒤 새로운 토큰을 반환합니다.