build.gradle
//스프링 시큐리티
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
클라이언트에게 토큰을 발급해주기 위해 JwtToken DTO을 생성해준다.
@Builder
@Data
@AllArgsConstructor
public class JwtToken {
private String grantType;
private String accessToken;
private String refreshToken;
}
JwtToken의 필드중 grantType은 JWT에 대한 인증 타입이다. 인증 타입에 대해서 잘 모르므로 널리 사용되는 "Bearer"방식을 사용할 것이다.
ex) Authorization: Bearer <access_token>
Bearer 인증 방식이란 ?
ACCESS TOKEN을 HTTP REQEUST HEADER의 Authorization을 포함하여 전송하는 방식이다.
JWT를 만들 때, 토큰의 서명(Signature)을 생성하는 데 사용할 암호화 키를 설정해야 한다.
openssl rand -hex 32
생성된 secret key를 application.properties에서 설정한다.(필자의 경우, application-scret.properties에 설정함)
application-scret.properties
jwt.secret=your_secret_key
Spring Security와 JWT 토큰을 사용하여 인증과 권한 부여를 처리하는 클래스이다.JWT 토큰의 생성, 복호화, 검증 기능을 구현한 클래스이다.
이후 로그인 기능에서 JWT를 생성하고 JwtAuthenticationFilter에서 유효성 검사를 하는데 사용된다.
@Component
public class JwtTokenProvider {
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public JwtToken generateToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date accessTokenExpiresIn = new Date(now + 86400000);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + 86400000))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return JwtToken.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
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 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();
}
}
}
Spring Security의 Authentication 객체에서 권한정보(Authorities)를 추출한 후, 이를 쉼표(,)로 구분된 문자열로 반환하여 JWT의 클레임(auth)에 저장할 수 있도록 가공한다.
AccessToken는 다음과 같이 생성된다.
Header
기본적인 헤더는 자동으로 설정되므로 따로 설정하지 않았다.
Payload
만료시간(exp): setExpiration(accessTokenExpiresIn)
제목(sub): setSubject(authentication.getName())
권한정보(auth): claim("auth", authorities)
Signature
signWith(key, SignatureAlgorithm.HS256)
를 설정하고 signature(signWith)을 설정해준다.
refreshToken은 Access Token을 재발급받기 위한 용도로 사용되므로 만료시간,과 서명정보만 생성한다.
Access token을 복호화하여 사용자의 인증 정보(Authentication)를 생성한다.
parseClaims()를 사용하여 권한 정보를 추출하여 권한 정보가 없을경우 RuntimeException을 일으킨다.
Collection<? extends GrantedAuthority>로 리턴받는 이유?
권한 정보를 다양한 타입의 객체로 처리할 수 기 떄문이다.
GrantedAuthority란?
Authorities이라는 목록의 타입이다. Collection와 같은 형태를 의미한다.
필자는 GrantedAuthority의 대표적 구현체인 SimpleGrantedAuthority를 사용했다. SimpleGrantedAuthority는 문자열로 역할(예: "USER")을 하는 객체이다. 최종적으로 Collection=[SimpleGrantedAuthority("USER"),SimpleGrantedAuthority("ADMIN")]과 같은 형태를 가진다.
Authentication란?
SicurityContext에 의해 관리하고 Authentication는 principal, Credentials, Authorities로 구성되어 있다. 참고
토큰의 유효성을 검증한다.
토큰의 서명키를 설정하여 예외 처리를 통해 토큰의 유효성 여부를 판단한다.
SecurityException | MalformedJwtException : 서명(Signature)이 잘못되었거나, JWT 형식이 잘못된 경우
ExpiredJwtException : 토큰의 exp(만료시간)가 지나서 더 이상 사용할 수 없는 경우
UnsupportedJwtException : 지원되지 않는 JWT 구조나 알고리즘인 경우
IllegalArgumentException : 토큰이 null이거나 빈 문자열인 경우
AccessToken을 복호화하여 서명(Signature)을 검증하고 payload의 claim들을 반환한다.
만료된 토큰인 경우에도 Clam들을 반환한다.
클라이언트 요청 시 JWT 인증을 하기 위해 설치하는 커스텀 필터로
이후 구현할 SecurityConfig에서 addFilterBefore()를 통해 UsernamePasswordAuthenticationFilter 이전에 JwtAuthenticationFilter가 실행되게 할 것이다.
클라이언트의 요청에서 JWT 토큰의 유효성 검사를 하고 해당 토큰의 인증 정보(Authentication)를 Security Context에 저장하여 인증된 요청을 처리할 수 있도록 설정한다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String path = httpRequest.getRequestURI();
// 필터를 적용하지 않을 경로 설정
if (path.startsWith("/members/sign-in") || path.startsWith("/members/sign-in/test")
|| path.startsWith("/members/signup")) {
chain.doFilter(request, response);
return;
}
String token = resolveToken(httpRequest);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7);
}
return null;
}
}
회원가입, 로그인, 필터 작동 테스트에서는 필터가 작동하지 않게 설정.
jwtTokenProvider에서 작성한 validateToken()을 사용하여 토큰의 유효성을 검사하고 jwtTokenProvider.getAuthentication()을 이용하여 Authentication객체를 생성한 후 SecurityContext에 Authentication를 저장한다.
HttpServletRequest의 Header에서 토큰을 추출하여 반환한다.
Bearer 방식에서는 JWT가 Authorization 헤더에 "Bearer <토큰값>" 형식으로 포함된다.
Spring Security의 설정을 담당한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/members/login","/members/sign-in", "/members/sign-in/test").permitAll()
.requestMatchers("/members/test").hasRole("USER")
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class).build();
}
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt Encoder 사용
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
REST API이므로 basic auth 및 csrf보안을 사용하지 않는다.
JWT를 사용하므로 세션을 사용하지 않는다.
권한별로 요청을 허가하도록 요청에 대한 인가 규칙 설정한다.
JWT 인증을 위하여 직접 구현한 필터를UsernamePasswordAuthenticationFilter 전에 실행한다.
requestMatchers를 통해 회원가입, 로그인, 필터 작동 테스트의 요청을 권한이 없어도 요청을 허가했지만 JwtAuthenticationFilter에서 다시 필터를 적용하지 않을 경로 설정한 이유?
DelegatingPasswordEncoder를 사용하여 기본 인코딩 알고리즘을 "bcrypt"로 설정하며, 동시에 여러 인코딩 방식을 지원할 수 있는 구조를 설정한다.
자신의 프로젝트에 맞는 Entity를 설정하고 UserDetails interface를 구현한다.
혼동을 피하기 위해 username은 사용자 ID로 사용되며 인증은 username과 password를 이용하여 진행한다.
package com.agora.debate.member.entity;
import com.agora.debate.global.enums.Gender;
import jakarta.persistence.*;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
/**
* TODO: @EqualsAndHashCode(of = "id")공부
*/
@Entity
@Table(name = "member")
@Builder
@EqualsAndHashCode(of = "id")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@ToString
public class Member implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
@Column(nullable = false,unique = true)
private String name;
@Column(name = "username", nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false, unique = true)
private String email;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Gender gender;
@Column(name = "birth_date", nullable = false)
private LocalDate birthday;
@Builder.Default
@Column(nullable = false)
private int score=100;
@Builder.Default
@Column(nullable = false)
private int level=1;
@Builder.Default
@Column(nullable = false)
private int win=0;
@Builder.Default
@Column(nullable = false)
private int lose=0;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "member_roles", joinColumns = @JoinColumn(name = "member_id"))
@Column(name = "role")
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
//Default=true
@Override
public boolean isAccountNonExpired() {
return UserDetails.super.isAccountNonExpired();
}
//Default=true
@Override
public boolean isAccountNonLocked() {
return UserDetails.super.isAccountNonLocked();
}
//Default=true
@Override
public boolean isCredentialsNonExpired() {
return UserDetails.super.isCredentialsNonExpired();
}
//Default=true
@Override
public boolean isEnabled() {
return UserDetails.super.isEnabled();
}
}
멤버가 가지고 있는 권한 목록(rloes)을 SimpleGrantedAuthority로 변환하여 반환한다.
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
....
Optional<Member> findByUsername(String username);
....
}
@Transactional
public JwtToken signIn(SignInDto signInDto) {
memberRepository.findByUsername(signInDto.getUsername())
.orElseThrow(() -> new UserNameNotMatchException());
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(signInDto.getUsername(), signInDto.getPassword());
Authentication authentication = null;
try {
authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
} catch (AuthenticationException e) {
throw new BadCredentialsException("authentication실패");
}
JwtToken jwtToken = jwtTokenProvider.generateToken(authentication);
return jwtToken;
}
사용자 존재 여부 확인
사용자의 입력값(username, password)을 기반으로 Authentication객체 생성한다.
authenticate() 메서드를 통해 요청된 Member에 대한 검증 진행한다.
이떄 authenticate()가 내부적으로 loadUserByUsername()을 (아래 CustomUserDetailsService()에서 구현 예정)
검증에 성공시 인증된 Authentication객체를 기반으로 jwtTokenProvider.generateToken()을 이용하여 JWT토큰을 생성한다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) {
return memberRepository.findByUsername(username)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException("해당하는 회원을 찾을 수 없습니다."));
}
private UserDetails createUserDetails(Member member) {
return User.builder()
.username(member.getUsername())
.password(member.getPassword())
.roles(member.getRoles().toArray(new String[0]))
.build();
}
}
Spring Security에서 제공하는 UserDetailsService 인터페이스를 구현한 클래스이다.
로그인 시 입력된 username을 기반으로 사용자 정보를 조회하고,
Spring Security가 인증에 사용할 수 있도록 UserDetails 객체로 변환해 반환한다.