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
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,19 @@ public class AuthController {

@Operation(
summary = "회원가입 API",
description = "회원 이메일, 비밀번호, 이름, 전화번호 정보를 입력받아 새로운 사용자를 생성합니다.",
description = """
회원 이메일, 비밀번호, 이름, 전화번호 정보를 입력받아 새로운 사용자를 생성합니다.

비밀번호 정책:
- 길이: 8~20자
- 최소 1개의 대문자(A-Z)
- 최소 1개의 소문자(a-z)
- 최소 1개의 숫자(0-9)
- 최소 1개의 특수문자(!@#$%^&*()_+=-{};:'",.<>/?)

위 조건을 모두 만족하지 않으면 400 (INVALID_INPUT) 예외가 발생합니다.
""",

responses = {
@ApiResponse(
responseCode = "201",
Expand All @@ -83,11 +95,20 @@ public class AuthController {
"email": "testuser@example.com",
"name": "홍길동",
"phoneNumber": "01012345678",
"role": "USER"
"role": "TEAM_MEMBER"
}
"""))
),
@ApiResponse(responseCode = "400", description = "요청 데이터 유효성 검증 실패")
@ApiResponse(
responseCode = "400",
description = "요청 데이터 유효성 검증 실패 (비밀번호 정책 미준수 포함)",
content = @Content(mediaType = "application/json",
examples = @ExampleObject(value = """
{
"message": "비밀번호는 8~20자, 대소문자/숫자/특수문자를 모두 포함해야 합니다."
}
"""))
)
}
)
@PostMapping("/signup")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,45 @@ private String generateCode() {
return sb.toString();
}

// 비밀번호 인증 관련 메서드
public void sendResetEmail(String email) {
if(!userRepository.existsByEmail(email)){
log.debug("Password reset requested for non-existent email {}", email);
return;
}

String code = generateCode();
redisTemplate.opsForValue().set("PASSWORD_RESET_EMAIL:" + email, code, emailProperties.getCodeExpire());

try {
MimeMessage message = createResetMessage(email, code);
mailSender.send(message);
} catch (MessagingException e) {
throw new MailSendException("failed to send mail", e);
}
}

public void verifyResetEmail(String email, String code) {
String stored = redisTemplate.opsForValue().get("PASSWORD_RESET_EMAIL:" + email);
if (stored == null) throw new CustomException(ErrorCode.EMAIL_CODE_NOT_FOUND);
if(!stored.equals(code)) throw new CustomException(ErrorCode.EMAIL_CODE_MISMATCH);
redisTemplate.delete("PASSWORD_RESET_EMAIL:" + email);
}

private MimeMessage createResetMessage(String email, String code) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
message.setFrom(new InternetAddress(from));
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(email));
message.setSubject("비밀번호 재설정 인증코드");

Context context = new Context();
context.setVariable("email", email);
context.setVariable("code", code);

String body = templateEngine.process("mail/resetEmail", context);
message.setText(body, "UTF-8", "html");
return message;
}



Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails;
import org.sejongisc.backend.auth.dto.SignupRequest;
import org.sejongisc.backend.auth.dto.SignupResponse;
import org.sejongisc.backend.user.dto.UserInfoResponse;
import org.sejongisc.backend.user.dto.UserUpdateRequest;
import org.sejongisc.backend.user.dto.*;
import org.sejongisc.backend.user.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
Expand Down Expand Up @@ -147,4 +147,185 @@ public ResponseEntity<?> updateUser(
userService.updateUser(userId, request);
return ResponseEntity.ok("회원 정보가 수정되었습니다.");
}
}

@Operation(
summary = "아이디 찾기 API",
description = """
사용자의 이름과 전화번호를 입력하면 가입된 이메일 주소를 반환합니다.
- 이름(name)과 전화번호(phoneNumber)가 모두 일치하는 회원만 조회됩니다.
- 일치하는 회원이 없을 경우 404 응답을 반환합니다.
""",
responses = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"email": "testuser@example.com"
}
""")
)
),
@ApiResponse(
responseCode = "404",
description = "일치하는 회원 없음",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"message": "해당 정보로 가입된 사용자를 찾을 수 없습니다."
}
""")
)
)
}
)
@PostMapping("/id/find")
public ResponseEntity<?> findUserID(@RequestBody @Valid UserIdFindRequest request) {
String name = request.name();
String phone = request.phoneNumber();
String email = userService.findEmailByNameAndPhone(name, phone);

if (email == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("message", "해당 정보로 가입된 사용자를 찾을 수 없습니다."));
}

return ResponseEntity.ok(Map.of("email", email));
}

@Operation(
summary = "비밀번호 재설정: 인증코드 발송 API",
description = """
가입된 이메일 주소로 비밀번호 재설정을 위한 인증코드를 전송합니다.
- 인증코드는 3분간 유효합니다.
- 존재하지 않는 이메일일 경우 404 에러를 반환합니다.
""",
responses = {
@ApiResponse(
responseCode = "200",
description = "인증코드 발송 성공",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"message": "인증코드를 전송했습니다."
}
""")
)
),
@ApiResponse(
responseCode = "404",
description = "이메일 미존재",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"message": "해당 이메일로 가입된 사용자를 찾을 수 없습니다."
}
""")
)
)
}
)
@PostMapping("/password/reset/send")
public ResponseEntity<?> sendReset(@RequestBody @Valid PasswordResetSendRequest req){
String email = req.email().trim();
log.info("비밀번호 재설정 요청"); // 개인정보 로그 남기지 않기
userService.passwordReset(email);
return ResponseEntity.ok(Map.of("message", "인증코드를 전송했습니다."));
}

@Operation(
summary = "비밀번호 재설정: 인증코드 검증 API",
description = """
이메일과 인증코드를 검증하고, 유효한 경우 비밀번호 재설정용 토큰(`resetToken`)을 발급합니다.
- 인증코드는 3분간만 유효합니다.
- 검증에 성공하면 resetToken(10분 유효)을 반환합니다.
""",
responses = {
@ApiResponse(
responseCode = "200",
description = "검증 성공 및 resetToken 발급",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"resetToken": "c8a2434d-7e11-4f7e-a201-b9fbc9d7d43a"
}
""")
)
),
@ApiResponse(
responseCode = "400",
description = "잘못된 코드 또는 만료된 코드",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"message": "인증코드가 올바르지 않거나 만료되었습니다."
}
""")
)
)
}
)
@PostMapping("/password/reset/verify")
public ResponseEntity<?> verifyReset(@RequestBody @Valid PasswordResetVerifyRequest req){
String email = req.email().trim();
String code = req.code().trim();

String token = userService.verifyResetCodeAndIssueToken(email, code);
return ResponseEntity.ok(Map.of("resetToken", token));
}

@Operation(
summary = "비밀번호 재설정 최종 API",
description = """
검증된 resetToken과 새 비밀번호를 전달하면 비밀번호를 최종 변경합니다.
- resetToken은 10분간 유효합니다.
- 비밀번호 정책:
• 길이: 8~20자
• 최소 1개의 대문자(A-Z)
• 최소 1개의 소문자(a-z)
• 최소 1개의 숫자(0-9)
• 최소 1개의 특수문자(!@#$%^&*()_+=-{};:'",.<>/?)
- 위 조건을 만족하지 않으면 400 응답을 반환합니다.
- 변경 완료 후, 로그인 화면으로 이동하여 새 비밀번호로 로그인할 수 있습니다.
""",
responses = {
@ApiResponse(
responseCode = "200",
description = "비밀번호 변경 성공",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"message": "비밀번호가 변경되었습니다. 다시 로그인해 주세요."
}
""")
)
),
@ApiResponse(
responseCode = "400",
description = "비밀번호 정책 위반 또는 잘못된/만료된 토큰",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"message": "비밀번호는 8~20자, 대소문자/숫자/특수문자를 모두 포함해야 합니다."
}
""")
)
)
}
)
@PostMapping("/password/reset/commit")
public ResponseEntity<?> commitReset(@RequestBody @Valid PasswordResetCommitRequest req){
userService.resetPasswordByToken(req.resetToken(), req.newPassword());
return ResponseEntity.ok(Map.of("message", "비밀번호가 변경되었습니다. 다시 로그인해 주세요."));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findUserByEmail(String email);

List<User> findAllByOrderByPointDesc();

Optional<User> findByNameAndPhoneNumber(String name, String phoneNumber);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.sejongisc.backend.user.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;

public record PasswordResetCommitRequest(
@NotBlank(message = "resetToken은 필수입니다.")
String resetToken,

@NotBlank(message = "새 비밀번호는 필수입니다.")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{}\\[\\];:'\",.<>/?]).{8,20}$",
message = "비밀번호는 8~20자, 대소문자/숫자/특수문자를 모두 포함해야 합니다."
)
String newPassword
) { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.sejongisc.backend.user.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record PasswordResetSendRequest(
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "올바른 이메일 형식이 아닙니다.")
String email
) { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.sejongisc.backend.user.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record PasswordResetVerifyRequest(
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "올바른 이메일 형식이 아닙니다.")
String email,

@NotBlank(message = "인증코드는 필수입니다.")
@Size(min = 6, max = 6, message = "인증코드는 6자리여야 합니다.")
String code
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.sejongisc.backend.user.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;

public record UserIdFindRequest(@NotBlank(message = "이름은 필수입니다.")
String name,

@NotBlank(message = "전화번호는 필수입니다.")
@Pattern(regexp = "^010\\d{8}$", message = "전화번호 형식이 올바르지 않습니다.")
String phoneNumber) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.sejongisc.backend.user.entity.User;
import org.sejongisc.backend.auth.oauth.OauthUserInfo;

import java.util.Optional;
import java.util.UUID;

public interface UserService {
Expand All @@ -18,4 +19,12 @@ public interface UserService {
User getUserById(UUID userId);

void deleteUserWithOauth(UUID userId);

String findEmailByNameAndPhone(String name, String phoneNumber);

void passwordReset(String email);

String verifyResetCodeAndIssueToken(String email, String code);

void resetPasswordByToken(String resetToken, String newPassword);
}
Loading