Json Web Token은 선택적 서명 및 선택적 암호화를 사용하여 데이터를 만들기 위한 인터넷 표준으로이다.
로그인 이후에 성공한다면 해당 계정정보가 담긴 JWT Token을 발급받고
jwt의 인증 유효사항을 ServletFilter를 이용해 관리할 수 있을거라고 생각했다.
servletFilter란 ?
dispatcher servlet에 도달하기 전에 servletFilter가 위치해 있어, dispatcher servlet에 향하거나 거쳐 돌아온 요청들에 부가작업을 진행할 수 있다.
개념만 보았을때는 interceptor와 비슷하지만 interceptor는 spring container 안에 있고 dispatcher servlet 이후에 위치해 있다.
먼저 gradle에 의존성부터 추가해준다.
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
주의해야할 것은 starter-security 를 depedencies의 추가하면 자동으로 DefaultAuthenticationEventPublisher가 Bean으로 등록된다. 따라서 인증이 없는 컨트롤러 테스트가 다 무용지물이 돼버리니 참고하자.
JWT Token에 access/refresh token을 생성/관리 해주는 class 이다.
@Slf4j
@Component
public class JwtTokenProvider {
private final Key key;
private final long accessTokenValidTime = Duration.ofMinutes(30).toMillis();
private final long refreshTokenValidTime = Duration.ofDays(14).toMillis();
/**
* @param secretKey
* 지정한 secretKey를 base64로 디코딩하여 저장한다.
* 이 decoding 한 key는 token을 암호화, 복호화할 때 사용된다.
*/
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
/**
* @param authentication
* 유저 정보를 받고 accessToken 과 refreshToken을 생성하는 메소드이다.
* 기본 설정시간은 accessTokenValidTime, refreshTokenValidTime에 설정된시간 으로 적용되고
* 각 시간은 accessToken은 30분 refreshToken은 14일이다.
* 그렇게 적용된 시간은 Member객체에 추가하여 반환한다.
* 각 객체에 서명은 HS256으로 암호화 한다.
* @return TokenInfo 객체
*/
public TokenInfo generateToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date accessTokenExpiresIn = new Date(now + this.accessTokenValidTime);
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 + this.refreshTokenValidTime))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return TokenInfo.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
/**
* @param accessToken
* JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드이다.
* accessToken에 claims(사용자 ID, 전자 메일 주소, 역할 또는 권한, 인증 또는 부여 프로세스와 관련된
* 기타 속성과 같은 정보가 있다.)을 파싱(복호화)하여 권한정보들을 List객체에 답아놓고 해당 List에 있는 값들로
* 계정 정보를 생성한 뒤 반환한다.
* @return token
*/
public Authentication getAuthentication(String accessToken) {
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 리턴
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
/**
* @param token
* 토큰 정보를 검증하는 메서드
*
* @return boolean
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.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;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException, ServletException, IOException {
// 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;
}
}
이후 filter를 적용하기 위한
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests()
.requestMatchers("/member/login").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
사용할 Member entity에 UserDetails를 implements해주고 해당 interface를 상속하기위해 override할 method들을 override해준다.
@Table(name = "MEMBER")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@EqualsAndHashCode(of="userId")
@ToString
@DynamicUpdate
@DynamicInsert
public class MemberEntity implements UserDetails {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "SEQ_NO")
private Long seqNo;
@Column(name = "USER_ID", unique = true, nullable = false)
private String userId;
@Column(name = "USER_PWD", nullable = false)
private String userPwd;
@Column(name = "USER_NAME", nullable = false)
private String userName;
@Column(name = "EMAIL", nullable = false)
private String email;
@Column(name = "PHONE", nullable = false)
private String phone;
@Column(name = "CREATE_DATE")
private LocalDateTime createDate;
@Column(name = "MODIFY_DATE")
private LocalDateTime modifyDate;
@Column(name = "STATUS", columnDefinition = "varchar(1) default 'Y'", nullable = false)
@Check(constraints = "(STATUS IN ('Y', 'N'))")
private String status;
@Column(name = "CREATE_ID", nullable = false)
private String createName;
@Column(name = "MODIFY_ID", nullable = false)
private String modifyName;
@Column(name = "DEPARTMENT")
private String department;
@Column(name = "LIFE_DATE", nullable = false)
private LocalDateTime lifeDate;
@Column(name = "AUTHORITY", nullable = false)
private String authority;
@PrePersist
protected void onCreate(){
this.modifyDate = LocalDateTime.now();
this.createDate = LocalDateTime.now();
this.status = "Y";
}
@PreUpdate
protected void onUpdate(){
modifyDate = LocalDateTime.now();
}
public Member toDefaultDto(){
return Member.builder()
.userId(this.userId)
.userPwd(this.userPwd)
.createDate(this.createDate)
.modifyDate(this.modifyDate)
.status(this.status)
.email(this.email)
.phone(this.phone)
.createName(this.createName)
.modifyName(this.modifyName)
.department(this.department)
.lifeDate(this.lifeDate)
.seqNo(this.seqNo)
.userName(this.userName)
.authority(this.authority)
.build();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
ArrayList<SimpleGrantedAuthority>list = new ArrayList<SimpleGrantedAuthority>();
list.add(new SimpleGrantedAuthority(this.authority));
return list;
}
@Override
public String getPassword() {
return this.userPwd;
}
@Override
public String getUsername() {
return this.userName;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
/**
* @param userId
* @param password
* authenticationToken객체를 생성합니다. 이때 생성한 객체는 "인증"을 거친 객체는 아니며 이를 authenticationManagerBuilder에 전달할 때
* 인증이 진행됩니다. 이떼 CustomUserDetailService에서 만든 loadUserByUsername 메소드가 실행됩니다.
* @return TokenInfo
*/
@Transactional
public TokenInfo loginMember(String userId, String password){
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userId, password);
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
return jwtTokenProvider.generateToken(authentication);
}
해당 클래스는 memberRepository로 멤버 id로 정보를 조회해 온다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
MemberEntity entity = memberRepository.findByUserId(username);
if(entity == null) throw new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다.");
return createUserDetails(entity);
}
private UserDetails createUserDetails(MemberEntity member) {
return User.builder()
.username(member.getUsername())
.password(passwordEncoder.encode(member.getPassword()))
.roles(member.getAuthority())
.build();
}
}
@PostMapping("/login")
public HashMap<String, Object> loginMember(@RequestBody @Validated Member m){
response = new HashMap<String, Object>();
String userId = m.getUserId();
String userPwd = m.getUserPwd();
TokenInfo tokenInfo = ms.loginMember(userId, userPwd);
if(tokenInfo != null){
response.put("status", "success");
response.put("Member", tokenInfo);
}else{
response.put("status", "false");
}
System.out.println(response);
return response;
}