-
[Spring] Spring Security + JWT 로그인 구현하기 - 2Spring 2023. 6. 15. 01:43
📝 지난 포스팅
➡︎ Spring Security + JWT 로그인 구현하기 - 1
지난 포스팅에서 Spring Security + JWT 로그인 작동 원리를 알아봤으니, 이번 포스팅에서는 본격적으로 기능을 구현해 볼 것이다.
1️⃣ 라이브러리 설정
Spring Security와 JWT를 사용하기 위해 다음 라이브러리들을 추가해준다.
build.gradle
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' }
2️⃣ JwtToken DTO 생성
클라이언트에 토큰을 보내기 위해 JwtToken DTO를 생성한다.
JwtToken.java
@Builder @Data @AllArgsConstructor public class JwtToken { private String grantType; private String accessToken; private String refreshToken; }
JwtToken의 필드 중 grantType는 JWT에 대한 인증 타입이다.
이 포스팅에서는 단순하고 직관적이며 널리 사용되는 "Bearer" 인증 방식을 사용할 것이다.
이 인증 방식은 Access Token을 HTTP 요청의 Authorization 헤더에 포함하여 전송한다.
ex) Authorization: Bearer <access_token>
3️⃣ 암호 키 설정
터미널에 다음 명령어를 작성하여 랜덤으로 암호 키를 생성한 후, 생성된 secret key를 application.yml에서 설정한다.
해당 키는 토큰의 암호화 복호화에 사용될 것이다. HS256 알고리즘을 사용하기 위해 32글자 이상으로 설정해준다.
openssl rand -hex 32
application.yml
... jwt: secret: 64461f01e1af406da538b9c48d801ce59142452199ff112fb5404c8e7e98e3ff
4️⃣ JwtTokenProvider 구현
Spring Security와 JWT 토큰을 사용하여 인증과 권한 부여를 처리하는 클래스이다.
이 클래스에서 JWT 토큰의 생성, 복호화, 검증 기능을 구현하였다.
JwtTokenProvider.java
@Slf4j @Component public class JwtTokenProvider { private final Key key; // application.yml에서 secret 값 가져와서 key에 저장 public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) { byte[] keyBytes = Decoders.BASE64.decode(secretKey); this.key = Keys.hmacShaKeyFor(keyBytes); } // Member 정보를 가지고 AccessToken, RefreshToken을 생성하는 메서드 public JwtToken generateToken(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 + 86400000); String accessToken = Jwts.builder() .setSubject(authentication.getName()) .claim("auth", authorities) .setExpiration(accessTokenExpiresIn) .signWith(key, SignatureAlgorithm.HS256) .compact(); // Refresh Token 생성 String refreshToken = Jwts.builder() .setExpiration(new Date(now + 86400000)) .signWith(key, SignatureAlgorithm.HS256) .compact(); return JwtToken.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) .collect(Collectors.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.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token); return true; } catch (SecurityException | MalformedJwtException e) { log.info("Invalid JWT Token", e); } catch (ExpiredJwtException e) { log.info("Expired JWT Token", e); } catch (UnsupportedJwtException e) { log.info("Unsupported JWT Token", e); } catch (IllegalArgumentException e) { log.info("JWT claims string is empty.", e); } return false; } // accessToken private Claims parseClaims(String accessToken) { try { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(accessToken) .getBody(); } catch (ExpiredJwtException e) { return e.getClaims(); } } }
🖤 generateToken()
- 인증(Authentication) 객체를 기반으로 Access Token과 Refresh Token 생성
- Access Token: 인증된 사용자의 권한 정보와 만료 시간을 담고 있음
- Refresh Token: Access Token의 갱신을 위해 사용 됨
🖤 getAuthentication()
- 주어진 Access token을 복호화하여 사용자의 인증 정보(Authentication)를 생성
- 토큰의 Claims에서 권한 정보를 추출하고, User 객체를 생성하여 Authentication 객체로 반환
- 🧐 Collection<? extends GrantedAuthority>로 리턴받는 이유
➡️ 권한 정보를 다양한 타입의 객체로 처리할 수 있고, 더 큰 유연성과 확장성을 가질 수 있음
[ Authentication 객체 생성하는 과정 ]
- 토큰의 클레임에서 권한 정보를 가져옴. "auth" 클레임은 토큰에 저장된 권한 정보를 나타냄
- 가져온 권한 정보를 SimpleGrantedAuthority 객체로 변환하여 컬렉션에 추가
- UserDetails 객체를 생성하여 주체(subject)와 권한 정보, 기타 필요한 정보를 설정
- UsernamepasswordAuthenticationToken 객체를 생성하여 주체와 권한 정보를 포함한 인증(Authentication) 객체를 생성
🖤 validateToken()
- 주어진 토큰을 검증하여 유효성을 확인
- Jwts.parserBuilder를 사용하여 토큰의 서명 키를 설정하고, 예외 처리를 통해 토큰의 유효성 여부를 판단
- 🧐 IllegalArgumentException 발생하는 경우
➡️ 토큰이 올바른 형식이 아니거나 클레임이 비어있는 경우 등에 발생 - claim.getSubject()는 주어진 토큰의 클레임에서 "sub" 클레임의 값을 반환
➡️ 토큰의 주체를 나타냄. ex) 사용자의 식별자나 이메일 주소
🖤 parseClaims()
- 클레임(Claims): 토큰에서 사용할 정보의 조각
- 주어진 Access token을 복호화하고, 만료된 토큰인 경우에도 Claims 반환
- parseClaimsJws() 메서드가 JWT 토큰의 검증과 파싱을 모두 수행
5️⃣ JwtAuthenticationFilter 구현
클라이언트 요청 시 JWT 인증을 하기 위해 설치하는 커스텀 필터로, UsernamePasswordAuthenticationFilter 이전에 실행 할 것이다.
클라이언트로부터 들어오는 요청에서 JWT 토큰을 처리하고, 유효한 토큰인 경우 해당 토큰의 인증 정보(Authentication)를 SecurityContext에 저장하여 인증된 요청을 처리할 수 있도록 한다.
JWT를 통해 username + password 인증을 수행한다는 뜻!
JwtAuthenticationFilter.java
@RequiredArgsConstructor public class JwtAuthenticationFilter extends GenericFilterBean { private final JwtTokenProvider jwtTokenProvider; @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 && jwtTokenProvider.validateToken(token)) { // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장 Authentication authentication = jwtTokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); } chain.doFilter(request, response); } // Request Header에서 토큰 정보 추출 private String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) { return bearerToken.substring(7); } return null; } }
🖤 doFilter()
- resolveToken() 메서드를 사용하여 요청 헤더에서 JWT 토큰을 추출
- JwtTokenProvider의 validateToken() 메서드로 JWT 토큰의 유효성 검증
- 토큰이 유효하면 JwtTokenProvider의 getAuthentication() 메서드로 인증 객체 가져와서 SecurityContext에 저장
➡︎ 요청을 처리하는 동안 인증 정보가 유지된다 ✨ - chain.doFilter()를 호출하여 다음 필터로 요청을 전달
🖤 resolveToken()
- 주어진 HttpServletRequest에서 토큰 정보를 추출하는 역할
- "Authorization" 헤더에서 "Bearer" 접두사로 시작하는 토큰을 추출하여 반환
6️⃣ SecurityConfig 설정
Spring Security의 설정을 담당하는 SecurityConfig를 작성한다.
SecurityConfig.java
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final JwtTokenProvider jwtTokenProvider; @Bean public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { return httpSecurity // REST API이므로 basic auth 및 csrf 보안을 사용하지 않음 .httpBasic().disable() .csrf().disable() // JWT를 사용하기 때문에 세션을 사용하지 않음 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests() // 해당 API에 대해서는 모든 요청을 허가 .requestMatchers("/members/sign-in").permitAll() // USER 권한이 있어야 요청할 수 있음 .requestMatchers("/members/test").hasRole("USER") // 이 밖에 모든 요청에 대해서 인증을 필요로 한다는 설정 .anyRequest().authenticated() .and() // JWT 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행 .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class).build(); } @Bean public PasswordEncoder passwordEncoder() { // BCrypt Encoder 사용 return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } }
🖤 filterChain() ➡︎ HttpSecurity를 구성하여 보안 설정을 정의
- httpBasic.disable(): Basic 인증을 사용하지 않음
- csrf().disable(): CSRF(Cross-Site Request Forgery) 보안을 비활성화
- .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS): JWT를 사용하기 때문에 세션을 사용하지 않음
- authorizeHttpRequests(): 요청에 대한 인가 규칙 설정
- requestmatchers("members/sign-in").permitAll(): "members/sign-in" 경로에 대한 요청은 모든 사용자에게 허용
- requestmatchers("members/test").hasRole("USER") "members/test" 경로에 대한 요청은 "USER" 권한을 가진 사용자만 허용
- anyRequest().authenticated(): 나머지 모든 요청은 인증을 필요로 함
- addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class): JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가하여 JWT 인증을 처리
🖤 passwordEncoder()
- DelegatingPasswordEncoder를 생성하여 반환
- DelegatingPasswordEncoder는 여러 암호화 알고리즘을 지원하며, Spring Security의 기본 권장 알고리즘을 사용하여 비밀번호를 인코딩
7️⃣ 인증을 위한 Domain, Repository 설정
자신의 프로젝트에 맞는 요구사항을 설정해주고, UserDetails interface를 구현한다.
인증은 username과 password로 진행할 것이다.
Member.java
@Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder @EqualsAndHashCode(of = "id") public class Member implements UserDetails { @Id @GeneratedValue @Column(name = "member_id", updatable = false, unique = true, nullable = false) private Long id; @Column(nullable = false) private String username; @Column(nullable = false) private String password; private String nickname; private String address; // 도로명 주소 private String phone; private String profileImg; @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 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로 반환하도록 설정
MemberRepository.java
public interface MemberRepository extends JpaRepository<Member, Long> { Optional<Member> findByUsername(String username); }
8️⃣ 인증을 위한 Service 구현
MemberService.java
@Service @RequiredArgsConstructor @Transactional(readOnly = true) @Slf4j public class MemberServiceImpl implements MemberService{ private final MemberRepository memberRepository; private final AuthenticationManagerBuilder authenticationManagerBuilder; private final JwtTokenProvider jwtTokenProvider; @Transactional @Override public JwtToken signIn(String username, String password) { // 1. username + password 를 기반으로 Authentication 객체 생성 // 이때 authentication 은 인증 여부를 확인하는 authenticated 값이 false UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password); // 2. 실제 검증. authenticate() 메서드를 통해 요청된 Member 에 대한 검증 진행 // authenticate 메서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드 실행 Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); // 3. 인증 정보를 기반으로 JWT 토큰 생성 JwtToken jwtToken = jwtTokenProvider.generateToken(authentication); return jwtToken; } }
🖤 signIn()
- 로그인 요청으로 들어온 username + password를 기반으로 Authentication 객체 생성
- authenticate() 메서드를 통해 요청된 Member에 대한 검증 진행
➡︎ loadUserByUsername 메서드가 실행됨 (어떤 객체를 검증할 것인지에 대해 직접 구현해야 함) - 검증에 성공하면 인증된 Authentication 객체를 기반으로 JWT 토큰 생성
9️⃣ CustomUserDetailsService 구현
CustomUserDetailsService.java
@Service @RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { return memberRepository.findByUsername(username) .map(this::createUserDetails) .orElseThrow(() -> new UsernameNotFoundException("해당하는 회원을 찾을 수 없습니다.")); } // 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 return private UserDetails createUserDetails(Member member) { return User.builder() .username(member.getUsername()) .password(passwordEncoder.encode(member.getPassword())) .roles(member.getRoles().toArray(new String[0])) .build(); } }
- 실제로는 DB 자체에 encoding된 password 값을 갖고 있고 그냥 memer.getPassword()로 encoding된 password를 꺼내는 것이 좋지만, 예제에서는 편의를 위해 검증 객체를 생성할 때 encoding을 해줬다.
📝 이어지는 포스팅
➡︎ Spring Security + JWT 로그인 구현하기 - 3
📋 참고 자료
728x90'Spring' 카테고리의 다른 글
[Spring] Spring Security + JWT 로그인 구현하기 - 4 (6) 2023.06.16 [Spring] Spring Security + JWT 로그인 구현하기 - 3 (0) 2023.06.15 [Spring] Spring Security + JWT 로그인 구현하기 - 1 (4) 2023.06.14 [Spring] JWT(Json Web Token)란? | 구조, 암호화 방법, 장단점 (0) 2023.06.14 [Spring] 테스트 코드에서의 @Transactional 사용 (0) 2023.05.30