Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Binary file modified .DS_Store
Binary file not shown.
Binary file modified src/.DS_Store
Binary file not shown.
Binary file modified src/main/.DS_Store
Binary file not shown.
Binary file modified src/main/java/.DS_Store
Binary file not shown.
Binary file modified src/main/java/org/.DS_Store
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.ezcode.codetest.application.usermanagement.user.service;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
Expand Down Expand Up @@ -56,14 +57,18 @@ public class UserService {
private final RedisTemplate<String, String> redisTemplate;
private final S3Uploader s3Uploader;

@Transactional(readOnly = true)
@Transactional
public UserInfoResponse getUserInfo(AuthUser authUser) {
log.info("authUserEmail: {}, authUserID : {}", authUser.getEmail(), authUser.getId());
User user = userDomainService.getUserById(authUser.getId());
int userSubmissionCount = submissionDomainService.findSubmissionCountByUserId(user.getId());
List<UserAuthType> userAuthTypes = userDomainService.getUserAuthTypesByUser(user);
List<AuthType> authTypes = userAuthTypes.stream()
.map(UserAuthType::getAuthType).toList();
if (user.getLanguage() == null) {
Language userLanguage = languageDomainService.getLanguage(1L);
user.setLanguage(userLanguage);
}

return UserInfoResponse.builder()
.username(user.getUsername())
Expand All @@ -87,6 +92,12 @@ public UserInfoResponse getUserInfo(AuthUser authUser) {
public UserInfoResponse modifyUserInfo(AuthUser authUser, ModifyUserInfoRequest request, MultipartFile image) {
User user = userDomainService.getUserById(authUser.getId());
Language findLangauge = languageDomainService.getLanguage(request.languageId());
if (request.nickname() != null && !request.nickname().equals(user.getNickname())) {
if (userDomainService.existsByNickname(request.nickname())) {
log.info("중복 닉네임");
throw new UserException(UserExceptionCode.ALREADY_EXIST_NICKNAME);
}
}
Comment on lines +95 to +100
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

닉네임 중복 선검사만으로는 부족 — DB 유니크 제약과 예외 매핑 병행 필요

현재 선검사로는 경쟁 상태를 막지 못합니다. 유니크 제약 추가 후 DataIntegrityViolationExceptionALREADY_EXIST_NICKNAME으로 매핑하는 방식을 병행하세요. 또한 공백/대소문자 정규화도 고려 바랍니다.

정규화 예시:

-        if (request.nickname() != null && !request.nickname().equals(user.getNickname())) {
-            if (userDomainService.existsByNickname(request.nickname())) {
+        if (request.nickname() != null) {
+            String newNickname = request.nickname().trim();
+            if (!newNickname.equals(user.getNickname()) && userDomainService.existsByNickname(newNickname)) {
                 log.info("중복 닉네임");
                 throw new UserException(UserExceptionCode.ALREADY_EXIST_NICKNAME);
             }
         }
🤖 Prompt for AI Agents
In
src/main/java/org/ezcode/codetest/application/usermanagement/user/service/UserService.java
around lines 95-100, the current pre-check for duplicate nicknames is
insufficient for race conditions; add DB-level unique constraint on the nickname
column (entity and migration) and keep the pre-check, but also catch
DataIntegrityViolationException (or the specific persistence exception your
stack throws) where the user is saved and map it to new
UserException(UserExceptionCode.ALREADY_EXIST_NICKNAME). Additionally normalize
nicknames (trim and a consistent case, e.g., toLowerCase()) before checking and
persisting so checks and DB values are consistent. Ensure your
global/transactional save path wraps persistence exceptions to translate them to
the ALREADY_EXIST_NICKNAME business exception.


user.modifyUserInfo(
request.nickname(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
package org.ezcode.codetest.domain.submission.dto;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;

public record DailyCorrectCount(
LocalDate date,
int count
int count,
List<Long> problemIds
) {
public DailyCorrectCount(java.sql.Date date, int count) {
this(date.toLocalDate(), count);

public DailyCorrectCount(java.sql.Date date, Set<Long> problemIds) {
this(
date.toLocalDate(),
problemIds.size(),
new ArrayList<>(problemIds)
);
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

가변 리스트 노출 및 null 안전성 개선

  • new ArrayList<>(problemIds)는 외부에서 변경 가능해 레코드 불변성이 깨질 수 있습니다.
  • null 입력 시 NPE 위험이 있습니다.
  • 순서를 보장하려면 정렬을 명시하세요(클라이언트 일관성 향상).
-    public DailyCorrectCount(java.sql.Date date, Set<Long> problemIds) {
-        this(
-            date.toLocalDate(),
-            problemIds.size(),
-            new ArrayList<>(problemIds)
-        );
-    }
+    public DailyCorrectCount(java.sql.Date date, Set<Long> problemIds) {
+        var ids = (problemIds == null)
+            ? java.util.List.<Long>of()
+            : problemIds.stream().sorted().toList(); // 정렬 + 불변
+        this(date.toLocalDate(), 0, ids); // count는 카논 생성자에서 재계산
+    }

또한, ArrayList import는 불필요해집니다.

-import java.util.ArrayList;

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
src/main/java/org/ezcode/codetest/domain/submission/dto/DailyCorrectCount.java
around lines 14-19, the constructor currently does new ArrayList<>(problemIds)
which exposes a mutable list, can NPE on null input, and doesn't guarantee
order; change it to defensively create an immutable, sorted list from problemIds
(treat null as empty), set the count from the safe collection (or 0 if null),
and remove the unused ArrayList import; in short: null-check problemIds, produce
a deterministically sorted copy, wrap as an unmodifiable/immutable List, and use
that list for the record field and size.

}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.ezcode.codetest.domain.submission.service;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
Expand Down Expand Up @@ -120,4 +121,5 @@ private void modifyUserProblemResult(UserProblemResult userProblemResult, boolea
public int findSubmissionCountByUserId(Long userId) {
return submissionRepository.findSubmissionCountByUserId(userId);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ public enum UserExceptionCode implements ResponseCode {
NO_GITHUB_INFO(false, HttpStatus.BAD_REQUEST, "깃허브 정보가 없습니다."),
NO_GITHUB_REPO(false, HttpStatus.BAD_REQUEST, "해당하는 Repository를 찾을 수 없습니다."),


;
ALREADY_EXIST_NICKNAME(false, HttpStatus.BAD_REQUEST, "이미 존재하는 닉네임입니다");
private final boolean success;
private final HttpStatus status;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,8 @@ public void modifyProfileImage(String profileImageUrl) {
public void modifyUserRole(UserRole userRole) {
this.role = userRole;
}

public void setLanguage(Language userLanguage) {
this.language = userLanguage;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ public interface UserRepository {

void updateUserGithubAccessToken(User loginUser);

List<String> getUserNicknames();
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public MimeMessage CreateButtonMail(Long userId, String email, String redirectUr
String body = "";
body += "<h3>" + "아래 버튼을 클릭하여 이메일 인증을 완료해 주세요" + "</h3>";
// 이메일 버튼
body += "<a href='" + redirectUrl + "/api/auth/verify?email="+ email + "&key=" + key + "' target='_blenk'>이메일 인증 확인</a>";
body += "<a href='" + redirectUrl + "/api/auth/verify-page?email="+ email + "&key=" + key + "' target='_blenk'>이메일 인증 확인</a>";
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

사용자 입력 redirectUrl을 링크 호스트로 사용 → 인증코드 유출/피싱 위험 + URL 인코딩/타겟 오타 수정 필요

클라이언트가 제공하는 redirectUrl을 그대로 메일 링크의 호스트로 사용하면 공격자가 자신의 도메인으로 email/key를 수집할 수 있습니다. 서버가 신뢰하는 백엔드 퍼블릭 베이스 URL을 설정에서 주입받아 사용하고, 쿼리 파라미터는 안전하게 인코딩하세요. 또한 target='_blenk' 오타를 '_blank'로 수정해야 합니다.

적용 제안(diff):

@@
-import org.springframework.beans.factory.annotation.Value;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.util.UriComponentsBuilder;
@@
 	@Value("${spring.mail.username}")
 	private String senderEmail;
 
+	// 메일 링크의 호스트는 사용자 입력이 아닌, 신뢰 가능한 설정값을 사용
+	@Value("${app.backend.public-base-url}")
+	private String backendPublicBaseUrl;
@@ public MimeMessage CreateButtonMail(Long userId, String email, String redirectUrl) {
-			// 이메일 버튼
-			body += "<a href='" + redirectUrl + "/api/auth/verify-page?email="+ email + "&key=" + key + "' target='_blenk'>이메일 인증 확인</a>";
+			// 안전한 URL 빌드 및 오타 수정
+			String href = UriComponentsBuilder.fromHttpUrl(backendPublicBaseUrl)
+				.path("/api/auth/verify-page")
+				.queryParam("email", email)
+				.queryParam("key", key)
+				.build()
+				.toUriString();
+			body += "<a href=\"" + href + "\" target=\"_blank\">이메일 인증 확인</a>";

비밀번호 메일에도 동일 오타가 있으므로 함께 수정 권장:

-			body += "<a href='"+redirectUrl+"/api/auth/find-password-verify?email="+ email + "&key=" + verificationCode + "' target='_blenk'>비밀번호 변경하기</a>";
+			body += "<a href='"+redirectUrl+"/api/auth/find-password-verify?email="+ email + "&key=" + verificationCode + "' target=\"_blank\">비밀번호 변경하기</a>";

검증용 스크립트:


🏁 Script executed:

#!/bin/bash
# redirectUrl 사용처 및 verify-page 링크 조립 위치 확인
rg -n -C2 '\bredirectUrl\b'
rg -n '/api/auth/verify-page'

Length of output: 7587


redirectUrl 직접 사용·쿼리 미인코딩·타겟 오타 수정

  • 클라이언트 입력 redirectUrl 대신 설정값(app.backend.public-base-url)으로 링크 호스트 고정
  • UriComponentsBuilder로 쿼리 파라미터(email, key) 안전 인코딩
  • target='_blenk'target='_blank' 오타 수정

적용 예시:

+ import org.springframework.web.util.UriComponentsBuilder;
@@ public MimeMessage CreateButtonMail(…)
- body += "<a href='" + redirectUrl + "/api/auth/verify-page?email="+ email + "&key=" + key + "' target='_blenk'>이메일 인증 확인</a>";
+ String href = UriComponentsBuilder
+     .fromHttpUrl(backendPublicBaseUrl)
+     .path("/api/auth/verify-page")
+     .queryParam("email", email)
+     .queryParam("key", key)
+     .build()
+     .toUriString();
+ body += "<a href=\"" + href + "\" target=\"_blank\">이메일 인증 확인</a>";

CreatePasswordMail에도 동일한 target 오타 수정 권장.

📝 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
body += "<a href='" + redirectUrl + "/api/auth/verify-page?email="+ email + "&key=" + key + "' target='_blenk'>이메일 인증 확인</a>";
// add at top of file
import org.springframework.web.util.UriComponentsBuilder;
public MimeMessage CreateButtonMail(…) {
// build a safe, encoded verification link using the configured base URL
String href = UriComponentsBuilder
.fromHttpUrl(backendPublicBaseUrl)
.path("/api/auth/verify-page")
.queryParam("email", email)
.queryParam("key", key)
.build()
.toUriString();
// use the new href and correct the target typo
body += "<a href=\"" + href + "\" target=\"_blank\">이메일 인증 확인</a>";
}
🤖 Prompt for AI Agents
In src/main/java/org/ezcode/codetest/domain/user/service/MailService.java around
line 54, stop using the client-supplied redirectUrl and instead build the
verification link using the configured app.backend.public-base-url as the host;
construct the URL with Spring's UriComponentsBuilder (set the base URL, path
/api/auth/verify-page, and add email and key as query parameters so they are
percent-encoded) and use the builtUriString() result for the href, and fix the
anchor target typo from '_blenk' to '_blank'; apply the same target '_blank' fix
to CreatePasswordMail as well.

body += "<h3>" + "감사합니다." + "</h3>";
message.setText(body,"UTF-8", "html");
} catch (MessagingException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,11 @@ public List<UserAuthType> getUserAuthTypesByUser(User user) {
return userAuthTypeRepository.getUserAuthTypesByUser(user);
}

public List<String> getUserNicknames() {
return userRepository.getUserNicknames();
}

public boolean existsByNickname(String nickname) {
return userRepository.existsByNickname(nickname);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.ezcode.codetest.domain.submission.model.entity.QUserProblemResult;
import org.springframework.stereotype.Repository;

import com.querydsl.core.group.GroupBy;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.jpa.impl.JPAQueryFactory;
Expand All @@ -27,17 +28,20 @@ public List<DailyCorrectCount> countCorrectByUserGroupedByDate(Long userId) {
var date = Expressions.dateTemplate(java.sql.Date.class, "DATE({0})", upr.modifiedAt);

return queryFactory
.select(Projections.constructor(DailyCorrectCount.class,
date,
upr.count().intValue()
))
.from(upr)
.where(
upr.user.id.eq(userId),
upr.isCorrect.eq(true)
)
.groupBy(date)
.orderBy(date.asc())
.fetch();
.transform(
GroupBy.groupBy(date).list(
Projections.constructor(
DailyCorrectCount.class,
date,
GroupBy.set(upr.problem.id)
)
)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ void updateReviewTokens(
@Param("ids") List<Long> ids,
@Param("newToken") int newToken
);

@Query("select u.nickname from User u ")
List<String> findAllNicknames();
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public void updateUserGithubAccessToken(User loginUser) {
userJpaRepository.save(loginUser);
}


@Override
public List<String> getUserNicknames() {
return userJpaRepository.findAllNicknames();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.ezcode.codetest.application.usermanagement.user.service.UserService;
import org.ezcode.codetest.domain.user.model.entity.AuthUser;
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.DeleteMapping;
Expand Down Expand Up @@ -46,7 +47,7 @@ public ResponseEntity<UserInfoResponse> getUserInfo(@AuthenticationPrincipal Aut
}

@Operation(summary = "내 정보 수정", description = "닉네임, 블로그, 깃허브, 소개 등 개인 정보를 추가하거나 수정합니다.")
@PutMapping("/users")
@PutMapping(value = "/users", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<UserInfoResponse> modifyUserInfo(
@AuthenticationPrincipal AuthUser authUser,
@Valid @RequestPart("request") ModifyUserInfoRequest request,
Expand Down Expand Up @@ -90,6 +91,7 @@ public ResponseEntity<UserReviewTokenResponse> getReviewToken(
return ResponseEntity.status(HttpStatus.OK).body(userService.getReviewToken(authUser));
}

@Operation(summary = "회원의 푼 문제 수 조회", description = "날짜, 날짜마다 푼 문제 번호 리스트, 푼 문제 개수")
@GetMapping("/users/daily-solved")
public ResponseEntity<UserDailySolvedHistoryResponse> getUserDailySolvedHistory(
@AuthenticationPrincipal AuthUser authUser
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.ezcode.codetest.presentation.usermanagement;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.ezcode.codetest.application.usermanagement.auth.dto.request.FindPasswordRequest;
import org.ezcode.codetest.application.usermanagement.user.dto.request.ResetPasswordRequest;
import org.ezcode.codetest.application.usermanagement.auth.dto.request.SendEmailRequest;
Expand Down Expand Up @@ -34,6 +36,12 @@
public class UserVerifyController {
private final AuthService authService;

@Value("${app.redirect.verify.success:/}")
private String verifySuccessRedirect;

@Value("${app.redirect.verify.failure:/}")
private String verifyFailureRedirect;

@Operation(summary = "이메일 인증 코드 전송", description = "현재 로그인된 회원의 이메일로 인증 코드를 전송합니다.")
@PostMapping("/email/send")
public ResponseEntity<SendEmailResponse> sendMailCode(
Expand All @@ -53,6 +61,24 @@ public ResponseEntity<VerifyEmailCodeResponse> verifyEmailCode(
return ResponseEntity.status(HttpStatus.OK).body(authService.verifyEmailCode(email, key));
}

@Operation(summary = "이메일 코드 인증 후 페이지 리다이렉트", description = "이메일의 '인증하기' 버튼 클릭 시 성공/실패 페이지로 리다이렉트합니다.")
@GetMapping("/auth/verify-page")
public ResponseEntity<Void> verifyEmailCodeAndRedirect(
@RequestParam String email,
@RequestParam String key
){
try {
authService.verifyEmailCode(email, key);
return ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, verifySuccessRedirect)
.build();
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, verifyFailureRedirect)
.build();
}
}


//비밀번호 찾기 요청
@Operation(summary = "비밀번호 찾기 요청", description = "비밀번호를 찾기 위해 이메일로 인증코드를 전송합니다.")
Expand Down
10 changes: 9 additions & 1 deletion src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,12 @@ spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.idle-timeout=30000
spring.datasource.hikari.max-lifetime=600000
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.validation-timeout=5000
spring.datasource.hikari.validation-timeout=5000

# ========================
# App Redirects (Email Verify)
# ========================
# 인증 성공 시 이동할 페이지 URL (예: 프론트의 성공 안내 페이지)
app.redirect.verify.success=${APP_REDIRECT_VERIFY_SUCCESS:https://your-frontend.example.com/verify/success}
# 인증 실패 시 이동할 페이지 URL (예: 프론트의 실패 안내 페이지)
app.redirect.verify.failure=${APP_REDIRECT_VERIFY_FAILURE:https://your-frontend.example.com/verify/failure}
Comment on lines +164 to +170
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

리다이렉트 속성 추가는 적절. 메일 링크용 백엔드 베이스 URL 속성도 함께 정의 권장

메일 링크 호스트를 신뢰 가능한 설정에서 주입받을 수 있도록 백엔드 퍼블릭 베이스 URL 속성을 추가하세요.

 # ========================
 # App Redirects (Email Verify)
 # ========================
+# 백엔드 퍼블릭 베이스 URL (메일 인증 링크 생성 시 사용)
+app.backend.public-base-url=${APP_BACKEND_PUBLIC_BASE_URL:https://api.your-backend.example.com}
+
 # 인증 성공 시 이동할 페이지 URL (예: 프론트의 성공 안내 페이지)
 app.redirect.verify.success=${APP_REDIRECT_VERIFY_SUCCESS:https://your-frontend.example.com/verify/success}
 # 인증 실패 시 이동할 페이지 URL (예: 프론트의 실패 안내 페이지)
 app.redirect.verify.failure=${APP_REDIRECT_VERIFY_FAILURE:https://your-frontend.example.com/verify/failure}
📝 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
# ========================
# App Redirects (Email Verify)
# ========================
# 인증 성공 시 이동할 페이지 URL (예: 프론트의 성공 안내 페이지)
app.redirect.verify.success=${APP_REDIRECT_VERIFY_SUCCESS:https://your-frontend.example.com/verify/success}
# 인증 실패 시 이동할 페이지 URL (예: 프론트의 실패 안내 페이지)
app.redirect.verify.failure=${APP_REDIRECT_VERIFY_FAILURE:https://your-frontend.example.com/verify/failure}
# ========================
# App Redirects (Email Verify)
# ========================
# 백엔드 퍼블릭 베이스 URL (메일 인증 링크 생성 시 사용)
app.backend.public-base-url=${APP_BACKEND_PUBLIC_BASE_URL:https://api.your-backend.example.com}
# 인증 성공 시 이동할 페이지 URL (예: 프론트의 성공 안내 페이지)
app.redirect.verify.success=${APP_REDIRECT_VERIFY_SUCCESS:https://your-frontend.example.com/verify/success}
# 인증 실패 시 이동할 페이지 URL (예: 프론트의 실패 안내 페이지)
app.redirect.verify.failure=${APP_REDIRECT_VERIFY_FAILURE:https://your-frontend.example.com/verify/failure}
🤖 Prompt for AI Agents
In src/main/resources/application.properties around lines 164 to 170, you
currently define frontend redirect URLs for email verification but lack a
configurable backend/public base URL for constructing mail links; add a new
property (e.g. app.public.base-url with an environment-backed default like
${APP_PUBLIC_BASE_URL:https://your-backend.example.com}) adjacent to the
redirect entries so the mailer can build absolute links using that value instead
of hardcoding hostnames; ensure the property key is documented with a brief
comment and referenced by the email-generation code to compose verification
links.