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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ COOKIE_SAME_SITE=Lax
# POST /api/v1/auth/refresh의 CSRF 방어에도 쓰인다. 이 목록에 없는 오리진의 재발급 요청은 403으로 차단된다
CORS_ALLOWED_ORIGINS=http://localhost:3000

# 인증번호 발송용 Gmail SMTP 계정
# MAIL_PASSWORD는 계정 비밀번호가 아니라 2단계 인증을 켠 뒤 발급한 16자리 앱 비밀번호다
MAIL_USERNAME=slatto.official.kr@gmail.com
MAIL_PASSWORD=<gmail-app-password>
MAIL_FROM_NAME=슬레이투

# S3 dev 테스트
AWS_ACCESS_KEY_ID=<aws-access-key-id>
AWS_SECRET_ACCESS_KEY=<aws-secret-access-key>
Expand Down
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies {
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.springframework.boot:spring-boot-starter-mail'
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16'
Expand Down
131 changes: 131 additions & 0 deletions src/main/java/com/slatto/domain/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
package com.slatto.domain.auth.controller;

import com.slatto.domain.auth.dto.AccessTokenResponse;
import com.slatto.domain.auth.dto.EmailAuthResponse;
import com.slatto.domain.auth.dto.EmailLoginRequest;
import com.slatto.domain.auth.dto.EmailSignupRequest;
import com.slatto.domain.auth.dto.EmailVerificationConfirmRequest;
import com.slatto.domain.auth.dto.EmailVerificationConfirmResponse;
import com.slatto.domain.auth.dto.EmailVerificationSendRequest;
import com.slatto.domain.auth.dto.EmailVerificationSendResponse;
import com.slatto.domain.auth.dto.PasswordResetRequest;
import com.slatto.domain.auth.service.AuthService;
import com.slatto.domain.auth.service.EmailVerificationService;
import com.slatto.domain.auth.support.AuthCookieFactory;
import com.slatto.global.response.ApiResponse;
import com.slatto.global.response.code.CommonSuccessCode;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
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;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import java.net.URI;
Expand All @@ -28,6 +41,7 @@
public class AuthController {

private final AuthService authService;
private final EmailVerificationService emailVerificationService;
private final AuthCookieFactory authCookieFactory;

@Operation(
Expand Down Expand Up @@ -93,6 +107,113 @@ public ApiResponse<AccessTokenResponse> reissueAccessToken(
return ApiResponse.success(CommonSuccessCode.OK, authService.reissueAccessToken(refreshToken));
}

@Operation(
summary = "이메일 회원가입",
description = """
이메일 인증을 마친 사용자의 계정을 생성하고 즉시 로그인 상태로 만든다.
액세스 토큰은 본문으로, 리프레시 토큰은 HttpOnly 쿠키로 내려간다.

`onboardingCompleted`는 항상 `false`다. 이어서 온보딩 화면으로 이동한다.
약관 동의는 이 API 가 아니라 온보딩 API 가 받는다.
"""
)
@SecurityRequirements
@PostMapping("/signup")
public ResponseEntity<ApiResponse<EmailAuthResponse>> signup(
@Valid @RequestBody EmailSignupRequest request
) {
AuthService.EmailAuthResult result = authService.signup(
request.name(), request.email(), request.password()
);

return ResponseEntity
.status(HttpStatus.CREATED)
.header(HttpHeaders.SET_COOKIE, refreshTokenCookie(result))
.body(ApiResponse.success(CommonSuccessCode.CREATED, toEmailAuthResponse(result)));
}

@Operation(
summary = "이메일 로그인",
description = """
이메일과 비밀번호로 로그인한다.
액세스 토큰은 본문으로, 리프레시 토큰은 HttpOnly 쿠키로 내려간다.

이메일 미존재·비밀번호 불일치·소셜 전용 계정을 구분하지 않고 모두 같은 401 을 반환한다.
"""
)
@SecurityRequirements
@PostMapping("/login")
public ResponseEntity<ApiResponse<EmailAuthResponse>> login(
@Valid @RequestBody EmailLoginRequest request
) {
AuthService.EmailAuthResult result = authService.login(request.email(), request.password());

return ResponseEntity
.ok()
.header(HttpHeaders.SET_COOKIE, refreshTokenCookie(result))
.body(ApiResponse.success(CommonSuccessCode.OK, toEmailAuthResponse(result)));
}

@Operation(
summary = "이메일 인증번호 발송",
description = """
입력한 이메일로 6자리 인증번호를 발송한다. 인증번호는 5분간 유효하다.

회원가입(`SIGNUP`)과 비밀번호 재설정(`PASSWORD_RESET`)이 같은 엔드포인트를 쓰며 `purpose`로 구분한다.

**가입 여부와 무관하게 항상 성공을 반환한다.** 계정 존재 여부를 노출하면 아무 이메일이나
넣어보고 가입 여부를 알아낼 수 있기 때문이다.
"""
)
@SecurityRequirements
@ResponseStatus(HttpStatus.CREATED)
@PostMapping("/email/verification-codes")
public ApiResponse<EmailVerificationSendResponse> sendEmailVerificationCode(
@Valid @RequestBody EmailVerificationSendRequest request
) {
EmailVerificationSendResponse response = emailVerificationService.send(request.email(), request.purpose());

return ApiResponse.success(CommonSuccessCode.CREATED, response);
}

@Operation(
summary = "이메일 인증번호 확인",
description = """
인증번호를 확인하고 해당 이메일을 인증 완료 상태로 만든다.
성공 후 30분 안에 회원가입 또는 비밀번호 재설정을 마쳐야 한다.

불일치·만료·시도 횟수 초과를 구분하지 않고 같은 코드로 응답한다.
"""
)
@SecurityRequirements
@PostMapping("/email/verification-codes/confirm")
public ApiResponse<EmailVerificationConfirmResponse> confirmEmailVerificationCode(
@Valid @RequestBody EmailVerificationConfirmRequest request
) {
EmailVerificationConfirmResponse response = emailVerificationService.confirm(
request.email(), request.code(), request.purpose()
);

return ApiResponse.success(CommonSuccessCode.OK, response);
}

@Operation(
summary = "비밀번호 재설정",
description = """
`PASSWORD_RESET` 목적의 이메일 인증을 마친 사용자의 비밀번호를 변경한다.

성공하면 해당 유저의 리프레시 토큰을 전부 삭제해 기존 세션을 끊는다.
구글로만 가입해 비밀번호가 없던 계정도 이 경로로 비밀번호를 설정할 수 있다.
"""
)
@SecurityRequirements
@PostMapping("/password/reset")
public ApiResponse<Void> resetPassword(@Valid @RequestBody PasswordResetRequest request) {
authService.resetPassword(request.email(), request.newPassword());

return ApiResponse.success(CommonSuccessCode.OK, null);
}

@Operation(summary = "로그아웃", description = "서버에 저장된 리프레시 토큰을 무효화하고 쿠키를 삭제한다.")
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(
Expand All @@ -106,4 +227,14 @@ public ResponseEntity<ApiResponse<Void>> logout(
.body(ApiResponse.<Void>success(CommonSuccessCode.OK, null));
}

private String refreshTokenCookie(AuthService.EmailAuthResult result) {
return authCookieFactory
.refreshToken(result.refreshToken(), result.refreshTokenMaxAgeSeconds())
.toString();
}

private EmailAuthResponse toEmailAuthResponse(AuthService.EmailAuthResult result) {
return new EmailAuthResponse(result.userId(), result.accessToken(), result.onboardingCompleted());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.slatto.domain.auth.dto;

public record EmailAuthResponse(
Long userId,
String accessToken,
Boolean onboardingCompleted
) {
}
15 changes: 15 additions & 0 deletions src/main/java/com/slatto/domain/auth/dto/EmailLoginRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.slatto.domain.auth.dto;

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

public record EmailLoginRequest(

@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email,

@NotBlank(message = "비밀번호는 필수입니다.")
String password
) {
}
26 changes: 26 additions & 0 deletions src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.slatto.domain.auth.dto;

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

public record EmailSignupRequest(

@NotBlank(message = "이름은 필수입니다.")
@Size(min = 1, max = 20, message = "이름은 1자 이상 20자 이하로 입력해야 합니다.")
String name,

@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
@Size(max = 255, message = "이메일은 255자 이하로 입력해야 합니다.")
String email,

@NotBlank(message = "비밀번호는 필수입니다.")
@Pattern(
regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{8,64}$",
message = "비밀번호는 영문·숫자·특수문자를 포함해 8자 이상 64자 이하로 입력해야 합니다."
)
String password
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.slatto.domain.auth.dto;

import com.slatto.domain.auth.enums.VerificationPurpose;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;

public record EmailVerificationConfirmRequest(

@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email,

@NotBlank(message = "인증번호는 필수입니다.")
@Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자입니다.")
String code,

@NotNull(message = "인증 용도는 필수입니다.")
VerificationPurpose purpose
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.slatto.domain.auth.dto;

import java.time.LocalDateTime;

public record EmailVerificationConfirmResponse(LocalDateTime verifiedUntil) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.slatto.domain.auth.dto;

import com.slatto.domain.auth.enums.VerificationPurpose;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record EmailVerificationSendRequest(

@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
@Size(max = 255, message = "이메일은 255자 이하로 입력해야 합니다.")
String email,

@NotNull(message = "인증 용도는 필수입니다.")
VerificationPurpose purpose
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.slatto.domain.auth.dto;

import java.time.LocalDateTime;

public record EmailVerificationSendResponse(
LocalDateTime expiresAt,
LocalDateTime resendAvailableAt
) {
}
20 changes: 20 additions & 0 deletions src/main/java/com/slatto/domain/auth/dto/PasswordResetRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.slatto.domain.auth.dto;

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

public record PasswordResetRequest(

@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email,

@NotBlank(message = "새 비밀번호는 필수입니다.")
@Pattern(
regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{8,64}$",
message = "비밀번호는 영문·숫자·특수문자를 포함해 8자 이상 64자 이하로 입력해야 합니다."
)
String newPassword
) {
}
Loading
Loading