diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 072650e..56a2618 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ jobs: build: runs-on: ubuntu-latest + env: + JWT_SECRET: ${{ secrets.JWT_SECRET }} + steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 4d0a1cb..dbe1a27 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,6 @@ out/ ###custom### application-secret.yml +.env db_dev.mv.db db_dev.trace.db \ No newline at end of file diff --git a/build.gradle b/build.gradle index 675faa6..d35c4aa 100644 --- a/build.gradle +++ b/build.gradle @@ -41,6 +41,13 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13") runtimeOnly("com.mysql:mysql-connector-j") + + // JWT 라이브러리 + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") } tasks.named('test') { diff --git a/src/main/java/back/kalender/KalenderApplication.java b/src/main/java/back/kalender/KalenderApplication.java index 1f1d349..9900d3f 100644 --- a/src/main/java/back/kalender/KalenderApplication.java +++ b/src/main/java/back/kalender/KalenderApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling +@ConfigurationPropertiesScan public class KalenderApplication { public static void main(String[] args) { diff --git a/src/main/java/back/kalender/domain/auth/controller/UserAuthController.java b/src/main/java/back/kalender/domain/auth/controller/UserAuthController.java index c389041..0d49f58 100644 --- a/src/main/java/back/kalender/domain/auth/controller/UserAuthController.java +++ b/src/main/java/back/kalender/domain/auth/controller/UserAuthController.java @@ -2,56 +2,85 @@ import back.kalender.domain.auth.dto.request.*; import back.kalender.domain.auth.dto.response.*; +import back.kalender.domain.auth.service.AuthService; +import back.kalender.global.security.util.SecurityUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/auth") @Tag(name = "UserAuthController", description = "유저 인증 인가 API") +@RequiredArgsConstructor public class UserAuthController { + private final AuthService authService; + @PostMapping("/login") @Operation(summary = "로그인", description = "이메일과 비밀번호로 로그인합니다. 성공 시 access token은 Authorization 헤더로, refresh token은 httpOnly secure 쿠키로 전달됩니다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그인 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (이메일 형식 오류, 필수 값 누락)"), - @ApiResponse(responseCode = "401", description = "로그인 실패 (값 불일치)") + @ApiResponse(responseCode = "200", description = "로그인 성공", + content = @Content(schema = @Schema(implementation = UserLoginResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(examples = @ExampleObject(value = """ + { + "error": { + "code": "BAD_REQUEST", + "status": "400", + "message": "잘못된 요청입니다." + } + } + """))), + @ApiResponse(responseCode = "401", description = "로그인 실패", + content = @Content(examples = @ExampleObject(value = """ + { + "error": { + "code": "INVALID_CREDENTIALS", + "status": "401", + "message": "이메일 또는 비밀번호가 올바르지 않습니다." + } + } + """))) }) public ResponseEntity login( - @Valid @RequestBody UserLoginRequest request + @Valid @RequestBody UserLoginRequest request, + HttpServletResponse response ) { - // 항상 성공 반환 - // 실제 구현 시 -> access token은 Response Header의 Authorization에 설정, refresh token은 httpOnly secure 쿠키로 설정 - UserLoginResponse response = new UserLoginResponse( - 1L, - "홍길동", - request.email(), - "test.com/profile.jpg", - true - ); - return ResponseEntity.ok(response); + UserLoginResponse loginResponse = authService.login(request, response); + return ResponseEntity.ok(loginResponse); } @PostMapping("/logout") @Operation(summary = "로그아웃", description = "Authorization 헤더의 access token과 쿠키의 refresh token을 무효화하고 로그아웃합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그아웃 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패") + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(examples = @ExampleObject(value = """ + { + "error": { + "code": "UNAUTHORIZED", + "status": "401", + "message": "로그인이 필요합니다." + } + } + """))) }) public ResponseEntity logout( - @Parameter(description = "Access Token (Authorization 헤더)", hidden = false) - @RequestHeader(value = "Authorization", required = false) String authorization, @Parameter(description = "Refresh Token (httpOnly secure 쿠키)", hidden = false) - @CookieValue(value = "refreshToken", required = false) String refreshToken + @CookieValue(value = "refreshToken", required = false) String refreshToken, + HttpServletResponse response ) { - // 항상 성공 반환 - // 실제 구현 시 -> Authorization 헤더에서 Bearer 토큰 추출하여 무효화, refresh token 쿠키 삭제 필요 + authService.logout(refreshToken, response); return ResponseEntity.ok().build(); } @@ -59,14 +88,34 @@ public ResponseEntity logout( @Operation(summary = "토큰 갱신", description = "httpOnly secure 쿠키에 담긴 refresh token으로 새로운 access token과 refresh token을 발급받습니다. 새 access token은 Authorization 헤더로, 새 refresh token은 httpOnly secure 쿠키로 전달됩니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "토큰 갱신 성공"), - @ApiResponse(responseCode = "401", description = "토큰 갱신 실패 (유효하지 않은 refresh token)") + @ApiResponse(responseCode = "401", description = "토큰 갱신 실패", + content = @Content(examples = { + @ExampleObject(name = "유효하지 않은 토큰", value = """ + { + "error": { + "code": "INVALID_REFRESH_TOKEN", + "status": "401", + "message": "유효하지 않은 refresh token입니다." + } + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "error": { + "code": "EXPIRED_REFRESH_TOKEN", + "status": "401", + "message": "만료된 refresh token입니다." + } + } + """) + })) }) public ResponseEntity refreshToken( @Parameter(description = "Refresh Token (httpOnly secure 쿠키)", hidden = false) - @CookieValue(value = "refreshToken", required = false) String refreshToken + @CookieValue(value = "refreshToken", required = false) String refreshToken, + HttpServletResponse response ) { - // 항상 성공 반환 - // 실제 구현 시 -> 새 access token은 Response Header의 Authorization에 설정, 새 refresh token은 httpOnly secure 쿠키로 설정 + authService.refreshToken(refreshToken, response); return ResponseEntity.ok().build(); } @@ -74,14 +123,31 @@ public ResponseEntity refreshToken( @Operation(summary = "비밀번호 재설정 이메일 발송", description = "비밀번호 재설정을 위한 이메일을 발송합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "이메일 발송 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (이메일 형식 오류)"), - @ApiResponse(responseCode = "404", description = "해당 이메일로 가입된 유저를 찾을 수 없음") + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(examples = @ExampleObject(value = """ + { + "error": { + "code": "BAD_REQUEST", + "status": "400", + "message": "잘못된 요청입니다." + } + } + """))), + @ApiResponse(responseCode = "404", description = "유저를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = """ + { + "error": { + "code": "USER_NOT_FOUND", + "status": "404", + "message": "유저를 찾을 수 없습니다." + } + } + """))) }) public ResponseEntity sendPasswordResetEmail( @Valid @RequestBody UserPasswordResetSendRequest request ) { - // 항상 성공 반환 - // 실제 구현 시 -> 토큰 생성 및 이메일 발송 로직 필요 + authService.sendPasswordResetEmail(request); return ResponseEntity.ok().build(); } @@ -89,14 +155,61 @@ public ResponseEntity sendPasswordResetEmail( @Operation(summary = "비밀번호 재설정", description = "이메일로 받은 토큰을 사용하여 비밀번호를 재설정합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "비밀번호 재설정 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (토큰 오류, 비밀번호 형식 오류, 비밀번호 불일치)"), - @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰 또는 만료된 토큰") + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(examples = { + @ExampleObject(name = "유효하지 않은 토큰", value = """ + { + "error": { + "code": "INVALID_PASSWORD_RESET_TOKEN", + "status": "400", + "message": "유효하지 않은 비밀번호 재설정 토큰입니다." + } + } + """), + @ExampleObject(name = "비밀번호 불일치", value = """ + { + "error": { + "code": "PASSWORD_MISMATCH", + "status": "400", + "message": "비밀번호가 일치하지 않습니다." + } + } + """), + @ExampleObject(name = "이미 사용된 토큰", value = """ + { + "error": { + "code": "PASSWORD_RESET_TOKEN_ALREADY_USED", + "status": "400", + "message": "이미 사용된 비밀번호 재설정 토큰입니다." + } + } + """) + })), + @ApiResponse(responseCode = "401", description = "만료된 토큰", + content = @Content(examples = @ExampleObject(value = """ + { + "error": { + "code": "EXPIRED_PASSWORD_RESET_TOKEN", + "status": "401", + "message": "만료된 비밀번호 재설정 토큰입니다." + } + } + """))), + @ApiResponse(responseCode = "404", description = "토큰을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = """ + { + "error": { + "code": "PASSWORD_RESET_TOKEN_NOT_FOUND", + "status": "404", + "message": "비밀번호 재설정 토큰을 찾을 수 없습니다." + } + } + """))) }) public ResponseEntity resetPassword( @Valid @RequestBody UserPasswordResetRequest request ) { - // 항상 성공 반환 - // 실제 구현 시 -> 토큰 검증 및 비밀번호 업데이트 로직 필요 + authService.resetPassword(request); return ResponseEntity.ok().build(); } @@ -104,55 +217,139 @@ public ResponseEntity resetPassword( @Operation(summary = "이메일 인증 코드 발송", description = "이메일 인증을 위한 인증 코드를 발송합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "인증 코드 발송 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (이메일 형식 오류)"), - @ApiResponse(responseCode = "429", description = "너무 많은 요청 (인증 코드 재발송 제한)") + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(examples = { + @ExampleObject(name = "잘못된 요청", value = """ + { + "error": { + "code": "BAD_REQUEST", + "status": "400", + "message": "잘못된 요청입니다." + } + } + """), + @ExampleObject(name = "이미 인증된 이메일", value = """ + { + "error": { + "code": "EMAIL_ALREADY_VERIFIED", + "status": "400", + "message": "이미 인증된 이메일입니다." + } + } + """) + })), + @ApiResponse(responseCode = "404", description = "유저를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = """ + { + "error": { + "code": "USER_NOT_FOUND", + "status": "404", + "message": "유저를 찾을 수 없습니다." + } + } + """))), + @ApiResponse(responseCode = "429", description = "너무 많은 요청", + content = @Content(examples = @ExampleObject(value = """ + { + "error": { + "code": "EMAIL_VERIFICATION_LIMIT_EXCEEDED", + "status": "429", + "message": "인증 코드 발송 횟수를 초과했습니다." + } + } + """))) }) public ResponseEntity sendVerifyEmail( @Valid @RequestBody VerifyEmailSendRequest request ) { - // 항상 성공 반환 - // 실제 구현 시-> 인증 코드 생성 및 이메일 발송 로직 필요 + authService.sendVerifyEmail(request); return ResponseEntity.ok().build(); } @PostMapping("/email/verify") @Operation(summary = "이메일 인증 확인", description = "발송된 인증 코드를 확인하여 이메일을 인증합니다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "이메일 인증 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (인증 코드 오류, 이메일 형식 오류)"), - @ApiResponse(responseCode = "401", description = "인증 코드 불일치 또는 만료") + @ApiResponse(responseCode = "200", description = "이메일 인증 성공", + content = @Content(schema = @Schema(implementation = VerifyEmailResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(examples = @ExampleObject(value = """ + { + "error": { + "code": "INVALID_EMAIL_VERIFICATION_CODE", + "status": "400", + "message": "유효하지 않은 인증 코드입니다." + } + } + """))), + @ApiResponse(responseCode = "401", description = "만료된 인증 코드", + content = @Content(examples = @ExampleObject(value = """ + { + "error": { + "code": "EXPIRED_EMAIL_VERIFICATION_CODE", + "status": "401", + "message": "만료된 인증 코드입니다." + } + } + """))), + @ApiResponse(responseCode = "404", description = "찾을 수 없음", + content = @Content(examples = { + @ExampleObject(name = "인증 코드를 찾을 수 없음", value = """ + { + "error": { + "code": "EMAIL_VERIFICATION_CODE_NOT_FOUND", + "status": "404", + "message": "인증 코드를 찾을 수 없습니다." + } + } + """), + @ExampleObject(name = "유저를 찾을 수 없음", value = """ + { + "error": { + "code": "USER_NOT_FOUND", + "status": "404", + "message": "유저를 찾을 수 없습니다." + } + } + """) + })) }) public ResponseEntity verifyEmail( @Valid @RequestBody VerifyEmailRequest request ) { - // 항상 성공 반환 - // 실제 구현 시 -> 인증 코드 검증 로직 필요 - VerifyEmailResponse response = new VerifyEmailResponse( - request.email(), - true - ); + VerifyEmailResponse response = authService.verifyEmail(request); return ResponseEntity.ok(response); } @GetMapping("/email") @Operation(summary = "이메일 인증 상태 확인", description = "유저의 이메일 인증 상태를 조회합니다. Authorization 헤더의 access token이 필요합니다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "조회 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패"), - @ApiResponse(responseCode = "404", description = "유저를 찾을 수 없음") + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = EmailStatusResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(examples = @ExampleObject(value = """ + { + "error": { + "code": "UNAUTHORIZED", + "status": "401", + "message": "로그인이 필요합니다." + } + } + """))), + @ApiResponse(responseCode = "404", description = "유저를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = """ + { + "error": { + "code": "USER_NOT_FOUND", + "status": "404", + "message": "유저를 찾을 수 없습니다." + } + } + """))) }) - public ResponseEntity getEmailStatus( - @Parameter(description = "Access Token (Authorization 헤더)", hidden = false) - @RequestHeader(value = "Authorization", required = false) String authorization - ) { - // 항상 성공 반환 - // 실제 구현 시 -> Authorization 헤더에서 Bearer 토큰 추출하여 JWT 파싱, 유저 정보 추출 후 DB에서 인증 상태 조회 - EmailStatusResponse response = new EmailStatusResponse( - 1L, - "user@example.com", - true, - java.time.LocalDateTime.now() - ); + public ResponseEntity getEmailStatus() { + Long userId = SecurityUtil.getCurrentUserIdOrThrow(); + + EmailStatusResponse response = authService.getEmailStatus(userId); return ResponseEntity.ok(response); } } diff --git a/src/main/java/back/kalender/domain/auth/entity/.gitkeep b/src/main/java/back/kalender/domain/auth/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/back/kalender/domain/auth/entity/EmailVerification.java b/src/main/java/back/kalender/domain/auth/entity/EmailVerification.java new file mode 100644 index 0000000..77b222b --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/entity/EmailVerification.java @@ -0,0 +1,57 @@ +package back.kalender.domain.auth.entity; + +import back.kalender.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +// 이메일 인증 +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "email_verifications", + indexes = { + @Index(name = "idx_user_id", columnList = "userId"), + @Index(name = "idx_code", columnList = "code") + } +) +public class EmailVerification extends BaseEntity { + + private static final int DEFAULT_EXPIRY_MINUTES = 5; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false, length = 50) + private String code; + + private boolean used; + + @Column(nullable = false) + private LocalDateTime expiredAt; + + public static EmailVerification create(Long userId, String code) { + return create(userId, code, DEFAULT_EXPIRY_MINUTES); + } + + public static EmailVerification create(Long userId, String code, int expiryMinutes) { + EmailVerification ev = new EmailVerification(); + ev.userId = userId; + ev.code = code; + ev.used = false; + ev.expiredAt = LocalDateTime.now().plusMinutes(expiryMinutes); + return ev; + } + + public void markUsed() { + this.used = true; + } + + public boolean isExpired() { + return expiredAt.isBefore(LocalDateTime.now()); + } +} diff --git a/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java b/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java new file mode 100644 index 0000000..6bb7cba --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java @@ -0,0 +1,57 @@ +package back.kalender.domain.auth.entity; + +import back.kalender.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +// 비밀번호 변경 +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "password_reset_tokens", + indexes = { + @Index(name = "idx_password_user_id", columnList = "userId"), + @Index(name = "idx_password_token", columnList = "token") + } +) +public class PasswordResetToken extends BaseEntity { + + private static final int DEFAULT_EXPIRY_MINUTES = 5; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false, length = 500) + private String token; + + private boolean used; + + @Column(nullable = false) + private LocalDateTime expiredAt; + + public static PasswordResetToken create(Long userId, String code) { + return create(userId, code, DEFAULT_EXPIRY_MINUTES); + } + + public static PasswordResetToken create(Long userId, String code, int expiryMinutes) { + PasswordResetToken token = new PasswordResetToken(); + token.userId = userId; + token.token = code; + token.used = false; + token.expiredAt = LocalDateTime.now().plusMinutes(expiryMinutes); + return token; + } + + public void markUsed() { + this.used = true; + } + + public boolean isExpired() { + return expiredAt.isBefore(LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java b/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java new file mode 100644 index 0000000..0a84dba --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java @@ -0,0 +1,45 @@ +package back.kalender.domain.auth.entity; + +import back.kalender.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +// 리프레시 토큰 +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "refresh_tokens", + indexes = { + @Index(name = "idx_refresh_user_id", columnList = "userId"), + @Index(name = "idx_refresh_token", columnList = "token") + } +) +public class RefreshToken extends BaseEntity { + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false, length = 1000) + private String token; + + @Column(nullable = false) + private LocalDateTime expiredAt; + + public static RefreshToken create(Long userId, String token, long ttlDays) { + RefreshToken rt = new RefreshToken(); + rt.userId = userId; + rt.token = token; + rt.expiredAt = LocalDateTime.now().plusDays(ttlDays); + return rt; + } + + public boolean isExpired() { + return expiredAt.isBefore(LocalDateTime.now()); + } + +} \ No newline at end of file diff --git a/src/main/java/back/kalender/domain/auth/repository/.gitkeep b/src/main/java/back/kalender/domain/auth/repository/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/back/kalender/domain/auth/repository/EmailVerificationRepository.java b/src/main/java/back/kalender/domain/auth/repository/EmailVerificationRepository.java new file mode 100644 index 0000000..8a85cca --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/repository/EmailVerificationRepository.java @@ -0,0 +1,12 @@ +package back.kalender.domain.auth.repository; + +import back.kalender.domain.auth.entity.EmailVerification; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface EmailVerificationRepository extends JpaRepository { + Optional findTopByUserIdOrderByCreatedAtDesc(Long userId); + Optional findByUserIdAndCode(Long userId, String code); + void deleteByUserId(Long userId); +} \ No newline at end of file diff --git a/src/main/java/back/kalender/domain/auth/repository/PasswordResetTokenRepository.java b/src/main/java/back/kalender/domain/auth/repository/PasswordResetTokenRepository.java new file mode 100644 index 0000000..7327459 --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/repository/PasswordResetTokenRepository.java @@ -0,0 +1,11 @@ +package back.kalender.domain.auth.repository; + +import back.kalender.domain.auth.entity.PasswordResetToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PasswordResetTokenRepository extends JpaRepository { + Optional findByToken(String token); + void deleteByUserId(Long userId); +} \ No newline at end of file diff --git a/src/main/java/back/kalender/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/back/kalender/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..448ac24 --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,10 @@ +package back.kalender.domain.auth.repository; + +import back.kalender.domain.auth.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByToken(String token); +} \ No newline at end of file diff --git a/src/main/java/back/kalender/domain/auth/service/.gitkeep b/src/main/java/back/kalender/domain/auth/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/back/kalender/domain/auth/service/AuthService.java b/src/main/java/back/kalender/domain/auth/service/AuthService.java new file mode 100644 index 0000000..d07b67c --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/service/AuthService.java @@ -0,0 +1,19 @@ +package back.kalender.domain.auth.service; + +import back.kalender.domain.auth.dto.request.*; +import back.kalender.domain.auth.dto.response.EmailStatusResponse; +import back.kalender.domain.auth.dto.response.UserLoginResponse; +import back.kalender.domain.auth.dto.response.VerifyEmailResponse; +import jakarta.servlet.http.HttpServletResponse; + +public interface AuthService { + UserLoginResponse login(UserLoginRequest request, HttpServletResponse response); + void logout(String refreshToken, HttpServletResponse response); + void refreshToken(String refreshToken, HttpServletResponse response); + void sendPasswordResetEmail(UserPasswordResetSendRequest request); + void resetPassword(UserPasswordResetRequest request); + void sendVerifyEmail(VerifyEmailSendRequest request); + VerifyEmailResponse verifyEmail(VerifyEmailRequest request); + EmailStatusResponse getEmailStatus(Long userId); +} + diff --git a/src/main/java/back/kalender/domain/auth/service/AuthServiceImpl.java b/src/main/java/back/kalender/domain/auth/service/AuthServiceImpl.java new file mode 100644 index 0000000..e1cdcfe --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/service/AuthServiceImpl.java @@ -0,0 +1,292 @@ +package back.kalender.domain.auth.service; + +import back.kalender.domain.auth.dto.request.*; +import back.kalender.domain.auth.dto.response.EmailStatusResponse; +import back.kalender.domain.auth.dto.response.UserLoginResponse; +import back.kalender.domain.auth.dto.response.VerifyEmailResponse; +import back.kalender.domain.auth.entity.EmailVerification; +import back.kalender.domain.auth.entity.PasswordResetToken; +import back.kalender.domain.auth.entity.RefreshToken; +import back.kalender.domain.auth.repository.EmailVerificationRepository; +import back.kalender.domain.auth.repository.PasswordResetTokenRepository; +import back.kalender.domain.auth.repository.RefreshTokenRepository; +import back.kalender.domain.user.entity.User; +import back.kalender.domain.user.repository.UserRepository; +import back.kalender.global.exception.ErrorCode; +import back.kalender.global.exception.ServiceException; +import back.kalender.global.security.jwt.JwtProperties; +import back.kalender.global.security.jwt.JwtTokenProvider; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthServiceImpl implements AuthService { + + private static final int EMAIL_VERIFICATION_RESEND_LIMIT_MINUTES = 5; + + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final EmailVerificationRepository emailVerificationRepository; + private final PasswordResetTokenRepository passwordResetTokenRepository; + private final JwtTokenProvider jwtTokenProvider; + private final JwtProperties jwtProperties; + private final PasswordEncoder passwordEncoder; + + @Override + @Transactional + public UserLoginResponse login(UserLoginRequest request, HttpServletResponse response) { + // 유저 조회 + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new ServiceException(ErrorCode.INVALID_CREDENTIALS)); + + // 비밀번호 검증 + if (!passwordEncoder.matches(request.password(), user.getPassword())) { + throw new ServiceException(ErrorCode.INVALID_CREDENTIALS); + } + + // 토큰 생성, 저장 및 응답 설정 + createAndSaveTokens(user, response); + + return new UserLoginResponse( + user.getId(), + user.getNickname(), + user.getEmail(), + user.getProfileImage(), + user.getEmailVerified() != null ? user.getEmailVerified() : false + ); + } + + @Override + @Transactional + public void logout(String refreshToken, HttpServletResponse response) { + if (refreshToken != null) { + refreshTokenRepository.findByToken(refreshToken) + .ifPresent(refreshTokenRepository::delete); + } + + // Refresh Token 쿠키 삭제 + clearRefreshTokenCookie(response); + } + + @Override + @Transactional + public void refreshToken(String refreshToken, HttpServletResponse response) { + if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) { + throw new ServiceException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + // DB에서 Refresh Token 확인 + RefreshToken refreshTokenEntity = refreshTokenRepository.findByToken(refreshToken) + .orElseThrow(() -> new ServiceException(ErrorCode.INVALID_REFRESH_TOKEN)); + + // 만료 확인 + if (refreshTokenEntity.isExpired()) { + refreshTokenRepository.delete(refreshTokenEntity); + throw new ServiceException(ErrorCode.EXPIRED_REFRESH_TOKEN); + } + + // 유저 조회 + User user = userRepository.findById(refreshTokenEntity.getUserId()) + .orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); + + // 기존 Refresh Token 삭제 + refreshTokenRepository.delete(refreshTokenEntity); + + // 새 토큰 생성, 저장 및 응답 설정 + createAndSaveTokens(user, response); + } + + @Override + @Transactional + public void sendPasswordResetEmail(UserPasswordResetSendRequest request) { + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); + + // 기존 토큰 삭제 + passwordResetTokenRepository.deleteByUserId(user.getId()); + + // 새 토큰 생성 + String token = UUID.randomUUID().toString(); + PasswordResetToken resetToken = PasswordResetToken.create(user.getId(), token); + passwordResetTokenRepository.save(resetToken); + + // TODO: 이메일 발송 로직 구현 + // emailService.sendPasswordResetEmail(user.getEmail(), token); + } + + @Override + @Transactional + public void resetPassword(UserPasswordResetRequest request) { + // 비밀번호 일치 확인 + if (!request.newPassword().equals(request.newPasswordConfirm())) { + throw new ServiceException(ErrorCode.PASSWORD_MISMATCH); + } + + // 토큰 조회 + PasswordResetToken resetToken = passwordResetTokenRepository.findByToken(request.token()) + .orElseThrow(() -> new ServiceException(ErrorCode.PASSWORD_RESET_TOKEN_NOT_FOUND)); + + // 사용 여부 확인 + if (resetToken.isUsed()) { + throw new ServiceException(ErrorCode.PASSWORD_RESET_TOKEN_ALREADY_USED); + } + + // 만료 확인 + if (resetToken.isExpired()) { + throw new ServiceException(ErrorCode.EXPIRED_PASSWORD_RESET_TOKEN); + } + + // 유저 조회 및 비밀번호 업데이트 + User user = userRepository.findById(resetToken.getUserId()) + .orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); + + user.updatePassword(passwordEncoder.encode(request.newPassword())); + + // 토큰 사용 처리 + resetToken.markUsed(); + } + + @Override + @Transactional + public void sendVerifyEmail(VerifyEmailSendRequest request) { + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); + + // 이미 인증된 경우 + if (user.getEmailVerified() != null && user.getEmailVerified()) { + throw new ServiceException(ErrorCode.EMAIL_ALREADY_VERIFIED); + } + + // 최근 N분 이내 발송된 인증 코드 확인 (재발송 제한) + emailVerificationRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId()) + .ifPresent(verification -> { + if (verification.getCreatedAt().isAfter(LocalDateTime.now().minusMinutes(EMAIL_VERIFICATION_RESEND_LIMIT_MINUTES))) { + throw new ServiceException(ErrorCode.EMAIL_VERIFICATION_LIMIT_EXCEEDED); + } + }); + + // 인증 코드 생성 (6자리 숫자) + String code = String.format("%06d", (int) (Math.random() * 1000000)); + + // 기존 인증 코드 삭제 + emailVerificationRepository.deleteByUserId(user.getId()); + + // 새 인증 코드 저장 + EmailVerification verification = EmailVerification.create(user.getId(), code); + emailVerificationRepository.save(verification); + + // TODO: 이메일 발송 로직 구현 + // emailService.sendVerificationEmail(user.getEmail(), code); + } + + @Override + @Transactional + public VerifyEmailResponse verifyEmail(VerifyEmailRequest request) { + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); + + // 인증 코드 조회 + EmailVerification verification = emailVerificationRepository + .findByUserIdAndCode(user.getId(), request.code()) + .orElseThrow(() -> new ServiceException(ErrorCode.EMAIL_VERIFICATION_CODE_NOT_FOUND)); + + // 사용 여부 확인 + if (verification.isUsed()) { + throw new ServiceException(ErrorCode.INVALID_EMAIL_VERIFICATION_CODE); + } + + // 만료 확인 + if (verification.isExpired()) { + throw new ServiceException(ErrorCode.EXPIRED_EMAIL_VERIFICATION_CODE); + } + + // 인증 처리 + verification.markUsed(); + user.verifyEmail(); + + return new VerifyEmailResponse(request.email(), true); + } + + @Override + public EmailStatusResponse getEmailStatus(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); + + // 이메일 인증 시간 조회 + LocalDateTime verifiedAt = emailVerificationRepository + .findTopByUserIdOrderByCreatedAtDesc(userId) + .filter(EmailVerification::isUsed) + .map(EmailVerification::getUpdatedAt) + .orElse(null); + + return new EmailStatusResponse( + user.getId(), + user.getEmail(), + user.getEmailVerified() != null ? user.getEmailVerified() : false, + verifiedAt + ); + } + + // ------------------------------ HELPERS ------------------------------------------ + private void createAndSaveTokens(User user, HttpServletResponse response) { + // 토큰 생성 + String accessToken = createAccessToken(user); + String refreshToken = createRefreshToken(user); + + // Refresh Token DB 저장 + RefreshToken refreshTokenEntity = RefreshToken.create( + user.getId(), + refreshToken, + jwtProperties.getTokenExpiration().getRefresh() + ); + refreshTokenRepository.save(refreshTokenEntity); + + // 토큰을 Response에 설정 + setTokensToResponse(response, accessToken, refreshToken); + } + + private String createAccessToken(User user) { + long validityInMillis = jwtProperties.getTokenExpiration().getAccessInMillis(); + return jwtTokenProvider.createToken(String.valueOf(user.getId()), validityInMillis); + } + + private String createRefreshToken(User user) { + long validityInMillis = jwtProperties.getTokenExpiration().getRefreshInMillis(); + return jwtTokenProvider.createToken(String.valueOf(user.getId()), validityInMillis); + } + + private void setTokensToResponse(HttpServletResponse response, String accessToken, String refreshToken) { + response.setHeader("Authorization", "Bearer " + accessToken); + response.addCookie(buildRefreshTokenCookie(refreshToken)); + } + + private Cookie buildRefreshTokenCookie(String token) { + Cookie cookie = new Cookie("refreshToken", token); + cookie.setHttpOnly(true); + cookie.setSecure(jwtProperties.getCookie().isSecure()); + cookie.setPath("/"); + cookie.setMaxAge((int) jwtProperties.getTokenExpiration().getRefreshInSeconds()); + return cookie; + } + + private void clearRefreshTokenCookie(HttpServletResponse response) { + Cookie cookie = new Cookie("refreshToken", null); + cookie.setHttpOnly(true); + cookie.setSecure(jwtProperties.getCookie().isSecure()); + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + } + + +} + diff --git a/src/main/java/back/kalender/domain/auth/service/CustomUserDetailsService.java b/src/main/java/back/kalender/domain/auth/service/CustomUserDetailsService.java new file mode 100644 index 0000000..ceee165 --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/service/CustomUserDetailsService.java @@ -0,0 +1,46 @@ +package back.kalender.domain.auth.service; + +import back.kalender.domain.user.entity.User; +import back.kalender.domain.user.repository.UserRepository; +import back.kalender.global.exception.ErrorCode; +import back.kalender.global.exception.ServiceException; +import back.kalender.global.security.user.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); + + List authorities = createAuthorities("ROLE_USER"); + + return new CustomUserDetails( + user.getId(), + user.getEmail(), + user.getPassword(), + authorities + ); + } + + private List createAuthorities(String roleName) { + return Collections.singletonList(new SimpleGrantedAuthority(roleName)); + } +} + diff --git a/src/main/java/back/kalender/domain/user/controller/UserController.java b/src/main/java/back/kalender/domain/user/controller/UserController.java index 6039c63..ec22f81 100644 --- a/src/main/java/back/kalender/domain/user/controller/UserController.java +++ b/src/main/java/back/kalender/domain/user/controller/UserController.java @@ -19,8 +19,6 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.time.LocalDateTime; - @Tag(name = "User", description = "회원가입, 회원 정보 관련 API") @RestController @RequestMapping("/api/v1/user") @@ -94,18 +92,8 @@ public class UserController { public ResponseEntity signup( @RequestBody UserSignupRequest request ) { - // TODO: 더미데이터 삭제, 서비스 연결 필요 - - // 더미 데이터 - UserSignupResponse dummyData = new UserSignupResponse( - 1L, - request.email(), - request.nickname(), - request.birthDate(), - LocalDateTime.now() - ); - - return ResponseEntity.ok(dummyData); + UserSignupResponse response = userService.signup(request); + return ResponseEntity.ok(response); } diff --git a/src/main/java/back/kalender/domain/user/entity/User.java b/src/main/java/back/kalender/domain/user/entity/User.java index 5f5a5e9..abc338e 100644 --- a/src/main/java/back/kalender/domain/user/entity/User.java +++ b/src/main/java/back/kalender/domain/user/entity/User.java @@ -4,11 +4,8 @@ import back.kalender.global.common.Enum.Gender; import jakarta.persistence.*; import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDate; -import java.time.LocalDateTime; @Entity @Table(name = "users") @@ -36,6 +33,10 @@ public class User extends BaseEntity { private LocalDate birthDate; + @Column(name = "email_verified", nullable = false) + @Builder.Default + private Boolean emailVerified = false; + public void updateNickname(String nickname) { this.nickname = nickname; } @@ -44,6 +45,14 @@ public void updateProfileImage(String profileImage) { this.profileImage = profileImage; } + public void updatePassword(String password) { + this.password = password; + } + + public void verifyEmail() { + this.emailVerified = true; + } + // 나이 계산 메서드 public Integer getAge() { if (birthDate == null) { diff --git a/src/main/java/back/kalender/domain/user/repository/UserRepository.java b/src/main/java/back/kalender/domain/user/repository/UserRepository.java index 98a092f..d11e9b2 100644 --- a/src/main/java/back/kalender/domain/user/repository/UserRepository.java +++ b/src/main/java/back/kalender/domain/user/repository/UserRepository.java @@ -9,4 +9,5 @@ @Repository public interface UserRepository extends JpaRepository { Optional findByNickname(String nickname); + Optional findByEmail(String email); } diff --git a/src/main/java/back/kalender/domain/user/service/UserService.java b/src/main/java/back/kalender/domain/user/service/UserService.java index 65c1275..c5cefb7 100644 --- a/src/main/java/back/kalender/domain/user/service/UserService.java +++ b/src/main/java/back/kalender/domain/user/service/UserService.java @@ -2,12 +2,15 @@ import back.kalender.domain.user.dto.request.UpdateProfileRequest; +import back.kalender.domain.user.dto.request.UserSignupRequest; import back.kalender.domain.user.dto.response.UploadProfileImgResponse; import back.kalender.domain.user.dto.response.UserProfileResponse; +import back.kalender.domain.user.dto.response.UserSignupResponse; import org.springframework.web.multipart.MultipartFile; public interface UserService { + UserSignupResponse signup(UserSignupRequest request); UserProfileResponse getMyProfile(Long userId); UploadProfileImgResponse uploadProfileImage(Long userId, MultipartFile profileImage); UserProfileResponse updateMyProfile(Long userId, UpdateProfileRequest request); diff --git a/src/main/java/back/kalender/domain/user/service/UserServiceImpl.java b/src/main/java/back/kalender/domain/user/service/UserServiceImpl.java index 633852c..0174bd4 100644 --- a/src/main/java/back/kalender/domain/user/service/UserServiceImpl.java +++ b/src/main/java/back/kalender/domain/user/service/UserServiceImpl.java @@ -1,13 +1,17 @@ package back.kalender.domain.user.service; import back.kalender.domain.user.dto.request.UpdateProfileRequest; +import back.kalender.domain.user.dto.request.UserSignupRequest; import back.kalender.domain.user.dto.response.UploadProfileImgResponse; import back.kalender.domain.user.dto.response.UserProfileResponse; +import back.kalender.domain.user.dto.response.UserSignupResponse; import back.kalender.domain.user.entity.User; import back.kalender.domain.user.repository.UserRepository; +import back.kalender.global.common.Enum.Gender; import back.kalender.global.exception.ErrorCode; import back.kalender.global.exception.ServiceException; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -17,6 +21,49 @@ @Transactional(readOnly = true) public class UserServiceImpl implements UserService{ private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + // 회원가입 251211 ahnbs + @Override + @Transactional + public UserSignupResponse signup(UserSignupRequest request) { + // 이메일 중복 확인 + if (userRepository.findByEmail(request.email()).isPresent()) { + throw new ServiceException(ErrorCode.DUPLICATE_EMAIL); + } + + // 닉네임 중복 확인 + if (userRepository.findByNickname(request.nickname()).isPresent()) { + throw new ServiceException(ErrorCode.DUPLICATE_NICKNAME); + } + + // Gender 변환 + Gender gender = null; + if (request.gender() != null && !request.gender().isEmpty()) { + try { + gender = Gender.valueOf(request.gender().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new ServiceException(ErrorCode.BAD_REQUEST); + } + } + + // 비밀번호 암호화 + String encodedPassword = passwordEncoder.encode(request.password()); + + // 유저 생성 + User user = User.builder() + .email(request.email()) + .password(encodedPassword) + .nickname(request.nickname()) + .gender(gender) + .birthDate(request.birthDate()) + .emailVerified(false) + .build(); + + User savedUser = userRepository.save(user); + + return UserSignupResponse.from(savedUser); + } /** * 내 정보 조회 diff --git a/src/main/java/back/kalender/global/exception/ErrorCode.java b/src/main/java/back/kalender/global/exception/ErrorCode.java index 4bc66aa..079794f 100644 --- a/src/main/java/back/kalender/global/exception/ErrorCode.java +++ b/src/main/java/back/kalender/global/exception/ErrorCode.java @@ -13,6 +13,7 @@ public enum ErrorCode { // User 1000 USER_NOT_FOUND("1001", HttpStatus.NOT_FOUND, "유저를 찾을 수 없습니다."), DUPLICATE_NICKNAME("1002", HttpStatus.CONFLICT, "이미 사용 중인 닉네임입니다."), + DUPLICATE_EMAIL("1003", HttpStatus.CONFLICT, "이미 사용 중인 이메일입니다."), // Artist 2000 ARTIST_NOT_FOUND("2001",HttpStatus.NOT_FOUND,"아티스트를 찾을 수 없습니다."), @@ -48,10 +49,25 @@ public enum ErrorCode { // Performance 5000 PERFORMANCE_NOT_FOUND("5001", HttpStatus.NOT_FOUND, "공연을 찾을 수 없습니다."), PERFORMANCE_HALL_NOT_FOUND("5002", HttpStatus.NOT_FOUND, "공연장을 찾을 수 없습니다."), - PRICE_GRADE_NOT_FOUND("5003", HttpStatus.NOT_FOUND, "가격 등급을 찾을 수 없습니다."); + PRICE_GRADE_NOT_FOUND("5003", HttpStatus.NOT_FOUND, "가격 등급을 찾을 수 없습니다."), // MyPage 6000 + // Auth 7000 + INVALID_CREDENTIALS("7001", HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."), + INVALID_REFRESH_TOKEN("7002", HttpStatus.UNAUTHORIZED, "유효하지 않은 refresh token입니다."), + EXPIRED_REFRESH_TOKEN("7003", HttpStatus.UNAUTHORIZED, "만료된 refresh token입니다."), + INVALID_EMAIL_VERIFICATION_CODE("7004", HttpStatus.BAD_REQUEST, "유효하지 않은 인증 코드입니다."), + EXPIRED_EMAIL_VERIFICATION_CODE("7005", HttpStatus.UNAUTHORIZED, "만료된 인증 코드입니다."), + EMAIL_VERIFICATION_CODE_NOT_FOUND("7006", HttpStatus.NOT_FOUND, "인증 코드를 찾을 수 없습니다."), + EMAIL_ALREADY_VERIFIED("7007", HttpStatus.BAD_REQUEST, "이미 인증된 이메일입니다."), + EMAIL_VERIFICATION_LIMIT_EXCEEDED("7008", HttpStatus.TOO_MANY_REQUESTS, "인증 코드 발송 횟수를 초과했습니다."), + INVALID_PASSWORD_RESET_TOKEN("7009", HttpStatus.BAD_REQUEST, "유효하지 않은 비밀번호 재설정 토큰입니다."), + EXPIRED_PASSWORD_RESET_TOKEN("7010", HttpStatus.UNAUTHORIZED, "만료된 비밀번호 재설정 토큰입니다."), + PASSWORD_RESET_TOKEN_NOT_FOUND("7011", HttpStatus.NOT_FOUND, "비밀번호 재설정 토큰을 찾을 수 없습니다."), + PASSWORD_RESET_TOKEN_ALREADY_USED("7012", HttpStatus.BAD_REQUEST, "이미 사용된 비밀번호 재설정 토큰입니다."), + PASSWORD_MISMATCH("7013", HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."); + private final String code; private final HttpStatus status; private final String message; diff --git a/src/main/java/back/kalender/global/security/.gitkeep b/src/main/java/back/kalender/global/security/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/back/kalender/global/security/SecurityConfig.java b/src/main/java/back/kalender/global/security/SecurityConfig.java index bbd958c..e16ae4f 100644 --- a/src/main/java/back/kalender/global/security/SecurityConfig.java +++ b/src/main/java/back/kalender/global/security/SecurityConfig.java @@ -1,25 +1,58 @@ package back.kalender.global.security; +import back.kalender.global.security.jwt.JwtAuthFilter; +import back.kalender.global.security.jwt.JwtTokenProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; /** * Spring Security 설정 - * 개발 단계에서 모든 경로 허용 (추후 인증/인가 추가 예정) */ @Configuration @EnableWebSecurity public class SecurityConfig { @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public JwtAuthFilter jwtAuthFilter(JwtTokenProvider jwtTokenProvider) { + return new JwtAuthFilter(jwtTokenProvider); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception { http - .csrf(csrf -> csrf.disable()) // CSRF 비활성화 + .csrf(csrf -> csrf.disable()) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() // 모든 요청 허용 (개발용) + // 공개 엔드포인트 (인증 불필요) + .requestMatchers( + "/api/v1/auth/login", + "/api/v1/auth/refresh", + "/api/v1/auth/password/send", + "/api/v1/auth/password/reset", + "/api/v1/auth/email/send", + "/api/v1/auth/email/verify", + "/api/v1/user", + "/api/v1/schedule/public/**", + "/api/v1/artist/**", + "/h2-console/**", + "/favicon.ico", + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**" + ).permitAll() + // 그 외 모든 요청은 인증 필요 + .anyRequest().authenticated() ); return http.build(); diff --git a/src/main/java/back/kalender/global/security/jwt/JwtAuthEntryPoint.java b/src/main/java/back/kalender/global/security/jwt/JwtAuthEntryPoint.java new file mode 100644 index 0000000..3d6279d --- /dev/null +++ b/src/main/java/back/kalender/global/security/jwt/JwtAuthEntryPoint.java @@ -0,0 +1,43 @@ +package back.kalender.global.security.jwt; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class JwtAuthEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException, ServletException { + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + + Map body = new HashMap<>(); + body.put("timestamp", LocalDateTime.now().toString()); + body.put("status", HttpServletResponse.SC_UNAUTHORIZED); + body.put("error", "Unauthorized"); + body.put("message", authException.getMessage()); + body.put("path", request.getRequestURI()); + + String json = objectMapper.writeValueAsString(body); + response.getWriter().write(json); + } +} diff --git a/src/main/java/back/kalender/global/security/jwt/JwtAuthFilter.java b/src/main/java/back/kalender/global/security/jwt/JwtAuthFilter.java new file mode 100644 index 0000000..449d63f --- /dev/null +++ b/src/main/java/back/kalender/global/security/jwt/JwtAuthFilter.java @@ -0,0 +1,49 @@ +package back.kalender.global.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = resolveToken(request); + + if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + // Authorization 헤더에서 Bearer 토큰 찾기 + String header = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(header) && header.startsWith(BEARER_PREFIX)) { + return header.substring(BEARER_PREFIX.length()); + } + + return null; + } +} diff --git a/src/main/java/back/kalender/global/security/jwt/JwtProperties.java b/src/main/java/back/kalender/global/security/jwt/JwtProperties.java new file mode 100644 index 0000000..cc72e5d --- /dev/null +++ b/src/main/java/back/kalender/global/security/jwt/JwtProperties.java @@ -0,0 +1,57 @@ +package back.kalender.global.security.jwt; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@ConfigurationProperties(prefix = "custom.jwt") +public class JwtProperties { + + private final String secret; + private final TokenExpiration tokenExpiration; + private final CookieProperties cookie; + + public JwtProperties(String secret, TokenExpiration tokenExpiration, CookieProperties cookie) { + this.secret = secret; + this.tokenExpiration = tokenExpiration; + this.cookie = cookie; + } + + @Getter + public static class TokenExpiration { + private static final long MILLIS_PER_SECOND = 1000L; + private static final long MILLIS_PER_MINUTE = 60 * MILLIS_PER_SECOND; + private static final long MILLIS_PER_HOUR = 60 * MILLIS_PER_MINUTE; + private static final long MILLIS_PER_DAY = 24 * MILLIS_PER_HOUR; + private static final long SECONDS_PER_DAY = 24 * 60 * 60L; + + private final long access; // 초 단위 + private final long refresh; // 일 단위 + + public TokenExpiration(long access, long refresh) { + this.access = access; + this.refresh = refresh; + } + + public long getAccessInMillis() { + return access * MILLIS_PER_SECOND; + } + + public long getRefreshInMillis() { + return refresh * MILLIS_PER_DAY; + } + + public long getRefreshInSeconds() { + return refresh * SECONDS_PER_DAY; + } + } + + @Getter + public static class CookieProperties { + private final boolean secure; + + public CookieProperties(boolean secure) { + this.secure = secure; + } + } +} diff --git a/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java b/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..b6940f7 --- /dev/null +++ b/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,132 @@ +package back.kalender.global.security.jwt; + +import back.kalender.domain.user.entity.User; +import back.kalender.domain.user.repository.UserRepository; +import back.kalender.global.exception.ErrorCode; +import back.kalender.global.exception.ServiceException; +import back.kalender.global.security.user.CustomUserDetails; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final JwtProperties jwtProperties; + private final UserRepository userRepository; + + private SecretKey signingKey; + + @PostConstruct + public void init() { + // secret 문자열로 HMAC 키 생성 + this.signingKey = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)); + } + + public String createToken(String subject, long validityInMillis) { + return createToken(subject, null, validityInMillis); + } + + public String createToken(String subject, Map additionalClaims, long validityInMillis) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + validityInMillis); + + JwtBuilder builder = Jwts.builder() + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(signingKey); + + if (additionalClaims != null && !additionalClaims.isEmpty()) { + builder.addClaims(additionalClaims); + } + + return builder.compact(); + } + + public boolean validateToken(String token) { + if (!StringUtils.hasText(token)) { + return false; + } + + try { + Jwts.parser() + .verifyWith(signingKey) + .build() + .parseClaimsJws(token); + return true; + } catch (ExpiredJwtException e) { + // 만료된 토큰은 정상적인 케이스 + return false; + } catch (JwtException | SecurityException e) { + log.warn("[JWT] [ValidateToken] 유효하지 않은 토큰 - message={}", e.getMessage()); + return false; + } catch (IllegalArgumentException e) { + log.warn("[JWT] [ValidateToken] 토큰 파싱 실패 - message={}", e.getMessage()); + return false; + } + } + + public String getSubject(String token) { + Claims claims = Jwts.parser() + .verifyWith(signingKey) + .build() + .parseClaimsJws(token) + .getBody(); + + return claims.getSubject(); + } + + public Authentication getAuthentication(String token) { + String userIdStr = getSubject(token); + Long userId = Long.parseLong(userIdStr); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); + + List authorities = createAuthorities("ROLE_USER"); + + CustomUserDetails userDetails = new CustomUserDetails( + user.getId(), + user.getEmail(), + user.getPassword(), + authorities + ); + + return new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + } + + private List createAuthorities(String roleName) { + return Collections.singletonList(new SimpleGrantedAuthority(roleName)); + } + + public Long getUserId(String token) { + String userIdStr = getSubject(token); + try { + return Long.parseLong(userIdStr); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/back/kalender/global/security/user/CustomUserDetails.java b/src/main/java/back/kalender/global/security/user/CustomUserDetails.java new file mode 100644 index 0000000..3c2e6e8 --- /dev/null +++ b/src/main/java/back/kalender/global/security/user/CustomUserDetails.java @@ -0,0 +1,59 @@ +package back.kalender.global.security.user; + +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; + +@Getter +public class CustomUserDetails implements UserDetails { + + private final Long userId; + private final String email; + private final String password; + private final Collection authorities; + + public CustomUserDetails(Long userId, String email, String password, Collection authorities) { + this.userId = userId; + this.email = email; + this.password = password; + this.authorities = authorities; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return email; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} + diff --git a/src/main/java/back/kalender/global/security/util/SecurityUtil.java b/src/main/java/back/kalender/global/security/util/SecurityUtil.java new file mode 100644 index 0000000..3b9e804 --- /dev/null +++ b/src/main/java/back/kalender/global/security/util/SecurityUtil.java @@ -0,0 +1,62 @@ +package back.kalender.global.security.util; + +import back.kalender.global.exception.ErrorCode; +import back.kalender.global.exception.ServiceException; +import back.kalender.global.security.user.CustomUserDetails; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public class SecurityUtil { + + + public static Long getCurrentUserId() { + Authentication authentication = getAuthentication(); + if (authentication != null && authentication.isAuthenticated()) { + Object principal = authentication.getPrincipal(); + if (principal instanceof CustomUserDetails) { + return ((CustomUserDetails) principal).getUserId(); + } + } + return null; + } + + /** + * SecurityContext에서 현재 인증된 사용자의 ID를 가져옵니다. + * 인증되지 않은 경우 예외를 발생시킵니다. + * @return 사용자 ID + * @throws ServiceException 인증되지 않은 경우 + */ + public static Long getCurrentUserIdOrThrow() { + Long userId = getCurrentUserId(); + if (userId == null) { + throw new ServiceException(ErrorCode.UNAUTHORIZED); + } + return userId; + } + + + public static String getCurrentUserEmail() { + Authentication authentication = getAuthentication(); + if (authentication != null && authentication.isAuthenticated()) { + return authentication.getName(); + } + return null; + } + + + public static CustomUserDetails getCurrentUserDetails() { + Authentication authentication = getAuthentication(); + if (authentication != null && authentication.isAuthenticated()) { + Object principal = authentication.getPrincipal(); + if (principal instanceof CustomUserDetails) { + return (CustomUserDetails) principal; + } + } + return null; + } + + private static Authentication getAuthentication() { + return SecurityContextHolder.getContext().getAuthentication(); + } +} + diff --git a/src/main/resources/application-prop.yml b/src/main/resources/application-prop.yml index 5297bec..7797d12 100644 --- a/src/main/resources/application-prop.yml +++ b/src/main/resources/application-prop.yml @@ -28,4 +28,8 @@ custom: site: domain: "${custom.prod.domain}" backUrl: "${custom.prod.backUrl}" - frontUrl: "${custom.prod.frontUrl}" \ No newline at end of file + frontUrl: "${custom.prod.frontUrl}" + + jwt: + cookie: + secure: false \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index fad25d9..0087481 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -1,3 +1,10 @@ spring: datasource: url: jdbc:h2:mem:db_dev;MODE=MySQL + +custom: + jwt: + tokenExpiration: + access: 1800 + refresh: 14 + secret: test-jwt-secret-key-minimum-32-characters-long-for-hmac-sha256 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5a69115..e152ea7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -54,5 +54,9 @@ custom: frontUrl: "${custom.dev.frontUrl}" jwt: - expireSeconds: "#{30 * 60}" - secretPattern: ${custom.jwt.secretPattern} \ No newline at end of file + tokenExpiration: + access: 1800 + refresh: 14 + secret: ${JWT_SECRET} + cookie: + secure: false \ No newline at end of file diff --git a/src/test/java/back/kalender/KalenderApplicationTests.java b/src/test/java/back/kalender/KalenderApplicationTests.java deleted file mode 100644 index 67bcaac..0000000 --- a/src/test/java/back/kalender/KalenderApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package back.kalender; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class KalenderApplicationTests { - - @Test - void contextLoads() { - } - -}