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
8 changes: 5 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
plugins {
id 'java'
id 'org.springframework.boot' version '4.1.0'
id 'org.springframework.boot' version '3.5.16'
id 'io.spring.dependency-management' version '1.1.7'
}

Expand All @@ -16,15 +16,17 @@ java {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.1.1'
implementation 'org.yaml:snakeyaml'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17'

runtimeOnly 'com.mysql:mysql-connector-j'
implementation 'org.eclipse.angus:angus-mail'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'com.h2database:h2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
package com.teamnative.moss.domain.auth.controller;

import com.teamnative.moss.domain.auth.dto.BusinessSignupRequest;
import com.teamnative.moss.domain.auth.dto.FindIdRequest;
import com.teamnative.moss.domain.auth.dto.FindIdResponse;
import com.teamnative.moss.domain.auth.dto.LoginRequest;
import com.teamnative.moss.domain.auth.dto.LoginResponse;
import com.teamnative.moss.domain.auth.dto.RefreshTokenRequest;
import com.teamnative.moss.domain.auth.dto.ResetPasswordRequest;
import com.teamnative.moss.domain.auth.dto.SignupResponse;
import com.teamnative.moss.domain.auth.dto.SendEmailRequest;
import com.teamnative.moss.domain.auth.dto.VerifyEmailRequest;
import com.teamnative.moss.domain.auth.dto.VerifyEmailResponse;
import com.teamnative.moss.domain.auth.dto.StudentSignupRequest;
import com.teamnative.moss.domain.auth.service.BusinessSignupService;
import com.teamnative.moss.domain.auth.service.FindIdService;
import com.teamnative.moss.domain.auth.service.EmailVerificationService;
import com.teamnative.moss.domain.auth.service.LoginService;
import com.teamnative.moss.domain.auth.service.StudentSignupService;
import com.teamnative.moss.global.dto.ApiResponse;
Expand All @@ -16,6 +24,7 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -27,15 +36,21 @@ public class AuthController {
private final StudentSignupService studentSignupService;
private final BusinessSignupService businessSignupService;
private final LoginService loginService;
private final FindIdService findIdService;
private final EmailVerificationService emailVerificationService;

public AuthController(
StudentSignupService studentSignupService,
BusinessSignupService businessSignupService,
LoginService loginService
LoginService loginService,
FindIdService findIdService,
EmailVerificationService emailVerificationService
) {
this.studentSignupService = studentSignupService;
this.businessSignupService = businessSignupService;
this.loginService = loginService;
this.findIdService = findIdService;
this.emailVerificationService = emailVerificationService;
}

@PostMapping("/signup/business")
Expand Down Expand Up @@ -65,4 +80,30 @@ public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request
public ApiResponse<LoginResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
return ApiResponse.success("토큰이 갱신되었습니다.", loginService.refresh(request));
}

@PostMapping("/find-id")
@Operation(summary = "아이디 찾기", description = "이름과 전화번호로 가입된 이메일과 계정 생성일을 조회합니다.")
public ApiResponse<FindIdResponse> findId(@Valid @RequestBody FindIdRequest request) {
return ApiResponse.success("계정 정보를 찾았습니다.", findIdService.findId(request));
}

@PostMapping("/send-email")
@Operation(summary = "이메일 인증번호 발송", description = "입력한 이메일 주소로 5분간 유효한 인증번호를 발송합니다.")
public ApiResponse<Void> sendEmail(@Valid @RequestBody SendEmailRequest request) {
emailVerificationService.sendVerificationCode(request);
return ApiResponse.success("인증번호를 이메일로 발송했습니다.", null);
}

@PostMapping("/verify-email")
@Operation(summary = "이메일 인증번호 확인", description = "유효한 인증번호를 확인하고 비밀번호 재설정 토큰을 발급합니다.")
public ApiResponse<VerifyEmailResponse> verifyEmail(@Valid @RequestBody VerifyEmailRequest request) {
return ApiResponse.success("이메일 인증에 성공했습니다.", emailVerificationService.verifyEmail(request));
}

@PatchMapping("/reset-password")
@Operation(summary = "비밀번호 재설정", description = "이메일 인증으로 발급된 토큰을 사용해 비밀번호를 재설정합니다.")
public ApiResponse<Void> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
emailVerificationService.resetPassword(request);
return ApiResponse.success("비밀번호가 변경되었습니다.", null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.teamnative.moss.domain.auth.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;

public record FindIdRequest(
@NotBlank(message = "이름을 입력해 주세요.")
String name,

@NotBlank(message = "전화번호를 입력해 주세요.")
@Pattern(regexp = "^\\+?[0-9]{10,15}$", message = "전화번호 형식이 올바르지 않습니다.")
String number
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.teamnative.moss.domain.auth.dto;

import java.time.LocalDate;

public record FindIdResponse(String email, LocalDate createdAt) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.teamnative.moss.domain.auth.dto;

import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record ResetPasswordRequest(
@NotBlank(message = "재설정 토큰을 입력해주세요.")
String resetToken,
@NotBlank(message = "비밀번호를 입력해주세요.")
@Size(min = 6, max = 15, message = "비밀번호는 6자 이상 15자 이하여야 합니다.")
String password,
@NotBlank(message = "비밀번호 확인을 입력해주세요.")
String passwordConfirm
) {
@AssertTrue(message = "비밀번호가 일치하지 않습니다.")
public boolean isPasswordConfirmed() {
return password != null && password.equals(passwordConfirm);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.teamnative.moss.domain.auth.dto;

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

public record SendEmailRequest(
@NotBlank(message = "이메일을 입력해 주세요.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.teamnative.moss.domain.auth.dto;

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

public record VerifyEmailRequest(
@NotBlank(message = "이메일을 입력해 주세요.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email,

@NotBlank(message = "인증번호를 입력해 주세요.")
@Pattern(regexp = "^[0-9]{6}$", message = "인증번호는 숫자 6자리여야 합니다.")
String code
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.teamnative.moss.domain.auth.dto;

import java.time.LocalDateTime;

public record VerifyEmailResponse(String resetToken, LocalDateTime expiresAt) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.teamnative.moss.domain.auth.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;

@Entity
@Table(name = "email_verifications")
public class EmailVerification {
@Id
@Column(nullable = false, length = 254)
private String email;

@Column(name = "code_hash", nullable = false, length = 100)
private String codeHash;

@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;

@Column(name = "sent_at", nullable = false)
private LocalDateTime sentAt;

@Column(name = "verification_attempts", nullable = false)
private int verificationAttempts;

@Column(name = "verified_at")
private LocalDateTime verifiedAt;

@Column(name = "reset_token_hash", length = 100)
private String resetTokenHash;

@Column(name = "reset_token_expires_at")
private LocalDateTime resetTokenExpiresAt;

protected EmailVerification() {
}

public EmailVerification(String email, String codeHash, LocalDateTime sentAt, LocalDateTime expiresAt) {
this.email = email;
this.codeHash = codeHash;
this.sentAt = sentAt;
this.expiresAt = expiresAt;
this.verificationAttempts = 0;
}

public LocalDateTime getSentAt() {
return sentAt;
}

public String getCodeHash() {
return codeHash;
}

public boolean isVerified() {
return verifiedAt != null;
}

public LocalDateTime getExpiresAt() {
return expiresAt;
}

public int getVerificationAttempts() {
return verificationAttempts;
}

public void recordVerificationFailure() {
verificationAttempts++;
}

public void markVerified(String resetTokenHash, LocalDateTime verifiedAt, LocalDateTime resetTokenExpiresAt) {
this.verifiedAt = verifiedAt;
this.resetTokenHash = resetTokenHash;
this.resetTokenExpiresAt = resetTokenExpiresAt;
}

public void consumeResetToken() {
this.resetTokenHash = null;
this.resetTokenExpiresAt = null;
}

public String getEmail() {
return email;
}

public String getResetTokenHash() {
return resetTokenHash;
}

public LocalDateTime getResetTokenExpiresAt() {
return resetTokenExpiresAt;
}

public void replaceCode(String codeHash, LocalDateTime sentAt, LocalDateTime expiresAt) {
this.codeHash = codeHash;
this.sentAt = sentAt;
this.expiresAt = expiresAt;
this.verificationAttempts = 0;
this.verifiedAt = null;
this.resetTokenHash = null;
this.resetTokenExpiresAt = null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ public String getPasswordHash() {
return passwordHash;
}

public void changePassword(String passwordHash) {
this.passwordHash = passwordHash;
}

public LocalDateTime getCreatedAt() {
return createdAt;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.teamnative.moss.domain.auth.repository;

import com.teamnative.moss.domain.auth.entity.EmailVerification;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

public interface EmailVerificationRepository extends JpaRepository<EmailVerification, String> {
List<EmailVerification> findAllByVerifiedAtIsNotNullAndResetTokenExpiresAtAfter(LocalDateTime now);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select verification from EmailVerification verification where verification.email = :email")
Optional<EmailVerification> findByEmailForUpdate(@Param("email") String email);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ public interface UserRepository extends JpaRepository<UserAccount, Long> {

Optional<UserAccount> findByEmail(String email);

Optional<UserAccount> findByNameAndNumber(String name, String number);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select user from UserAccount user where user.email = :email")
Optional<UserAccount> findByEmailForUpdate(@Param("email") String email);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select user from UserAccount user where user.email = :email")
Optional<UserAccount> findByEmailForPasswordUpdate(@Param("email") String email);

}
Loading
Loading