[회원관리] 로그아웃

2023. 1. 21. 18:09프로젝트/라이프 챌린지

728x90
SMALL

로그인을 통해서 jwt 토큰이 정상적으로 발급이 된 것을 확인하였다.

이제 발급된 토큰을 여러 api들을 요청할 때 헤더에 포함하여 정상적으로 동작되게끔 구현하면 될 것이다.

이번에는 로그아웃을 구현해보도록 하자.

 


MemberController

// 로그아웃
@PostMapping("/logout")
public ResponseEntity<ResponseBody> memberLogout(HttpServletRequest request){
    log.info("로그아웃 - 유저 액세스 토큰 : {}, 유저 정보 : {}", request.getHeader("Authorization"), request.getUserPrincipal());

    return memberService.memberLogout(request);
}

로그아웃 api를 만들어준다.

  • 지난 로그인 기능 구현 시에 정상적으로 토큰이 발급된 이후에 HttpResponse 헤더에 토큰을 추가해주었다. 따라서, 이제 기능을 요청할 때 토큰이 필요한 api 라면 발급된 토큰이 포함된 HttpServletRequest 요청을 보내 기능을 정상적으로 수행되게끔 한다.
  • request.getHeader("Authorization") : 로그인을 하고 헤더에 추가할 떄 액세스 토큰이 들어갈 헤더 부분을 Authorization이라고 이름을 지어주었다. getHeader("Authorization") 을 하게 되면 정상적으로 액세스토큰이 출력될 것이다.
  • 해당 Request를 Service 단으로 넘긴다.

 

 

JwtTokenProvider

import com.example.lifechallenge.controller.request.TokenDto;
import com.example.lifechallenge.domain.Member;
import com.example.lifechallenge.domain.UserDetailsImpl;
import com.querydsl.jpa.impl.JPAQueryFactory;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

import static com.example.lifechallenge.domain.QMember.member;

@Slf4j
@Component
public class JwtTokenProvider {

    private final Key key;
    private final JPAQueryFactory queryFactory;

    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey, JPAQueryFactory queryFactory) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.queryFactory = queryFactory;
    }

    // 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
    public TokenDto generateToken(Authentication authentication) {
        // 권한 가져오기
        //String authorities = authentication.getAuthorities().stream()
        //        .map(GrantedAuthority::getAuthority)
        //        .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + 86400000);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("auth", "USER")
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + 86400000))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return TokenDto.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .accessTokenExpiresIn(accessTokenExpiresIn)
                .build();
    }

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

        log.info("클레임 정보 초기값 - {}", claims);

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

        log.info("클레임 정보 - {}", claims.get("auth"));

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("auth").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

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

    public Member getMemberFromAuthentication() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || AnonymousAuthenticationToken.class.
                isAssignableFrom(authentication.getClass())) {
            return null;
        }

        String account_id = ((UserDetails)authentication.getPrincipal()).getUsername();

        log.info("authentication 아이디 - {}",account_id);

        Member auth_member = queryFactory
                .selectFrom(member)
                .where(member.member_id.eq(account_id))
                .fetchOne();

        return auth_member;
    }

    // 토큰 정보를 검증하는 메서드
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

TokenProvider 는 토큰을 생성하는 동작도 수행하지만 토큰 검증 메소드도 존재한다.

Service 단에서 요청할 검증 메소드 부분과 일부 코드들을 좀 수정하였다.

  • generateToken 메소드는 토큰이 생성되는 메소드인데, 어쨰서인지 기존에 했던 방식을 그대로 만들었더니 "auth" claim 부분에 정상적으로 데이터가 들어가지 않았다. 따라서, "auth" claim 에 "USER' 권한을 그대로 넣어주었다.
  • 원래는 getAuthentication() 메소드에서 토큰을 복호 화하여 존재하는 데이터 정보들을 꺼내와야하는데 앞서 말한것 처럼 claim에 대한 부분이 정상적으로 동작하지 않았다. 따라서 getMemberFromAuthentication() 메소드를 따로 만들어서 비슷한 역할을 수행하게끔 구현해주었다.

 

 

 

MemberService

// 로그아웃
@Transactional
public ResponseEntity<ResponseBody> memberLogout(HttpServletRequest request){

    // request 에서 액세스토큰 정보 추출
    String refreshToken = request.getHeader("Refresh-Token");

    // 토큰이 유효한지 유효하지 않은지 확인 후 처리
    if(!jwtTokenProvider.validateToken(refreshToken)){
        return new ResponseEntity<>(new ResponseBody(StatusCode.USELESS_TOKEN.getStatusCode(), StatusCode.USELESS_TOKEN.getStatus(), null), HttpStatus.BAD_REQUEST);
    }

    if(jwtTokenProvider.getMemberFromAuthentication() == null){
        return new ResponseEntity(new ResponseBody(StatusCode.NOT_EXIST_ACCOUNT.getStatusCode(), StatusCode.NOT_EXIST_ACCOUNT.getStatus(), null), HttpStatus.BAD_REQUEST);
    }

    // 로그아웃 된 계정의 토큰 삭제
    queryFactory
            .delete(token)
            .where(token.refreshToken.eq(refreshToken))
            .execute();

    return new ResponseEntity<>(new ResponseBody(StatusCode.OK.getStatusCode(), StatusCode.OK.getStatus(), "로그아웃 되셨습니다."), HttpStatus.OK);
}

넘겨받은 Request로 동작을 수행할 Service 로직을 작성한다.

  • Service를 수행할 로직에 앞서서 해당 토큰이 유효한 토큰인지 검증한다.
  • 검증은 액세스 토큰이 아니라 리프레시 토큰으로 수행한다.
  • request.getHeader("Refresh_Token") 으로 리프레시 토큰을 가져온다.
  • 가져온 리프레시 토큰을 jwtTokenProvider.validateToken 에 인자값으로 넘겨서 1차 검증을 수행한다. 유효한 토큰이 아니라면 에러처리를 수행한다.
  • 유효성 검사가 정상적으로 통과되었다면 이번에는 해당 토큰이 정상적으로 검증된 Authentication 객체를 내포하고 있는지 확인한다. 마찬가지로 jwtTokenProvider 에서 getMemberFromAuthentication() 메소드를 통해 검증한다. (원래 getAuthentication() 메소드로 진행하였으나 claim 이슈로 인해 다시 만들었다.)
  • 위의 두 가지 검증이 정상적으로 완료가 통과되었다면, 현재 로그인하여 저장된 유저의 토큰 정보를 삭제한다.
  • 정상적으로 토큰이 삭제가 되었다면 포스트맨에 "로그아웃 되셨습니다." 라고 결과가 출력될 것이다.

 

 

이제 포스트맨에서 생성된 토큰을 포함하여 로그아웃 api를 요청해보도록 하자.

 

/lc/logout 주소로 Headers에 Authorization과 Refresh-Token 헤더를 추가하여 각각 생성된 토큰 정보들을 넣어주고 요청을 보냈다.

정상적으로 "로그아웃 되셨습니다." 문구가 출력되는 것을 확인할 수 있다.

 

이처럼 api요청을 보낼 때 토큰을 반드시 포함해야 한다면, SecurityConfig 에서 antmatchers.permitAll()을 해당 주소를 기입하지 않으면 된다.

주소를 기입하게 되면 토큰이 없어도 모든 유저가 해당 기능을 수행할 수 있게 된다.

 

728x90
반응형
LIST

'프로젝트 > 라이프 챌린지' 카테고리의 다른 글

[게시판] 게시글 작성  (0) 2023.01.25
[회원관리] 회원탈퇴  (0) 2023.01.21
[회원관리] 로그인  (0) 2023.01.20
[회원관리] 회원가입  (0) 2023.01.17
[회원관리] JWT 세팅  (0) 2023.01.16