JWT로 로그인/로그아웃 구현하기(실습)

심주흔·2023년 9월 24일
0
post-thumbnail

🦧 JWT 서비스 구현하기

🐖 의존성 추가하기

build.gradle 파일에 dependencies{ }안에 추가한다.

//자바 JWT 라이브러리
implementation 'io.jsonwebtoken:jjwt:0.9.1' 
// XML 문서와 Java 객체 간 매핑을 자동화
implementation 'javax.xml.bind:jaxb-api:2.3.1'

🐖 토큰 제공자 추가하기

JWT를 사용해서 JWT를 생성하고 유효한 토큰인지 검증하는 클래스 작성

jwt:
  issuer: 블라블라@gmail.com
  secret_key: 블라블라1234

⬆️ JWT 토큰을 만들기 위해서는 이슈 발급자(issuer), 비밀키(secret_key)를 필수로 설정해야한다. > application.yml에 설정

@Setter
@Getter
@Component
@ConfigurationProperties("jwt") //자바 클래스에 프로퍼티값을 가져와서 사용하는 애너테이션
public class JwtProperties {

    private String issuer;
    private String secretKey;
}

⬆️ 해당 값들을 변수로 접근하는데 사용할 JWTProperties 클래스를 만든다.

@RequiredArgsConstructor
@Service
public class TokenProvider {

    private final JwtProperties jwtProperties;

    public String generateToken(User user, Duration expiredAt) {
        Date now = new Date();
        return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
    }

//JWT 토큰 생성 메서드
    private String makeToken(Date expiry, User user) {
        Date now = new Date();

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)	//헤더 typ : JWT
                //내용 iss : 앞에 propertise에 설정한 값
                .setIssuer(jwtProperties.getIssuer())
                .setIssuedAt(now)			//현재 시간
                .setExpiration(expiry)		//expiry 맴버 변수 값
                .setSubject(user.getEmail()) //유저 이메일
                .claim("id", user.getId() //유저 ID
                // 서명 : 비밀값과 함께 해시값을 HS256 방식으로 암호화
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                .compact();
    }

// JWT 토큰 유효성 검증 메서드
    public boolean validToken(String token) {
        try {
            Jwts.parser()
            		//비밀 값으로 복호화
                    .setSigningKey(jwtProperties.getSecretKey())
                    .parseClaimsJws(token);

            return true;
        } catch (Exception e) {
            return false;
        }
    }


// 토큰 기반으로 인증 정보를 가져오는 메서드
    public Authentication getAuthentication(String token) {
        Claims claims = getClaims(token);
        Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));

        return new UsernamePasswordAuthenticationToken(new org.springframework.security.core.userdetails.User(claims.getSubject
                (), "", authorities), token, authorities);
    }

// 토큰 기반으로 유저 ID를 가져오는 메서드
    public Long getUserId(String token) {
        Claims claims = getClaims(token);
        return claims.get("id", Long.class);
    }

    private Claims getClaims(String token) {
        return Jwts.parser() 		//클레임 조회
                .setSigningKey(jwtProperties.getSecretKey())
                .parseClaimsJws(token)
                .getBody();
    }
}

⬆️ 토큰을 계속해서 생성하고 올바른 토큰인지 유효성 검사, 토큰에서 필요한 정보를 가져오는 클래스

  • getAuthentication() : 토큰을 받아 인증정보를 담고 Authentication을 반환한다. 프로퍼티즈 파일에 저장한 비밀 값으로 토큰을 복호화한 뒤 클레임을 가져오는 private 메서드인 getClaims()를 호출해서 클레임 정보를 반환받아 사용자 이메일이 있는 토큰 제목 sub와 토큰 기반 인증으로 정보를 생성한다.
    이때 UsernamePasswordAuthenticationToken의 첫 인자로 들어가는 User는 프로젝트에서 만든 User 클래스가 아닌, 스프링 시큐리티에서 제공하는 객체인 User 클래스를 임포트해야한다.
  • getClaim() : 토큰 기반으로 사용자 ID를 가져오는 메서드. 프로퍼티즈 파일에 저장한 비밀값으로 토큰을 복호화한 다음 클레임을 가져오는 private 메서드인 getClaims()를 호출해서 클레임 정보를 반환받고 클레임에서 id키로 저장된 값을 가져와 반환한다.

🐖 리프레시 토큰 도메인 구현하기

id, user_id, refresh_token의 엔터티를 domain 디렉터리 RefreshToken 파일에 작성한다.

repository 디렉토리에 RefreshTokenRepository파일을 만들어 작성한다.

🐖 토큰 필터 구현하기

토큰 필터란?
: 실제로 요청이 전달되기 전과 후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공
요청이 오면 헤더값을 비교해서 토큰이 있는지 확인하고 유효 토큰이라면 시큐리티 콘텍스트 홀더(security context holder)에 인증 정보를 저장

시큐리티 콘텍스트 객체

  • 스레드 로컬에 저장되므로 코드의 아무 곳에서나 참조할 수 있다.
  • 다른 스레드와 공유하지 않으므로 독립적으로 사용할 수 있다.
  • 시큐리티 콘텍스트 객체를 저장하는 곳이 시큐리티 컨텍스트 홀더이다.
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    private final TokenProvider tokenProvider;

    private final static String HEADER_AUTHORIZATION = "Authorization";
    private final static String TOKEN_PREFIX = "Bearer ";

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain)  throws ServletException, IOException {

        //요청 헤더의 Authorization 키이 값 조회
        String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
        // 가져온 값에서 접두사 제거
        String token = getAccessToken(authorizationHeader);
        // 가져온 토큰이 유효한지 확인하고, 유효한 때는 인증 정보를 설정
        if (tokenProvider.validToken(token)) {
            Authentication authentication = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String getAccessToken(String authorizationHeader) {
        if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
            return authorizationHeader.substring(TOKEN_PREFIX.length());
        }

        return null;
    }
}

⬆️ 액세스 토큰값이 담긴 Authorizaion 헤더값을 가져온 뒤 액세스 토큰이 유효하다면 인증 정보를 설정한다. 요청 헤더에 키가 Authorizaion인 필드의 값을 가져온 다음 토큰의 접두사가 Bearer를 제외한 값을 가져온다. 접두사가 null이거나 Bearer가 아니라면 null을 반환한다. 이후 토큰이 유효한지 확인하고, 유효하다면 인증 정보를 관리하는 시큐리티 컨텍스트에 인증 정보를 설정함. 작성한 코드가 실행되며 인증 정보가 설정된 이후에 컨텍스트 홀더에서 getAuthentication() 메서드를 사용해 인증 정보를 가져오면 유저 객체가 반환된다.

🦧 토큰 API 구현하기

유효한 리프레시 토큰을 받고 새로운 액세스 토큰을 생성하는 토큰 API를 구현한다.

🐖 토큰 서비스 추가하기

리프레시 토큰을 전달받아 토큰 제공자를 사용해 새로운 액세스 토큰을 만드는 토큰 서비스 클래스를 생성

 public User findByEmail(String email) {
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
    }

⬆️ 전달받은 유저 ID로 유저를 검색해서 전달하는 findById()

@RequiredArgsConstructor
@Service
public class RefreshTokenService {
    private final RefreshTokenRepository refreshTokenRepository;

    public RefreshToken findByRefreshToken(String refreshToken) {
        return refreshTokenRepository.findByRefreshToken(refreshToken)
                .orElseThrow(() -> new IllegalArgumentException("Unexpected token"));
    }
}

⬆️ 전달받은 리프레시 토큰으로 리프레시 토큰 객체를 검색해서 전달하는 RefreshTokenService()

@RequiredArgsConstructor
@Service
public class TokenService {

    private final TokenProvider tokenProvider;
    private final RefreshTokenService refreshTokenService;
    private final UserService userService;

    public String createNewAccessToken(String refreshToken) {
        // 토큰 유효성 검사에 실패하면 예외 발생
        if(!tokenProvider.validToken(refreshToken)) {
            throw new IllegalArgumentException("Unexpected token");
        }

        Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId();
        User user = userService.findById(userId);

        return tokenProvider.generateToken(user, Duration.ofHours(2));
    }
}

⬆️ 전달받은 리프레시 토큰으로 토큰 유효성 검사를 진행하고, 유효한 토큰일 떄 리프레시 토큰으로 사용자 ID를 찾는 createNewAccessToekn()
사용자 ID로 사용자를 찾은 후에 토큰 제공자의 generateToken()메서드를 호출해서 새로운 액세스 토큰을 생성

🐖 컨트롤러 추가하기

토큰을 발급받는 API를 생성

@Getter
@Setter
public class CreateAccessTokenRequest {
    private String refreshToken;
}
@AllArgsConstructor
@Getter
public class CreateAccessTokenResponse {
    private String accessToken;
}

⬆️ dto 패키지에 토큰 생성 요청 및 응답을 담당할 DTO인 CreateAccessTokenRequestCreateAccessTokenResponse 클래스를 만든다. dto 디렉터리에 CreateAccessTokenRequest.java CreateAccessTokenResponse.java 파일을 만들어 각각 작성

@RequiredArgsConstructor
@RestController
public class TokenApiController {

    private final TokenService tokenService;

    @PostMapping("/api/token")
    public ResponseEntity<CreateAccessTokenResponse> createNewAccessToken(@RequestBody CreateAccessTokenRequest request) {
        String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken());

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(new CreateAccessTokenResponse(newAccessToken));
    }
}

⬆️ 실제로 요청을 받고 처리하는 컨트롤러 /api/token POST 요청이 오면 토큰 서비스에서 리프레시 토큰을 기반으로 새로운 액세스 토큰을 만들어준다.

이후 테스트코드는 추후에 작성하는걸로...

profile
이봐... 해보기는 했어?

0개의 댓글