Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
79 changes: 49 additions & 30 deletions src/main/java/com/umc/timeto/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
package com.umc.timeto.auth.controller;

import com.umc.timeto.auth.dto.*;
import com.umc.timeto.auth.dto.KakaoLoginRequest;
import com.umc.timeto.auth.dto.KakaoLoginResponse;
import com.umc.timeto.auth.dto.LogoutResponse;
import com.umc.timeto.auth.dto.TokenRefreshRequest;
import com.umc.timeto.auth.dto.TokenRefreshResponse;
import com.umc.timeto.auth.service.AuthService;
import com.umc.timeto.global.apiPayload.code.ErrorCode;
import com.umc.timeto.global.apiPayload.code.ResponseCode;
import com.umc.timeto.global.apiPayload.dto.ResponseDTO;
import com.umc.timeto.global.apiPayload.exception.GlobalException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
Expand All @@ -20,16 +32,13 @@ public class AuthController {
public ResponseEntity<ResponseDTO<KakaoLoginResponse>> kakaoLogin(
@RequestBody KakaoLoginRequest request
) {
if (request.getAuthorizationCode() == null ||
request.getAuthorizationCode().isBlank()) {

if (request.getAuthorizationCode() == null || request.getAuthorizationCode().isBlank()) {
return ResponseEntity
.status(ResponseCode.COMMON400.getStatus())
.body(new ResponseDTO<>(ResponseCode.COMMON400));
}

AuthService.LoginResult result =
authService.kakaoLogin(request.getAuthorizationCode());
AuthService.LoginResult result = authService.kakaoLogin(request.getAuthorizationCode());

ResponseCode responseCode = result.isNewMember()
? ResponseCode.COMMON201
Expand All @@ -40,53 +49,63 @@ public ResponseEntity<ResponseDTO<KakaoLoginResponse>> kakaoLogin(
.body(new ResponseDTO<>(responseCode, result.response()));
}

//로그아웃
// 로그아웃
@PostMapping("/logout")
public ResponseEntity<ResponseDTO<LogoutResponse>> logout(
@RequestHeader(value = "Authorization", required = false) String authorization
) {
System.out.println("Authorization header = " + authorization);

if (authorization == null || !authorization.startsWith("Bearer ")) {
return ResponseEntity
.status(ResponseCode.COMMON401.getStatus())
.body(new ResponseDTO<>(ResponseCode.COMMON401));
throw new GlobalException(ErrorCode.AUTHORIZATION_HEADER_MISSING);
}

String accessToken = authorization.substring(7).trim();
System.out.println("accessToken = " + accessToken);

authService.logout(accessToken);

return ResponseEntity
.status(ResponseCode.COMMON200.getStatus())
.body(new ResponseDTO<>(ResponseCode.COMMON200, new LogoutResponse("로그아웃 성공")));
.status(ResponseCode.AUTH_LOGOUT_SUCCESS.getStatus())
.body(new ResponseDTO<>(ResponseCode.AUTH_LOGOUT_SUCCESS, new LogoutResponse("로그아웃 성공")));
}

// 토큰 재발급
@PostMapping("/refresh")
public ResponseEntity<ResponseDTO<TokenRefreshResponse>> refresh(
@RequestBody TokenRefreshRequest request
) {
String refreshToken = request.refreshToken();

if (refreshToken == null || refreshToken.isBlank()) {
return ResponseEntity
.status(ResponseCode.COMMON401.getStatus())
.body(new ResponseDTO<>(ResponseCode.COMMON401));
throw new GlobalException(ErrorCode.BAD_REQUEST);
}

try {
TokenRefreshResponse response = authService.refresh(refreshToken.trim());
TokenRefreshResponse response = authService.refresh(refreshToken.trim());

return ResponseEntity
.status(ResponseCode.AUTH200.getStatus())
.body(new ResponseDTO<>(ResponseCode.AUTH200, response));
return ResponseEntity
.status(ResponseCode.AUTH200.getStatus())
.body(new ResponseDTO<>(ResponseCode.AUTH200, response));
}

} catch (IllegalArgumentException e) {
return ResponseEntity
.status(ResponseCode.COMMON401.getStatus())
.body(new ResponseDTO<>(ResponseCode.COMMON401));
// 회원 탈퇴(소프트 딜리트)
@DeleteMapping("/delete")
public ResponseEntity<ResponseDTO<String>> deleteAccount(Authentication authentication) {
if (authentication == null || authentication.getPrincipal() == null) {
throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN);
}
}

Object principal = authentication.getPrincipal();
Long memberId;

if (principal instanceof Long) {
memberId = (Long) principal;
} else if (principal instanceof String) {
memberId = Long.parseLong((String) principal);
} else {
throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN);
}

authService.requestDelete(memberId);

return ResponseEntity
.status(ResponseCode.AUTH_DELETE_SUCCESS.getStatus())
.body(new ResponseDTO<>(ResponseCode.AUTH_DELETE_SUCCESS, "성공"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.umc.timeto.auth.scheduler;

import com.umc.timeto.auth.service.AuthService;
import com.umc.timeto.member.entity.Member;
import com.umc.timeto.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.List;

@Component
@RequiredArgsConstructor
public class AccountCleanupScheduler {

private final MemberRepository memberRepository;
private final AuthService authService;

private static final int deleteGraceDays = 14;

// 탈퇴 2주 경과 회원 완전 삭제
@Scheduled(cron = "0 0 3 * * *") // 매일 03:00
public void hardDeleteExpiredMembers() {
LocalDateTime cutoff = LocalDateTime.now().minusDays(deleteGraceDays);
List<Member> expiredMembers =
memberRepository.findAllByDeletedAtIsNotNullAndDeletedAtBefore(cutoff);

for (Member member : expiredMembers) {
authService.hardDeleteMember(member.getMemberId());
}
}
}
84 changes: 75 additions & 9 deletions src/main/java/com/umc/timeto/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@
import com.umc.timeto.auth.entity.RefreshToken;
import com.umc.timeto.auth.jwt.JwtProvider;
import com.umc.timeto.auth.repository.RefreshTokenRepository;
import com.umc.timeto.dailyLog.repository.DailyLogRepository;
import com.umc.timeto.folder.repository.FolderRepository;
import com.umc.timeto.global.apiPayload.code.ErrorCode;
import com.umc.timeto.global.apiPayload.exception.GlobalException;
import com.umc.timeto.goal.repository.GoalRepository;
import com.umc.timeto.member.entity.Member;
import com.umc.timeto.member.repository.MemberRepository;
import com.umc.timeto.todo.repository.TodoRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Optional;

@Service
Expand All @@ -24,6 +31,13 @@ public class AuthService {
private final JwtProvider jwtProvider;
private final RefreshTokenRepository refreshTokenRepository;

private static final int deleteGraceDays = 14;

private final DailyLogRepository dailyLogRepository;
private final TodoRepository todoRepository;
private final FolderRepository folderRepository;
private final GoalRepository goalRepository;

// 카카오 로그인(신규면 회원 생성)
@Transactional
public LoginResult kakaoLogin(String authorizationCode) {
Expand All @@ -39,6 +53,10 @@ public LoginResult kakaoLogin(String authorizationCode) {
if (existingMember.isPresent()) {
member = existingMember.get();
isNewMember = false;

// 회원탈퇴(소프트딜리트)된 계정이라면 14일 내 재로그인 시 복구
restoreIfWithinGracePeriod(member);

} else {
member = memberRepository.save(
Member.createKakaoMember(
Expand Down Expand Up @@ -73,7 +91,7 @@ public LoginResult kakaoLogin(String authorizationCode) {
@Transactional
public void logout(String accessToken) {
if (!jwtProvider.validateToken(accessToken)) {
throw new IllegalArgumentException("Invalid access token");
throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN);
}

Long memberId = jwtProvider.getMemberId(accessToken);
Expand All @@ -85,29 +103,77 @@ public record LoginResult(
boolean isNewMember
) { }

// accessToken 재발급
// accessToken 재발급
@Transactional
public TokenRefreshResponse refresh(String refreshToken) {
if (!jwtProvider.validateToken(refreshToken)) {
throw new IllegalArgumentException("Invalid refresh token");
throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN);
}

Long memberId = jwtProvider.getMemberId(refreshToken);

RefreshToken savedToken = refreshTokenRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("Refresh token not found"));

System.out.println("requestRefreshToken = " + refreshToken);
System.out.println("dbRefreshToken = " + savedToken.getToken());
System.out.println("tokenEqual = " + savedToken.getToken().equals(refreshToken));
.orElseThrow(() -> new GlobalException(ErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND));

if (!savedToken.getToken().equals(refreshToken)) {
throw new IllegalArgumentException("Refresh token mismatch");
throw new GlobalException(ErrorCode.AUTH_REFRESH_TOKEN_MISMATCH);
}

String newAccessToken = jwtProvider.createAccessToken(memberId);

return new TokenRefreshResponse(memberId, newAccessToken, refreshToken);
}

// 회원 탈퇴 요청(소프트 딜리트 14일)
@Transactional
public void requestDelete(Long memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new GlobalException(ErrorCode.MEMBER_NOT_FOUND));

if (member.isDeleted()) {
throw new GlobalException(ErrorCode.BAD_REQUEST);
}

member.markDeleted(LocalDateTime.now());
refreshTokenRepository.deleteById(memberId);
}

// 탈퇴 계정 14일 내 재로그인 시 복구
private void restoreIfWithinGracePeriod(Member member) {
if (!member.isDeleted()) {
return;
}

LocalDateTime deletedAt = member.getDeletedAt();
if (deletedAt == null) {
return;
}

LocalDateTime now = LocalDateTime.now();
LocalDateTime restoreDeadline = deletedAt.plusDays(deleteGraceDays);

if (now.isBefore(restoreDeadline) || now.isEqual(restoreDeadline)) {
member.restore();
}
}

// 회원 완전 삭제(스케줄러에서 호출)
@Transactional
public void hardDeleteMember(Long memberId) {

// 1) member FK 가진 테이블들 먼저 삭제
dailyLogRepository.deleteAllByMemberId(memberId);

// 2) goal → folder → todo 방향으로 FK 걸려있으니, 역순으로 삭제
// todo 삭제 시 block은 orphanRemoval로 자동 삭제됨
todoRepository.deleteAllByMemberId(memberId);
folderRepository.deleteAllByMemberId(memberId);
goalRepository.deleteAllByMemberId(memberId);

// 3) auth 토큰 삭제
refreshTokenRepository.deleteById(memberId);

// 4) 마지막에 member 삭제
memberRepository.deleteById(memberId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import com.umc.timeto.dailyLog.entity.DailyLog;
import com.umc.timeto.member.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
Expand All @@ -13,4 +16,9 @@
public interface DailyLogRepository extends JpaRepository<DailyLog, Long> {
Optional<DailyLog> findByMemberAndCreatedAt(Member member, LocalDate createdAt);
List<DailyLog> findByMemberAndCreatedAtBetween(Member member, LocalDate start, LocalDate end);

// 회원의 일지 전체 삭제
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("delete from DailyLog d where d.member.memberId = :memberId")
void deleteAllByMemberId(@Param("memberId") Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import com.umc.timeto.folder.entity.Folder;
import com.umc.timeto.goal.entity.Goal;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.List;
Expand All @@ -14,4 +17,14 @@ public interface FolderRepository extends JpaRepository<Folder, Long> {
List<Folder> findAllByGoalOrderBySortOrderAsc(Goal goal);
Optional<Folder> findTopByGoalOrderBySortOrderDesc(Goal goal);

// 회원의 폴더 전체 삭제
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
delete from Folder f
where f.goal.id in (
select g.id from Goal g where g.member.memberId = :memberId
)
""")
void deleteAllByMemberId(@Param("memberId") Long memberId);

}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ public enum ErrorCode {
* 401 UNAUTHORIZED - 인증 실패
*/

AUTHORIZATION_HEADER_MISSING(HttpStatus.UNAUTHORIZED, "Authorization 헤더가 필요합니다."),
AUTH_INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."),
AUTH_REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 존재하지 않습니다."),
AUTH_REFRESH_TOKEN_MISMATCH(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 일치하지 않습니다."),


/**
* 403 FORBIDDEN - 권한 없음
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,13 @@ public enum ResponseCode {
COMMON201(HttpStatus.CREATED, "회원 가입 및 로그인 성공"),
COMMON400(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),
COMMON401(HttpStatus.UNAUTHORIZED, "인증에 실패하였습니다."),
// Auth
AUTH200(HttpStatus.OK, "토큰 재발급에 성공하였습니다."),


// Auth
AUTH200(HttpStatus.OK, "토큰 재발급에 성공하였습니다."),
AUTH_LOGIN_SUCCESS(HttpStatus.OK, "로그인에 성공하였습니다."),
AUTH_LOGOUT_SUCCESS(HttpStatus.OK, "로그아웃에 성공하였습니다."),
AUTH_DELETE_SUCCESS(HttpStatus.OK, "회원 탈퇴가 완료되었습니다."),


// DailyLog
SUCCESS_SAVE_LOG(HttpStatus.CREATED, "일지를 성공적으로 저장했습니다."),
Expand Down
Loading