새소식

Java/Spring

Spring - Spring Security 6 + JWT 로그인, 로그아웃(3편 - jwt 파일)

  • -

개요

Spring Security 6 + JWT 로그인, 로그아웃 과정의 세번째 글인 jwt 패키지에 대한 글이다.


파일 구조

/src/main/java/project
├── global
│   ├── config
│   │   └── security
│   │        └── SecurityConfig.java
│   └── entity
│        └── AuditingFields.java
├── jwt
│   ├── entity
│   │    └── Token.java
│   ├── filter
│   │    └── TokenAuthenticationFilter.java
│   ├── infrastructure
│   │    ├── CustomUserDetails.java
│   │    └── JwtAuthenticationToken.java
│   ├── repository
│   │    └── TokenRepository.java
│   ├── service
│   │    ├── CustomUserDetailsService.java
│   │    └── TokenService.java
│   └── util
│        └── JwtTokenizer.java
└── user
    ├── controller
    │   └── UserController.java
    ├── dto
    │   ├── UserJoinDto.java
    │   ├── UserLoginDto.java
    │   └── UserLoginResponseDto.java
    ├── entity
    │   ├── Authority.java
    │   └── User.java
    ├── repository
    │   └── UserRepository.java
    └── service
        └── UserService.java

Token.java

import jakarta.persistence.*;
import lombok.*;

@Entity
@Table(name = "tokens")
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
public class Token extends AuditingFields {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @Setter
    @Column(nullable = false, length = 300)
    private String accessToken;

    @Column(nullable = false, length = 300)
    private String refreshToken;

    @Column(nullable = false)
    private String grantType;

    // 업데이트 빌더
    public Token update(String accessToken, String refreshToken, String grantType) {
        return Token.builder()
                .id(this.id)
                .user(this.user)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .grantType(grantType)
                .build();
    }
}

TokenAuthenticationFilter.java

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Collection;
import java.util.Collections;
import java.util.List;

/**
 * JWT 토큰 인증 필터 클래스
 */
@Slf4j
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenizer jwtTokenizer;

    /**
     * 필터 메서드
     * 각 요청마다 JWT 토큰을 검증하고 인증을 설정
     *
     * @param request     요청 객체
     * @param response    응답 객체
     * @param filterChain 필터 체인
     * @throws ServletException 서블릿 예외
     * @throws IOException      입출력 예외
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = getToken(request); // 요청에서 토큰을 추출
        if (StringUtils.hasText(token)) {
            try {
                // 토큰을 사용하여 인증 설정
                getAuthentication(token);
            } catch (ExpiredJwtException e) { // 토큰 만료 시
                request.setAttribute("exception", "EXPIRED_TOKEN");
                log.error("Expired Token : {}", token, e);
                throw new BadCredentialsException("Expired token exception", e);
            } catch (UnsupportedJwtException e) { // 지원하지 않는 토큰 사용 시
                request.setAttribute("exception", "UNSUPPORTED_TOKEN");
                log.error("Unsupported Token: {}", token, e);
                throw new BadCredentialsException("Unsupported token exception", e);
            } catch (MalformedJwtException e) { // 유효하지 않은 토큰 사용 시
                request.setAttribute("exception", "INVALID_TOKEN");
                log.error("Invalid Token: {}", token, e);
                throw new BadCredentialsException("Invalid token exception", e);
            } catch (IllegalArgumentException e) { // 올바르지 않은 파라미터 전달 시
                request.setAttribute("exception", "NOT_FOUND_TOKEN");
                log.error("Token not found: {}", token, e);
                throw new BadCredentialsException("Token not found exception", e);
            } catch (Exception e) { // 알 수 없는 예외 발생 시
                request.setAttribute("exception", "NOT_FOUND_TOKEN");
                log.error("JWT Filter - Internal Error: {}", token, e);
                throw new BadCredentialsException("JWT filter internal exception", e);
            }
        }
        filterChain.doFilter(request, response); // 다음 필터로 요청을 전달
    }

    /**
     * 토큰을 사용하여 인증 설정
     *
     * @param token JWT 토큰
     */
    private void getAuthentication(String token) {
        Claims claims = jwtTokenizer.parseAccessToken(token); // 토큰에서 클레임을 파싱
        String email = claims.getSubject(); // 이메일을 가져옴
        Long userId = claims.get("userId", Long.class); // 사용자 ID를 가져옴
        String nickname = claims.get("nickname", String.class); // 이름을 가져옴
        Authority authority = Authority.valueOf(claims.get("authority", String.class)); // 사용자 권한을 가져옴

        Collection<? extends GrantedAuthority> authorities = Collections.singletonList(authority);

        CustomUserDetails userDetails = new CustomUserDetails(email, "", nickname, (List<GrantedAuthority>) authorities);
        Authentication authentication = new JwtAuthenticationToken(authorities, userDetails, null); // 인증 객체 생성
        SecurityContextHolder.getContext().setAuthentication(authentication); // SecurityContextHolder에 인증 객체 설정
    }

    /**
     * 요청에서 토큰을 추출
     *
     * @param request 요청 객체
     * @return JWT 토큰
     */
    private String getToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies(); // 쿠키에서 토큰을 찾음
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("accessToken".equals(cookie.getName())) {
                    return cookie.getValue(); // accessToken 쿠키에서 토큰 반환
                }
            }
        }

        return null; // 토큰을 찾지 못한 경우 null 반환
    }
}

CustomUserDetails.java

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

/**
 * 사용자 인증 및 권한 관리를 위한 사용자 세부 정보 클래스
 */
public class CustomUserDetails implements UserDetails {
    private final String email;  // 사용자 이름
    private final String password;  // 비밀번호
    private final String nickname;      // 이름
    private final List<GrantedAuthority> authorities;  // 권한 목록

    /**
     * 생성자
     *
     * @param email 이메일
     * @param password 비밀번호
     * @param nickname 닉네임
     * @param authorities 권한 목록
     */
    public CustomUserDetails(String email, String password, String nickname, List<GrantedAuthority> authorities){
        this.email = email;
        this.password = password;
        this.nickname = nickname;
        this.authorities = authorities;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

JwtAuthenticationToken.java

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

import java.util.Collection;

/**
 * JWT 인증 토큰 클래스
 */
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
    private String token;      // JWT 토큰
    private Object principal;  // 주체 (사용자)
    private Object credentials; // 인증 정보 (자격 증명)

    /**
     * 인증된 토큰을 생성하는 생성자
     *
     * @param authorities 권한
     * @param principal   주체 (사용자)
     * @param credentials 인증 정보 (자격 증명)
     */
    public JwtAuthenticationToken(Collection<? extends GrantedAuthority> authorities, Object principal, Object credentials) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        this.setAuthenticated(true); // 토큰을 인증된 상태로 설정
    }

    /**
     * 인증되지 않은 토큰을 생성하는 생성자
     *
     * @param token JWT 토큰
     */
    public JwtAuthenticationToken(String token) {
        super(null);
        this.token = token;
        this.setAuthenticated(false); // 토큰을 인증되지 않은 상태로 설정
    }

    /**
     * 자격 증명을 반환
     *
     * @return 자격 증명
     */
    @Override
    public Object getCredentials() {
        return this.credentials;
    }

    /**
     * 주체 (사용자)를 반환
     *
     * @return 주체 (사용자)
     */
    @Override
    public Object getPrincipal() {
        return this.principal;
    }
}

TokenRepository.java

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface TokenRepository extends JpaRepository<Token, Long> {
    Token findByUserId(Long userId);
    Optional<Token> findByAccessToken(String accessToken);
    Optional<Token> findByRefreshToken(String refreshToken);
}

CustomUserDetailsService.java

실제로 사용하지는 않지만 서버 실행 시 spring security 관리자계정 자동으로 생성되지 않게 설정하기 위함.

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;


@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return null;
    }
}

TokenService.java

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Service
@RequiredArgsConstructor
public class TokenService {
    private final TokenRepository tokenRepository;
    /**
     * 토큰 사용자의 토큰이 저장되어 있을 경우 update, 없을 경우 create
     * @param token Token 데이터
     */
    @Transactional
    public void saveOrUpdate(Token token) {
        Token oldToken = tokenRepository.findByUserId(token.getUser().getId());
        if (oldToken != null) {
            oldToken.update(token.getAccessToken(), token.getRefreshToken(), token.getGrantType());

            tokenRepository.save(oldToken);
        } else {
            tokenRepository.save(token);
        }
    }

    /**
     * access token으로 Token 데이터를 가져와 삭제하는 메소드
     *
     * @param token
     */
    @Transactional
    public void deleteByAccessToken(String token) {
        tokenRepository.findByAccessToken(token).ifPresent(tokenRepository::delete);
    }

    /**
     * access token으로 Token 데이터를 가져오는 메소드
     *
     * @param token access token
     * @return Token 데이터
     */
    @Transactional(readOnly = true)
    public Optional<Token> findByAccessToken(String token) {
        return tokenRepository.findByAccessToken(token);
    }

    /**
     * refresh token으로 Token 데이터를 가져오는 메소드
     *
     * @param token refresh token
     * @return Token 데이터
     */
    @Transactional(readOnly = true)
    public Optional<Token> findByRefreshToken(String token) {
        return tokenRepository.findByRefreshToken(token);
    }

    /**
     * refresh token으로 Token 데이터를 가져와 access token 값을 업데이트하는 메소드
     *
     * @param refreshToken
     * @param accessToken
     */
    @Transactional
    public void updateByRefreshToken(String refreshToken, String accessToken) {
        Token token = tokenRepository.findByRefreshToken(refreshToken)
                .orElseThrow(() -> new RestApiException(TokenExceptionCode.JWT_UNKNOWN_ERROR));

        token.setAccessToken(accessToken);
        tokenRepository.save(token);
    }
}

JwtTokenizer.java

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;

/**
 * JWT 생성 / 검증 유틸리티 클래스
 */
@Component
public class JwtTokenizer {
    private final byte[] accessSecret;
    private final byte[] refreshSecret;
    public static Long accessTokenExpire;
    public static Long refreshTokenExpire;

    public JwtTokenizer(@Value("${jwt.accessSecret}") String accessSecret, @Value("${jwt.refreshSecret}") String refreshSecret, @Value("${jwt.accessTokenExpire}") Long accessTokenExpire, @Value("${jwt.refreshTokenExpire}") Long refreshTokenExpire) {
        this.accessSecret = accessSecret.getBytes(StandardCharsets.UTF_8);
        this.refreshSecret = refreshSecret.getBytes(StandardCharsets.UTF_8);
        this.accessTokenExpire = accessTokenExpire;
        this.refreshTokenExpire = refreshTokenExpire;
    }

    /**
     * AccessToken 생성
     *
     * @param id
     * @param email
     * @param nickname
     * @param authority
     * @return AccessToken
     */
    public String createAccessToken(Long id, String email, String nickname, Authority authority) {
        return createToken(id, email, nickname, authority, accessTokenExpire, accessSecret);
    }

    /**
     * RefreshToken 생성
     *
     * @param id
     * @param email
     * @param nickname
     * @param authority
     * @return RefreshToken
     */
    public String createRefreshToken(Long id, String email, String nickname, Authority authority) {
        return createToken(id, email, nickname, authority, refreshTokenExpire, refreshSecret);
    }

    /**
     * Jwts 빌더를 사용하여 token 생성
     *
     * @param id
     * @param email
     * @param nickname
     * @param authority
     * @param expire
     * @param secretKey
     * @return
     */
    private String createToken(Long id, String email, String nickname, Authority authority, Long expire, byte[] secretKey) {
        // 기본으로 가지고 있는 claim : subject
        Claims claims = Jwts.claims().setSubject(email);
        claims.put("authority", authority);
        claims.put("userId", id);
        claims.put("nickname", nickname);
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date())
                .setExpiration(new Date(new Date().getTime() + expire))
                .signWith(getSigningKey(secretKey))
                .compact();
    }

    /**
     * 토큰에서 유저 아이디 얻기
     *
     * @param token 토큰
     * @return userId
     */
    public Long getUserIdFromToken(String token) {
        String[] tokenArr = token.split(" ");
        token = tokenArr[1];
        Claims claims = parseToken(token, accessSecret);
        return Long.valueOf((Integer) claims.get("userId"));
    }

    public Claims parseAccessToken(String accessToken) {
        return parseToken(accessToken, accessSecret);
    }

    public Claims parseRefreshToken(String refreshToken) {
        return parseToken(refreshToken, refreshSecret);
    }

    public Claims parseToken(String token, byte[] secretKey) {
        Claims claims = null;
        try {
            claims = Jwts.parserBuilder()
                    .setSigningKey(getSigningKey(secretKey))
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (SignatureException e) { // 토큰 유효성 체크 실패 시
            throw new RestApiException(TokenExceptionCode.JWT_INVALID_ERROR);
        }

        return claims;
    }

    /**
     * @param secretKey - byte형식
     * @return Key 형식 시크릿 키
     */
    public static Key getSigningKey(byte[] secretKey) {
        return Keys.hmacShaKeyFor(secretKey);
    }
}

이전 글

 

Spring - Spring Security 6 + JWT 로그인, 로그아웃(2편 - global 파일)

개요Spring Security 6 + JWT 로그인, 로그아웃 과정의 두번째 글인 global 패키지에 대한 글이다.파일 구조/src/main/java/project├── global│ ├── config│ │ └── security│ │ └── SecurityConfig.java│

dev-kimchi.tistory.com

다음 글

 

Spring - Spring Security 6 + JWT 로그인, 로그아웃(4편 - user 파일)

개요Spring Security 6 + JWT 로그인, 로그아웃 과정의 마지막 글인 user 패키지에 대한 글이다.파일 구조/src/main/java/project├── global│ ├── config│ │ └── security│ │ └── SecurityConfig.java│

dev-kimchi.tistory.com

 

728x90
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.