Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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