본문 바로가기
Spring/Spring-Security

Spring Security + JWT 로그인 구현하기 (Access Token)

by YoonJong 2023. 1. 23.
728x90

스프링 시큐리티의 구조 및 JWT로 구현하는 예제는 정말 많다.

정말 어려운 부분이면서 깊이가 상당한 라이브러리를 시작하기 위해서 따라해보고 첨삭을 통해 프로젝트에 필요없는 코드는 제외했다.

 

또한, 리프레시 토큰도 처음에는 구현했으나, 제외하고 엑세스 토큰만 구현했다.

1. 가장 기본이 되는 엑세스 토큰으로 구현하고 필요할 때, 리프레시 토큰을 사용하는 것이 옳은 방법이라고 판단했다.

( 로그인만 사용하는 단계에서 그 이상으로 구현할 때, 해당 코드에 대해 자세히 분석하지 못하고 구현하거나 그 이상으로 복잡해지면 이후 리팩토링 하는 과정이 더 힘들것이라고 생각했다 )

2. 리프레시 토큰을 탈취당하면 엑세스 토큰을 탈취당하는 것보다 이상의 문제가 생길것이라고 판단했다. 

3. 기준은 없지만, 엑세스 시간을 적당히 두고 프론트에서 로그인 유지시간을 알림해주는 것으로 로그아웃이 되는 것을 미리 인지하도록 하는게 좋을것이라 판단했다.

 

+ 1/26 추가 

JWT 로그아웃은 따로 추가하지 않았다.  어떠한 방법이 있는지 개념적으로 이해하고 이후 추가적으로 학습할 계획이다.

1. 토큰의 만료시간을 기다리는 방법이 있다. 

2. 레디스를 이용하여 블랙리스트를 만들어 로그아웃한 회원의 토큰을 저장하고 이후 해당 토큰으로 로그인을 하려고 시도할 때 블랙리스트에 등록되어있다면 로그인을 할 수 없다.


먼저 흐름은 아래와 같다.

1. MemberRepository에 값을 넣어주기 위해서 회원가입을 진행한다.

2. 로그인 시 요청한 아이디와 비밀번호를  MemberRepository에 있는지 확인 후, 존재한다면 role 과 다른 정보를 포함한 JWT Token 을 만들어준다.

3. 로그인이 완료되며, 로그인한 사용자는 권한에 따라 서비스를 이용할 수 있다.

 

 

토큰을 만들어주는 TokenProvider 클래스이다.

토큰은 로그인할 때, 엑세스 토큰을 생성해준다.

@Slf4j
@Component
@RequiredArgsConstructor
public class TokenProvider {

    private static final String AUTHORITIES_KEY  = "auth";
    private static final String BEARER_TYPE = "Bearer";
    private static final String MEMBER_ID_CLAIM_KEY = "memberId";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24;        // 1일

    private final MemberRepository memberRepository;
    private final ObjectMapper objectMapper;

    private final SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);

    public JwtTokenDto generateToken(LoginRequest loginRequest) throws JsonProcessingException {

        long nowTime = new Date().getTime();
        Member member = memberRepository.findByLoginId(loginRequest.getLoginId()).orElseThrow(
                () -> new BusinessException(ErrorCode.NOT_FOUND_MEMBER));
        List<String> roles = member.getRoles().stream().map(x -> x.getRoleType().toString()).toList();

        String data = objectMapper.writeValueAsString(roles);

        Date accessTokenExpiresIn = new Date(nowTime + ACCESS_TOKEN_EXPIRE_TIME); // 1일
        String accessToken = Jwts.builder()
                .setSubject(loginRequest.getLoginId())              //"sub":"소셜닉네임"
                .claim(MEMBER_ID_CLAIM_KEY, member.getId())         //"memberId":"1"
                .claim(AUTHORITIES_KEY, data)                       //"auth":"[ROLE_USER]"
                .claim("LOGIN_TYPE", member.getLoginType()) // "LOGIN_TYPE":"KAKAO"
                .setExpiration(accessTokenExpiresIn)                //"exp":"12345678"
                .signWith(key)
                .compact();

        return JwtTokenDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .build();
    }

    //JWT 토큰을 복호화해서 토큰에 들어있는 정보를 꺼내는 메서드
    public Authentication getAuthentication(String accessToken) throws JsonProcessingException {
        //토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) { // "auth"
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        List<String> data = objectMapper.readValue(claims.get(AUTHORITIES_KEY).toString(), List.class) ;
        List<SimpleGrantedAuthority> authorities = data
                .stream().map(role -> new SimpleGrantedAuthority(role))
                .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);

    }

    // 토큰정보를 검증하는 메서드
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다. ");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalStateException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

    // 복호화 메서드 따로 생성
    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(accessToken)
                    .getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

 

 

long nowTime = new Date().getTime();
Member member = memberRepository.findByLoginId(loginRequest.getLoginId()).orElseThrow(
        () -> new BusinessException(ErrorCode.NOT_FOUND_MEMBER));
List<String> roles = member.getRoles().stream().map(x -> x.getRoleType().toString()).toList();

String data = objectMapper.writeValueAsString(roles);

엑세스 토큰의 유효기간은 1일로 잡았다 ( 60 * 60 * 24 * 1000 )

로그인 요청이 온 loginId 를 memberRepository 에서 찾는다. 없으면 찾지못하는 예외처리를 한다.

내가 기획한 role 은 총 3가지이며, ( USER, SELLER, ADMIN ), 로그인할 때 어떤 권한을 가지고 있는지도 확인해야한다.

역할마다 접근, 수행 가능한 메서드가 존재하기 때문이다.

objectMapper 를 이용해서 JSON 객체로 변환해준다.

 

Date accessTokenExpiresIn = new Date(nowTime + ACCESS_TOKEN_EXPIRE_TIME); // 1일
String accessToken = Jwts.builder()
        .setSubject(loginRequest.getLoginId())              //"sub":"소셜닉네임"
        .claim(MEMBER_ID_CLAIM_KEY, member.getId())         //"memberId":"1"
        .claim(AUTHORITIES_KEY, data)                       //"auth":"[ROLE_USER]"
        .claim("LOGIN_TYPE", member.getLoginType()) // "LOGIN_TYPE":"KAKAO"
        .setExpiration(accessTokenExpiresIn)                //"exp":"12345678"
        .signWith(key)
        .compact();

엑세스토큰 만료시간을 설정해주었다. 현재시간 + 1일로 계산한 값이다.

Jwts.builder 를 통해 토큰에 들어갈 값을 설정해준다. 여기는 다양하게 값을 설정할 수 있는다.

 

return JwtTokenDto.builder()
        .grantType(BEARER_TYPE)
        .accessToken(accessToken)
        .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
        .build();
@Data
@Builder
@AllArgsConstructor
public class JwtTokenDto {
    private String grantType;
    private String accessToken;
    private Long accessTokenExpiresIn;
}

JwtTokenDto를 만들어주었다.

grantType 은 Bearer_ 으로 설정해준다.
( IBM 문서에서 참고 -  https://www.ibm.com/docs/ko/was-liberty/base?topic=uocpao2as-json-web-token-jwt-oauth-client-authorization-grants )

accessToken 에는 아까 만들어준 토큰을 넣어주고, 토큰 만료시간 또한 입력해준다.

 

이후 로그인을 진행하면 토큰을 발급 받을 수 있다.

토큰을 발급받고 내용을 보고 싶으면 jwt.io 에서 확인할 수 있다.

회원가입 한 정보
토큰 정보 (TokenDto)
만들어진 토큰을 확인 jwt.io

 

 

이제 로그인한 회원의 토큰을 가지고 권한이 필요한 기능을 사용해보자.

내가 테스트해볼 기능은 상품등록기능이며, 해당 기능은 ROLE_SELLER 만 접근이 가능하다.

 

이 과정에서 Filter 가 작동한다. 모든 HTTP 요청에서 DispatcherServlet 앞에 있는 Filter 에 먼저 접근하지만, 현재까지는 필터를 들리기만 할뿐, 사용하지 않았다. 

아래의 JwtFilter 의 흐름은 권한이 필요한 작업이 필요할 때, 토큰정보를 꺼내서 확인 후 접근 가능여부를 확인한다.

 

JwtSecurityConfig 클래스이며 설정에 필요하다.

// 직접 만든 TokenProvider 와 JwtFilter 를 SecurityConfig 에 적용할 때 사용
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final TokenProvider tokenProvider;

    // TokenProvider 를 주입받아서 JwtFilter 를 통해 Security 로직에 필터를 등록
    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

 

JwtFilter 클래스

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final TokenProvider tokenProvider;

    // JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        // 1. Request Header 에서 토큰을 꺼냄
        String token = resolveToken(request);

        // 2. validateToken 으로 토큰 유효성 검사
        // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
        if (token != null && tokenProvider.validateToken(token)) {
            Authentication authentication = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);

            log.info("authentication.getAuthorities() = " + authentication.getAuthorities());
            log.info("authentication.getPrincipal() = " + authentication.getPrincipal());
            log.info("Security Context 에 ' {} ' 인증 정보를 저장했습니다. uri : {} 로 요청이 들어옴", authentication.getName(), request.getRequestURI());
        }
        filterChain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보를 꺼내오기
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7); // Bearer_ 이후로 잘라서 가져오기
        }
        return null;
    }
}

1. Request Header에서 토큰을 꺼내오는 작업을 먼저 해주어야 한다.

현재 모든 토큰은 grantType 이 Bearer 로 되어있다.

요청해더에서 인증 토큰에서 subString 을 통해 Bearer 토큰 뒷부분을 가져와야한다.

토큰을 생성하면 예를들어 Bearer asdfhjk12h3lkjhasdlkfjadslkf 이렇게 되어있는데, Bearer(공백) 까지 자르고 가져와야 한다.

 

resolveToken(request) 로직이 완료되면 asdfhjk12h3lkjhasdlkfjadslkf 이렇게만 남게된다.

 

2. 토큰의 유효성을 검사해준다.

TokenProvider 클래스의 validateToken 과 getAuthentication 의 메서드를 사용한다.

먼저 validateToken 메서드를 통해 검증을 완료한다.

이후, getAuthentication 에서 토큰 복호화를 통해 사용자가 가지고있는 권한 정보를 가져온다.

 

권한에 맞는 메서드에 접근한다면 정상적으로 접근되며 인증이 필요한 메서드인 경우에는 401 에러가 발생한다.

728x90

댓글