[회원관리] 로그아웃
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 |