Book/스프링부트 핵심가이드

스프링 시큐리티(Spring Security) 구현(of. Jwt Token)

블로그 주인장 2023. 12. 6.

스프링 시큐리티 구현

토큰값(JWT) 을 활용하여 스프링 시큐리티를 적용하기 위한 컴포넌트에 대해 알아보겠습니다.


JWT 의존성 추가(Maven -> pom.xml)


<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>
  • 스프링 시큐리티는 기본적으로 UsernamePasswordAuthenticationFilter를 통해 인증을 수행하도록 구성되어있다.
  • 해당 필터는 인증이 실패하면 로그인 폼이 포함된 화면을 전달한다.

 

UserDetails와 UserDetailsService 구현


User Entity 생성

@Getter
@Setter
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String uid;

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String name;

    @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());
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public String getUsername() {
        return this.uid;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • User 엔티티는 UserDetails 인터페이스를 구현하고 있다.
  • UserDetails는 UserDetailsService를 통해 입력된 로그인 정보를 가지고 데이터베이스에서 사용자 정보를 가져오는 역할을 수행한다.

 

UserDetails 인터페이스

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}
  • getAuthorities( ) : 계정이 가지고 있는 권한 목록을 리턴
  • getPassword( ) : 계정의 비밀번호 리턴
  • getUsername( ) : 계정의 이름을 리턴, 일반적으로 아이디를 리턴
  • isAccountNonExpired( ) : 계정 만료 여부 리턴, true는 만료되지 않았다는 의미이다.
  • isAccountNonLocked( ) : 계정 잠김 여부 리턴, true는 잠기지 않았다는 의미이다.
  • isCredentialNonExpired( ) : 비밀번호 만료 여부 리턴, true는 만료되지 않았다는 의미이다.
  • isEnabled( ) : 계정 활성화 여부 리턴, true는 활성화 상태를 의미한다.

 

UserRepository 구현

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    User getByUid(String uid);
}
  • JpaRepository를 상속받고 User 엔티티에 대해 설정한다.
  • 현재 ID 값은 인덱스 값이기 때문에 id 값을 토큰 생성 정보로 사용하기 위해 getByUid( ) 메서드를 생성한다.

 

UserDetailsServiceImpl 구현

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
    private final Logger LOGGER = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
    
    private final UserRepository userRepository;
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        LOGGER.info("[loadUserByUsername] loadUserByUsername 수행. username : {}"
                , username);
        return userRepository.getByUid(username);
    }
}
  • UserDetails는 스프링 시큐리티에서 제공하는 개념이다.
  • UserDetails의 username은 각 사용자를 구분할 수 있는 ID를 의미한다.
  • username을 가지고 UserDetails 객체를 리턴하게끔 정의되어 있는데, UserDetails의 구현체로 User 엔티티를 생성했기에 User 객체를 리턴하게끔 구현하였다.

 

JwtTokenProvider 구현


  • JWT 토큰을 생성하는 데 필요한 정보를 UserDetails에서 가져올 수 있다.
  • JWT 토큰을 생성하는 JwtTokenProvider를 생성한다.

 

JwtTokenProvider 클래스

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
    private final UserDetailsService userDetailsService;

    @Value("${springboot.jwt.secret}")
    private String secretKey = "secretKey";
    private final long tokenValidMillisecond = 1000L * 60 * 60;

    //... 생략
}
  • 토큰을 생성하기 위해서는 secretKey가 필요하므로 secretKey 값을 정의한다.
  • @Value의 값은 application.properites 파일에서 정의할 수 있다.
    springboot.jwt.secret=flature!@#​
  • application.properties 파일에서 값을 가져올 수 없는 경우 기본값인 'secretkey'를 가져온다.

 

init( ) 메서드

@PostConstruct
protected void init() {
    LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");

    secretKey = Base64.getEncoder().encodeToString(
            secretKey.getBytes(StandardCharsets.UTF_8));

    LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
}
 
  • @PostConstruct 어노테이션은 해당 객체가 빈 객체로 주입된 이후 수행하는 메서드를 가리킨다.
  • JwtTokenProvider 클래스에는 @Componet 어노테이션이 지정되있기에, 애플리케이션이 가동되면 빈으로 자동 주입된다.
  • @PostConstruct가 지정되어있는 메서드인 init( ) 메서드는 자동으로 실행된다.
  • secretkey를 Base64 형식으로 인코딩한다.
    // 인코딩 전 원본 문자열
    flature!@#
    
    // Base64 인코딩 결과
    ZmxhdHVyZSFAIw==​

 

createToken( ) 메서드

public String createToken(String userUid, List<String> roles) {
    LOGGER.info("[createToken] JwtTokenProvider 토큰 생성 시작");
    Claims claims = Jwts.claims().setSubject(userUid);
    claims.put("roles", roles);

    Date now = new Date();

    String token = Jwts.builder()
        .setClaims(claims)
        .setIssuedAt(now)
        .setExpiration(new Date(now.getTime() + tokenValidMillisecond))
        .signWith(SignatureAlgorithm.HS256, secretKey)
        .compact();

    LOGGER.info("[createToken] JwtTokenProvider 토큰 생성 완료");
    return token;
}
  • JWT 토큰의 내용에 값을 넣기 위해 Claims 객체를 생성한다.
  • setSubject( ) 메서드를 통해 sub 속성에 값을 추가하려면 User의 uid 값을 사용한다.
  • 해당 토큰을 사용하는 사용자의 권한을 확인할 수 있는 role의 값을 별개로 추가한다.
  • Jwts.builder( ) 를 사용하여 토큰을 생성한다.

 

getAuthentication( ) 메서드

public Authentication getAuthentication(String token) {
    LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");

    UserDetails userDetails =
            userDetailsService.loadUserByUsername(this.getUsername(token));

    LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료," +
            " UserDetails UserName : {}", userDetails.getUsername());

    return new UsernamePasswordAuthenticationToken(
            userDetails, "", userDetails.getAuthorities());
}
  • 필터에서 인증이 성공했을 시에 SecurityContextHolder에 저장할 Authentication을 생성하는 역할을 한다.
  • Authentication을 구현하는 편한 방법은 UsernamePasswordAuthenticationToken을 사용하는 것이다.
    UsernamePasswordAuthenticationToken 상속 구조
  • UsernamePasswordAuthenticationToken은 AbstactAuthenticationToken을 상속받고 있다.
  • 해당 클래스를 사용하려면 초기화를 위한 UserDetails가 필요하다.

 

getUsername( ) 메서드

private String getUsername(String token) {
    LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");

    String info = Jwts.parser().setSigningKey(secretKey)
            .parseClaimsJws(token).getBody().getSubject();

    LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 완료, info : {}", info);

    return info;
}
  • Jwts.parser( ) 를 통해 secretKey를 설정하고 클레임을 추출하여 토큰을 생성할 때 넣었던 sub 값을 추출한다.

 

resolveToken( ) 메서드

public String resolveToken(HttpServletRequest request) {
    LOGGER.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
    return request.getHeader("X-AUTH-TOKEN");
}
  • HttpServletRequest를 파라미터로 받아 헤더 값으로 전달된 "X-AUTH-TOKEN" 값을 가져와서 리턴한다.
  • 클라이언트가 헤더를 통해 애플리케이션 서버로 JWT 토큰 값을 전달해야 정상적인 추출이 가능하다.
  • 헤더의 이름은 임의로 변경이 가능하다.

 

validateToken( ) 메서드

public boolean validateToken(String token) {
    LOGGER.info("[validateToken] 토큰 유효 체크 시작");
    try {
        Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);

        return !claims.getBody().getExpiration().before(new Date());

    } catch (Exception e) {
        LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
        return false;
    }
}
  • 해당 메서드는 토큰을 전달받아 클레임의 유효기간을 체크하고 boolean 타입의 값을 리턴하는 역할을 한다.

 

JwtAuthenticationFilter 구현


JWT 토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스이다.

 

JwtAuthenticationFilter 클래스 (OncePerRequestFilter)

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);

    private final JwtTokenProvider jwtTokenProvider;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                HttpServletResponse response,
                FilterChain filterChain) throws ServletException, IOException {
        
        String token = jwtTokenProvider.resolveToken(request);
        LOGGER.info("[doFilterInternal] token 값 추출 완료. token : {}", token);
        
        LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
        }
        
        filterChain.doFilter(request, response);
    }
}
  • 스프링부트에서는 필터를 여러 방법으로 구현이 가능하다.
  • 가장 편한 방법은 필터를 상속받아 사용하는 것이다.
  • [대표적인 예시] GenericFilterBean 과 OncePerRequestFilter가 있다.
  • OncePerRequestFilter 로부터 오버로딩한 doFilterInternal( ) 메서드가 있다.
    • doFilter( ) 메서드는 서블릿을 실행하는 메서드이다.
    • doFilter( ) 메서드를 기준으로 앞에 작성한 코드는 서블릿이 실행되기 전에 실행되고, 뒤에 작성한 코드는 서블릿이 실행된 후에 실행된다.
  • 내부 로직 순서
    1. JwtTokenProvider를 통해 ServletRequest에서 토큰을 추출한다.
    2. 토큰에 대한 유효성을 검사한다. [validateToken( ) 메서드]
    3. 토큰이 유효하다면 Authentication 객체를 생성해서 SecurityContextHolder에 추가하는 작업을 수행한다.
반응형

댓글