해당 글은 Spring으로 구현한 프로젝트를 SpringBoot로 리팩토링 하는 과정을 담은 글입니다.
- Index -
[SpringSecurity] SpringSecurity + JWT 로그인 구현_1
[SpringSecurity] SpringSecurity + JWT 로그인 구현_2
[SpringSecurity] SpringSecurity + JWT 로그인 구현_3
[SpringSecurity] SpringSecurity + JWT 회원가입, 테스트 코드 작성
[번외]JwtAuthorizationFilter에서 상속받을 필터에 대한 고민
기능 구현에 앞서 SpringSecurity + JWT 동작 원리를 살펴보자.
[SpringSecurity + JWT 동작 원리]
1. 클라이언트가 서버에 로그인 요청
2. 서버는 검증 단계를 거쳐 유저가 존재하면, Access Token과 Refresh Token을 발급
3. 클라이언트는 요청 헤더에 Access Token 을 포함하여 API 요청
Access Token : 인증된 사용자가 특정 리소스에 접근할 때 사용한다.
Refresh Token : Access Token의 갱신을 위해 사용한다.
1. 라이브러리 설정
기존 스프링 부트 설정에 spring security와 jwt 관련 라이브러리를 추가한다.
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
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. 토큰 DTO 설정
클라이언트에게 전송할 토큰 정보를 담을 DTO를 생성한다.
@Builder
@Data
@AllArgsConstructor
public class JwtToken {
private String grantType;
private String accessToken;
private String refreshToken;
}
grantType은 JWT 인증 타입으로 Bearer 인증 방식을 사용한다.
3. 토큰의 암호화 키 설정
application.properties
jwt.secret=6502699ecce7c7dcef082b94fbe31260cdb685167d4cd710a1906579532ff806
토큰의 암호화, 복호화 과정에서 사용, HS256 알고리즘을 사용하기 위해 32글자 이상으로 설정한다.
4. JwtTokenProvider 구현
Spring Security, JWT 토큰 사용하여 인증과 권한을 부여한다.
@Slf4j
@Component
public class JwtTokenProvider {
private final Key key;
// application.properties에서 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();
}
}
}
5. JwtAuthenticationFilter 구현
클라이언트의 JWT 토큰과 username + password를 받아서 인증 수행한다.
@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;
}
}
6. SecurityConfig 설정
Spring Security에 대한 설정을 해준다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic(HttpBasicConfigurer::disable)
.csrf(CsrfConfigurer::disable)
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize ->
authorize
.requestMatchers("/members/sign-in").permitAll()
.requestMatchers("/members/test").hasRole("USER")
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class).build();
}
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt Encoder 사용
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
7. Entity, Repository 설정
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@EqualsAndHashCode(of = "id")
@Table(name = "MEMBER")
public class Member implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name="MEMBER_ID")
private Long id;
@Column(name = "K_NAME",nullable = false)
private String username;
@Column(name = "M_PW", nullable = false)
private String password;
private String country;
private String eName;
private String birth;
private String phone;
private String email;
private String insDate;
private String modDate;
@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;
}
}
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByUsername(String username);
}
8. service 구현
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class MemberService {
private final MemberRepository memberRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
@Transactional
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;
}
}
9. CustomUserDetailsService 구현
@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();
}
}
참고 블로그
[Spring] Spring Security + JWT 로그인 구현하기 - 2
📝 지난 포스팅 ➡︎ Spring Security + JWT 로그인 구현하기 - 1 [Spring] Spring Security + JWT 로그인 구현하기 - 1 Session vs Token 사용자 인증 방식은 일반적으로 세션 기반 방식과 토큰 기반 방식(JWT)으로 나
suddiyo.tistory.com
'Spring > SpringSecurity' 카테고리의 다른 글
JwtAuthorizationFilter에서 상속받을 필터에 대한 고민 (3) | 2024.12.01 |
---|---|
[SpringSecurity] SpringSecurity + JWT 회원가입, 테스트 코드 작성 (0) | 2024.03.19 |
[SpringSecurity] SpringSecurity + JWT 로그인 구현_3 (0) | 2024.03.16 |
[SpringSecurity] SpringSecurity + JWT 로그인 구현_1 (0) | 2024.03.11 |