Spring Boot - Jwt (4) 구현 - 토큰 인증, 재로그인(Jwt 재발급)

ysh·2023년 11월 27일
0

인턴십

목록 보기
25/25

3. 토큰 인증

  • 이전 포스트에서 config를 그대로 사용했다면, 현재 이런 상태일 것이다.
		// 인증, 권한 필터 설정
        httpSecurity.authorizeHttpRequests(config -> config
                .requestMatchers(PathRequest.toH2Console()).permitAll()
                .requestMatchers(
                        mvc.pattern("/"),
                        mvc.pattern("/auth/**")
                ).permitAll()
                .requestMatchers(mvc.pattern("/api/v1/auth/**")).permitAll()
                .anyRequest().authenticated());
  • 현재 "/"나 auth 관련 주소 말고는 전부 authenticated 상태이다.
  • 즉, permitAll된 주소 이외에는 전부 인증 필터를 타야하는 상태이다.
  • 이제 인증이 필요한 요청을 하나 만들고, Jwt를 검증하는 Filter를 만들어 우리가 만든 Jwt로 인증하는 로직을 만들어보자.

TempControllerApiV1.java - 인증 테스트 API

  • 인증이 성공하면 간단하게 Username(Email로 설정할 예정)을 가져오는 API 생성.
package com.example.jwtvelog.domain.auth.temp.controller;

import com.example.jwtvelog.auth.jwt.JwtToken;
import com.example.jwtvelog.auth.session.CustomUserDetails;
import com.example.jwtvelog.common.dto.ResDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/temp")
public class TempControllerApiV1 {

    @GetMapping
    public HttpEntity<?> temp(@AuthenticationPrincipal CustomUserDetails customUserDetails){
        // customUserDetails에는 로그인한 유저의 정보가 들어있다.
        return new ResponseEntity<>(
                ResDTO.builder()
                        .code(0)
                        .message("인증 성공")
                        .data(customUserDetails.getUsername())
                        .build(),
                HttpStatus.OK);
    }
}

JwtAuthorizationFilter.java - Jwt 검증 필터 작성

  • 여기서 직접 만든 CustomUserByIdxService를 사용하는데,
    UserDetailsService를 구현하여 사용하지 않는 이유는 내부의 loadUserByUsername이라는 메소드의 이름이
    내가 구현할 jwt에서 idx를 받아와 customUserDetails를 만드는 로직과 맞지 않다고 판단하여 새로 만들기로 함.
  • 검증 과정에서 UnauthorizedException이 발생하면, 401에러 json을 바로 던진다.
  • 틀린 방식일 수 있다.
package com.example.jwtvelog.auth.jwt;

import com.auth0.jwt.interfaces.DecodedJWT;
import com.example.jwtvelog.auth.session.CustomUserByIdxService;
import com.example.jwtvelog.auth.session.CustomUserDetails;
import com.example.jwtvelog.common.exception.UnauthorizedException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.GenericFilterBean;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Component
public class JwtAuthorizationFilter extends GenericFilterBean {

    // idx를 이용해 CustomUserDetails를 가져오는 서비스
    private final CustomUserByIdxService customUserByIdxService;

    // 생성자
    @Autowired
    public JwtAuthorizationFilter(CustomUserByIdxService customUserByIdxService) {
        this.customUserByIdxService = customUserByIdxService;
    }

    // 필터 로직 구현
    @Override
    public void doFilter(
            ServletRequest servletRequest,
            ServletResponse servletResponse,
            FilterChain filterChain
    )
            throws IOException, ServletException {
        // HttpServletRequest 객체로 서블릿 요청 객체를 캐스팅
        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;

        // HttpServletResponse 객체로 서블릿 응답 객체를 캐스팅
        HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;

        // HTTP 요청의 헤더에서 JWT 토큰을 가져온다.
        // JwtProvider.HEADER는 토큰을 저장하는 데 사용되는 HTTP 헤더의 이름
        String prefixJwt = httpRequest.getHeader(JwtProvider.HEADER);

        // 토큰이 없으면 다음 필터로 넘긴다.
        if (prefixJwt == null) {
            filterChain.doFilter(servletRequest, servletResponse);
            return;
        }

        // 토큰 접두사 "Bearer"을 제거한다.
        String jwt = prefixJwt.replace(JwtProvider.TOKEN_PREFIX, "");
        try {
            // jwtProvider 객체를 생성하고
            JwtProvider jwtProvider = new JwtProvider();

            // jwtProvider 객체의 verify 메서드를 사용해 토큰을 검증한다.
            DecodedJWT decodedJWT = jwtProvider.verify(jwt);

            // 토큰에서 idx를 가져온다. (우리가 idx로 설정 해놓은 주제, 즉 subject)
            Long idx = Long.parseLong(decodedJWT.getSubject());

            // idx를 이용해 CustomUserDetails를 가져온다.
            CustomUserDetails customUserDetails = customUserByIdxService.loadUserByIdx(idx);

            // 가져온 CustomUserDetails를 사용해
            // Authentication에 담길 UsernamePasswordAuthenticationToken 객체를 생성한다.
            Authentication authentication = new UsernamePasswordAuthenticationToken(
                    customUserDetails,
                    customUserDetails.getPassword(),
                    customUserDetails.getAuthorities());

            // SecurityContext에 Authentication 객체를 저장한다.
            // 이로써 Spring Security가 인증된 사용자라고 인식하고,
            // 컨트롤러에서 @AuthenticationPrincipal 어노테이션을 사용해 사용자 정보를 가져올 수 있다.
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } catch (UnauthorizedException e) {
            // json 형태로 401 응답
            httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 상태 코드 설정
            httpResponse.setContentType("application/json;charset=UTF-8"); // 컨텐츠 타입 설정

            // JSON으로 오류 메시지 생성
            Map<String, Object> data = new HashMap<>();
            data.put("code", -1);
            data.put("message", "인증 오류: " + e.getMessage());
            data.put("data", "Unauthorized");
            data.put("status", HttpServletResponse.SC_UNAUTHORIZED);

            // ObjectMapper를 사용해 Map을 JSON으로 변환
            String json = new ObjectMapper().writeValueAsString(data);

            // 응답에 JSON 쓰기
            httpResponse.getWriter().write(json);
            httpResponse.getWriter().flush();
            httpResponse.getWriter().close();
            return;
        } finally {
            filterChain.doFilter(servletRequest, servletResponse);
        }
    }
}

CustomUserDetails.java - 인증 정보를 담을 객체

package com.example.jwtvelog.auth.session;

import com.example.jwtvelog.model.member.entity.MemberEntity;
import lombok.Getter;
import lombok.Setter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

@Setter
@Getter
public class CustomUserDetails implements UserDetails {
    // 유저 정보를 담을 필드
    private MemberEntity member;

    // 생성자로 MemberEntity를 받아서 CustomUserDetails를 생성한다.
    public CustomUserDetails(MemberEntity member) {
        this.member = member;
    }

    // 권한을 가져온다.
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collector = new ArrayList<>();
        collector.add(() -> member.getRole());
        return collector;
    }

    @Override
    public String getPassword() {
        return member.getPassword();
    }

    @Override
    public String getUsername() {
        return member.getEmail();
    }

    // 계정이 만료되지 않았는지 확인한다.
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // 계정이 잠겨있지 않은지 확인한다.
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    // 계정의 인증 정보가 만료되지 않았는지 확인한다.
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // 계정이 활성화되어있는지 확인한다.
    @Override
    public boolean isEnabled() {
        return true;
    }
}

CustomUserByIdxService.java - jwt에서 유저의 idx로 CustomUserDetails 생성

package com.example.jwtvelog.auth.session;

import com.example.jwtvelog.model.member.entity.MemberEntity;
import com.example.jwtvelog.model.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class CustomUserByIdxService {

    // 유저 정보를 찾아오기 위해 MemberRepository를 주입받는다.
    private final MemberRepository memberRepository;

    public CustomUserDetails loadUserByIdx(Long idx) {
        // idx로 유저 정보를 찾아온다.
        // 존재하지 않는다면 UsernameNotFoundException을 발생시킨다.
        MemberEntity userEntity = memberRepository.findByIdx(idx).orElseThrow(
                () -> new UsernameNotFoundException("User not found with id : " + idx)
        );
        // 유저 정보를 기반으로 CustomUserDetails를 생성하여 반환한다.
        return new CustomUserDetails(userEntity);
    }
}

index.html - 인증 테스트 버튼

  • 간단히 버튼을 누르면 '/api/v1/temp'로 요청을 보내도록 만들어보자.
  • 현재 SecurityConfig에서 permitAll 설정이 되어있지 않기에 Filter를 타서 저장된 사용자의 인증 정보가 Controller에 전달될 것이다.
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<a href="/auth/login">로그인</a>
<a href="/auth/sign-up">회원가입</a>
<button id="jwt-test">JWT 테스트</button>
</body>
<script>
    document.querySelector("#jwt-test").addEventListener("click", () => {
        fetch("/api/v1/temp", {
            method: "GET",
            headers: {
                "Content-Type": "application/json",
                // 쿠키에 저장된 토큰을 헤더에 포함시켜 보냄
                "Authorization": getCookie("ACCESS-TOKEN")
            }
        }).then(response => response.json())
            .then((result) => {
                    // result.code가 0이 아닐 시 에러 메시지 출력
                    if (result.code !== 0) {
                        alert(result.message);
                        return;
                    }
                    // result.code가 0일 시 성공
                    console.log(result);
                    alert(result.message);
                }
            )
    });


    // 쿠키에서 토큰을 가져오는 함수
    function getCookie(name) {
        // 쿠키를 가져옴
        const value = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)');
        // 쿠키가 존재할 경우 토큰을 반환
        return value ? value[2] : null;
    }
</script>
</html>

토큰 인증 테스트

  • index 페이지(localhost:8080/)로 접근해서 회원가입, 로그인을 진행하자.
  • 토큰을 확인하고 jwt 테스트 버튼 클릭
  • 성공했다면 성공 알림창이 뜨고, console에 우리가 controller에서 보낸 우리의 email 데이터가 출력이 되는 걸 확인할 수 있다.

4. 재로그인(Jwt 재발급)

  • 기간이 만료된 AccessToken으로 요청을 하면 JwtProvider의 verify 함수에서 정의해놨듯 401 에러가 발생할 것이다.
  • 하지만 현재 기간을 하루로 지정해놓았기 때문에 확인하기가 어렵다. (하루를 기다려도 되긴 한다)
  • 일단 JwtProvider에서 유효 기간을 AccessToken 30초, RefreshToken 60초로 설정 후 테스트 해보자.
  • 만료된 AccessToken으로 요청 후 401에러가 뜨면, RefreshToken으로 다시 요청하여 AccessToken, RefreshToken을 새로 발급받고,
  • 그 과정에서 RefreshToken까지 만료되었다면 사용자에게 다시 로그인을 요청해야 한다.

JwtProvider.java - 유효기간 단축

	// 엑세스 토큰 유효기간 1일 설정
    private static final int EXP_ACCESS = 1000 * 60 * 60 * 24;
    // 리프레시 토큰 유효기간 7일 설정
    private static final int EXP_REFRESH = 1000 * 60 * 60 * 24 * 7;

	// 엑세스 토큰 유효기간 30초 설정
    private static final int EXP_ACCESS = 1000 * 30;
    // 리프레시 토큰 유효기간 60초 설정
    private static final int EXP_REFRESH = 1000 * 60;

index.html - 오류 출력

  • 에러 시에도 console을 띄워보자.
document.querySelector("#jwt-test").addEventListener("click", () => {
        fetch("/api/v1/temp", {
            method: "GET",
            headers: {
                "Content-Type": "application/json",
                // 쿠키에 저장된 토큰을 헤더에 포함시켜 보냄
                "Authorization": "Bearer " + getCookie("ACCESS-TOKEN")
            }
        }).then(response => response.json())
            .then((result) => {
                    // result.code가 0이 아닐 시 에러 메시지 출력
                    if (result.code !== 0) {
                        // 콘솔 출력 추가
                        console.log(result);
                        alert(result.message);
                        return;
                    }
                    // result.code가 0일 시 성공
                    console.log(result);
                    alert(result.message);
                }
            )
    });
  • 로그인 후 45초 정도 있다가 요청해보면,
  • 이런 오류가 뜬다.
  • ResDTO 자체에 status를 넣어놨지만, 지금은 저 데이터를 쓰지 않고 response 자체의 status를 사용하겠다.

AuthControllerApiV1DTO.java - 재로그인 요청 매핑

	// 재 로그인 (AccessToken 만료 시 RefreshToken으로 재발급, RefreshToken 만료 시 UnauthorizedException 발생)
    @PostMapping("/relogin")
    public HttpEntity<?> reLogin(@RequestBody @Valid ReqReLoginApiV1DTO reqReLoginApiV1DTO, Errors error) {
        if (error.hasErrors()) {
            throw new BadRequestException(error.getAllErrors().get(0).getDefaultMessage());
        }
        return authServiceApiV1.reLogin(reqReLoginApiV1DTO);
    }

AuthServiceApiV1DTO.java - 재로그인 로직

  • 재로그인 시에는 AccessToken이 이미 만료된 상황이기 때문에, header에 token이 들어가지 않고, 그로 인해 jwt 필터에서 SecurityContextHolder에 유저 정보를 넣어주지 않는다.
  • 그래서 RefreshToken을 직접 입력받아 서비스에서 검증하여 해당 토큰의 유저 idx와 role으로 jwt를 생성하여 반환한다.
		@Transactional
        public HttpEntity<?> reLogin(ReqReLoginApiV1DTO reqReLoginApiV1DTO) {

                // jwt 선언 (try-catch 문에서 선언 시 스코프 바깥에서 사용 불가)
                DecodedJWT decodedJwt = null;
                try {
                        // jwtProvider의 verify 함수를 사용하여 token 검증
                        decodedJwt = jwtProvider.verify(reqReLoginApiV1DTO.getRefreshToken());
                } catch (UnauthorizedException e) {
                        // refreshToken 자체가 만료되었거나, 문제가 있으면 UnauthorizedException 발생
                        throw new UnauthorizedException(e.getMessage());
                }

                // 검증 성공 시 토큰의 타입이 RefreshToken 인지 검사
                if (!decodedJwt.getClaim("token-type").asString().equals(JwtTokenType.REFRESH_TOKEN.name())) {
                        // 토큰 타입이 RefreshToken이 아니면 UnauthorizedException 발생
                        throw new UnauthorizedException("토큰 타입이 잘못되었습니다.");
                }

                // RefreshToken이며, 검증 성공 시 유저 정보 얻어오기
                String accessToken = jwtProvider.createToken(Long.parseLong(decodedJwt.getSubject()),
                        decodedJwt.getClaim("role").asString(), JwtTokenType.ACCESS_TOKEN);
                String refreshToken = jwtProvider.createToken(Long.parseLong(decodedJwt.getSubject()),
                        decodedJwt.getClaim("role").asString(), JwtTokenType.REFRESH_TOKEN);

                // accessToken, refreshToken 을 JwtToken 객체에 담아서 반환
                return new ResponseEntity<>(
                        ResDTO.builder()
                                .code(0)
                                .message("refreshToken 재발급 완료")
                                .data(JwtToken.builder()
                                        .accessToken(accessToken)
                                        .refreshToken(refreshToken)
                                        .build())
                                .build(),
                        HttpStatus.OK);
        }

JwtProvider.java - 재로그인 시 토큰 생성 함수 작성

  • idx, role로 jwt를 생성하는 함수 작성
public String createToken(Long idx, String role, JwtTokenType tokenType) {
        // 입력된 토큰 타입에 따라 유효기간 설정
        int exp = tokenType.compareTo(JwtTokenType.ACCESS_TOKEN) == 0 ? EXP_ACCESS : EXP_REFRESH;

        return JWT.create()
                .withSubject(idx.toString())
                .withExpiresAt(new Date(System.currentTimeMillis() + exp))
                .withClaim("role", role)
                .withClaim("token-type", tokenType.name())
                .sign(Algorithm.HMAC512(SECRET));
    }

index.html - jwt로 요청 후 401 에러 발생 시 재로그인 요청

  • 상단의 temp 요청에서 401에러 발생 시 새로운 fetch문으로 relogin 요청을 날려서 토큰을 재발급 받고 쿠키에 저장한다.
document.querySelector("#jwt-test").addEventListener("click", () => {
        fetch("/api/v1/temp", {
            method: "GET",
            headers: {
                "Content-Type": "application/json",
                // 쿠키에 저장된 토큰을 헤더에 포함시켜 보냄
                "Authorization": "Bearer " + getCookie("ACCESS-TOKEN")
            }
        }).then(response => {
            // response의 status가 401(JwtProvider의 verify가 실패)일 경우
            // 다른 경우일 수도 있다.
            if(response.status === 401) {
                // 재발급 요청에 필요한 데이터를 담은 DTO
                reloginDTO = {
                    refreshToken: getCookie("REFRESH-TOKEN")
                }
                // 재발급 요청
                fetch("/api/v1/auth/relogin",{
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json",
                    },
                    body: JSON.stringify(reloginDTO)
                }).then(response => {
                    // 재발급 요청 실패 시 (리프레시 토큰도 만료되었을 경우)
                    if(response.status === 401) {
                        // 사용자를 로그인 페이지로 이동시킨다.
                        alert("로그인이 필요합니다.");
                        location.href = "/auth/login";
                        return;
                    }
                    // 재발급 요청 성공 시 파싱하여 return
                    return response.json();
                }).then(result => {
                    // 토큰 만료 이외의 이유로 재발급 요청 실패 시 에러 메시지 출력
                    if(result.code !== 0) {
                        alert(result.message);
                        return;
                    }
                    // 토큰 재발급 성공 시 콘솔에 띄워보고 쿠키에 저장
                    console.log(result);
                    document.cookie = `ACCESS-TOKEN=${result.data.accessToken}`;
                    document.cookie = `REFRESH-TOKEN=${result.data.refreshToken}`;
                })
            }
            return response.json()
        })
            .then((result) => {
                    // result.code가 0이 아닐 시 에러 메시지 출력
                    if (result.code !== 0) {
                        console.log(result);
                        return;
                    }

                    // result.code가 0일 시 성공
                    console.log(result);
                    alert(result.message);
                }
            )
    });

재로그인 테스트

  • 일단 index로 접속해 회원가입, 로그인 후 45초 정도 기다려보자.
  • 그리고 jwt 테스트 버튼을 클릭하면
  • 2번의 통신이 간 걸 확인할 수 있다.
  • 혹시 token 값 오류 알람창이 뜬다면 위의 index.html에서 alert를 삭제했으므로 확인 바람.
  • 둘 중 아래의 로그를 확인해보면
  • AccessToken과 RefreshToken이 반환되었고, 쿠키도 확인해보면 잘 들어갔을 것이다.
  • 그리고 요청 이후 60초가 지난 후(RefreshToken도 만료되었을 때)에 테스트 해보면
  • 로그인이 필요하다고 뜨고, 확인 버튼 클릭 시 로그인 페이지로 넘어간다.

JwtProvider.java - 유효 기간 원상복구

    // 엑세스 토큰 유효기간 1일 설정
    private static final int EXP_ACCESS = 1000 * 60 * 60 * 24;
    // 리프레시 토큰 유효기간 7일 설정
    private static final int EXP_REFRESH = 1000 * 60 * 60 * 24 * 7;

끝.

마무리

  • 여러가지 어거지로 한 부분이 있어보인다.
  • JwtProvider에 설정한 SECRET은 실제로 서비스 할 일이 생긴다면 꼭 볼 수 없는 곳에 숨겨두자.
  • 전체 코드 github 주소

    https://github.com/dbtmdgks7897/jwt-velog

profile
유승한

0개의 댓글

관련 채용 정보