Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE/FEAT] 로그아웃 및 회원 탈퇴 구현 #311

Merged
merged 19 commits into from
Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a2bde81
feat : 쿠키를 삭제하는 쿠키 관리 메서드명 추가
kssumin Mar 9, 2024
9755376
refactor : 블랙리스트 토큰을 담는 클래스임을 나타내기 위해 클래스명 변경
kssumin Mar 9, 2024
9c40e2c
refactor : repository 이름 변경으로 인한 변경
kssumin Mar 10, 2024
919aef3
feat : jwt 파싱 예외 클래스 생성
kssumin Mar 10, 2024
bfc4266
refactor : 토큰에서 값 추출하는 메서드명 변경
kssumin Mar 10, 2024
4bf50c6
feat : 로그아웃 후 해당 토큰 블랙리스트로 등록하도록 구현
kssumin Mar 10, 2024
aa99451
feat : 로그아웃 api 구현
kssumin Mar 10, 2024
776213e
fix : 토큰 추출 메서드명 변경으로 인한 변경
kssumin Mar 10, 2024
42b293b
fix : 멤버는 soft delete 하지 않도록 변경
kssumin Mar 10, 2024
f6c0efd
feat : 멤버 삭제 발생 시 후처리 구현
kssumin Mar 10, 2024
7bccbd7
feat : 회원 탈퇴 구현
kssumin Mar 10, 2024
69760fd
feat : 회원 탈퇴 api 구현
kssumin Mar 10, 2024
72bcbc4
feat : redis 트렌젝션 적용
kssumin Mar 10, 2024
92e1bed
fix : 통과하지 않는 테스트 통과시키도록 변경
kssumin Mar 10, 2024
38be3b2
refactor : 멤버와 관련되지 않은 작업 후처리 작업으로 처리
kssumin Mar 11, 2024
d424a93
refactor : 이미 탈퇴 처리 된 회원이면 에러 발생하지 않고 return 되도록 변경
kssumin Mar 11, 2024
d960171
chore : 불필요한 파일 제거
kssumin Mar 11, 2024
02d7e3c
docs : readme 수정
kssumin Mar 11, 2024
320b819
Merge pull request #69 from kssumin/feat/#184/logout
kssumin Mar 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed BE/BE.md
Empty file.
25 changes: 9 additions & 16 deletions BE/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,16 @@
| 운영 환경 구축 | EC2, Docker |
| Build | Gradle |
| CI/CD | Github Actions |
| Library | Spring Open Feign, OAuth 2.0, Actuator |
| Library | Spring Open Feign, OAuth 2.0, spring actuator |

## 실행방법
1. git clone
1. git clone [repository url)
2. 환경변수를 설정해줍니다.(env.properties)
3. docker-compose를 통해 db를 실행합니다.
4. spring boot를 실행합니다.


## docs
[서브 도메인 적용하기](https://velog.io/@kssumin/%EC%84%9C%EB%B8%8C-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0)

[타임존 설정](https://velog.io/@kssumin/DB%EC%97%90-%EB%93%A4%EC%96%B4%EA%B0%80%EB%8A%94-%EC%8B%9C%EA%B0%84%EC%9D%B4-%EC%9D%B4%EC%83%81%ED%95%98%EB%8B%A4)

[분기 처리 코드 리팩터링하기](https://velog.io/@kssumin/%EB%B6%84%EA%B8%B0%EC%B2%98%EB%A6%AC-%EC%BD%94%EB%93%9C-%EB%A6%AC%ED%8C%A9%ED%84%B0%EB%A7%81-%ED%95%98%EA%B8%B0)

[슬랙 API 호출 코드를 추상화하기](https://velog.io/@kssumin/%EC%8A%AC%EB%9E%99-API-%ED%98%B8%EC%B6%9C-%EC%BD%94%EB%93%9C%EB%A5%BC-%EC%B6%94%EC%83%81%ED%99%94%ED%95%98%EA%B8%B0)

[200 status code만 사용하지 말자](https://velog.io/@kssumin/EEOS-%EC%BA%90%EC%8B%B1)
3. db를 실행합니다.
```shell
cd resources
cd local-develop-environment
docker-compose up
```
5. spring boot를 실행합니다.

Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package com.blackcompany.eeos.auth.application.domain.token;

import com.blackcompany.eeos.auth.application.exception.NotFoundCookieException;
import com.blackcompany.eeos.auth.application.exception.NotFoundHeaderTokenException;
import com.blackcompany.eeos.auth.application.exception.RtTokenExpiredException;
import com.blackcompany.eeos.auth.application.exception.TokenExpiredException;
import com.blackcompany.eeos.auth.application.exception.TokenParsingException;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
Expand All @@ -27,61 +25,57 @@ public class TokenResolver {
public TokenResolver(
@Value("${security.jwt.access.secretKey}") String accessSecretKey,
@Value("${security.jwt.refresh.secretKey}") String refreshSecretKey) {
this.accessSecretKey = Keys.hmacShaKeyFor(accessSecretKey.getBytes(StandardCharsets.UTF_8));
this.refreshSecretKey = Keys.hmacShaKeyFor(refreshSecretKey.getBytes(StandardCharsets.UTF_8));
this.accessSecretKey = generateSecretKey(accessSecretKey);
this.refreshSecretKey = generateSecretKey(refreshSecretKey);
}

public Long getExpiredDateByHeader(final String token) {
Date expiration = getAccessClaims(token).getExpiration();

return expiration.getTime();
public Long getExpiredDateByAccessToken(final String token) {
return getExpirationTime(getAccessClaims(token));
}

public Long getUserInfoByCookie(final String token) {
return getRefreshClaims(token).get(MEMBER_ID_CLAIM_KEY, Long.class);
public Long getExpiredDateByRefreshToken(final String token) {
return getExpirationTime(getRefreshClaims(token));
}

public Long getExpiredDateByCookie(final String token) {
Date expiration = getRefreshClaims(token).getExpiration();
public Long getUserDataByAccessToken(final String token) {
return getClaimValue(getAccessClaims(token), MEMBER_ID_CLAIM_KEY);
}

return expiration.getTime();
public Long getUserDataByRefreshToken(final String token) {
return getClaimValue(getRefreshClaims(token), MEMBER_ID_CLAIM_KEY);
}

public Long getUserInfoByHeader(final String token) {
return getAccessClaims(token).get(MEMBER_ID_CLAIM_KEY, Long.class);
private SecretKey generateSecretKey(String key) {
return Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8));
}

private Claims getAccessClaims(final String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(accessSecretKey)
.build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
throw new TokenExpiredException();
} catch (SignatureException e) {
log.error("jwt 파싱에 에러가 발생했습니다.");
throw new NotFoundHeaderTokenException();
} catch (IllegalStateException e) {
throw new NotFoundHeaderTokenException();
}
return parseClaims(token, accessSecretKey);
}

private Claims getRefreshClaims(final String token) {
return parseClaims(token, refreshSecretKey);
}

private Claims parseClaims(final String token, final SecretKey secretKey) {
try {
return Jwts.parserBuilder()
.setSigningKey(refreshSecretKey)
.build()
.parseClaimsJws(token)
.getBody();
return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody();
} catch (ExpiredJwtException e) {
throw new RtTokenExpiredException();
throw new TokenExpiredException();
} catch (SignatureException e) {
log.error("jwt 파싱에 에러가 발생했습니다.");
throw new NotFoundCookieException();
} catch (IllegalStateException e) {
throw new NotFoundCookieException();
throw new TokenParsingException(e);
} catch (Exception e) {
log.error("JWT 파싱 중 오류 발생: {}", e.getMessage(), e);
throw new TokenParsingException(e);
}
}

private Long getExpirationTime(Claims claims) {
Date expiration = claims.getExpiration();
return expiration.getTime();
}

private Long getClaimValue(Claims claims, String claimKey) {
return claims.get(claimKey, Long.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.blackcompany.eeos.auth.application.event;

import lombok.Getter;

@Getter
public class DeletedMemberEvent {
private final Long memberId;
private final String token;

private DeletedMemberEvent(Long memberId, String token) {
this.memberId = memberId;
this.token = token;
}

/**
* 멤버 탈퇴 이벤트를 발행한다.
*
* @param memberId 회원 탈퇴를 원하는 멤버 id
* @param token 회원 탈퇴 시 사용한 토큰
* @return
*/
public static DeletedMemberEvent of(Long memberId, String token) {
return new DeletedMemberEvent(memberId, token);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.blackcompany.eeos.auth.application.event;

import com.blackcompany.eeos.auth.application.domain.token.TokenResolver;
import com.blackcompany.eeos.auth.persistence.BlackAuthenticationRepository;
import com.blackcompany.eeos.target.persistence.AttendRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
@Slf4j
public class DeletedMemberEventListener {
private final AttendRepository attendRepository;
private final BlackAuthenticationRepository blackAuthenticationRepository;
private final TokenResolver tokenResolver;

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleDeletedProgram(DeletedMemberEvent event) {
deleteTargetData(event.getMemberId());
saveUsedToken(event.getToken(), event.getMemberId());
}

private void deleteTargetData(Long memberId) {
attendRepository.deleteAllByMemberId(memberId);
}

private void saveUsedToken(String token, Long memberId) {
blackAuthenticationRepository.save(token, memberId, getExpiredDate(token));
}

private Long getExpiredDate(String token) {
return tokenResolver.getExpiredDateByRefreshToken(token);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.blackcompany.eeos.auth.application.exception;

import com.blackcompany.eeos.common.exception.BusinessException;
import org.springframework.http.HttpStatus;

/** JWT 파싱 중 발생한 예외 */
public class TokenParsingException extends BusinessException {
private static final String FAIL_CODE = "4007";
private String message;

public TokenParsingException(Exception e) {
super(FAIL_CODE, HttpStatus.BAD_REQUEST);
message = e.getMessage();
}

@Override
public String getMessage() {
return String.format("JWT 파싱 중 오류 발생: {}", message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.blackcompany.eeos.auth.application.service;

import com.blackcompany.eeos.auth.application.domain.token.TokenResolver;
import com.blackcompany.eeos.auth.application.event.DeletedMemberEvent;
import com.blackcompany.eeos.auth.application.usecase.LogOutUsecase;
import com.blackcompany.eeos.auth.application.usecase.WithDrawUsecase;
import com.blackcompany.eeos.auth.persistence.BlackAuthenticationRepository;
import com.blackcompany.eeos.auth.persistence.OAuthMemberRepository;
import com.blackcompany.eeos.member.persistence.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class DeactivateMemberService implements LogOutUsecase, WithDrawUsecase {
private final BlackAuthenticationRepository blackAuthenticationRepository;
private final TokenResolver tokenResolver;
private final ApplicationEventPublisher eventPublisher;
private final MemberRepository memberRepository;
private final OAuthMemberRepository oAuthMemberRepository;

@Override
@Transactional
public void logOut(final String token, final Long memberId) {
saveUsedToken(token, memberId);
}

@Override
@Transactional
public void withDraw(final String token, final Long memberId) {
if (isNotMember(memberId)) {
return;
}
deleteMemberData(memberId);
eventPublisher.publishEvent(DeletedMemberEvent.of(memberId, token));
}

private void saveUsedToken(final String token, final Long memberId) {
blackAuthenticationRepository.save(token, memberId, getExpiredToken(token));
}

private Long getExpiredToken(final String token) {
return tokenResolver.getExpiredDateByRefreshToken(token);
}

private boolean isNotMember(Long memberId) {
return memberRepository.findById(memberId).isEmpty();
}

private void deleteMemberData(Long memberId) {
memberRepository.deleteById(memberId);
oAuthMemberRepository.findByMemberId(memberId).ifPresent(oAuthMemberRepository::delete);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import com.blackcompany.eeos.auth.application.exception.InvalidTokenException;
import com.blackcompany.eeos.auth.application.support.AuthenticationTokenGenerator;
import com.blackcompany.eeos.auth.application.usecase.ReissueUsecase;
import com.blackcompany.eeos.auth.persistence.MemberAuthenticationRepository;
import com.blackcompany.eeos.auth.persistence.BlackAuthenticationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -15,13 +15,13 @@
@Transactional(readOnly = true)
public class ReissueService implements ReissueUsecase {
private final AuthenticationTokenGenerator authenticationTokenGenerator;
private final MemberAuthenticationRepository memberAuthenticationRepository;
private final BlackAuthenticationRepository blackAuthenticationRepository;
private final TokenResolver tokenResolver;

@Transactional
@Override
public TokenModel execute(final String token) {
Long memberId = tokenResolver.getUserInfoByCookie(token);
Long memberId = tokenResolver.getUserDataByRefreshToken(token);

validateToken(token);
saveUsedToken(token, memberId);
Expand All @@ -30,18 +30,18 @@ public TokenModel execute(final String token) {
}

private void validateToken(final String token) {
boolean isExistToken = memberAuthenticationRepository.isExistToken(token);
boolean isExistToken = blackAuthenticationRepository.isExistToken(token);

if (isExistToken) {
throw new InvalidTokenException();
}
}

private void saveUsedToken(final String token, final Long memberId) {
memberAuthenticationRepository.save(token, memberId, getExpiredToken(token));
blackAuthenticationRepository.save(token, memberId, getExpiredToken(token));
}

private Long getExpiredToken(final String token) {
return tokenResolver.getExpiredDateByHeader(token);
return tokenResolver.getExpiredDateByRefreshToken(token);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ public TokenModel execute(final Long memberId) {

return tokenModelConverter.from(
accessToken,
tokenResolver.getExpiredDateByHeader(accessToken),
tokenResolver.getExpiredDateByAccessToken(accessToken),
refreshToken,
tokenResolver.getExpiredDateByCookie(refreshToken));
tokenResolver.getExpiredDateByRefreshToken(refreshToken));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.blackcompany.eeos.auth.application.usecase;

public interface LogOutUsecase {
void logOut(String token, final Long memberId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.blackcompany.eeos.auth.application.usecase;

public interface WithDrawUsecase {
void withDraw(String token, Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class MemberAuthenticationEntity {
public class BlackAuthenticationEntity {
@Id private String token;
private Long memberId;
@TimeToLive private Long expiration;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@

@Repository
@RequiredArgsConstructor
public class MemberAuthenticationRepository {
public class BlackAuthenticationRepository {

private final RedisTemplate<String, Object> redisTemplate;

public void save(String key, Long value, Long expiredTime) {
redisTemplate.opsForValue().set(key, value, expiredTime, TimeUnit.MILLISECONDS);
public void save(String token, Long memberId, Long expiration) {
redisTemplate.opsForValue().set(token, memberId, expiration, TimeUnit.MILLISECONDS);
}

public boolean isExistToken(String key) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@
public interface OAuthMemberRepository extends JpaRepository<OAuthMemberEntity, Long> {
@Query("SELECT o FROM OAuthMemberEntity o WHERE o.oauthId=:oauthId")
Optional<OAuthMemberEntity> findByOauthId(@Param("oauthId") String oauthId);

@Query("SELECT o FROM OAuthMemberEntity o WHERE o.memberId=:memberId")
Optional<OAuthMemberEntity> findByMemberId(@Param("memberId") Long memberId);
}
Loading
Loading