Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ dependencies {
testAnnotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.awaitility:awaitility:4.2.0'

// Mail
implementation 'org.springframework.boot:spring-boot-starter-mail'

// Etc
implementation platform('software.amazon.awssdk:bom:2.41.4')
implementation 'software.amazon.awssdk:s3'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
public record AdminHomeUniversityCreateRequest(
@NotBlank(message = "협정 대학명은 필수입니다")
@Size(max = 100, message = "협정 대학명은 100자 이하여야 합니다")
String name
String name,
@Size(max = 100, message = "이메일 도메인은 100자 이하여야 합니다")
String emailDomain
) {

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

public record AdminHomeUniversityResponse(
long id,
String name
String name,
String emailDomain
) {

public static AdminHomeUniversityResponse from(HomeUniversity homeUniversity) {
return new AdminHomeUniversityResponse(
homeUniversity.getId(),
homeUniversity.getName()
homeUniversity.getName(),
homeUniversity.getEmailDomain()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
public record AdminHomeUniversityUpdateRequest(
@NotBlank(message = "협정 대학명은 필수입니다")
@Size(max = 100, message = "협정 대학명은 100자 이하여야 합니다")
String name
String name,
@Size(max = 100, message = "이메일 도메인은 100자 이하여야 합니다")
String emailDomain
) {

}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public AdminHomeUniversityResponse getHomeUniversity(Long id) {
)
public AdminHomeUniversityResponse createHomeUniversity(AdminHomeUniversityCreateRequest request) {
validateNameNotExists(request.name());
HomeUniversity homeUniversity = new HomeUniversity(null, request.name());
HomeUniversity homeUniversity = new HomeUniversity(null, request.name(), request.emailDomain());
return AdminHomeUniversityResponse.from(homeUniversityRepository.save(homeUniversity));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand All @@ -69,7 +69,7 @@ public AdminHomeUniversityResponse updateHomeUniversity(Long id, AdminHomeUniver
HomeUniversity homeUniversity = homeUniversityRepository.findById(id)
.orElseThrow(() -> new CustomException(HOME_UNIVERSITY_NOT_FOUND));
validateNameNotDuplicated(request.name(), id);
homeUniversity.update(request.name());
homeUniversity.update(request.name(), request.emailDomain());
return AdminHomeUniversityResponse.from(homeUniversity);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ public enum ErrorCode {
SIGN_IN_FAILED(HttpStatus.UNAUTHORIZED.value(), "로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요."),
OAUTH_USER_CANNOT_CHANGE_PASSWORD(HttpStatus.BAD_REQUEST.value(), "소셜 로그인 사용자는 비밀번호를 변경할 수 없습니다."),

// school email verification
SCHOOL_EMAIL_ALREADY_VERIFIED(HttpStatus.BAD_REQUEST.value(), "이미 학교 이메일 인증이 완료되었습니다."),
SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 학교 이메일 도메인입니다."),
SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "학교 이메일 인증 요청을 찾을 수 없습니다. 인증 코드 발송을 다시 요청해주세요."),
SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT(HttpStatus.BAD_REQUEST.value(), "인증 코드가 일치하지 않습니다."),
SCHOOL_EMAIL_VERIFICATION_INFO_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "학교 이메일 인증 정보 저장에 실패했습니다."),
SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "학교 이메일 인증 정보가 손상되었습니다. 인증 코드 발송을 다시 요청해주세요."),

// s3
S3_SERVICE_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 서비스 에러 발생"),
S3_CLIENT_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 클라이언트 에러 발생"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.solidconnection.common.mail;

import lombok.RequiredArgsConstructor;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class MailService {

private final JavaMailSender javaMailSender;

public void sendVerificationEmail(String to, String verificationCode) {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(to);
message.setSubject("[Solid Connect] 학교 이메일 인증");
message.setText("인증 코드: " + verificationCode + "\n\n인증 코드는 5분간 유효합니다.");
javaMailSender.send(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
import com.example.solidconnection.siteuser.dto.LocationUpdateRequest;
import com.example.solidconnection.siteuser.dto.MyPageResponse;
import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest;
import com.example.solidconnection.siteuser.dto.SchoolEmailConfirmRequest;
import com.example.solidconnection.siteuser.dto.SchoolEmailRequest;
import com.example.solidconnection.siteuser.service.MyPageService;
import com.example.solidconnection.siteuser.service.SchoolEmailService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
Expand All @@ -23,6 +27,7 @@
class MyPageController {

private final MyPageService myPageService;
private final SchoolEmailService schoolEmailService;

@GetMapping
public ResponseEntity<MyPageResponse> getMyPageInfo(
Expand Down Expand Up @@ -59,4 +64,22 @@ public ResponseEntity<Void> updateLocation(
myPageService.updateLocation(siteUserId, request);
return ResponseEntity.ok().build();
}

@PostMapping("/school-email")
public ResponseEntity<Void> requestSchoolEmailVerification(
@AuthorizedUser long siteUserId,
@RequestBody @Valid SchoolEmailRequest request
) {
schoolEmailService.requestSchoolEmailVerification(siteUserId, request.schoolEmail());
return ResponseEntity.ok().build();
}

@PostMapping("/school-email/confirm")
public ResponseEntity<Void> confirmSchoolEmail(
@AuthorizedUser long siteUserId,
@RequestBody @Valid SchoolEmailConfirmRequest request
) {
schoolEmailService.confirmSchoolEmail(siteUserId, request.code());
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ public void updateUserStatus(UserStatus status) {
this.userStatus = status;
}

public void verifySchool(Long homeUniversityId) {
this.homeUniversityId = homeUniversityId;
}

public void becomeMentor() {
this.role = Role.MENTOR;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.solidconnection.siteuser.dto;

import jakarta.validation.constraints.NotBlank;

public record SchoolEmailConfirmRequest(
@NotBlank(message = "인증 코드는 필수입니다")
String code
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.solidconnection.siteuser.dto;

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

public record SchoolEmailRequest(
@NotBlank(message = "학교 이메일은 필수입니다")
@Email(message = "올바른 이메일 형식이 아닙니다")
String schoolEmail
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.example.solidconnection.siteuser.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class SchoolVerificationInfo {

private String schoolEmail;
private Long homeUniversityId;
private String code;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package com.example.solidconnection.siteuser.service;

import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_ALREADY_VERIFIED;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_VERIFICATION_INFO_SAVE_FAILED;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED;
import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND;

import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.common.mail.MailService;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.dto.SchoolVerificationInfo;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import com.example.solidconnection.university.domain.HomeUniversity;
import com.example.solidconnection.university.repository.HomeUniversityRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

미사용 import문이라 없어도 될 거 같습니다 ~!

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class SchoolEmailService {

private static final long VERIFICATION_CODE_TTL_SECONDS = 300;
private static final String KEY_PREFIX = "school-email:";

private final SiteUserRepository siteUserRepository;
private final HomeUniversityRepository homeUniversityRepository;
private final MailService mailService;
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;

@Transactional
public void requestSchoolEmailVerification(long siteUserId, String schoolEmail) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

트랜잭션 어노테이션 달아주세요 !

SiteUser siteUser = siteUserRepository.findById(siteUserId)
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));

if (siteUser.getHomeUniversityId() != null) {
throw new CustomException(SCHOOL_EMAIL_ALREADY_VERIFIED);
}

String domain = extractEmailDomain(schoolEmail);
HomeUniversity homeUniversity = homeUniversityRepository.findByEmailDomain(domain)
.orElseThrow(() -> new CustomException(SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED));

String code = generateVerificationCode();
saveVerificationInfo(siteUserId, new SchoolVerificationInfo(schoolEmail, homeUniversity.getId(), code));

mailService.sendVerificationEmail(schoolEmail, code);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Transactional
public void confirmSchoolEmail(long siteUserId, String code) {
SiteUser siteUser = siteUserRepository.findById(siteUserId)
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));

SchoolVerificationInfo info = getVerificationInfo(siteUserId);

if (!info.getCode().equals(code)) {
throw new CustomException(SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT);
}

siteUser.verifySchool(info.getHomeUniversityId());
redisTemplate.delete(KEY_PREFIX + siteUserId);
}

private void saveVerificationInfo(long siteUserId, SchoolVerificationInfo info) {
try {
redisTemplate.opsForValue().set(
KEY_PREFIX + siteUserId,
objectMapper.writeValueAsString(info),
VERIFICATION_CODE_TTL_SECONDS,
TimeUnit.SECONDS
);
} catch (JsonProcessingException e) {
throw new CustomException(SCHOOL_EMAIL_VERIFICATION_INFO_SAVE_FAILED);
}
}

private SchoolVerificationInfo getVerificationInfo(long siteUserId) {
String jsonInfo = redisTemplate.opsForValue().get(KEY_PREFIX + siteUserId);
if (jsonInfo == null) {
throw new CustomException(SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND);
}
try {
return objectMapper.readValue(jsonInfo, SchoolVerificationInfo.class);
} catch (JsonProcessingException e) {
redisTemplate.delete(KEY_PREFIX + siteUserId);
throw new CustomException(SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED);
}
}

private String extractEmailDomain(String email) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

DTO에서 이미 @Email로 검증되지 않나요 ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

중복 검증이네요 바로 substring으로 잘라도 무방할것같습니다 수정하겠음돠

int atIndex = email.indexOf('@');
if (atIndex == -1) {
throw new CustomException(SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED);
}
return email.substring(atIndex + 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize email domains before lookup

When a user submits a valid school email with uppercase letters in the domain (for example student@INHA.EDU), validation still accepts it, but this exact substring is used for findByEmailDomain while the stored domains are lowercase (inha.edu, inu.ac.kr, etc.). Because email domains are case-insensitive, supported users can be incorrectly rejected as SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED; lower-case the extracted domain before the repository lookup.

Useful? React with 👍 / 👎.

}
Comment on lines +105 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

이메일 형식 검증 강화 필요

현재 @ 문자 존재 여부만 확인하고 있어, @inha.edu(로컬 파트 없음)나 test@(도메인 없음) 같은 잘못된 형식이 통과될 수 있습니다. 또한 103-104번 라인에서 @가 없을 때 SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED 에러를 던지는데, 이는 형식 오류이지 도메인 미지원 문제가 아니므로 에러 코드가 오해를 불러일으킬 수 있습니다.

개선 방안:

  1. @ 전후에 실제 텍스트가 있는지 확인 (atIndex > 0 && atIndex < email.length() - 1)
  2. 필요시 정규식 또는 Jakarta Validation의 @Email 어노테이션 활용
  3. 형식 오류용 별도 에러 코드 추가 고려 (예: SCHOOL_EMAIL_INVALID_FORMAT)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/example/solidconnection/siteuser/service/SchoolEmailService.java`
around lines 101 - 107, The extractEmailDomain method currently only checks for
presence of '@' and can accept invalid emails like "`@domain`" or "local@"; update
extractEmailDomain to validate both sides of '@' (ensure atIndex > 0 && atIndex
< email.length() - 1) and throw a distinct format error (e.g.,
SCHOOL_EMAIL_INVALID_FORMAT) when that check fails instead of
SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED; optionally mention replacing or supplementing
this simple check with a proper regex or Jakarta `@Email` validation in the
calling flow for stricter validation.


private String generateVerificationCode() {
return String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ public class HomeUniversity extends BaseEntity {
@Column(name = "name", nullable = false, unique = true, length = 100)
private String name;

public void update(String name) {
@Column(name = "email_domain", unique = true, length = 100)
private String emailDomain;

public void update(String name, String emailDomain) {
this.name = name;
this.emailDomain = emailDomain;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ public interface HomeUniversityRepository extends JpaRepository<HomeUniversity,
List<HomeUniversity> findAllByIdIn(List<Long> ids);

Optional<HomeUniversity> findByName(String name);

Optional<HomeUniversity> findByEmailDomain(String emailDomain);
}
16 changes: 14 additions & 2 deletions src/main/resources/data.sql
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,20 @@ VALUES ('test@test.email', 'yonso', 'https://github.com/nayonsoso.png',
'CONSIDERING', 'MENTEE',
'$2a$10$psmwlxPfqWnIlq9JrlQJkuXr1XtjRNsyVOgcTWYZub5jFfn0TML76', 'EMAIL'); -- 12341234

INSERT INTO home_university (id, name)
VALUES (1, '인하대학교');
INSERT INTO home_university (id, name, email_domain)
VALUES (1, '인하대학교','inha.edu');

INSERT INTO home_university (id, name, email_domain)
VALUES (2, '경희대학교','khu.ac.kr');

INSERT INTO home_university (id, name, email_domain)
VALUES (3, '중앙대학교','cau.ac.kr');

INSERT INTO home_university (id, name, email_domain)
VALUES (4, '성신여자대학교','sungshin.ac.kr');

INSERT INTO home_university (id, name, email_domain)
VALUES (5, '인천대학교','inu.ac.kr');

INSERT INTO host_university(id, country_code, region_code, english_name, format_name, korean_name,
accommodation_url, english_course_url, homepage_url,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE home_university
ADD COLUMN email_domain VARCHAR(100) NULL UNIQUE;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Backfill email domains in the migration

In the dev/prod profiles I inspected (application-db.yml), Flyway runs but data.sql is not initialized, so this migration leaves all existing home_university rows with email_domain = NULL. The new verification path then calls findByEmailDomain(...), so after deployment supported existing schools such as Inha/Incheon will be rejected as unsupported unless someone manually edits every row; include the domain UPDATEs in the Flyway migration rather than only in data.sql.

Useful? React with 👍 / 👎.

Loading
Loading