Skip to content
Open
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
8 changes: 8 additions & 0 deletions src/main/java/com/kt/common/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public enum ErrorCode {
DUPLICATED_LOGIN_ID(HttpStatus.BAD_REQUEST, "이미 존재하는 아이디입니다."),
DOES_NOT_MATCH_OLD_PASSWORD(HttpStatus.BAD_REQUEST, "기존 비밀번호가 일치하지 않습니다."),
CAN_NOT_ALLOWED_SAME_PASSWORD(HttpStatus.BAD_REQUEST, "기존 비밀번호와 동일한 비밀번호로 변경할 수 없습니다."),
DUPLICATED_EMAIL(HttpStatus.BAD_REQUEST, "중복되는 이메일입니다."),

//product
NOT_FOUND_PRODUCT(HttpStatus.BAD_REQUEST, "상품을 찾을 수 없습니다."),
Expand Down Expand Up @@ -112,6 +113,13 @@ public enum ErrorCode {
WISHLIST_ADD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "찜 추가에 실패했습니다."),
NOT_FOUND_WISHLIST(HttpStatus.BAD_REQUEST, "찜하지 않은 상품입니다."),

//certify
EXPIRED_CERTIFICATION_CODE(HttpStatus.BAD_REQUEST,"만료된 코드입니다."),
FAILED_MORE_THAN_FIVE_TIMES(HttpStatus.BAD_REQUEST,"5회이상 시도하여 잠금상태입니다."),
CERTIFICATION_CODE_NOT_FOUND(HttpStatus.BAD_REQUEST,"인증코드를 찾을 수 없습니다."),
INVALID_CERTIFICATION_CODE(HttpStatus.BAD_REQUEST,"틀린 코드입니다."),
NOT_VERIFIED_EMAIL(HttpStatus.BAD_REQUEST,"인증되지않은 이메일입니다."),

//vector
NOT_FOUND_VECTOR_STORE(HttpStatus.BAD_REQUEST, "존재하지 않는 벡터 스토어입니다."),

Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/kt/config/SecurityConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
public class SecurityConfiguration {

private static final String[] GET_PERMIT_ALL = {"/api/health/**", "/swagger-ui.html","/swagger-ui/**", "/v3/api-docs/**", "/actuator/**", "/payment-*.html", "/api/payments/client-key", "/*.css", "/api/chats", "/api/chats/**"};
private static final String[] POST_PERMIT_ALL = {"/api/users/auth/signup", "/api/users/auth/login","/api/admin/users/auth/signup", "/api/admin/users/auth/login","/api/users/reissue", "/api/payments/confirm"};
private static final String[] POST_PERMIT_ALL = {"/api/users/auth/signup", "/api/users/auth/login","/api/admin/users/auth/signup", "/api/admin/users/auth/login","/api/users/reissue", "/api/payments/confirm","/api/certify/**"};
private static final String[] PUT_PERMIT_ALL = {"/api/v1/public/**"};
private static final String[] PATCH_PERMIT_ALL = {"/api/v1/public/**"};
private static final String[] DELETE_PERMIT_ALL = {"/api/v1/public/**"};
Expand Down
38 changes: 38 additions & 0 deletions src/main/java/com/kt/controller/certify/CertifyController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.kt.controller.certify;

import com.kt.common.response.ApiResult;
import com.kt.common.support.SwaggerAssistance;
import com.kt.dto.certify.EmailCertificationRequest;
import com.kt.dto.certify.EmailRequest;
import com.kt.service.certify.CertifyService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

@Tag(name = "Certify", description = "이메일 검증 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/certify")
public class CertifyController extends SwaggerAssistance{

private final CertifyService certifyService;

@PostMapping("/code")
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "이메일 검증 코드 생성")
public ApiResult<Void> createCode(@RequestBody EmailRequest email){
certifyService.createCode(email.email());
return ApiResult.ok();
}

@PostMapping("/email")
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "코드를 통한 이메일 검증")
public ApiResult<Void> certify(@Valid @RequestBody EmailCertificationRequest emailCertificationRequest){
certifyService.certifyEmail(emailCertificationRequest);
return ApiResult.ok();
}
}
93 changes: 93 additions & 0 deletions src/main/java/com/kt/domain/certify/Certify.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.kt.domain.certify;

import com.kt.common.support.BaseEntity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor
public class Certify extends BaseEntity {

@Column(nullable = false)
private String email;

@Column(nullable = false)
private String certifyCode;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private CertifyStatus codeStatus;

@Column(nullable = false)
private int attempts;

@Column(nullable = false)
private LocalDateTime expiresAt;

private LocalDateTime lockedAt;

private LocalDateTime verifiedAt;

public void changeStatus(CertifyStatus codeStatus){ // 코드 사용여부등 상태확인용도
this.codeStatus = codeStatus;
}

public void initAttempts(){ // 시도횟수 초기화
this.attempts = 0;
}

public void increaseAttemptsAndLock() {
this.attempts++;
if (this.attempts >= 5) {
this.codeStatus = CertifyStatus.LOCKED;
this.lockedAt = LocalDateTime.now();
}
}

public static Certify create(String email, String code, LocalDateTime expire) {
Certify certify = new Certify();
certify.email = email;
certify.certifyCode = code;
certify.codeStatus = CertifyStatus.PENDING;
certify.attempts = 0;
certify.expiresAt = expire;
return certify;
}

public boolean isExpired(LocalDateTime now) {
return this.expiresAt.isBefore(now);
}

public boolean isLocked() {
return this.codeStatus == CertifyStatus.LOCKED;
}

public boolean isSameCode(String code) {
return this.certifyCode.equals(code);
}

public boolean isVerified() {
if(this.codeStatus.equals(CertifyStatus.VERIFIED)){
this.verifiedAt = LocalDateTime.now();
return true;
}
return false;
}
public void markVerifiedForTest(LocalDateTime expiresAt) {
this.codeStatus = CertifyStatus.VERIFIED;
this.expiresAt = expiresAt;
}
public static Certify TestVerify(String email, String code, LocalDateTime expiresAt) {
Certify c = new Certify();
c.email = email;
c.certifyCode = code;
c.codeStatus = CertifyStatus.PENDING;
c.expiresAt = expiresAt;
return c;
}


}
8 changes: 8 additions & 0 deletions src/main/java/com/kt/domain/certify/CertifyStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.kt.domain.certify;

public enum CertifyStatus {
PENDING,
LOCKED,
VERIFIED,
EXPIRED
}
16 changes: 16 additions & 0 deletions src/main/java/com/kt/dto/certify/EmailCertificationRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.kt.dto.certify;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;

public record EmailCertificationRequest(
@Schema(description = "이메일", example = "test@example.com")
@NotBlank
@Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
String email,
@NotNull
String code
) {
}
13 changes: 13 additions & 0 deletions src/main/java/com/kt/dto/certify/EmailRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.kt.dto.certify;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;

public record EmailRequest(
@Schema(description = "이메일", example = "test@example.com")
@NotBlank
@Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
String email
) {
}
10 changes: 10 additions & 0 deletions src/main/java/com/kt/repository/certify/CertifyRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.kt.repository.certify;

import com.kt.domain.certify.Certify;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface CertifyRepository extends JpaRepository<Certify, Long> {
Optional<Certify> findTopByEmailOrderByCreatedAtDesc(String email);

}
1 change: 1 addition & 0 deletions src/main/java/com/kt/repository/user/UserRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ default User findByIdOrThrow(Long id, ErrorCode errorCode) {
return findById(id).orElseThrow(() -> new CustomException(errorCode));
}
Optional<User> findByLoginId(String loginId);
Boolean existsByEmailAndIsDeletedFalse(String email);
}
89 changes: 89 additions & 0 deletions src/main/java/com/kt/service/certify/CertifyService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.kt.service.certify;

import com.kt.common.exception.CustomException;
import com.kt.common.exception.ErrorCode;
import com.kt.domain.certify.Certify;
import com.kt.domain.certify.CertifyStatus;
import com.kt.dto.certify.EmailCertificationRequest;
import com.kt.notification.MailSendRequest;
import com.kt.notification.MailSendService;
import com.kt.repository.certify.CertifyRepository;
import com.kt.repository.user.UserRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.Random;

@RequiredArgsConstructor
@Service
public class CertifyService {

private final UserRepository userRepository;
private final CertifyRepository certifyRepository;
private final MailSendService mailSendService;
@Transactional
public void certifyEmail(EmailCertificationRequest req) {
String email = req.email();
LocalDateTime now = LocalDateTime.now();

if (userRepository.existsByEmailAndIsDeletedFalse(email)) {
throw new CustomException(ErrorCode.DUPLICATED_EMAIL);
}

Certify certify = certifyRepository.findTopByEmailOrderByCreatedAtDesc(email)
.orElseThrow(() -> new CustomException(ErrorCode.CERTIFICATION_CODE_NOT_FOUND));

if (certify.isLocked()) { // 5회이상 틀릴시 잠금, 초과처럼 에러코드를 작성했지만 이상이 맞습니다...
throw new CustomException(ErrorCode.FAILED_MORE_THAN_FIVE_TIMES);
}

if (certify.isExpired(now)) { // 코드 유효기간 만료
certify.changeStatus(CertifyStatus.EXPIRED);
throw new CustomException(ErrorCode.EXPIRED_CERTIFICATION_CODE);
}

if (certify.isSameCode(req.code())) { // 이메일 인증통과
certify.changeStatus(CertifyStatus.VERIFIED);
certify.initAttempts();
return;
}

certify.increaseAttemptsAndLock(); // 실패시 틀림 처리
throw new CustomException(ErrorCode.INVALID_CERTIFICATION_CODE);
}


@Transactional
public void createCode(String email) { // 인증을 위한 난수 생성, secureRandom을 통해 강한 난수를 생성할수도있다 -> 좀 과한 것 같으니 제외
Random RANDOM = new Random();
StringBuilder builder = new StringBuilder(6);
for (int i = 0; i < 6; i++) {
builder.append(RANDOM.nextInt(10)); // 0 ~ 9
}
String code = builder.toString();
LocalDateTime expire = LocalDateTime.now().plusMinutes(5L);
Certify certifyCode = Certify.create(email,code,expire);
certifyRepository.save(certifyCode);

MailSendRequest request = new MailSendRequest(
email,
"이메일 인증 코드입니다.",
"코드: \n" + code
);
mailSendService.sendEmail(request);
}

public boolean validateEmailVerified(String email) {
Certify certify = certifyRepository.findTopByEmailOrderByCreatedAtDesc(email)
.orElseThrow(() -> new CustomException(ErrorCode.CERTIFICATION_CODE_NOT_FOUND));

if (certify.isVerified()) {
return true;
}
return false;
}


}
21 changes: 6 additions & 15 deletions src/main/java/com/kt/service/user/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.kt.common.exception.CustomException;
import com.kt.common.exception.ErrorCode;
import com.kt.domain.auth.RefreshToken;
import com.kt.domain.certify.Certify;
import com.kt.domain.membership.Membership;
import com.kt.domain.shoppingaddress.ShoppingAddress;
import com.kt.domain.user.Role;
Expand All @@ -17,21 +18,14 @@
import com.kt.repository.user.UserRepository;
import com.kt.security.CustomUserDetails;
import com.kt.security.JwtTokenProvider;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import jakarta.servlet.http.HttpServletRequest;
import com.kt.service.certify.CertifyService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Date;

import static com.kt.common.support.ObjectUtils.orElseIfEmpty;

Expand All @@ -49,6 +43,7 @@ public class UserService {
private static final String DEFAULT_MEMBERSHIP_LEVEL = "BRONZE";
private static final String BEARER_PREFIX = "Bearer ";
private static final String AUTH_HEADER = "Authorization";
private final CertifyService certifyService;

public boolean checkLoginIdDuplicated(String loginId) {
return userRepository.existsByLoginIdAndIsDeletedFalse(loginId);
Expand All @@ -60,6 +55,9 @@ public void create(UserCreateRequest request) {
if (checkLoginIdDuplicated(request.loginId())) {
throw new CustomException(ErrorCode.DUPLICATED_LOGIN_ID);
}
if(!certifyService.validateEmailVerified(request.email())){
throw new CustomException(ErrorCode.NOT_VERIFIED_EMAIL);
}
Membership defaultMembership = membershipRepository.findByLevel(DEFAULT_MEMBERSHIP_LEVEL)
.orElseThrow(() -> new IllegalStateException("기본 멤버십이 설정되어 있지 않습니다."));

Expand Down Expand Up @@ -233,11 +231,4 @@ public void changeRole(UserChangeRole request){
user.changeRole(request.role());
}

private String resolveBearer(String request) {
if (request == null || !request.startsWith("Bearer ")) {
throw new CustomException(ErrorCode.INVALID_JWT_TOKEN);
}

return request.substring(BEARER_PREFIX.length());
}
}
Loading
Loading