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
@@ -0,0 +1,49 @@
package moadong.global.util;

import org.springframework.stereotype.Component;

import java.security.SecureRandom;

@Component
public class SecurePasswordGenerator {

// 알파벳 대소문자와 특수문자 배열
private static final String ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
private static final String NUMBERS = "0123456789";
private static final String SPECIAL = "!@#$%^";

private static final String ALL = ALPHABET + NUMBERS + SPECIAL;

private final SecureRandom secureRandom = new SecureRandom();

//영어, 숫자, 특수문자 각각 최소 1개씩
public String generate(int length) {
if (length < 8) {
throw new IllegalArgumentException("Length must be at least 8");}

StringBuilder password = new StringBuilder(length);

password.append(ALPHABET.charAt(secureRandom.nextInt(ALPHABET.length())));
password.append(NUMBERS.charAt(secureRandom.nextInt(NUMBERS.length())));
password.append(SPECIAL.charAt(secureRandom.nextInt(SPECIAL.length())));

for (int i = 3; i < length; i++) {
int index = secureRandom.nextInt(ALL.length());
password.append(ALL.charAt(index));
}

//뒤섞기
return shuffleString(password.toString(), secureRandom);
}

private static String shuffleString(String password, SecureRandom secureRandom) {
char[] characters = password.toCharArray();
for (int i = characters.length - 1; i > 0; i--) {
int j = secureRandom.nextInt(i + 1);
char tmp = characters[i];
characters[i] = characters[j];
characters[j] = tmp;
}
return new String(characters);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import moadong.global.payload.Response;
import moadong.global.util.JwtProvider;
import moadong.user.annotation.CurrentUser;
import moadong.user.payload.CustomUserDetails;
import moadong.user.payload.request.UserLoginRequest;
Expand All @@ -15,6 +14,7 @@
import moadong.user.payload.response.FindUserClubResponse;
import moadong.user.payload.response.LoginResponse;
import moadong.user.payload.response.RefreshResponse;
import moadong.user.payload.response.TempPasswordResponse;
import moadong.user.service.UserCommandService;
import moadong.user.view.UserSwaggerView;
import org.springframework.http.ResponseCookie;
Expand Down Expand Up @@ -93,6 +93,25 @@ public ResponseEntity<?> update(@CurrentUser CustomUserDetails user,
return Response.ok("success update");
}

@PostMapping("/reset")
@Operation(summary = "사용자 비밀번호 초기화", description = "사용자 비밀번호를 초기화합니다.")
@PreAuthorize("isAuthenticated()")
@SecurityRequirement(name = "BearerAuth")
public ResponseEntity<?> reset(@CurrentUser CustomUserDetails user,
HttpServletResponse response) {
TempPasswordResponse tempPwdResponse = userCommandService.reset(user.getUserId());

ResponseCookie cookie = ResponseCookie.from("refresh_token", "")
.path("/")
.maxAge(0)
.httpOnly(true)
.sameSite("None")
.secure(true)
.build();
response.addHeader("Set-Cookie", cookie.toString());
Comment on lines +104 to +111
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

쿠키 제거 시 Domain 정합성 확인

로그인 시 설정한 refresh_token의 Domain/Path와 동일해야 삭제됩니다. 현재 Domain 미설정 상태입니다. 발급부와 동일하게 맞추는지 확인 부탁드립니다.

아래 스크립트로 발급부 설정을 점검하세요:


🏁 Script executed:

#!/bin/bash
# refresh_token 쿠키 설정 위치/속성 확인
rg -nC2 "ResponseCookie\.from\(\s*\"refresh_token\"" --type=java

Length of output: 1706


🏁 Script executed:

#!/bin/bash
sed -n '1,200p' backend/src/main/java/moadong/user/util/CookieMaker.java

Length of output: 795


refresh_token 삭제 쿠키에 domain 설정 추가
UserController.java의 로그아웃(63–67행) 및 비밀번호 재설정(104–110행) 삭제 로직에서 ResponseCookie 빌더에 .domain(".moadong.com")을 추가해 주세요.

🤖 Prompt for AI Agents
In backend/src/main/java/moadong/user/controller/UserController.java around
lines 63–67 and 104–111, the ResponseCookie builders for clearing the
refresh_token do not set the cookie domain; update both builders to include
.domain(".moadong.com") in the fluent chain before build() so the Set-Cookie
includes the correct domain when removing the refresh_token.

return Response.ok(tempPwdResponse);
}
Comment on lines +96 to +113
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

보안: 임시 비밀번호 응답 반환 시 캐시/로그 차단 필요

평문 비밀번호가 프록시/브라우저/서버 로그에 남을 수 있습니다. 최소한 응답 캐시 비활성화와 응답/요청 로깅 마스킹을 적용하세요. 장기적으로는 메일/SMS 전송으로 전환 권장.

패치 예시(캐시 금지 헤더):

     public ResponseEntity<?> reset(@CurrentUser CustomUserDetails user,
                                    HttpServletResponse response) {
         TempPasswordResponse tempPwdResponse = userCommandService.reset(user.getUserId());

         ResponseCookie cookie = ResponseCookie.from("refresh_token", "")
                 .path("/")
                 .maxAge(0)
                 .httpOnly(true)
                 .sameSite("None")
                 .secure(true)
                 .build();
         response.addHeader("Set-Cookie", cookie.toString());
+        response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
+        response.setHeader("Pragma", "no-cache");
         return Response.ok(tempPwdResponse);
     }

또한, 액세스 토큰 무효화 전략(토큰 버전, 블랙리스트, 짧은 TTL)도 병행 검토 바랍니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@PostMapping("/reset")
@Operation(summary = "사용자 비밀번호 초기화", description = "사용자 비밀번호를 초기화합니다.")
@PreAuthorize("isAuthenticated()")
@SecurityRequirement(name = "BearerAuth")
public ResponseEntity<?> reset(@CurrentUser CustomUserDetails user,
HttpServletResponse response) {
TempPasswordResponse tempPwdResponse = userCommandService.reset(user.getUserId());
ResponseCookie cookie = ResponseCookie.from("refresh_token", "")
.path("/")
.maxAge(0)
.httpOnly(true)
.sameSite("None")
.secure(true)
.build();
response.addHeader("Set-Cookie", cookie.toString());
return Response.ok(tempPwdResponse);
}
@PostMapping("/reset")
@Operation(summary = "사용자 비밀번호 초기화", description = "사용자 비밀번호를 초기화합니다.")
@PreAuthorize("isAuthenticated()")
@SecurityRequirement(name = "BearerAuth")
public ResponseEntity<?> reset(@CurrentUser CustomUserDetails user,
HttpServletResponse response) {
TempPasswordResponse tempPwdResponse = userCommandService.reset(user.getUserId());
ResponseCookie cookie = ResponseCookie.from("refresh_token", "")
.path("/")
.maxAge(0)
.httpOnly(true)
.sameSite("None")
.secure(true)
.build();
response.addHeader("Set-Cookie", cookie.toString());
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
response.setHeader("Pragma", "no-cache");
return Response.ok(tempPwdResponse);
}


@PostMapping("/find/club")
@Operation(summary = "사용자 동아리 조회", description = "사용자의 동아리를 조회합니다.")
@PreAuthorize("isAuthenticated()")
Expand Down
5 changes: 4 additions & 1 deletion backend/src/main/java/moadong/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,12 @@ public String getUsername() {
}

public void updateUserProfile(UserUpdateRequest userUpdateRequest) {
this.userId = userUpdateRequest.userId();
this.password = userUpdateRequest.password();
}

public void resetPassword(String encodedPassword) { //초기화된 비밀번호 업데이트
this.password = encodedPassword;
}
Comment on lines +80 to +82
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

비밀번호 초기화 시 보안 후속처리 누락

resetPassword 호출 시점 기록과 세션 토큰 무효화를 엔티티/도메인 이벤트로 일관되게 다루면 안전합니다(토큰 버전 증가 또는 passwordChangedAt 비교).

예시 스니펫(참고):

+    private Date passwordChangedAt;
...
     public void resetPassword(String encodedPassword) { //초기화된 비밀번호 업데이트
         this.password = encodedPassword;
+        this.passwordChangedAt = new Date();
+        this.refreshToken = null;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void resetPassword(String encodedPassword) { //초기화된 비밀번호 업데이트
this.password = encodedPassword;
}
// in User.java, add at the top of the class alongside the other fields:
private Date passwordChangedAt;
...
// update the resetPassword method:
public void resetPassword(String encodedPassword) { // 초기화된 비밀번호 업데이트
this.password = encodedPassword;
this.passwordChangedAt = new Date();
this.refreshToken = null;
}
🤖 Prompt for AI Agents
In backend/src/main/java/moadong/user/entity/User.java around lines 80–82,
resetPassword currently only assigns the new encoded password; you must also
record the password-change moment and invalidate existing sessions/tokens.
Modify resetPassword to set a passwordChangedAt timestamp (e.g., Instant.now())
and/or increment a tokenVersion field, and publish a domain event (e.g.,
UserPasswordResetEvent) so downstream services can revoke sessions; ensure this
runs in the same transaction as the password update and that JWT/session
validation logic checks passwordChangedAt/tokenVersion to reject old tokens.

public void updateRefreshToken(RefreshToken refreshToken) {
this.refreshToken = refreshToken;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,12 @@
import org.springframework.security.crypto.password.PasswordEncoder;

public record UserUpdateRequest(
@NotNull
@UserId
String userId,
@NotNull
@Password
String password
) {
public UserUpdateRequest encryptPassword(PasswordEncoder passwordEncoder){
return new UserUpdateRequest(userId, passwordEncoder.encode(this.password));
return new UserUpdateRequest(passwordEncoder.encode(this.password));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package moadong.user.payload.response;

import jakarta.validation.constraints.NotNull;
import moadong.global.annotation.Password;

public record TempPasswordResponse(
@NotNull
@Password
String tempPassword
){ }
21 changes: 21 additions & 0 deletions backend/src/main/java/moadong/user/service/UserCommandService.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import moadong.global.exception.ErrorCode;
import moadong.global.exception.RestApiException;
import moadong.global.util.JwtProvider;
import moadong.global.util.SecurePasswordGenerator;
import moadong.user.entity.RefreshToken;
import moadong.user.entity.User;
import moadong.user.payload.CustomUserDetails;
Expand All @@ -17,6 +18,7 @@
import moadong.user.payload.request.UserUpdateRequest;
import moadong.user.payload.response.LoginResponse;
import moadong.user.payload.response.RefreshResponse;
import moadong.user.payload.response.TempPasswordResponse;
import moadong.user.repository.UserRepository;
import moadong.user.util.CookieMaker;
import org.springframework.http.ResponseCookie;
Expand All @@ -36,6 +38,7 @@ public class UserCommandService {
private final PasswordEncoder passwordEncoder;
private final ClubRepository clubRepository;
private final CookieMaker cookieMaker;
private final SecurePasswordGenerator securePasswordGenerator;

public User registerUser(UserRegisterRequest userRegisterRequest) {
try {
Expand Down Expand Up @@ -120,6 +123,23 @@ public void update(String userId,
response.addHeader("Set-Cookie", cookie.toString());
}

public TempPasswordResponse reset(String userId) {
User user = userRepository.findUserByUserId(userId)
.orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_EXIST));

//랜덤 임시 비밀번호 생성
TempPasswordResponse tempPwdResponse = new TempPasswordResponse(
securePasswordGenerator.generate(8));

Comment on lines +131 to +133
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

❓ Verification inconclusive

임시 비밀번호 길이 8 → 최소 16자로 상수/설정화하세요.
매직넘버 제거 및 구성 가능하게 하여 보안 정책을 코드 수정 없이 바꿀 수 있게 하는 것이 좋습니다.

아래 diff로 생성 길이를 상수로 치환하세요:

-        TempPasswordResponse tempPwdResponse = new TempPasswordResponse(
-                securePasswordGenerator.generate(8));
+        TempPasswordResponse tempPwdResponse = new TempPasswordResponse(
+                securePasswordGenerator.generate(TEMP_PASSWORD_LENGTH));

그리고 클래스 상단에 상수 추가:

// 클래스 필드 영역
private static final int TEMP_PASSWORD_LENGTH = 16; // 정책에 따라 16~20 권장

추가로, 생성기가 대/소문자+숫자+기호를 모두 포함하도록 보장하는지 점검 바랍니다.


임시 비밀번호 길이 매직 넘버 제거 및 상수화
securePasswordGenerator.generate(8)의 8을 private static final int TEMP_PASSWORD_LENGTH 또는 외부 설정(application.yml 등)으로 분리하고 기본값을 16 이상으로 지정하세요.
생성 로직이 대문자·소문자·숫자·특수문자를 모두 포함하도록 구현되어 있는지도 검증 바랍니다.

//암호화
user.resetPassword(passwordEncoder.encode(tempPwdResponse.tempPassword()));

user.updateRefreshToken(null);
userRepository.save(user);

Comment on lines +135 to +139
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

비밀번호 재설정 시 기존 Access Token 무효화 전략 필요
refresh 토큰만 null 처리하면 유출된 Access Token은 만료 시점까지 유효합니다. User에 passwordChangedAt(또는 tokenVersion)을 기록하고, JWT 검증 시 이전에 발급된 토큰을 거부하도록 하는 방식을 권장합니다.

원하시면 User 필드/필터/JWT 클레임 변경까지 포함한 패치안을 드리겠습니다.

return tempPwdResponse;
}

Comment on lines +126 to +142
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

❓ Verification inconclusive

임시 비밀번호를 응답 본문으로 반환하는 것은 고위험 보안 이슈입니다.

  • 즉시: 응답/액세스 로그에서 본문 마스킹, 캐시 방지 헤더(no-store) 적용, APM/디버그 로거에 노출 금지.
  • 근본: API 응답으로 비밀번호 전달을 제거하고, 일회성 리셋 토큰+아웃오브밴드(이메일/SMS)로 전환 권장.

원하시면 컨트롤러/필터 레벨 마스킹 및 헤더 설정 패치를 제안하겠습니다.


임시 비밀번호 평문 반환 차단

  • 서비스(UserCommandService.reset)에서 TempPasswordResponse에 평문 비밀번호를 포함하지 않도록 삭제
  • 로그·액세스 기록 마스킹, Cache-Control: no-store 헤더 적용, APM/디버거 노출 방지
  • 근본 대책: API 응답 대신 이메일/SMS 일회성 리셋 토큰 방식으로 전환
🤖 Prompt for AI Agents
In backend/src/main/java/moadong/user/service/UserCommandService.java around
lines 126 to 142, the method currently generates a plaintext temporary password,
places it in TempPasswordResponse and returns it — remove returning or storing
the plaintext in any API response or logs; instead generate a one-time reset
token (or store only a hashed temporary credential) and send the reset
link/token via email/SMS, clear any temp secrets from memory immediately, ensure
no plaintext is written to logs or caches, add Cache-Control: no-store on the
endpoint response, and update callers/tests to expect a token-sent
acknowledgement (not the password) while persisting only hashed values or token
metadata server-side with expiry.

public String findClubIdByUserId(String userID) {
Club club = clubRepository.findClubByUserId(userID)
.orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
Expand All @@ -130,4 +150,5 @@ private void createClub(String userId) {
Club club = new Club(userId);
clubRepository.save(club);
}

}
Loading