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
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ dependencies {

// email
implementation 'org.springframework.boot:spring-boot-starter-mail'

// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'io.lettuce:lettuce-core'

}

jar {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.indayvidual.server.domain.user.controller;

import com.indayvidual.server.domain.user.dto.request.ReauthKakaoRequest;
import com.indayvidual.server.domain.user.dto.request.ReauthPasswordRequest;
import com.indayvidual.server.domain.user.dto.response.ReauthResponse;
import com.indayvidual.server.domain.user.service.AuthService.ReauthService;
import com.indayvidual.server.global.api.response.ApiResponse;
import com.indayvidual.server.global.config.security.JwtUserPrincipal;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Re-Auth(재인증)", description = "민감 정보 변경 전 재인증(비밀번호/카카오)")
@RestController
@RequestMapping("/api/auth/re-auth")
@RequiredArgsConstructor
public class ReauthController {
private final ReauthService reauthService;

@Operation(summary = "비밀번호 재인증", description = "현재 비밀번호 검증 후 reauth_token 발급(TTL=5분)")
@PostMapping("/password")
public ResponseEntity<ApiResponse<ReauthResponse>> reauthByPassword(
@AuthenticationPrincipal JwtUserPrincipal principal,
@RequestBody @Valid ReauthPasswordRequest req) {
var res = reauthService.reauthByPassword(principal.userId(), req.getCurrentPassword());
return ResponseEntity.ok(ApiResponse.onSuccess(res));
}

@Operation(summary = "카카오 재인증", description = "카카오 accessToken 검증 후 reauth_token 발급(TTL=5분)")
@PostMapping("/kakao")
public ResponseEntity<ApiResponse<ReauthResponse>> reauthByKakao(
@AuthenticationPrincipal JwtUserPrincipal principal,
@RequestBody @Valid ReauthKakaoRequest req) {
var res = reauthService.reauthByKakao(principal.userId(), req.getKakaoAccessToken());
return ResponseEntity.ok(ApiResponse.onSuccess(res));
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package com.indayvidual.server.domain.user.controller;

import com.indayvidual.server.domain.user.dto.request.DeleteAccountRequest;
import com.indayvidual.server.domain.user.dto.request.UserRequestDTO;
import com.indayvidual.server.domain.user.dto.response.SimpleMessageResponse;
import com.indayvidual.server.domain.user.dto.response.UserResponseDTO;
import com.indayvidual.server.domain.user.service.AuthService.ReauthService;
import com.indayvidual.server.domain.user.service.UserService.UserProfileService;
import com.indayvidual.server.global.api.response.ApiResponse;
import com.indayvidual.server.global.config.security.JwtUserPrincipal;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
Expand All @@ -21,39 +25,62 @@
public class UserProfileController {

private final UserProfileService userProfileService;
private final ReauthService reauthService;

@GetMapping("/profile")
public ResponseEntity<ApiResponse<UserResponseDTO.Profile>> getMyProfile(
@RequestHeader("X-Reauth-Token") String reauthToken,
@AuthenticationPrincipal JwtUserPrincipal principal
) {
reauthService.assertReauthOrThrow(principal.userId(), reauthToken, false);
var profile = userProfileService.getMyProfile(principal.userId());
return ResponseEntity.ok(ApiResponse.onSuccess(profile));
}

@PatchMapping("/update_username")
public ResponseEntity<ApiResponse<String>> updateUsername(
@AuthenticationPrincipal JwtUserPrincipal principal,
@RequestHeader("X-Reauth-Token") String reauthToken,
@RequestBody @Valid UserRequestDTO.UpdateUsername dto
) {
reauthService.assertReauthOrThrow(principal.userId(), reauthToken, false);
userProfileService.updateUsername(principal.userId(), dto.getUsername());
return ResponseEntity.ok(ApiResponse.onSuccess("닉네임이 변경되었습니다."));
}

@PatchMapping("/update_password")
public ResponseEntity<ApiResponse<String>> updatePassword(
@AuthenticationPrincipal JwtUserPrincipal principal,
@RequestHeader("X-Reauth-Token") String reauthToken,
@RequestBody @Valid UserRequestDTO.UpdatePassword dto
) {
reauthService.assertReauthOrThrow(principal.userId(), reauthToken, true); // 1회 소비
userProfileService.updatePassword(principal.userId(), dto.getCurrentPassword(), dto.getNewPassword());
return ResponseEntity.ok(ApiResponse.onSuccess("비밀번호가 변경되었습니다."));
}

@PatchMapping(value = "/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<String>> updateProfileImage(
@AuthenticationPrincipal JwtUserPrincipal principal,
@RequestHeader("X-Reauth-Token") String reauthToken,
@RequestPart("image") MultipartFile image
) {
reauthService.assertReauthOrThrow(principal.userId(), reauthToken, false);
String url = userProfileService.updateProfileImage(principal.userId(), image);
return ResponseEntity.ok(ApiResponse.onSuccess(url));
}

@Operation(summary = "회원 탈퇴", description = "재인증 토큰(X-Reauth-Token) 필요. 기본은 소프트 삭제")
@DeleteMapping
public ResponseEntity<ApiResponse<SimpleMessageResponse>> deleteMe(
@AuthenticationPrincipal JwtUserPrincipal principal,
@RequestHeader("X-Reauth-Token") String reauthToken,
@RequestBody(required = false) DeleteAccountRequest req
) {
if (req == null) req = new DeleteAccountRequest(); // 기본값
userProfileService.deleteMyAccount(principal.userId(), reauthToken, req);
return ResponseEntity.ok(ApiResponse.onSuccess(
SimpleMessageResponse.builder().message("탈퇴가 완료되었습니다.").build()
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.indayvidual.server.domain.user.dto.external;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Getter;

@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
public class KakaoUserInfo {
private Long id; // 카카오 회원번호(필수)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.indayvidual.server.domain.user.dto.request;

import lombok.Getter;

@Getter
public class DeleteAccountRequest {
private boolean hard; // true면 하드 삭제
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.indayvidual.server.domain.user.dto.request;


import jakarta.validation.constraints.NotBlank;
import lombok.Getter;

@Getter
public class ReauthKakaoRequest {
@NotBlank
private String kakaoAccessToken;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.indayvidual.server.domain.user.dto.request;

import jakarta.validation.constraints.NotBlank;
import lombok.Getter;

@Getter
public class ReauthPasswordRequest {
@NotBlank
private String currentPassword;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.indayvidual.server.domain.user.dto.response;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class ReauthResponse {
private String reauthToken; // UUID
private long expiresInSeconds; // 예: 300
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.indayvidual.server.domain.user.dto.response;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class SimpleMessageResponse {
private String message;
}

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.ArrayList;
import java.util.List;

import lombok.*;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;

Expand All @@ -23,11 +24,6 @@
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
Expand All @@ -52,7 +48,8 @@ public class User extends BaseEntity {
@Column(name = "profile_image", length = 512)
private String profile_image;

@Enumerated(EnumType.STRING)
@Setter
@Enumerated(EnumType.STRING)
private Status status;

@Enumerated(EnumType.STRING)
Expand All @@ -70,7 +67,7 @@ public class User extends BaseEntity {
@Builder.Default
private List<Habit> habits = new ArrayList<>();

public void changeUsername(String username) { this.username = username; }
public void changeUsername(String username) { this.username = username; }
public void changePassword(String encodedPassword) { this.password = encodedPassword; }
public void changeProfileImage(String imageUrl) { this.profile_image = imageUrl; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@

import com.indayvidual.server.domain.user.entity.RefreshToken;
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 java.time.LocalDateTime;
import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByTokenId(String tokenId);
void deleteByExpiredAtBefore(LocalDateTime time);

@Modifying
@Query("delete from RefreshToken r where r.userId = :userId")
void deleteAllByUserId(@Param("userId") Long userId);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.indayvidual.server.domain.user.service.AuthService;

import com.indayvidual.server.domain.user.dto.external.KakaoUserInfo;
import com.indayvidual.server.domain.user.dto.response.ReauthResponse;
import com.indayvidual.server.domain.user.entity.User;
import com.indayvidual.server.domain.user.entity.UserProvider;
import com.indayvidual.server.domain.user.entity.enums.Provider;
import com.indayvidual.server.domain.user.repository.UserProviderRepository;
import com.indayvidual.server.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;// ReauthService.java 내
import org.springframework.http.HttpStatusCode;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class ReauthService {

@Value("${kakao.api-base}")
private String kakaoApiBase;

private final UserRepository userRepository;
private final UserProviderRepository userProviderRepository;
private final PasswordEncoder passwordEncoder;
private final StringRedisTemplate redis; // Lettuce/Jedis 아무거나

private static final Duration REAUTH_TTL = Duration.ofMinutes(5);

public ReauthResponse reauthByPassword(Long userId, String currentPassword) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));

if (user.getPassword() == null || !passwordEncoder.matches(currentPassword, user.getPassword())) {
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}
return issueToken(userId, "PASSWORD");
}

public ReauthResponse reauthByKakao(Long userId, String kakaoAccessToken) {
// 1) kakaoAccessToken 으로 카카오 유저 정보 조회 (userinfo)
KakaoUserInfo info = fetchKakaoUserInfo(kakaoAccessToken); // 아래 참고

// 2) 우리 시스템에 연동된 카카오 계정인지 확인
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
boolean linked = userProviderRepository
.findByUserAndProvider(user, Provider.KAKAO)
.filter(UserProvider::getIsActive)
.map(up -> Objects.equals(up.getProviderId(), String.valueOf(info.getId())))
.orElse(false);

if (!linked) throw new IllegalArgumentException("연결된 카카오 계정이 아닙니다.");

return issueToken(userId, "KAKAO");
}

private ReauthResponse issueToken(Long userId, String method) {
String token = UUID.randomUUID().toString();
String key = key(userId, token);
String value = "{\"method\":\"" + method + "\",\"ts\":\"" + Instant.now() + "\"}";

redis.opsForValue().set(key, value, REAUTH_TTL);
return ReauthResponse.builder()
.reauthToken(token)
.expiresInSeconds(REAUTH_TTL.toSeconds())
.build();
}

public void assertReauthOrThrow(Long userId, String reauthToken, boolean consumeOnce) {
String key = key(userId, reauthToken);
String v = redis.opsForValue().get(key);
if (v == null) throw new IllegalArgumentException("재인증이 필요합니다.");

if (consumeOnce) {
redis.delete(key);
}
}

private String key(Long userId, String token) {
return "reauth:" + userId + ":" + token;
}

// --- Kakao userinfo 호출 ---
private KakaoUserInfo fetchKakaoUserInfo(String kakaoAccessToken) {
WebClient web = WebClient.builder()
.baseUrl(kakaoApiBase)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + kakaoAccessToken)
.build();

return web.get()
.uri("/v2/user/me")
.retrieve()
.onStatus(HttpStatusCode::isError, rsp ->
rsp.bodyToMono(String.class)
.map(body -> new IllegalArgumentException("Kakao 인증 실패: " + body)))
.bodyToMono(KakaoUserInfo.class)
.block();
}
}
Loading