- Spring Security + JWT 토큰을 통한 로그인 구현2024년 05월 16일 10시 05분 39초에 업로드 된 글입니다.작성자: do_hyuk
현재 앱 개발을 위해 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 JwtDto 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 JwtDto.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을 사용한 회원가입 및 로그인 기능 자동 테스트
'포트폴리오 > Eighteen' 카테고리의 다른 글
[트러블 슈팅] redis 관련 문제 발생(2025.01.03) (0) 2025.01.03 [트러블 슈팅] 요청 값 Dto에 어떻게 매핑되는가 (0) 2024.11.15 [트러블 슈팅] JwtFilter와 Security Config의 동작 순서 (0) 2024.10.16 [트러블 슈팅] S3 HTTPS(443) 접근 문제 (1) 2024.10.13 [트러블 슈팅] ios랑 서버 연동 과정에서 401에러 발생 (2) 2024.10.11 댓글