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
17 changes: 15 additions & 2 deletions src/main/java/com/back/domain/user/controller/UserController.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.back.domain.user.controller;

import com.back.domain.user.dto.ChangePasswordRequest;
import com.back.domain.user.dto.UpdateUserProfileRequest;
import com.back.domain.user.dto.UserDetailResponse;
import com.back.domain.user.service.UserService;
Expand Down Expand Up @@ -41,8 +42,20 @@ public ResponseEntity<RsData<UserDetailResponse>> updateMyProfile(
.ok(RsData.success(
"회원 정보를 수정했습니다.",
updated
)
);
));
}

// 내 비밀번호 변경
@PatchMapping("/me/password")
public ResponseEntity<RsData<Void>> changeMyPassword(
@AuthenticationPrincipal CustomUserDetails user,
@Valid @RequestBody ChangePasswordRequest request
) {
userService.changePassword(user.getUserId(), request);
return ResponseEntity
.ok(RsData.success(
"비밀번호가 변경되었습니다."
));
}

// 내 계정 삭제
Expand Down
159 changes: 159 additions & 0 deletions src/main/java/com/back/domain/user/controller/UserControllerDocs.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.back.domain.user.controller;

import com.back.domain.user.dto.ChangePasswordRequest;
import com.back.domain.user.dto.UpdateUserProfileRequest;
import com.back.domain.user.dto.UserDetailResponse;
import com.back.global.common.dto.RsData;
Expand Down Expand Up @@ -304,6 +305,164 @@ ResponseEntity<RsData<UserDetailResponse>> updateMyProfile(
@Valid @RequestBody UpdateUserProfileRequest request
);

@Operation(
summary = "비밀번호 변경",
description = "로그인한 사용자가 본인 계정의 비밀번호를 변경합니다. " +
"현재 비밀번호를 검증하고, 새 비밀번호는 정책(최소 8자, 숫자/특수문자 포함)을 만족해야 합니다."
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "비밀번호 변경 성공",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"success": true,
"code": "SUCCESS_200",
"message": "비밀번호가 변경되었습니다.",
"data": null
}
""")
)
),
@ApiResponse(
responseCode = "400",
description = "새 비밀번호 정책 위반",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"success": false,
"code": "USER_005",
"message": "비밀번호는 최소 8자 이상, 숫자/특수문자를 포함해야 합니다.",
"data": null
}
""")
)
),
@ApiResponse(
responseCode = "403",
description = "소셜 로그인 회원 비밀번호 변경 불가",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"success": false,
"code": "USER_010",
"message": "소셜 로그인 회원은 비밀번호를 변경할 수 없습니다.",
"data": null
}
""")
)
),
@ApiResponse(
responseCode = "403",
description = "정지된 계정",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"success": false,
"code": "USER_008",
"message": "정지된 계정입니다. 관리자에게 문의하세요.",
"data": null
}
""")
)
),
@ApiResponse(
responseCode = "410",
description = "탈퇴한 계정",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"success": false,
"code": "USER_009",
"message": "탈퇴한 계정입니다.",
"data": null
}
""")
)
),
@ApiResponse(
responseCode = "401",
description = "인증 실패 (토큰 없음/잘못됨/만료 또는 현재 비밀번호 불일치)",
content = @Content(
mediaType = "application/json",
examples = {
@ExampleObject(name = "토큰 없음", value = """
{
"success": false,
"code": "AUTH_001",
"message": "인증이 필요합니다.",
"data": null
}
"""),
@ExampleObject(name = "잘못된 토큰", value = """
{
"success": false,
"code": "AUTH_002",
"message": "유효하지 않은 액세스 토큰입니다.",
"data": null
}
"""),
@ExampleObject(name = "만료된 토큰", value = """
{
"success": false,
"code": "AUTH_004",
"message": "만료된 액세스 토큰입니다.",
"data": null
}
"""),
@ExampleObject(name = "현재 비밀번호 불일치", value = """
{
"success": false,
"code": "USER_006",
"message": "아이디 또는 비밀번호가 올바르지 않습니다.",
"data": null
}
""")
}
)
),
@ApiResponse(
responseCode = "404",
description = "존재하지 않는 사용자",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"success": false,
"code": "USER_001",
"message": "존재하지 않는 사용자입니다.",
"data": null
}
""")
)
),
@ApiResponse(
responseCode = "500",
description = "서버 내부 오류",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"success": false,
"code": "COMMON_500",
"message": "서버 오류가 발생했습니다.",
"data": null
}
""")
)
)
})
ResponseEntity<RsData<Void>> changeMyPassword(
@AuthenticationPrincipal CustomUserDetails user,
@Valid @RequestBody ChangePasswordRequest request
);

@Operation(
summary = "내 계정 삭제",
description = "로그인한 사용자의 계정을 탈퇴 처리합니다. " +
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/com/back/domain/user/dto/ChangePasswordRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.back.domain.user.dto;

import jakarta.validation.constraints.NotBlank;

/**
* 비밀번호 변경 요청을 나타내는 DTO
*
* @param currentPassword 현재 비밀번호
* @param newPassword 새로운 비밀번호
*/
public record ChangePasswordRequest(
@NotBlank String currentPassword,
@NotBlank String newPassword
) {
}
15 changes: 2 additions & 13 deletions src/main/java/com/back/domain/user/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import com.back.global.exception.ErrorCode;
import com.back.global.security.jwt.JwtTokenProvider;
import com.back.global.util.CookieUtil;
import com.back.global.util.PasswordValidator;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
Expand Down Expand Up @@ -48,7 +49,7 @@ public UserResponse register(UserRegisterRequest request) {
validateDuplicate(request);

// 비밀번호 정책 검증
validatePasswordPolicy(request.password());
PasswordValidator.validate(request.password());

// User 엔티티 생성 (기본 Role.USER, Status.PENDING)
User user = User.createUser(
Expand Down Expand Up @@ -216,18 +217,6 @@ private void validateDuplicate(UserRegisterRequest request) {
}
}

/**
* 비밀번호 정책 검증
* - 최소 8자 이상
* - 숫자 및 특수문자 반드시 포함
*/
private void validatePasswordPolicy(String password) {
String regex = "^(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$";
if (!password.matches(regex)) {
throw new CustomException(ErrorCode.INVALID_PASSWORD);
}
}

/**
* 쿠키에서 Refresh Token 추출
*/
Expand Down
33 changes: 33 additions & 0 deletions src/main/java/com/back/domain/user/service/UserService.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.back.domain.user.service;

import com.back.domain.user.dto.ChangePasswordRequest;
import com.back.domain.user.dto.UpdateUserProfileRequest;
import com.back.domain.user.dto.UserDetailResponse;
import com.back.domain.user.entity.User;
Expand All @@ -9,7 +10,9 @@
import com.back.domain.user.repository.UserRepository;
import com.back.global.exception.CustomException;
import com.back.global.exception.ErrorCode;
import com.back.global.util.PasswordValidator;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -19,6 +22,7 @@
public class UserService {
private final UserRepository userRepository;
private final UserProfileRepository userProfileRepository;
private final PasswordEncoder passwordEncoder;

/**
* 사용자 정보 조회 서비스
Expand Down Expand Up @@ -62,6 +66,35 @@ public UserDetailResponse updateUserProfile(Long userId, UpdateUserProfileReques
return UserDetailResponse.from(user);
}

/**
* 비밀번호 변경 서비스
* 1. 사용자 조회 및 상태 검증
* 2. 현재 비밀번호 검증
* 3. 새 비밀번호 정책 검증
* 4. 비밀번호 변경
*/
public void changePassword(Long userId, ChangePasswordRequest request) {

// 사용자 조회 및 상태 검증
User user = getValidUser(userId);

// 소셜 로그인 사용자는 비밀번호 변경 불가
if (user.getProvider() != null) {
throw new CustomException(ErrorCode.SOCIAL_PASSWORD_CHANGE_FORBIDDEN);
}

// 현재 비밀번호 검증
if (!passwordEncoder.matches(request.currentPassword(), user.getPassword())) {
throw new CustomException(ErrorCode.INVALID_CREDENTIALS);
}

// 새 비밀번호 정책 검증
PasswordValidator.validate(request.newPassword());

// 비밀번호 변경
user.setPassword(passwordEncoder.encode(request.newPassword()));
}

/**
* 사용자 탈퇴 서비스 (soft delete)
* 1. 사용자 조회 및 상태 검증
Expand Down
1 change: 1 addition & 0 deletions src/main/java/com/back/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public enum ErrorCode {
USER_EMAIL_NOT_VERIFIED(HttpStatus.FORBIDDEN, "USER_007", "이메일 인증 후 로그인할 수 있습니다."),
USER_SUSPENDED(HttpStatus.FORBIDDEN, "USER_008", "정지된 계정입니다. 관리자에게 문의하세요."),
USER_DELETED(HttpStatus.GONE, "USER_009", "탈퇴한 계정입니다."),
SOCIAL_PASSWORD_CHANGE_FORBIDDEN(HttpStatus.FORBIDDEN, "USER_010", "소셜 로그인 회원은 비밀번호를 변경할 수 없습니다."),

// ======================== 스터디룸 관련 ========================
ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "ROOM_001", "존재하지 않는 방입니다."),
Expand Down
28 changes: 28 additions & 0 deletions src/main/java/com/back/global/util/PasswordValidator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.back.global.util;

import com.back.global.exception.CustomException;
import com.back.global.exception.ErrorCode;

/**
* 비밀번호 유효성 검증 유틸리티 클래스
*
* 비밀번호 정책:
* - 최소 8자 이상
* - 최소 하나의 숫자 포함
* - 최소 하나의 특수문자 포함 (!@#$%^&*)
*/
public class PasswordValidator {
private static final String PASSWORD_REGEX = "^(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$";

/**
* 비밀번호 유효성 검증 메서드
*
* @param password 검증할 비밀번호
* @throws CustomException 비밀번호가 정책에 맞지 않을 경우 USER_005 예외 발생
*/
public static void validate(String password) {
if (!password.matches(PASSWORD_REGEX)) {
throw new CustomException(ErrorCode.INVALID_PASSWORD);
}
}
}
Loading