지난 포스트 - 회원가입하기에서 이어집니다.
https://velog.io/@icebox127/Spring-Spring-Security%EB%A1%9C-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0
회원가입에 이어 로그인을 구현하였다.
가장 큰 차이점은 서버가 사용자 정보를 저장하냐 아니냐(Stateful/Stateless)이다.
jwt 토큰 기반은 무상태성을 가져 서버 부하를 줄이고 확장성을 높여 REST API 기반 서비스에 많이 쓰인다. 따라서 나도 jwt 방식으로 구현하였다.
JWT는 토큰의 한 종류로, JSON 형식과 서명을 통해 인증 정보를 안전하게 전달하는 표준화된 방식이다.
Header.Payload.Signature 세 부분으로 나뉜 긴 문자열이며, 각 부분은 .으로 구분된다.
서버가 클라이언트 상태를 저장하지 않고, 클라이언트가 매 요청마다 헤더에 JWT를 전달하여 검증하기 때문에 수평 확장에 유리하다. 세션 기반 인증처럼 서버 메모리에 상태를 저장하지 않아도 되므로 성능상 이점도 있다.
다만 JWT는 토큰 자체가 인증 수단이므로 탈취 시 곧바로 권한이 넘어가는 위험이 있다. 이를 보완하기 위해 Access Token은 짧은 유효 기간을 두고, Refresh Token을 별도로 관리한다.
// Jwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
implementation 'org.springframework.boot:spring-boot-configuration-processor'
spring:
... (생략)
jwt:
token:
secretKey: ${SECRET_KEY}
expiration:
access: 14400000
@PostMapping("/auth/login/email")
public ApiResponse<MemberResDto.LoginDto> emailLogin(@Valid @RequestBody MemberReqDto.LoginDto dto){
return ApiResponse.success(memberService.emailLogin(dto));
}
@Component
public class JwtUtil {
private final SecretKey secretKey;
private final Duration accessExpiration;
public JwtUtil(
@Value("${jwt.token.secretKey}") String secret,
@Value("${jwt.token.expiration.access}") Long accessExpiration
) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.accessExpiration = Duration.ofMillis(accessExpiration);
}
// AccessToken 생성
public String createAccessToken(Member user) {
return createToken(user, accessExpiration);
}
// 토큰에서 이메일 가져오기
public String getEmail(String token) {
return Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
// 토큰 유효성 확인
public boolean isValid(String token) {
try {
getClaims(token);
return true;
} catch (JwtException e) {
return false;
}
}
// 토큰 생성
private String createToken(Member user, Duration expiration) {
Instant now = Instant.now();
return Jwts.builder()
.subject(user.getEmail())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(expiration)))
.signWith(secretKey)
.compact();
}
// 토큰 정보 가져오기
private Jws<Claims> getClaims(String token) throws JwtException {
return Jwts.parser()
.verifyWith(secretKey)
.clockSkewSeconds(60)
.build()
.parseSignedClaims(token);
}
}
JwtFilter에서 사용할 Jwt와 관련 메서드들을 정의한다.
(토큰 생성, 유효성 확인, 토큰에서 정보 확인 등)
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final MemberRepository memberRepository;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
// 토큰 가져오기
String token = request.getHeader("Authorization");
// token이 없거나 Bearer가 아니면 넘기기
if (token == null || !token.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// Bearer이면 추출
token = token.replace("Bearer ", "");
// AccessToken 검증하기: 올바른 토큰이면
if (jwtUtil.isValid(token)) {
// 토큰에서 이메일 추출
String email = jwtUtil.getEmail(token);
// 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성
Member user = memberRepository.findByEmail(email)
.orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND));
Authentication auth = new UsernamePasswordAuthenticationToken(
user,
null,
null
);
// 인증 완료 후 SecurityContextHolder에 넣기
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
작동 흐름
1. 먼저 필터들을 쭉 통과하면서 JwtAuthFilter의 doFilterInternal 메서드를 실행
2. 요청 헤더 중 Authorization 헤더를 읽어서 Bearer 접두사를 제거
3. JWT 토큰의 정보를 읽고 만료되었는지 확인 (JwtUtil - isValid 사용)
4. JWT 토큰에서 사용자 정보를 추출 (JwtUtil - getEmail 사용)
5. 정보를 바탕으로 인증 객체 생성 및 컨테이너 삽입
SecurityContextHolder
현재 보안 컨텍스트에 대한 세부 정보를 보관한다.
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
private final MemberRepository memberRepository;
private final String[] allowUris = {
"/swagger-ui/**",
"/swagger-resources/**",
"/v3/api-docs/**",
"/auth/**"
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(requests -> requests
.requestMatchers(allowUris).permitAll()
.anyRequest().authenticated()
)
// 폼로그인 비활성화
.formLogin(AbstractHttpConfigurer::disable)
.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
.csrf(AbstractHttpConfigurer::disable)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.permitAll()
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtAuthFilter jwtAuthFilter() {
return new JwtAuthFilter(jwtUtil, memberRepository);
}
}
public class MemberResDto {
@Data
@Builder
public static class LoginDto{
Long memberId;
String accessToken;
}
}
private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder;
@Override
public MemberResDto.LoginDto emailLogin(MemberReqDto.LoginDto dto) {
Member member = memberRepository.findByEmail(dto.getEmail())
.orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND));
if (!passwordEncoder.matches(dto.getPassword(), member.getPassword())){
throw new MemberException(MemberErrorCode.INVALID_PW);
}
String accessToken = jwtUtil.createAccessToken(member);
return MemberConverter.toLoginResDto(member, accessToken);
}
실행 로직
