Repository navigation
[FLYW-210] 번호인증 구현 #258
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "FLYW-210-\uBC88\uD638\uC778\uC99D-\uAD6C\uD604"
[FLYW-210] 번호인증 구현 #258
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package com.flyway.sender.controller; | ||
|
|
||
| import com.flyway.sender.service.SmsVerificationService; | ||
| import com.flyway.template.common.ApiResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.*; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/sms") | ||
| @RequiredArgsConstructor | ||
| public class SmsVerificationApiController { | ||
|
|
||
| private final SmsVerificationService service; | ||
|
|
||
| @PostMapping("/send") | ||
| public ResponseEntity<ApiResponse<Void>> send(@RequestParam String phoneNumber) { | ||
| try { | ||
| service.sendCode(phoneNumber); | ||
| return ResponseEntity.ok(ApiResponse.success(null, "인증번호가 발송되었습니다.")); | ||
| } catch (Exception e) { | ||
| return ResponseEntity.badRequest() | ||
| .body(ApiResponse.error("FAIL", e.getMessage())); | ||
| } | ||
| } | ||
|
|
||
| @PostMapping("/verify") | ||
| public ResponseEntity<ApiResponse<Boolean>> verify( | ||
| @RequestParam String phoneNumber, | ||
| @RequestParam String code) { | ||
| boolean ok = service.verify(phoneNumber, code); | ||
| String msg = ok ? "인증되었습니다." : "인증번호가 일치하지 않습니다."; | ||
| return ResponseEntity.ok(ApiResponse.success(ok, msg)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package com.flyway.sender.domain; | ||
|
|
||
| import lombok.*; | ||
| import java.time.LocalDateTime; | ||
|
|
||
| @Getter | ||
| @Builder | ||
| @NoArgsConstructor | ||
| @AllArgsConstructor | ||
| public class SmsVerification { | ||
|
|
||
| private String smsVerificationId; | ||
| private String phoneNumber; | ||
| private String code; | ||
| private LocalDateTime expiresAt; | ||
| private LocalDateTime verifiedAt; | ||
| private LocalDateTime createdAt; | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package com.flyway.sender.mapper; | ||
|
|
||
| import com.flyway.sender.domain.SmsVerification; | ||
| import org.apache.ibatis.annotations.Mapper; | ||
| import org.apache.ibatis.annotations.Param; | ||
| import java.time.LocalDateTime; | ||
|
|
||
| @Mapper | ||
| public interface SmsVerificationMapper { | ||
| void insert(SmsVerification verification); | ||
|
|
||
| SmsVerification findLatestByPhone(@Param("phoneNumber") String phoneNumber); | ||
|
|
||
| int markVerified(@Param("smsVerificationId") String id, | ||
| @Param("verifiedAt") LocalDateTime verifiedAt); | ||
|
|
||
| int countRecentByPhone(@Param("phoneNumber") String phoneNumber, | ||
| @Param("since") LocalDateTime since); | ||
|
|
||
| // 인증 완료 여부 확인 (5분 내) | ||
| int existsVerifiedPhone(@Param("phoneNumber") String phoneNumber, | ||
| @Param("since") LocalDateTime since); | ||
|
Comment on lines
+20
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 주석과 실제 사용이 불일치 주석에는 "5분 내"라고 되어 있지만, 📝 수정 제안- // 인증 완료 여부 확인 (5분 내)
+ // 인증 완료 여부 확인 (since 시간 이후)
int existsVerifiedPhone(`@Param`("phoneNumber") String phoneNumber,
`@Param`("since") LocalDateTime since);🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -79,14 +79,21 @@ public void sendRefundComplete(String to, String reservationId, Long amount) { | |||||||||||||||||||||||||||||||||||||||||||
| sendSms(to, content); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // 탑승객에게 티켓 정보 SMS 발송 (reservationId만 받음) | ||||||||||||||||||||||||||||||||||||||||||||
| // 탑승객 티켓 정보를 예약자(회원) 번호로 SMS 발송 | ||||||||||||||||||||||||||||||||||||||||||||
| public void sendTicketInfoToPassengers(String reservationId) { | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // 항공편 정보 조회 | ||||||||||||||||||||||||||||||||||||||||||||
| // 1. 예약자(회원)의 전화번호 조회 | ||||||||||||||||||||||||||||||||||||||||||||
| String userPhone = smsMapper.selectPhoneByReservationId(reservationId); | ||||||||||||||||||||||||||||||||||||||||||||
| if (userPhone == null || userPhone.isEmpty()) { | ||||||||||||||||||||||||||||||||||||||||||||
| log.info("[SMS] 티켓 발송 실패 - 회원 전화번호 없음, reservationId: {}", reservationId); | ||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // 2. 항공편 정보 조회 | ||||||||||||||||||||||||||||||||||||||||||||
| List<ReservationSegmentView> segments = | ||||||||||||||||||||||||||||||||||||||||||||
| reservationBookingRepository.findSegments(reservationId); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // 탑승객별 티켓 정보 조회 (좌석/기내식/수하물 포함) | ||||||||||||||||||||||||||||||||||||||||||||
| // 3. 탑승객별 티켓 정보 조회 (좌석/기내식/수하물 포함) | ||||||||||||||||||||||||||||||||||||||||||||
| List<PassengerTicketInfo> ticketInfoList = | ||||||||||||||||||||||||||||||||||||||||||||
| smsMapper.selectPassengerTicketInfo(reservationId); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -95,19 +102,20 @@ public void sendTicketInfoToPassengers(String reservationId) { | |||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // passengerId별로 그룹핑 | ||||||||||||||||||||||||||||||||||||||||||||
| // 4. passengerId별로 그룹핑 | ||||||||||||||||||||||||||||||||||||||||||||
| Map<String, List<PassengerTicketInfo>> passengerMap = ticketInfoList.stream() | ||||||||||||||||||||||||||||||||||||||||||||
| .collect(Collectors.groupingBy(PassengerTicketInfo::getPassengerId)); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // 5. 각 탑승객 정보를 회원 번호로 발송 | ||||||||||||||||||||||||||||||||||||||||||||
| for (Map.Entry<String, List<PassengerTicketInfo>> entry : passengerMap.entrySet()) { | ||||||||||||||||||||||||||||||||||||||||||||
| List<PassengerTicketInfo> paxInfoList = entry.getValue(); | ||||||||||||||||||||||||||||||||||||||||||||
| PassengerTicketInfo firstInfo = paxInfoList.get(0); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||
| String content = buildTicketSmsContent(firstInfo, segments, paxInfoList); | ||||||||||||||||||||||||||||||||||||||||||||
| sendSms(firstInfo.getPhoneNumber(), content); | ||||||||||||||||||||||||||||||||||||||||||||
| log.info("[SMS] 티켓 발송 - passenger: {} {}, phone: {}", | ||||||||||||||||||||||||||||||||||||||||||||
| firstInfo.getFirstName(), firstInfo.getLastName(), firstInfo.getPhoneNumber()); | ||||||||||||||||||||||||||||||||||||||||||||
| sendSms(userPhone, content); // 회원 번호로 발송 | ||||||||||||||||||||||||||||||||||||||||||||
| log.info("[SMS] 티켓 발송 - passenger: {} {}, to userPhone: {}", | ||||||||||||||||||||||||||||||||||||||||||||
| firstInfo.getFirstName(), firstInfo.getLastName(), userPhone); | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
114
to
+118
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 전화번호 로그 마스킹 필요(PII 노출 위험) 🔒 제안 변경안- log.info("[SMS] 티켓 발송 - passenger: {} {}, to userPhone: {}",
- firstInfo.getFirstName(), firstInfo.getLastName(), userPhone);
+ log.info("[SMS] 티켓 발송 - passenger: {} {}, to userPhone: {}",
+ firstInfo.getFirstName(), firstInfo.getLastName(), maskPhone(userPhone));+ private String maskPhone(String phone) {
+ if (phone == null) return null;
+ int len = phone.length();
+ if (len <= 4) return "****";
+ return phone.substring(0, Math.min(3, len)) + "****" + phone.substring(len - 4);
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
| } catch (Exception e) { | ||||||||||||||||||||||||||||||||||||||||||||
| log.error("[SMS] 티켓 발송 실패 - passengerId: {}, error: {}", | ||||||||||||||||||||||||||||||||||||||||||||
| firstInfo.getPassengerId(), e.getMessage()); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| package com.flyway.sender.service; | ||
|
|
||
| import com.flyway.sender.domain.SmsVerification; | ||
| import com.flyway.sender.mapper.SmsVerificationMapper; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import java.util.Random; | ||
| import java.util.UUID; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class SmsVerificationService { | ||
|
|
||
| private final SmsVerificationMapper mapper; | ||
| private final SmsService smsService; | ||
|
|
||
| @Transactional | ||
| public void sendCode(String phoneNumber) { | ||
| // 5분 내 3회 제한 | ||
| int count = mapper.countRecentByPhone(phoneNumber, LocalDateTime.now().minusMinutes(5)); | ||
| if (count >= 3) { | ||
| throw new RuntimeException("잠시 후 다시 시도해주세요."); | ||
| } | ||
|
|
||
| String code = String.format("%06d", new Random().nextInt(1000000)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
인증 코드 생성에 🔒 수정 제안 import java.util.Random;
+import java.security.SecureRandom;
import java.util.UUID;
`@Slf4j`
`@Service`
`@RequiredArgsConstructor`
public class SmsVerificationService {
private final SmsVerificationMapper mapper;
private final SmsService smsService;
+ private final SecureRandom secureRandom = new SecureRandom();
`@Transactional`
public void sendCode(String phoneNumber) {
// 5분 내 3회 제한
int count = mapper.countRecentByPhone(phoneNumber, LocalDateTime.now().minusMinutes(5));
if (count >= 3) {
throw new RuntimeException("잠시 후 다시 시도해주세요.");
}
- String code = String.format("%06d", new Random().nextInt(1000000));
+ String code = String.format("%06d", secureRandom.nextInt(1000000));🤖 Prompt for AI Agents |
||
|
|
||
| SmsVerification v = SmsVerification.builder() | ||
| .smsVerificationId(UUID.randomUUID().toString()) | ||
| .phoneNumber(phoneNumber) | ||
| .code(code) | ||
| .expiresAt(LocalDateTime.now().plusMinutes(3)) | ||
| .build(); | ||
| mapper.insert(v); | ||
|
|
||
| smsService.sendSms(phoneNumber, "[Flyway] 인증번호 [" + code + "]"); | ||
| log.info("[SMS] 인증코드 발송 - {}", phoneNumber.substring(0, 3) + "****"); | ||
| } | ||
|
|
||
| @Transactional | ||
| public boolean verify(String phoneNumber, String code) { | ||
| SmsVerification v = mapper.findLatestByPhone(phoneNumber); | ||
| if (v == null) return false; | ||
| if (v.getExpiresAt().isBefore(LocalDateTime.now())) return false; | ||
| if (!v.getCode().equals(code)) return false; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 타이밍 공격 방지를 위한 상수 시간 비교 필요
🔒 수정 제안+import java.security.MessageDigest;
+import java.nio.charset.StandardCharsets;
`@Transactional`
public boolean verify(String phoneNumber, String code) {
SmsVerification v = mapper.findLatestByPhone(phoneNumber);
if (v == null) return false;
if (v.getExpiresAt().isBefore(LocalDateTime.now())) return false;
- if (!v.getCode().equals(code)) return false;
+ if (!constantTimeEquals(v.getCode(), code)) return false;
mapper.markVerified(v.getSmsVerificationId(), LocalDateTime.now());
return true;
}
+ private boolean constantTimeEquals(String a, String b) {
+ if (a == null || b == null) return false;
+ return MessageDigest.isEqual(
+ a.getBytes(StandardCharsets.UTF_8),
+ b.getBytes(StandardCharsets.UTF_8)
+ );
+ }🤖 Prompt for AI Agents |
||
|
|
||
| mapper.markVerified(v.getSmsVerificationId(), LocalDateTime.now()); | ||
| return true; | ||
| } | ||
|
|
||
| // 회원가입 시 인증 완료 여부 확인 (10분 내 인증 완료된 번호인지) | ||
| public boolean isVerified(String phoneNumber) { | ||
| return mapper.existsVerifiedPhone(phoneNumber, LocalDateTime.now().minusMinutes(10)) > 0; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| CREATE TABLE sms_verification ( | ||
| sms_verification_id CHAR(36) NOT NULL, | ||
| phone_number VARCHAR(20) NOT NULL, | ||
| code VARCHAR(6) NOT NULL, | ||
| expires_at DATETIME NOT NULL, | ||
| verified_at DATETIME NULL, | ||
| created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| PRIMARY KEY (sms_verification_id), | ||
| INDEX idx_sms_phone (phone_number, created_at) | ||
| ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | ||
| "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> | ||
|
|
||
| <mapper namespace="com.flyway.sender.mapper.SmsVerificationMapper"> | ||
|
|
||
| <insert id="insert"> | ||
| INSERT INTO sms_verification | ||
| (sms_verification_id, phone_number, code, expires_at, created_at) | ||
| VALUES (#{smsVerificationId}, #{phoneNumber}, #{code}, #{expiresAt}, NOW()) | ||
| </insert> | ||
|
|
||
| <select id="findLatestByPhone" resultType="com.flyway.sender.domain.SmsVerification"> | ||
| SELECT sms_verification_id AS smsVerificationId, | ||
| phone_number AS phoneNumber, code, | ||
| expires_at AS expiresAt, verified_at AS verifiedAt | ||
| FROM sms_verification | ||
| WHERE phone_number = #{phoneNumber} AND verified_at IS NULL | ||
| ORDER BY created_at DESC LIMIT 1 | ||
| </select> | ||
|
|
||
| <update id="markVerified"> | ||
| UPDATE sms_verification SET verified_at = #{verifiedAt} | ||
| WHERE sms_verification_id = #{smsVerificationId} AND verified_at IS NULL | ||
| </update> | ||
|
|
||
| <select id="countRecentByPhone" resultType="int"> | ||
| SELECT COUNT(*) FROM sms_verification | ||
| WHERE phone_number = #{phoneNumber} AND created_at >= #{since} | ||
| </select> | ||
|
|
||
| <select id="existsVerifiedPhone" resultType="int"> | ||
| SELECT COUNT(*) FROM sms_verification | ||
| WHERE phone_number = #{phoneNumber} | ||
| AND verified_at IS NOT NULL | ||
| AND verified_at >= #{since} | ||
| </select> | ||
|
|
||
| </mapper> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
입력값 검증 및 예외 처리 개선 필요
phoneNumber에 대한 유효성 검증이 없습니다. null, 빈 문자열, 또는 잘못된 형식의 전화번호가 서비스 레이어까지 전달될 수 있습니다.Exception을 포괄적으로 catch하면 예상치 못한 오류(예: NullPointerException, DB 연결 오류)가 일반적인 "FAIL" 응답으로 처리되어 디버깅이 어려워집니다.🛡️ 수정 제안
`@PostMapping`("/send") -public ResponseEntity<ApiResponse<Void>> send(`@RequestParam` String phoneNumber) { +public ResponseEntity<ApiResponse<Void>> send(`@RequestParam` String phoneNumber) { + if (phoneNumber == null || phoneNumber.isBlank()) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("INVALID_INPUT", "전화번호를 입력해주세요.")); + } try { service.sendCode(phoneNumber); return ResponseEntity.ok(ApiResponse.success(null, "인증번호가 발송되었습니다.")); - } catch (Exception e) { + } catch (IllegalArgumentException e) { return ResponseEntity.badRequest() .body(ApiResponse.error("FAIL", e.getMessage())); } }🤖 Prompt for AI Agents