Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Member", description = "회원 관리 API")
@Tag(name = "회원 관리", description = "회원 관리 API")
public class MemberController {

private final MemberService memberService;

@PostMapping("/signup")
@Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.")
@Operation(summary = "01. 회원가입", description = "새로운 회원을 등록합니다.")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "회원가입 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (중복 이메일/닉네임, 유효성 검증 실패)")
Expand All @@ -40,7 +40,7 @@ public ResponseEntity<MemberResponse> signup(@Valid @RequestBody MemberSignupReq
}

@PostMapping("/login")
@Operation(summary = "로그인", description = "이메일과 비밀번호로 로그인합니다.")
@Operation(summary = "02. 로그인", description = "이메일과 비밀번호로 로그인합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "로그인 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패 (존재하지 않는 회원, 비밀번호 불일치)")
Expand All @@ -55,7 +55,7 @@ public ResponseEntity<MemberResponse> login(@Valid @RequestBody MemberLoginReque
}

@PostMapping("/logout")
@Operation(summary = "로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.")
@Operation(summary = "08. 로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "로그아웃 성공")
})
Expand All @@ -76,7 +76,7 @@ public ResponseEntity<Void> logout(Authentication authentication, HttpServletRes
}

@PostMapping("/refresh")
@Operation(summary = "토큰 재발급", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.")
@Operation(summary = "04. 토큰 재발급", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "토큰 재발급 성공"),
@ApiResponse(responseCode = "401", description = "유효하지 않은 리프레시 토큰")
Expand All @@ -98,7 +98,7 @@ public ResponseEntity<MemberResponse> refreshToken(HttpServletRequest request,
}

@DeleteMapping("/withdraw")
@Operation(summary = "회원탈퇴", description = "현재 로그인된 사용자의 계정을 삭제합니다.")
@Operation(summary = "09. 회원탈퇴", description = "현재 로그인된 사용자의 계정을 삭제합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "회원탈퇴 성공"),
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"),
Expand All @@ -120,7 +120,7 @@ public ResponseEntity<Void> withdraw(Authentication authentication, HttpServletR
}

@GetMapping("/me")
@Operation(summary = "내 정보 조회", description = "현재 로그인된 사용자의 정보를 조회합니다.")
@Operation(summary = "03. 내 정보 조회", description = "현재 로그인된 사용자의 정보를 조회합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자")
Expand All @@ -139,7 +139,7 @@ public ResponseEntity<MemberResponse> getMyInfo(Authentication authentication) {
}

@PostMapping("/sendEmail")
@Operation(summary = "이메일 인증번호 전송", description = "로그인된 사용자는 자동으로 인증번호를 받고, 비로그인 사용자는 요청 바디의 loginId(이메일)로 인증번호를 받습니다.")
@Operation(summary = "05. 인증번호 전송", description = "로그인된 사용자는 자동으로 인증번호를 받고, 비로그인 사용자는 요청 바디의 loginId(이메일)로 인증번호를 받습니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "이메일 전송 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (loginId 없음)")
Expand Down Expand Up @@ -198,7 +198,7 @@ public ResponseEntity<EmailResponse> sendEmail(
}

@PostMapping("/verifyEmail")
@Operation(summary = "이메일 인증번호 검증", description = "로그인된 사용자는 자동으로 인증번호를 검증하고, 비로그인 사용자는 요청 바디의 loginId(이메일)와 함께 인증번호를 검증합니다.")
@Operation(summary = "06. 인증번호 검증", description = "로그인된 사용자는 자동으로 인증번호를 검증하고, 비로그인 사용자는 요청 바디의 loginId(이메일)와 함께 인증번호를 검증합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "인증번호 검증 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (인증번호 불일치, loginId 없음)")
Expand Down Expand Up @@ -265,18 +265,63 @@ public ResponseEntity<EmailResponse> verifyEmail(
// ===== 비밀번호 재설정 엔드포인트 =====

@PostMapping("/password-reset/reset")
@Operation(summary = "비밀번호 재설정", description = "인증 토큰과 함께 새 비밀번호로 재설정합니다.")
@Operation(summary = "07. 비밀번호 재설정", description = "인증 토큰과 함께 새 비밀번호로 재설정합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "비밀번호 재설정 성공"),
@ApiResponse(responseCode = "400", description = "인증되지 않았거나 잘못된 요청")
})
public ResponseEntity<PasswordResetResponse> resetPassword(@Valid @RequestBody ResetPasswordRequestDto request) {
log.info("비밀번호 재설정 요청: email={}", request.getLoginId());
public ResponseEntity<PasswordResetResponse> resetPassword(
@RequestBody ResetPasswordRequestDto request,
Authentication authentication,
HttpServletRequest httpRequest) {

// 입력값 검증
if (request.getNewPassword() == null || request.getNewPassword().isBlank()) {
throw new IllegalArgumentException("새 비밀번호를 입력해주세요.");
}
if (request.getSuccess() == null) {
throw new IllegalArgumentException("인증 성공 여부가 필요합니다.");
}

String loginId = null;

// 1. 로그인된 사용자인 경우 JWT 토큰에서 loginId 추출 (우선순위 1)
if (authentication != null && authentication.isAuthenticated() &&
!"anonymousUser".equals(authentication.getPrincipal())) {

// JWT 토큰에서 직접 loginid claim 추출
try {
String token = extractAccessTokenFromRequest(httpRequest);
if (token != null) {
loginId = memberService.extractLoginIdFromToken(token);
if (loginId != null) {
log.info("JWT 토큰에서 loginId 추출 성공: {}", loginId);
} else {
log.warn("JWT 토큰에서 loginId 추출 실패");
}
}
} catch (Exception e) {
log.warn("JWT 토큰에서 loginId 추출 중 오류: {}", e.getMessage());
}
}

// 2. 비로그인 사용자인 경우 요청 바디에서 loginId 추출 (우선순위 2)
if (loginId == null) {
if (request.getLoginId() != null && !request.getLoginId().isBlank()) {
loginId = request.getLoginId();
log.info("요청 바디에서 loginId 추출 성공: {}", loginId);
} else {
log.error("로그인하지 않은 상태에서 요청 바디에 loginId가 없음");
throw new IllegalArgumentException("비밀번호를 재설정할 이메일 주소가 필요합니다. 로그인하거나 요청 바디에 loginId를 포함해주세요.");
}
}

log.info("비밀번호 재설정 요청: email={}", loginId);

memberService.resetPassword(request.getLoginId(), request.getNewPassword(), request.getSuccess());
memberService.resetPassword(loginId, request.getNewPassword(), request.getSuccess());

log.info("비밀번호 재설정 성공: email={}", request.getLoginId());
return ResponseEntity.ok(PasswordResetResponse.success("비밀번호가 성공적으로 재설정되었습니다.", request.getLoginId()));
log.info("비밀번호 재설정 성공: email={}", loginId);
return ResponseEntity.ok(PasswordResetResponse.success("비밀번호가 성공적으로 재설정되었습니다.", loginId));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
@Getter
@Setter
public class ResetPasswordRequestDto {
@NotBlank(message = "이메일을 입력해주세요.")
private String loginId;

@NotBlank(message = "새 비밀번호를 입력해주세요.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,17 @@ public void resetPassword(String loginId, String newPassword, Boolean success) {
Member member = memberRepository.findByLoginId(loginId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다."));

// 인증 성공 여부 확인
if (!Boolean.TRUE.equals(success)) {
// 클라이언트에서 전달한 success 값과 Redis의 인증 성공 여부를 모두 확인
boolean clientSuccess = Boolean.TRUE.equals(success);

// 클라이언트 success가 false면 바로 실패
if (!clientSuccess) {
throw new IllegalArgumentException("이메일 인증을 완료해야 비밀번호를 재설정할 수 있습니다.");
}

// 클라이언트 success가 true면 Redis 인증 상태도 확인
boolean redisVerified = emailAuthService.isEmailVerified(loginId);
if (!redisVerified) {
throw new IllegalArgumentException("이메일 인증을 완료해야 비밀번호를 재설정할 수 있습니다.");
}

Expand All @@ -151,6 +160,9 @@ public void resetPassword(String loginId, String newPassword, Boolean success) {
member.updatePassword(encodedPassword);
memberRepository.save(member);

// 인증 데이터 삭제 (비밀번호 재설정 완료 후)
emailAuthService.clearAuthData(loginId);

// 기존 리프레시 토큰 삭제 (보안상 로그아웃 처리)
tokenProvider.deleteRefreshToken(loginId);
}
Expand Down
37 changes: 37 additions & 0 deletions backend/src/main/java/com/ai/lawyer/global/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
package com.ai.lawyer.global.config;

import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import redis.embedded.RedisServer;

@Slf4j
@Configuration
@ConditionalOnProperty(name = "spring.data.redis.embedded", havingValue = "false")
public class RedisConfig {
Expand All @@ -19,8 +26,11 @@ public class RedisConfig {
@Value("${spring.data.redis.port:6379}")
private int redisPort;

private RedisServer redisServer;

@Bean
public RedisConnectionFactory redisConnectionFactory() {
log.info("=== RedisConnectionFactory 생성: host={}, port={} ===", redisHost, redisPort);
return new LettuceConnectionFactory(redisHost, redisPort);
}

Expand All @@ -34,6 +44,33 @@ public RedisTemplate<String, Object> redisTemplate() {
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new StringRedisSerializer());

log.info("=== RedisTemplate 설정 완료 (host={}, port={}) ===", redisHost, redisPort);
return redisTemplate;
}

@EventListener(ContextRefreshedEvent.class)
public void startRedis() {
try {
redisServer = RedisServer.builder()
.port(redisPort)
.setting("maxmemory 128M")
.build();

if (!redisServer.isActive()) {
redisServer.start();
log.info("=== Redis 서버가 포트 {}에서 시작되었습니다 ===", redisPort);
}
} catch (Exception e) {
log.error("=== Redis 서버 시작 실패: {} ===", e.getMessage(), e);
}
}

@PreDestroy
@EventListener(ContextClosedEvent.class)
public void stopRedis() {
if (redisServer != null && redisServer.isActive()) {
redisServer.stop();
log.info("=== Redis 서버가 중지되었습니다 ===");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,39 @@ public boolean verifyAuthCode(String loginId, String inputCode) {
String key = buildKey(loginId);
String savedCode = (String) redisTemplate.opsForValue().get(key);
if (savedCode != null && savedCode.equals(inputCode)) {
redisTemplate.delete(key); // 성공 시 삭제
// 성공 시 삭제하지 않고 인증 성공 표시로 업데이트
String successKey = buildSuccessKey(loginId);
redisTemplate.opsForValue().set(successKey, "true", EXPIRATION_MINUTES, TimeUnit.MINUTES);
return true;
}
return false;
}

/**
* 이메일 인증 성공 여부 확인
*/
public boolean isEmailVerified(String loginId) {
String successKey = buildSuccessKey(loginId);
String isVerified = (String) redisTemplate.opsForValue().get(successKey);
return "true".equals(isVerified);
}

/**
* 비밀번호 재설정 완료 후 인증 데이터 삭제
*/
public void clearAuthData(String loginId) {
String key = buildKey(loginId);
String successKey = buildSuccessKey(loginId);
redisTemplate.delete(key);
redisTemplate.delete(successKey);
}

private String buildKey(String loginId) {
return "email:auth:" + loginId;
}

private String buildSuccessKey(String loginId) {
return "email:auth:success:" + loginId;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.PathItem;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@Configuration
@OpenAPIDefinition(info = @Info(title = "AI Lawyer API", version = "beta", description = "AI 변호사 서비스 API 문서입니다."))
@SecurityScheme(
Expand All @@ -24,6 +33,7 @@ public GroupedOpenApi memberApi() {
.group("Member API")
.pathsToMatch("/api/auth/**")
.packagesToScan("com.ai.lawyer.domain.member.controller")
.addOpenApiCustomizer(orderBySummaryNumber())
.build();
}

Expand Down Expand Up @@ -53,4 +63,33 @@ public GroupedOpenApi allApi() {
.packagesToScan("com.ai.lawyer.domain")
.build();
}

private OpenApiCustomizer orderBySummaryNumber() {
return openApi -> {
if (openApi.getPaths() == null) return;

Map<String, PathItem> sortedPaths = new LinkedHashMap<>();

// 정렬을 위해 summary 안에 있는 번호 추출
Pattern pattern = Pattern.compile("^(\\d+)\\..*");

openApi.getPaths().entrySet().stream()
.sorted(Comparator.comparingInt(e -> {
PathItem pathItem = e.getValue();
// POST, GET, 등등 중 첫 번째 Operation의 summary 사용
Operation op = pathItem.readOperations().stream().findFirst().orElse(null);
if (op == null || op.getSummary() == null) return Integer.MAX_VALUE;

Matcher matcher = pattern.matcher(op.getSummary());
if (matcher.find()) {
return Integer.parseInt(matcher.group(1));
}
return Integer.MAX_VALUE;
}))
.forEachOrdered(entry -> sortedPaths.put(entry.getKey(), entry.getValue()));

openApi.setPaths(new io.swagger.v3.oas.models.Paths());
sortedPaths.forEach(openApi.getPaths()::addPathItem);
};
}
}
2 changes: 1 addition & 1 deletion backend/src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ spring:
host: ${DEV_REDIS_HOST}
port: ${DEV_REDIS_PORT}
password: ${DEV_REDIS_PASSWORD}
embedded: true
embedded: false
Loading