2편에 이어,
웹서비스의 로그인, 회원가입을 제외한 모든 API사용을 로그인이 된 후에만 이용할 수 있도록 해보자.
(완성코드는 아래에)
사용자가 로그인을 하게 되면 토큰과 함께 권한(authentication)을 부여받게 된다.
이 권한은 일종의 "filter"를 거쳐 유효성을 검사한다.
이후 사용자가 API를 요청할때, header에 jwt 토큰을 주게 되면 웹서버는 해당 토큰이 filter에서 유효한지 확인 후 RestApi를 실행할 것이다.
JwtTokenFilter는 API요청이 들어올때 요청이Servlet Filter거치며 실행되고, 사용자가 request와 함께 요청한 token을 확인 후 권한을 넘긴다.
따라서
1. Resolve Header
2. Token Validation
3. get Username from token
-> user valid check
4. save Object in security context holer
5. filter 넘기기
다섯단계를 순서대로 구현해보자
addFilterBefore에 대해 더 참고 :
https://iseunghan.tistory.com/365
https://velog.io/@gwichanlee/Filter-FilterChain
addFilterBefore()을 사용하면 SpringSecurityFilter 보다 먼저 실행되게 된다.
SpringSecurityFilter (ex doFilter() )
공식문서 :
https://docs.spring.io/spring-security/site/docs/2.0.7.RELEASE/apidocs/org/springframework/security/ui/SpringSecurityFilter.html
final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if(header == null || !header.startsWith("Bearer ") )//jwt token은 bearer안에 담겨있음으로 헤더에도 bear담겨야함
{
log.error("Error occurs while getting header. header is null or invalid"); //header파싱 실패시
filterChain.doFilter(request, response); //: 다음 필터로 넘어가시오
return;
}
* log : lombok의 @Slf4j에서 추가, log 찍어서 error원인 파악하기 위함.
final String token = header.split(" ")[1].trim(); //header : Bearer+" "+token으로 구성됨
//token 만료 여부 확인
if(isExpired(token, key)){
//ture-> 만료됨
log.error("Error occurs bcs key is expired.");
filterChain.doFilter(request, response); //다음 필터로 넘김
return;
}
토큰 유효시간 검증 시 필요한 기능 구현하기
(물론, (2) 에서 구현해둔 JwtTokenUtils에 구현해도 무방함)
private static boolean isExpired(String token, String key) {
Date expiredAt = extractClaims(token, key).getExpiration();
return expiredAt.before(new Date()); //현재에 비해 만료시간이 더 빠르면 true
}
private static Claims extractClaims(String token, String key) {
//key로 token파싱해 claim추출 후 Body 반환
return Jwts.parserBuilder().setSigningKey(getKey(key))
.build().parseClaimsJws(token).getBody();
}
private static Key getKey(String key) {
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
참고로 Claims(Interface)의 구조는 다음과 같다
- iss: 토큰 발급자 (issuer)
- sub: 토큰 제목 (subject)
- aud: 토큰 대상자 (audience)
- exp: 토큰의 만료시간 (expiraton)
- nbf: Not Before 이 날짜가 지나기 전까지는 토큰이 처리되지 않습니다.
- iat: 토큰이 발급된 시간 (issued at)
- jti: JWT의 고유 식별자로서(일회용 토큰 사용시 유용)
private static String getUserName(String token, String key) {
return extractClaims(token, key).get("userName", String.class);
}
hash 값 형태로 key(string) : value(object)를 Claims에 넣고, 가져올 수 있다.
UsernamePasswordAuthenticationToken 에 User정보, User 권한 정보를 얻기 위해서는 User에 조금 변경이 필요하다.
User.getAuthorities()을 구현하기 위해 UserDetails를 impelment 해준다.
package com.haedal.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.haedal.model.entity.User;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true) //직렬화 속성무시
public class UserDto implements UserDetails {
Long userId;
String name;
String phone;
String password;
UserRole role;
public static UserDto of(Long userId,String name,String phone, String password, UserRole role){
return new UserDto(userId, name, phone, password, role);
}
public static UserDto from(User user) {
return new UserDto(
user.getUserId(),
user.getName(),
user.getPhone(),
user.getPassword(),
user.getRole()
);
}
public User toEntity(UserDto userDto){
return User.builder()
.userId(userId)
.name(name)
.phone(phone)
.password(password)
.build();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(this.getRole().toString()));
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.name;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
//save Object in security context holer
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
//complete principal, credentials, authorities
user, null, user.getAuthorities());
//enum.toString -> ADMIN(0) -> Admin 으로 변경
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//request 정보 넣어서 보내줌.
SecurityContextHolder.getContext().setAuthentication(authentication);
UsernamePasswordAuthenticationFilter
username, password를 쓰는 form기반 인증을 처리하는 필터.
AuthenticationManager를 통한 인증 실행
- 인증 전 : 로그인아이디/패스워드 제공
(현재 사용X)
- 토큰 유효성 성공하면, Authentication 객체를 SecurityContext에 저장 후 AuthenticationSuccessHandler 실행
- 실패하면 AuthenticationFailureHandler 실행
- setAuthentication(UsernamePasswordAuthenticationToken) :
UsernamePasswordAuthenticationToken :
principal - 인증이 완료된 사용자 객체(UserDetails의 구현체) credentials - 인증 완료후 유출 가능성을 줄이기 위해 삭제(null) authorities - 인증된 사용자가 가지는 권한 목록
프로젝트에는 User의 권한을 검사하는 항목을 위해 UserRole을 넣었지만 필요없다면 User.getAuthorities()에 return null로 설정하면 될듯함
filterChain.doFilter(request, response); //다음 필터로 넘김
return;
SecurityConfig에 JwtTokenFilter를 등록해준다.
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final UserService userService;
@Value("${jwt.secret-key}")
private String key;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/users/join", "/login").permitAll()
//로그인 하지 않아도 접근 가능한 주소 설정해주기
.antMatchers("/**").authenticated()
//그 외에는 로그인 필요(토큰 필요)
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(new JwtTokenFilter(key, userService), UsernamePasswordAuthenticationFilter.class)
. ;
//JWTTOkenFilter : 토큰을 분석해 유저의 정보 받아옴
// addFilter : UsernamePasswordAuthenticationFilter.class 필터 대신 직접만든 인증로직을 가진 필터를 생성하고 사용한다.
//.addFilterBefore : 지정된 필터 앞에 커스텀 필터를 추가 new JwtTokenFilter() 가 Username...보다 먼저 실행된다.
super.configure(http);
}
}
package com.haedal.config.filter;
import com.haedal.model.UserDto;
import com.haedal.service.UserService;
import com.haedal.util.JwtTokenUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter{
private final String key;
private final UserService userService;
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//Resolve Header
final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if(header == null || !header.startsWith("Bearer ") )//jwt token은 bearer안에 담겨있음으로 헤더에도 bear담겨야함
{
log.error("Error occurs while getting header. header is null or invalid"); //header파싱 실패시
filterChain.doFilter(request, response); //다음 필터로 넘김
return;
}
try{
//Token Validation
final String token = header.split(" ")[1].trim(); //header : Bearer+" "+token으로 구성됨
//token 만료 여부 확인
if(JwtTokenUtils.isExpired(token, key)){
//ture-> 만료됨
log.error("Error occurs bcs key is expired.");
filterChain.doFilter(request, response); //다음 필터로 넘김
return;
}
//get Username from token(claims)
String userName = JwtTokenUtils.getUserName(token, key);
// user valid check
UserDto user = userService.getUserbyUserName(userName);
if(user==null){
log.error("user is not exist"); //header파싱 실패시
filterChain.doFilter(request, response); //다음 필터로 넘김
return;
}
//save Object in security context holer
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
//complete principal, credentials, authorities
user, null, user.getAuthorities());
//enum.toString -> ADMIN(0) -> Admin 으로 변경
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//request 정보 넣어서 보내줌.
SecurityContextHolder.getContext().setAuthentication(authentication);
}catch (RuntimeException e){
log.error("Error occurs while validating.{}", e.toString());
filterChain.doFilter(request, response); //마저 필터에 추가
return;
}
//filter 넘기기
filterChain.doFilter(request, response);
}
}
package com.haedal.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
public class JwtTokenUtils {
public static String generateToken(String userName, String key, long exporedTimeMs){
Claims claims = Jwts.claims();
claims.put("userName", userName);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis()+exporedTimeMs))
.signWith(getKey(key), SignatureAlgorithm.HS256)
.compact();
}
public static boolean isExpired(String token, String key) {
Date expiredAt = extractClaims(token, key).getExpiration();
return expiredAt.before(new Date()); //현재에 비해 만료시간이 더 빠르면 true
}
public static Claims extractClaims(String token, String key) {
//key로 token파싱해 claim추출 후 Body 반환
return Jwts.parserBuilder().setSigningKey(getKey(key))
.build().parseClaimsJws(token).getBody();
}
public static Key getKey(String key) {
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
public static String getUserName(String token, String key) {
return extractClaims(token, key).get("userName", String.class);
}
}
이렇게 Jwt 설정이 완료되었음으로
4편에서는 PostMan을 이용해 Join/Login이 Jwt를 이용해 정상적으로 작동하고, 다른 API가 JwtTokenFilter를 거쳐 수행되는지 확인해보도록 하겠다.