From fa22d0110ec2b2eecdfb8cf5cc9b9d9d83771555 Mon Sep 17 00:00:00 2001 From: junyong Date: Fri, 27 Mar 2026 16:49:23 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=20=20-=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8,=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20API=20=20?= =?UTF-8?q?=20-=20=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=20=20-=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83,=20=EC=95=A1=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 18 +++ .../auth/controller/AuthController.java | 32 ----- .../server/domain/auth/dto/AuthResponse.java | 41 ------- .../domain/meeting/entity/Dialogue.java | 5 +- .../domain/meeting/entity/MeetingMember.java | 4 +- .../server/domain/team/entity/TeamMember.java | 4 +- .../user/controller/AuthController.java | 100 +++++++++++++++ ...rController.java => MemberController.java} | 2 +- .../user/dto/AccessTokenGenerateResponse.java | 12 ++ .../{auth => user}/dto/AuthRequest.java | 4 +- .../server/domain/user/dto/AuthResponse.java | 73 +++++++++++ .../user/entity/{User.java => Member.java} | 19 ++- .../whylog/server/domain/user/enums/.gitkeep | 0 .../whylog/server/domain/user/enums/Role.java | 19 +++ .../user/exception/AuthErrorStatus.java | 41 +++++++ .../user/exception/AuthSuccessStatus.java | 40 ++++++ .../user/repository/MemberRepository.java | 12 ++ .../user/repository/UserRepository.java | 7 -- .../user/service/AuthenticationService.java | 79 ++++++++++++ .../user/service/LocalLoginService.java | 49 ++++++++ .../domain/user/service/UserService.java | 10 -- .../global/auth/annotation/CurrentMember.java | 16 +++ .../auth/jwt/application/TokenService.java | 35 ++++++ .../global/auth/jwt/dao/TokenRepository.java | 10 ++ .../jwt/filter/JwtAuthenticationFilter.java | 75 ++++++++++++ .../auth/jwt/provider/JwtTokenProvider.java | 115 ++++++++++++++++++ .../auth/jwt/provider/JwtValidationType.java | 10 ++ .../server/global/auth/redis/Token.java | 26 ++++ .../CurrentMemberArgumentResolver.java | 35 ++++++ .../security/CustomAccessDeniedHandler.java | 36 ++++++ .../CustomJwtAuthenticationEntryPoint.java | 36 ++++++ .../auth/security/MemberAuthentication.java | 17 +++ .../server/global/config/PasswordConfig.java | 15 +++ .../server/global/config/SecurityConfig.java | 54 ++++++++ .../server/global/config/WebConfig.java | 10 ++ src/main/resources/application.yaml | 5 + 36 files changed, 965 insertions(+), 101 deletions(-) delete mode 100644 src/main/java/com/whylog/server/domain/auth/controller/AuthController.java delete mode 100644 src/main/java/com/whylog/server/domain/auth/dto/AuthResponse.java create mode 100644 src/main/java/com/whylog/server/domain/user/controller/AuthController.java rename src/main/java/com/whylog/server/domain/user/controller/{UserController.java => MemberController.java} (80%) create mode 100644 src/main/java/com/whylog/server/domain/user/dto/AccessTokenGenerateResponse.java rename src/main/java/com/whylog/server/domain/{auth => user}/dto/AuthRequest.java (89%) create mode 100644 src/main/java/com/whylog/server/domain/user/dto/AuthResponse.java rename src/main/java/com/whylog/server/domain/user/entity/{User.java => Member.java} (60%) delete mode 100644 src/main/java/com/whylog/server/domain/user/enums/.gitkeep create mode 100644 src/main/java/com/whylog/server/domain/user/enums/Role.java create mode 100644 src/main/java/com/whylog/server/domain/user/exception/AuthErrorStatus.java create mode 100644 src/main/java/com/whylog/server/domain/user/exception/AuthSuccessStatus.java create mode 100644 src/main/java/com/whylog/server/domain/user/repository/MemberRepository.java delete mode 100644 src/main/java/com/whylog/server/domain/user/repository/UserRepository.java create mode 100644 src/main/java/com/whylog/server/domain/user/service/AuthenticationService.java create mode 100644 src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java delete mode 100644 src/main/java/com/whylog/server/domain/user/service/UserService.java create mode 100644 src/main/java/com/whylog/server/global/auth/annotation/CurrentMember.java create mode 100644 src/main/java/com/whylog/server/global/auth/jwt/application/TokenService.java create mode 100644 src/main/java/com/whylog/server/global/auth/jwt/dao/TokenRepository.java create mode 100644 src/main/java/com/whylog/server/global/auth/jwt/filter/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/whylog/server/global/auth/jwt/provider/JwtTokenProvider.java create mode 100644 src/main/java/com/whylog/server/global/auth/jwt/provider/JwtValidationType.java create mode 100644 src/main/java/com/whylog/server/global/auth/redis/Token.java create mode 100644 src/main/java/com/whylog/server/global/auth/resolver/CurrentMemberArgumentResolver.java create mode 100644 src/main/java/com/whylog/server/global/auth/security/CustomAccessDeniedHandler.java create mode 100644 src/main/java/com/whylog/server/global/auth/security/CustomJwtAuthenticationEntryPoint.java create mode 100644 src/main/java/com/whylog/server/global/auth/security/MemberAuthentication.java create mode 100644 src/main/java/com/whylog/server/global/config/PasswordConfig.java create mode 100644 src/main/java/com/whylog/server/global/config/SecurityConfig.java diff --git a/build.gradle b/build.gradle index 8c61930..9040cd9 100644 --- a/build.gradle +++ b/build.gradle @@ -17,6 +17,11 @@ configurations { compileOnly { extendsFrom annotationProcessor } + + configureEach { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' + exclude group: 'commons-logging', module: 'commons-logging' + } } repositories { @@ -25,6 +30,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' @@ -35,6 +41,18 @@ dependencies { // implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + // log4j2 + implementation 'org.springframework.boot:spring-boot-starter-log4j2' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + } tasks.named('test') { diff --git a/src/main/java/com/whylog/server/domain/auth/controller/AuthController.java b/src/main/java/com/whylog/server/domain/auth/controller/AuthController.java deleted file mode 100644 index b4c8c71..0000000 --- a/src/main/java/com/whylog/server/domain/auth/controller/AuthController.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.whylog.server.domain.auth.controller; - -import com.whylog.server.domain.auth.dto.AuthRequest; -import com.whylog.server.domain.auth.dto.AuthResponse; -import com.whylog.server.global.apiPayload.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/auth") -@RequiredArgsConstructor -@Tag(name = "Auth", description = "인증 관련 API") -public class AuthController { - - @PostMapping("/signup") - @Operation(summary = "회원가입 API", description = "이메일과 비밀번호를 이용하여 새로운 회원을 등록하는 API입니다.") - public ApiResponse signup(@Valid @RequestBody AuthRequest.SignUpDTO request) { - return ApiResponse.onSuccess(null); - } - - @PostMapping("/login") - @Operation(summary = "로그인 API", description = "이메일과 비밀번호를 이용하여 로그인하고 액세스 토큰을 발급받는 API입니다.") - public ApiResponse login(@Valid @RequestBody AuthRequest.LoginDTO request) { - return ApiResponse.onSuccess(null); - } -} diff --git a/src/main/java/com/whylog/server/domain/auth/dto/AuthResponse.java b/src/main/java/com/whylog/server/domain/auth/dto/AuthResponse.java deleted file mode 100644 index 82f6497..0000000 --- a/src/main/java/com/whylog/server/domain/auth/dto/AuthResponse.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.whylog.server.domain.auth.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -public class AuthResponse { - - @Getter - @NoArgsConstructor - @AllArgsConstructor - @Builder - @Schema(description = "회원가입 응답") - public static class SignUpResponseDTO { - - @Schema(description = "회원 ID", example = "1") - private Long memberId; - - @Schema(description = "이메일", example = "user@example.com") - private String email; - } - - @Getter - @NoArgsConstructor - @AllArgsConstructor - @Builder - @Schema(description = "로그인 응답") - public static class LoginResponseDTO { - - @Schema(description = "액세스 토큰", example = "accessstokenenenen...") - private String accessToken; - - @Schema(description = "회원 ID", example = "1") - private Long memberId; - - @Schema(description = "이메일", example = "user@example.com") - private String email; - } -} diff --git a/src/main/java/com/whylog/server/domain/meeting/entity/Dialogue.java b/src/main/java/com/whylog/server/domain/meeting/entity/Dialogue.java index 811d1d9..07ff777 100644 --- a/src/main/java/com/whylog/server/domain/meeting/entity/Dialogue.java +++ b/src/main/java/com/whylog/server/domain/meeting/entity/Dialogue.java @@ -1,6 +1,6 @@ package com.whylog.server.domain.meeting.entity; -import com.whylog.server.domain.user.entity.User; +import com.whylog.server.domain.user.entity.Member; import com.whylog.server.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -9,7 +9,6 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; -import jakarta.persistence.Lob; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.LocalDateTime; @@ -34,7 +33,7 @@ public class Dialogue extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) - private User user; + private Member member; @Column(name = "content", columnDefinition = "TEXT", nullable = false) private String content; diff --git a/src/main/java/com/whylog/server/domain/meeting/entity/MeetingMember.java b/src/main/java/com/whylog/server/domain/meeting/entity/MeetingMember.java index adf8c07..d6c3567 100644 --- a/src/main/java/com/whylog/server/domain/meeting/entity/MeetingMember.java +++ b/src/main/java/com/whylog/server/domain/meeting/entity/MeetingMember.java @@ -1,7 +1,7 @@ package com.whylog.server.domain.meeting.entity; import com.whylog.server.domain.meeting.enums.MeetingRole; -import com.whylog.server.domain.user.entity.User; +import com.whylog.server.domain.user.entity.Member; import com.whylog.server.global.entity.BaseEntity; import jakarta.persistence.EmbeddedId; import jakarta.persistence.Entity; @@ -33,7 +33,7 @@ public class MeetingMember extends BaseEntity { @MapsId("memberId") @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) - private User user; + private Member member; @Enumerated(EnumType.STRING) private MeetingRole role; diff --git a/src/main/java/com/whylog/server/domain/team/entity/TeamMember.java b/src/main/java/com/whylog/server/domain/team/entity/TeamMember.java index e0007c0..e4e314f 100644 --- a/src/main/java/com/whylog/server/domain/team/entity/TeamMember.java +++ b/src/main/java/com/whylog/server/domain/team/entity/TeamMember.java @@ -1,7 +1,7 @@ package com.whylog.server.domain.team.entity; import com.whylog.server.domain.team.enums.TeamRole; -import com.whylog.server.domain.user.entity.User; +import com.whylog.server.domain.user.entity.Member; import com.whylog.server.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.EmbeddedId; @@ -34,7 +34,7 @@ public class TeamMember extends BaseEntity { @MapsId("memberId") @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) - private User user; + private Member member; @Column(name = "is_active") private Boolean active; diff --git a/src/main/java/com/whylog/server/domain/user/controller/AuthController.java b/src/main/java/com/whylog/server/domain/user/controller/AuthController.java new file mode 100644 index 0000000..86db6b8 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/controller/AuthController.java @@ -0,0 +1,100 @@ +package com.whylog.server.domain.user.controller; + +import com.whylog.server.domain.user.dto.AccessTokenGenerateResponse; +import com.whylog.server.domain.user.dto.AuthRequest; +import com.whylog.server.domain.user.dto.AuthResponse; +import com.whylog.server.domain.user.service.AuthenticationService; +import com.whylog.server.domain.user.service.LocalLoginService; +import com.whylog.server.domain.user.exception.AuthSuccessStatus; +import com.whylog.server.global.auth.annotation.CurrentMember; +import com.whylog.server.global.auth.jwt.application.TokenService; +import com.whylog.server.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +@Tag(name = "Auth", description = "인증 관련 API") +public class AuthController { + + private static final String REFRESH_TOKEN = "refreshToken"; + private static final int COOKIE_MAX_AGE = 7 * 24 * 60 * 60; + + private final LocalLoginService localLoginService; + private final AuthenticationService authenticationService; + private final TokenService tokenService; + + @PostMapping("/signup") + @Operation(summary = "회원가입 API", description = "이메일과 비밀번호를 이용하여 새로운 회원을 등록하는 API입니다.") + public ApiResponse signup( + @Valid @RequestBody AuthRequest.SignUpDTO request, + HttpServletResponse httpServletResponse + ) { + AuthResponse.LoginResponseDTO response = localLoginService.signUp(request); + writeRefreshTokenCookie(httpServletResponse, response.getRefreshToken()); + return ApiResponse.of(AuthSuccessStatus.SIGN_UP_SUCCESS, response.withoutRefreshToken()); + } + + @PostMapping("/login") + @Operation(summary = "로그인 API", description = "이메일과 비밀번호를 이용하여 로그인하고 액세스 토큰을 발급받는 API입니다.") + public ApiResponse login( + @Valid @RequestBody AuthRequest.LoginDTO request, + HttpServletResponse httpServletResponse + ) { + AuthResponse.LoginResponseDTO response = localLoginService.login(request); + writeRefreshTokenCookie(httpServletResponse, response.getRefreshToken()); + return ApiResponse.of(AuthSuccessStatus.LOGIN_SUCCESS, response.withoutRefreshToken()); + } + + @PostMapping("/refresh-token") + @Operation(summary = "액세스 토큰 재발급 API", description = "리프레시 토큰으로 새 액세스 토큰을 발급합니다.") + public ApiResponse refreshToken( + @CookieValue(REFRESH_TOKEN) String refreshToken + ) { + return ApiResponse.of( + AuthSuccessStatus.REFRESH_TOKEN_SUCCESS, + authenticationService.generateAccessTokenFromRefreshToken(refreshToken) + ); + } + + @PostMapping("/logout") + @Operation(summary = "로그아웃 API", description = "현재 사용자의 리프레시 토큰을 삭제합니다.") + public ApiResponse logout( + @CurrentMember Long memberId, + HttpServletResponse httpServletResponse + ) { + tokenService.deleteRefreshToken(memberId); + expireRefreshTokenCookie(httpServletResponse); + return ApiResponse.of(AuthSuccessStatus.LOGOUT_SUCCESS, null); + } + + private void writeRefreshTokenCookie(HttpServletResponse httpServletResponse, String refreshToken) { + ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN, refreshToken) + .maxAge(COOKIE_MAX_AGE) + .path("/") + .httpOnly(true) + .sameSite("Lax") + .build(); + httpServletResponse.addHeader("Set-Cookie", cookie.toString()); + } + + private void expireRefreshTokenCookie(HttpServletResponse httpServletResponse) { + ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN, "") + .maxAge(0) + .path("/") + .httpOnly(true) + .sameSite("Lax") + .build(); + httpServletResponse.addHeader("Set-Cookie", cookie.toString()); + } +} diff --git a/src/main/java/com/whylog/server/domain/user/controller/UserController.java b/src/main/java/com/whylog/server/domain/user/controller/MemberController.java similarity index 80% rename from src/main/java/com/whylog/server/domain/user/controller/UserController.java rename to src/main/java/com/whylog/server/domain/user/controller/MemberController.java index ff41f95..69d3b2b 100644 --- a/src/main/java/com/whylog/server/domain/user/controller/UserController.java +++ b/src/main/java/com/whylog/server/domain/user/controller/MemberController.java @@ -3,5 +3,5 @@ import org.springframework.web.bind.annotation.RestController; @RestController -public class UserController { +public class MemberController { } diff --git a/src/main/java/com/whylog/server/domain/user/dto/AccessTokenGenerateResponse.java b/src/main/java/com/whylog/server/domain/user/dto/AccessTokenGenerateResponse.java new file mode 100644 index 0000000..fdb4fc2 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/dto/AccessTokenGenerateResponse.java @@ -0,0 +1,12 @@ +package com.whylog.server.domain.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AccessTokenGenerateResponse( + @Schema(description = "새 액세스 토큰") + String accessToken +) { + public static AccessTokenGenerateResponse from(String accessToken) { + return new AccessTokenGenerateResponse(accessToken); + } +} diff --git a/src/main/java/com/whylog/server/domain/auth/dto/AuthRequest.java b/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java similarity index 89% rename from src/main/java/com/whylog/server/domain/auth/dto/AuthRequest.java rename to src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java index c9a2f94..b015d5d 100644 --- a/src/main/java/com/whylog/server/domain/auth/dto/AuthRequest.java +++ b/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java @@ -1,8 +1,9 @@ -package com.whylog.server.domain.auth.dto; +package com.whylog.server.domain.user.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.*; public class AuthRequest { @@ -20,6 +21,7 @@ public static class SignUpDTO { @Schema(description = "비밀번호", example = "wtf1234") @NotBlank + @Size(min = 8, max = 100) private String password; } diff --git a/src/main/java/com/whylog/server/domain/user/dto/AuthResponse.java b/src/main/java/com/whylog/server/domain/user/dto/AuthResponse.java new file mode 100644 index 0000000..7a59029 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/dto/AuthResponse.java @@ -0,0 +1,73 @@ +package com.whylog.server.domain.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class AuthResponse { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "회원가입 응답") + public static class SignUpResponseDTO { + + @Schema(description = "멤버 ID", example = "1") + private Long memberId; + + @Schema(description = "이메일", example = "user@example.com") + private String email; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "로그인 응답") + public static class LoginResponseDTO { + + @Schema(description = "액세스 토큰", example = "accessstokenenenen...") + private String accessToken; + + @Schema(description = "리프레시 토큰", example = "refreshtokenenenen...") + private String refreshToken; + + @Schema(description = "멤버 ID", example = "1") + private Long memberId; + + @Schema(description = "이메일", example = "user@example.com") + private String email; + + @Schema(description = "권한", example = "ROLE_USER") + private String role; + + public static LoginResponseDTO of( + String accessToken, + String refreshToken, + Long memberId, + String email, + String role + ) { + return LoginResponseDTO.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .memberId(memberId) + .email(email) + .role(role) + .build(); + } + + public LoginResponseDTO withoutRefreshToken() { + return LoginResponseDTO.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .memberId(memberId) + .email(email) + .role(role) + .build(); + } + } +} diff --git a/src/main/java/com/whylog/server/domain/user/entity/User.java b/src/main/java/com/whylog/server/domain/user/entity/Member.java similarity index 60% rename from src/main/java/com/whylog/server/domain/user/entity/User.java rename to src/main/java/com/whylog/server/domain/user/entity/Member.java index 960da14..5e9b882 100644 --- a/src/main/java/com/whylog/server/domain/user/entity/User.java +++ b/src/main/java/com/whylog/server/domain/user/entity/Member.java @@ -1,21 +1,25 @@ package com.whylog.server.domain.user.entity; +import com.whylog.server.domain.user.enums.Role; import com.whylog.server.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Entity @Getter -@Table(name = "Member") +@Table(name = "member") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class User extends BaseEntity { +public class Member extends BaseEntity { @Id @Column(name = "member_id") @@ -31,4 +35,15 @@ public class User extends BaseEntity { @Column(name = "profile_image", length = 255) private String profileImage; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Role role; + + @Builder + private Member(String email, String password, String profileImage, Role role) { + this.email = email; + this.password = password; + this.profileImage = profileImage; + this.role = role; + } } diff --git a/src/main/java/com/whylog/server/domain/user/enums/.gitkeep b/src/main/java/com/whylog/server/domain/user/enums/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/whylog/server/domain/user/enums/Role.java b/src/main/java/com/whylog/server/domain/user/enums/Role.java new file mode 100644 index 0000000..6944737 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/enums/Role.java @@ -0,0 +1,19 @@ +package com.whylog.server.domain.user.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +@Getter +@RequiredArgsConstructor +public enum Role { + + USER("ROLE_USER"); + + private final String roleName; + + public GrantedAuthority toGrantedAuthority() { + return new SimpleGrantedAuthority(roleName); + } +} diff --git a/src/main/java/com/whylog/server/domain/user/exception/AuthErrorStatus.java b/src/main/java/com/whylog/server/domain/user/exception/AuthErrorStatus.java new file mode 100644 index 0000000..fc87bf9 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/exception/AuthErrorStatus.java @@ -0,0 +1,41 @@ +package com.whylog.server.domain.user.exception; + +import com.whylog.server.global.apiPayload.code.BaseErrorCode; +import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AuthErrorStatus implements BaseErrorCode { + + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "AUTH409_1", "이미 가입된 이메일입니다."), + LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH401_1", "이메일 또는 비밀번호가 올바르지 않습니다."), + INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "AUTH400_1", "유효하지 않은 리프레시 토큰입니다."), + REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH401_2", "리프레시 토큰이 만료되었습니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH404_1", "저장된 리프레시 토큰이 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/whylog/server/domain/user/exception/AuthSuccessStatus.java b/src/main/java/com/whylog/server/domain/user/exception/AuthSuccessStatus.java new file mode 100644 index 0000000..eaf4931 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/exception/AuthSuccessStatus.java @@ -0,0 +1,40 @@ +package com.whylog.server.domain.user.exception; + +import com.whylog.server.global.apiPayload.code.BaseCode; +import com.whylog.server.global.apiPayload.code.ReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AuthSuccessStatus implements BaseCode { + + SIGN_UP_SUCCESS(HttpStatus.OK, "AUTH200_1", "회원가입에 성공했습니다."), + LOGIN_SUCCESS(HttpStatus.OK, "AUTH200_2", "로그인에 성공했습니다."), + REFRESH_TOKEN_SUCCESS(HttpStatus.OK, "AUTH200_3", "액세스 토큰 재발급에 성공했습니다."), + LOGOUT_SUCCESS(HttpStatus.OK, "AUTH200_4", "로그아웃에 성공했습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDTO getReason() { + return ReasonDTO.builder() + .isSuccess(true) + .code(code) + .message(message) + .build(); + } + + @Override + public ReasonDTO getReasonHttpStatus() { + return ReasonDTO.builder() + .httpStatus(httpStatus) + .isSuccess(true) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/whylog/server/domain/user/repository/MemberRepository.java b/src/main/java/com/whylog/server/domain/user/repository/MemberRepository.java new file mode 100644 index 0000000..1858cbd --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/repository/MemberRepository.java @@ -0,0 +1,12 @@ +package com.whylog.server.domain.user.repository; + +import com.whylog.server.domain.user.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + boolean existsByEmail(String email); + + Optional findByEmail(String email); +} diff --git a/src/main/java/com/whylog/server/domain/user/repository/UserRepository.java b/src/main/java/com/whylog/server/domain/user/repository/UserRepository.java deleted file mode 100644 index 9e728da..0000000 --- a/src/main/java/com/whylog/server/domain/user/repository/UserRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.whylog.server.domain.user.repository; - -import com.whylog.server.domain.user.entity.User; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface UserRepository extends JpaRepository { -} diff --git a/src/main/java/com/whylog/server/domain/user/service/AuthenticationService.java b/src/main/java/com/whylog/server/domain/user/service/AuthenticationService.java new file mode 100644 index 0000000..4c28ed5 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/service/AuthenticationService.java @@ -0,0 +1,79 @@ +package com.whylog.server.domain.user.service; + +import com.whylog.server.domain.user.dto.AccessTokenGenerateResponse; +import com.whylog.server.domain.user.dto.AuthResponse; +import com.whylog.server.domain.user.exception.AuthErrorStatus; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.enums.Role; +import com.whylog.server.global.auth.jwt.application.TokenService; +import com.whylog.server.global.auth.jwt.provider.JwtTokenProvider; +import com.whylog.server.global.auth.jwt.provider.JwtValidationType; +import com.whylog.server.global.auth.security.MemberAuthentication; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AuthenticationService { + + private final JwtTokenProvider jwtTokenProvider; + private final TokenService tokenService; + + @Transactional + public AuthResponse.LoginResponseDTO generateLoginResponse(Member member) { + Role role = member.getRole(); + Collection authorities = List.of(role.toGrantedAuthority()); + UsernamePasswordAuthenticationToken authentication = createAuthentication(member.getId(), role, authorities); + + String refreshToken = jwtTokenProvider.issueRefreshToken(authentication); + tokenService.saveRefreshToken(member.getId(), refreshToken); + + String accessToken = jwtTokenProvider.issueAccessToken(authentication); + return AuthResponse.LoginResponseDTO.of( + accessToken, + refreshToken, + member.getId(), + member.getEmail(), + role.getRoleName() + ); + } + + @Transactional(readOnly = true) + public AccessTokenGenerateResponse generateAccessTokenFromRefreshToken(String refreshToken) { + JwtValidationType validationType = jwtTokenProvider.validateToken(refreshToken); + if (validationType != JwtValidationType.VALID_JWT) { + throw new ErrorHandler(switch (validationType) { + case EXPIRED_JWT_TOKEN -> AuthErrorStatus.REFRESH_TOKEN_EXPIRED; + case INVALID_JWT_TOKEN, INVALID_JWT_SIGNATURE, UNSUPPORTED_JWT_TOKEN, EMPTY_JWT -> + AuthErrorStatus.INVALID_REFRESH_TOKEN; + default -> AuthErrorStatus.INVALID_REFRESH_TOKEN; + }); + } + + Long memberId = jwtTokenProvider.getMemberIdFromJwt(refreshToken); + Long storedMemberId = tokenService.findIdByRefreshToken(refreshToken); + if (!memberId.equals(storedMemberId)) { + throw new ErrorHandler(AuthErrorStatus.INVALID_REFRESH_TOKEN); + } + + Role role = jwtTokenProvider.getRoleFromJwt(refreshToken); + Collection authorities = List.of(role.toGrantedAuthority()); + UsernamePasswordAuthenticationToken authentication = createAuthentication(memberId, role, authorities); + return AccessTokenGenerateResponse.from(jwtTokenProvider.issueAccessToken(authentication)); + } + + private UsernamePasswordAuthenticationToken createAuthentication( + Long memberId, + Role role, + Collection authorities + ) { + return new MemberAuthentication(memberId, null, authorities); + } +} diff --git a/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java b/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java new file mode 100644 index 0000000..c24d212 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java @@ -0,0 +1,49 @@ +package com.whylog.server.domain.user.service; + +import com.whylog.server.domain.user.dto.AuthRequest; +import com.whylog.server.domain.user.dto.AuthResponse; +import com.whylog.server.domain.user.exception.AuthErrorStatus; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.enums.Role; +import com.whylog.server.domain.user.repository.MemberRepository; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LocalLoginService { + + private final MemberRepository memberRepository; + private final AuthenticationService authenticationService; + private final PasswordEncoder passwordEncoder; + + @Transactional + public AuthResponse.LoginResponseDTO signUp(AuthRequest.SignUpDTO request) { + if (memberRepository.existsByEmail(request.getEmail())) { + throw new ErrorHandler(AuthErrorStatus.EMAIL_ALREADY_EXISTS); + } + + Member member = memberRepository.save(Member.builder() + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) + .role(Role.USER) + .build()); + + return authenticationService.generateLoginResponse(member); + } + + @Transactional + public AuthResponse.LoginResponseDTO login(AuthRequest.LoginDTO request) { + Member member = memberRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new ErrorHandler(AuthErrorStatus.LOGIN_FAILED)); + + if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { + throw new ErrorHandler(AuthErrorStatus.LOGIN_FAILED); + } + + return authenticationService.generateLoginResponse(member); + } +} diff --git a/src/main/java/com/whylog/server/domain/user/service/UserService.java b/src/main/java/com/whylog/server/domain/user/service/UserService.java deleted file mode 100644 index 0ff1a7f..0000000 --- a/src/main/java/com/whylog/server/domain/user/service/UserService.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.whylog.server.domain.user.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserService { - -} \ No newline at end of file diff --git a/src/main/java/com/whylog/server/global/auth/annotation/CurrentMember.java b/src/main/java/com/whylog/server/global/auth/annotation/CurrentMember.java new file mode 100644 index 0000000..accad64 --- /dev/null +++ b/src/main/java/com/whylog/server/global/auth/annotation/CurrentMember.java @@ -0,0 +1,16 @@ +package com.whylog.server.global.auth.annotation; + +import io.swagger.v3.oas.annotations.Parameter; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Parameter(hidden = true) +public @interface CurrentMember { +} diff --git a/src/main/java/com/whylog/server/global/auth/jwt/application/TokenService.java b/src/main/java/com/whylog/server/global/auth/jwt/application/TokenService.java new file mode 100644 index 0000000..586a564 --- /dev/null +++ b/src/main/java/com/whylog/server/global/auth/jwt/application/TokenService.java @@ -0,0 +1,35 @@ +package com.whylog.server.global.auth.jwt.application; + +import com.whylog.server.domain.user.exception.AuthErrorStatus; +import com.whylog.server.global.auth.jwt.dao.TokenRepository; +import com.whylog.server.global.auth.redis.Token; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TokenService { + + private final TokenRepository tokenRepository; + + @Transactional + public void saveRefreshToken(Long memberId, String refreshToken) { + tokenRepository.save(Token.of(memberId, refreshToken)); + } + + @Transactional(readOnly = true) + public Long findIdByRefreshToken(String refreshToken) { + return tokenRepository.findByRefreshToken(refreshToken) + .map(Token::getId) + .orElseThrow(() -> new ErrorHandler(AuthErrorStatus.REFRESH_TOKEN_NOT_FOUND)); + } + + @Transactional + public void deleteRefreshToken(Long memberId) { + Token token = tokenRepository.findById(memberId) + .orElseThrow(() -> new ErrorHandler(AuthErrorStatus.REFRESH_TOKEN_NOT_FOUND)); + tokenRepository.delete(token); + } +} diff --git a/src/main/java/com/whylog/server/global/auth/jwt/dao/TokenRepository.java b/src/main/java/com/whylog/server/global/auth/jwt/dao/TokenRepository.java new file mode 100644 index 0000000..7c914c1 --- /dev/null +++ b/src/main/java/com/whylog/server/global/auth/jwt/dao/TokenRepository.java @@ -0,0 +1,10 @@ +package com.whylog.server.global.auth.jwt.dao; + +import com.whylog.server.global.auth.redis.Token; +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface TokenRepository extends CrudRepository { + Optional findByRefreshToken(String refreshToken); +} diff --git a/src/main/java/com/whylog/server/global/auth/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/whylog/server/global/auth/jwt/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..857aef5 --- /dev/null +++ b/src/main/java/com/whylog/server/global/auth/jwt/filter/JwtAuthenticationFilter.java @@ -0,0 +1,75 @@ +package com.whylog.server.global.auth.jwt.filter; + +import com.whylog.server.domain.user.enums.Role; +import com.whylog.server.global.auth.jwt.provider.JwtTokenProvider; +import com.whylog.server.global.auth.jwt.provider.JwtValidationType; +import com.whylog.server.global.auth.security.MemberAuthentication; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + String token = getJwtFromRequest(request); + if (!StringUtils.hasText(token)) { + filterChain.doFilter(request, response); + return; + } + + JwtValidationType validationType = jwtTokenProvider.validateToken(token); + if (validationType != JwtValidationType.VALID_JWT) { + response.setStatus( + validationType == JwtValidationType.EXPIRED_JWT_TOKEN + ? HttpServletResponse.SC_UNAUTHORIZED + : HttpServletResponse.SC_BAD_REQUEST + ); + return; + } + + setAuthentication(token, request); + filterChain.doFilter(request, response); + } + + private void setAuthentication(String token, HttpServletRequest request) { + Long memberId = jwtTokenProvider.getMemberIdFromJwt(token); + Role role = jwtTokenProvider.getRoleFromJwt(token); + + Collection authorities = List.of(role.toGrantedAuthority()); + UsernamePasswordAuthenticationToken authentication = new MemberAuthentication(memberId, null, authorities); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/whylog/server/global/auth/jwt/provider/JwtTokenProvider.java b/src/main/java/com/whylog/server/global/auth/jwt/provider/JwtTokenProvider.java new file mode 100644 index 0000000..a5e7e37 --- /dev/null +++ b/src/main/java/com/whylog/server/global/auth/jwt/provider/JwtTokenProvider.java @@ -0,0 +1,115 @@ +package com.whylog.server.global.auth.jwt.provider; + +import com.whylog.server.domain.user.enums.Role; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Header; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Date; + +@Component +public class JwtTokenProvider { + + private static final String MEMBER_ID = "memberId"; + private static final String ROLE = "role"; + + @Value("${jwt.secret}") + private String jwtSecret; + + @Value("${jwt.access-token-expire-time}") + private long accessTokenExpireTime; + + @Value("${jwt.refresh-token-expire-time}") + private long refreshTokenExpireTime; + + @PostConstruct + protected void init() { + jwtSecret = Base64.getEncoder().encodeToString(jwtSecret.getBytes(StandardCharsets.UTF_8)); + } + + public String issueAccessToken(Authentication authentication) { + return issueToken(authentication, accessTokenExpireTime); + } + + public String issueRefreshToken(Authentication authentication) { + return issueToken(authentication, refreshTokenExpireTime); + } + + public JwtValidationType validateToken(String token) { + try { + getBody(token); + return JwtValidationType.VALID_JWT; + } catch (MalformedJwtException ex) { + return JwtValidationType.INVALID_JWT_TOKEN; + } catch (ExpiredJwtException ex) { + return JwtValidationType.EXPIRED_JWT_TOKEN; + } catch (UnsupportedJwtException ex) { + return JwtValidationType.UNSUPPORTED_JWT_TOKEN; + } catch (IllegalArgumentException ex) { + return JwtValidationType.EMPTY_JWT; + } catch (SignatureException ex) { + return JwtValidationType.INVALID_JWT_SIGNATURE; + } + } + + public Long getMemberIdFromJwt(String token) { + Claims claims = getBody(token); + Object memberId = claims.get(MEMBER_ID); + if (memberId != null) { + return Long.valueOf(memberId.toString()); + } + return Long.valueOf(claims.get(MEMBER_ID).toString()); + } + + public Role getRoleFromJwt(String token) { + Claims claims = getBody(token); + String roleName = claims.get(ROLE, String.class); + return Role.valueOf(roleName.replace("ROLE_", "")); + } + + private String issueToken(Authentication authentication, long expiredTime) { + Date now = new Date(); + Claims claims = Jwts.claims() + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + expiredTime)); + + claims.put(MEMBER_ID, authentication.getPrincipal()); + String role = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No authorities found for member")); + claims.put(ROLE, role); + + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setClaims(claims) + .signWith(getSigningKey()) + .compact(); + } + + private Claims getBody(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private SecretKey getSigningKey() { + String encodedKey = Base64.getEncoder().encodeToString(jwtSecret.getBytes(StandardCharsets.UTF_8)); + return Keys.hmacShaKeyFor(encodedKey.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/whylog/server/global/auth/jwt/provider/JwtValidationType.java b/src/main/java/com/whylog/server/global/auth/jwt/provider/JwtValidationType.java new file mode 100644 index 0000000..df35427 --- /dev/null +++ b/src/main/java/com/whylog/server/global/auth/jwt/provider/JwtValidationType.java @@ -0,0 +1,10 @@ +package com.whylog.server.global.auth.jwt.provider; + +public enum JwtValidationType { + VALID_JWT, + INVALID_JWT_TOKEN, + EXPIRED_JWT_TOKEN, + UNSUPPORTED_JWT_TOKEN, + EMPTY_JWT, + INVALID_JWT_SIGNATURE +} diff --git a/src/main/java/com/whylog/server/global/auth/redis/Token.java b/src/main/java/com/whylog/server/global/auth/redis/Token.java new file mode 100644 index 0000000..add9d50 --- /dev/null +++ b/src/main/java/com/whylog/server/global/auth/redis/Token.java @@ -0,0 +1,26 @@ +package com.whylog.server.global.auth.redis; + +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@Getter +@Builder +@RedisHash(value = "refreshToken", timeToLive = 1209600) +public class Token { + + @Id + private Long id; + + @Indexed + private String refreshToken; + + public static Token of(Long id, String refreshToken) { + return Token.builder() + .id(id) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/src/main/java/com/whylog/server/global/auth/resolver/CurrentMemberArgumentResolver.java b/src/main/java/com/whylog/server/global/auth/resolver/CurrentMemberArgumentResolver.java new file mode 100644 index 0000000..006e9b6 --- /dev/null +++ b/src/main/java/com/whylog/server/global/auth/resolver/CurrentMemberArgumentResolver.java @@ -0,0 +1,35 @@ +package com.whylog.server.global.auth.resolver; + +import com.whylog.server.global.auth.annotation.CurrentMember; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class CurrentMemberArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterAnnotation(CurrentMember.class) != null + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + return Long.valueOf(authentication.getPrincipal().toString()); + } +} diff --git a/src/main/java/com/whylog/server/global/auth/security/CustomAccessDeniedHandler.java b/src/main/java/com/whylog/server/global/auth/security/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..66b714e --- /dev/null +++ b/src/main/java/com/whylog/server/global/auth/security/CustomAccessDeniedHandler.java @@ -0,0 +1,36 @@ +package com.whylog.server.global.auth.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.whylog.server.global.apiPayload.ApiResponse; +import com.whylog.server.global.apiPayload.code.status.ErrorStatus; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + objectMapper.writeValue( + response.getWriter(), + ApiResponse.onFailure(ErrorStatus._FORBIDDEN.getCode(), ErrorStatus._FORBIDDEN.getMessage(), null) + ); + } +} diff --git a/src/main/java/com/whylog/server/global/auth/security/CustomJwtAuthenticationEntryPoint.java b/src/main/java/com/whylog/server/global/auth/security/CustomJwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..a61427e --- /dev/null +++ b/src/main/java/com/whylog/server/global/auth/security/CustomJwtAuthenticationEntryPoint.java @@ -0,0 +1,36 @@ +package com.whylog.server.global.auth.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.whylog.server.global.apiPayload.ApiResponse; +import com.whylog.server.global.apiPayload.code.status.ErrorStatus; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +public class CustomJwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + objectMapper.writeValue( + response.getWriter(), + ApiResponse.onFailure(ErrorStatus._UNAUTHORIZED.getCode(), ErrorStatus._UNAUTHORIZED.getMessage(), null) + ); + } +} diff --git a/src/main/java/com/whylog/server/global/auth/security/MemberAuthentication.java b/src/main/java/com/whylog/server/global/auth/security/MemberAuthentication.java new file mode 100644 index 0000000..0407bac --- /dev/null +++ b/src/main/java/com/whylog/server/global/auth/security/MemberAuthentication.java @@ -0,0 +1,17 @@ +package com.whylog.server.global.auth.security; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class MemberAuthentication extends UsernamePasswordAuthenticationToken { + + public MemberAuthentication( + Object principal, + Object credentials, + Collection authorities + ) { + super(principal, credentials, authorities); + } +} diff --git a/src/main/java/com/whylog/server/global/config/PasswordConfig.java b/src/main/java/com/whylog/server/global/config/PasswordConfig.java new file mode 100644 index 0000000..7eabe41 --- /dev/null +++ b/src/main/java/com/whylog/server/global/config/PasswordConfig.java @@ -0,0 +1,15 @@ +package com.whylog.server.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/whylog/server/global/config/SecurityConfig.java b/src/main/java/com/whylog/server/global/config/SecurityConfig.java new file mode 100644 index 0000000..3b22fa3 --- /dev/null +++ b/src/main/java/com/whylog/server/global/config/SecurityConfig.java @@ -0,0 +1,54 @@ +package com.whylog.server.global.config; + +import com.whylog.server.global.auth.jwt.filter.JwtAuthenticationFilter; +import com.whylog.server.global.auth.security.CustomAccessDeniedHandler; +import com.whylog.server.global.auth.security.CustomJwtAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(customJwtAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler)); + + http.authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers( + "/api/auth/signup", + "/api/auth/login", + "/api/auth/refresh-token", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/error" + ).permitAll() + .anyRequest().authenticated()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/src/main/java/com/whylog/server/global/config/WebConfig.java b/src/main/java/com/whylog/server/global/config/WebConfig.java index b08f2b8..3c20600 100644 --- a/src/main/java/com/whylog/server/global/config/WebConfig.java +++ b/src/main/java/com/whylog/server/global/config/WebConfig.java @@ -1,17 +1,22 @@ package com.whylog.server.global.config; +import com.whylog.server.global.auth.resolver.CurrentMemberArgumentResolver; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.Arrays; +import java.util.List; @Configuration @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { + private final CurrentMemberArgumentResolver currentMemberArgumentResolver; + @Value("${cors.allowed-origins}") private String[] allowedOrigins; @@ -28,4 +33,9 @@ public void addCorsMappings(CorsRegistry registry) { .allowedHeaders("*") .allowCredentials(true); } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentMemberArgumentResolver); + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 89daea3..93652a7 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -39,3 +39,8 @@ spring: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS} + +jwt: + secret: ${JWT_SECRET} + access-token-expire-time: ${JWT_ACCESS_TOKEN_EXPIRE_TIME:3600000} + refresh-token-expire-time: ${JWT_REFRESH_TOKEN_EXPIRE_TIME:1209600000} From fb920563b40ae31a96ac0162f28fa9daee36258d Mon Sep 17 00:00:00 2001 From: junyong Date: Fri, 27 Mar 2026 16:55:00 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20cd=20jdk=20=EB=B9=8C=EB=93=9C?= =?UTF-8?q?=EA=B3=BC=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 2ac7d07..2d0165c 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -23,6 +23,18 @@ jobs: with: ref: ${{ github.event.pull_request.merge_commit_sha }} + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Grant execute permission for Gradle + run: chmod +x ./gradlew + + - name: Build with Gradle + run: ./gradlew clean build + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 From 820b139882eb74e8db4361a30ff86fc78b5f8d7b Mon Sep 17 00:00:00 2001 From: junyong Date: Fri, 27 Mar 2026 17:16:15 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EC=9D=B8=EC=A6=9D=20api=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/AuthController.java | 90 ++++++++++++++++++- 1 file changed, 86 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/whylog/server/domain/user/controller/AuthController.java b/src/main/java/com/whylog/server/domain/user/controller/AuthController.java index 86db6b8..830e2cb 100644 --- a/src/main/java/com/whylog/server/domain/user/controller/AuthController.java +++ b/src/main/java/com/whylog/server/domain/user/controller/AuthController.java @@ -35,7 +35,30 @@ public class AuthController { private final TokenService tokenService; @PostMapping("/signup") - @Operation(summary = "회원가입 API", description = "이메일과 비밀번호를 이용하여 새로운 회원을 등록하는 API입니다.") + @Operation(summary = "회원가입 API", + description = """ + + 요청 형식: + - `email`: 필수값이며 빈 문자열일 수 없습니다. + - `email`: 이메일 형식을 만족해야 합니다. 예: `user@example.com` + + 처리 방식: + - 동일 이메일이 이미 존재하면 회원가입에 실패합니다. + - 회원 생성 직후 자동 로그인 처리되어 액세스 토큰과 리프레시 토큰이 함께 발급됩니다. + + 토큰 응답 방식: + - 액세스 토큰은 응답 바디로 반환됩니다. + - 리프레시 토큰은 응답 바디에도 존재하지만, 실제 클라이언트 사용은 `Set-Cookie` 헤더의 HttpOnly 쿠키(`refreshToken`) 기준입니다. + - refresh token 쿠키는 `HttpOnly`, `Path=/`, `SameSite=Lax` 속성으로 내려갑니다. + + TTL 정책: + - 액세스 토큰 TTL: 1시간 (`3600000ms`) + - 리프레시 토큰 JWT TTL: 14일 (`1209600000ms`) + - 리프레시 토큰 쿠키 Max-Age: 7일 + + 그래도... + - 쿠키로 처리하기 귀찮거나...번거로울 경우를 대비해 Refresh Token을 Response Body에도 담아 보냅니다...😎 + """) public ApiResponse signup( @Valid @RequestBody AuthRequest.SignUpDTO request, HttpServletResponse httpServletResponse @@ -46,7 +69,30 @@ public ApiResponse signup( } @PostMapping("/login") - @Operation(summary = "로그인 API", description = "이메일과 비밀번호를 이용하여 로그인하고 액세스 토큰을 발급받는 API입니다.") + @Operation(summary = "로그인 API", description = """ + + 요청 형식: + - `email`: 필수값이며 빈 문자열일 수 없습니다. + - `email`: 이메일 형식을 만족해야 합니다. + - `password`: 필수값이며 빈 문자열일 수 없습니다. + + 처리 방식: + - 이메일로 회원을 조회한 뒤 비밀번호를 검증합니다. + - 이메일 또는 비밀번호가 올바르지 않으면 로그인에 실패합니다. + + 토큰 응답 방식: + - 액세스 토큰은 응답 바디로 반환됩니다. + - 리프레시 토큰은 `Set-Cookie` 헤더를 통해 HttpOnly 쿠키(`refreshToken`)로 저장됩니다. + - refresh token 쿠키는 브라우저가 이후 요청에 자동 포함하도록 설계되어 있습니다. + + TTL 정책: + - 액세스 토큰 TTL: 1시간 (`3600000ms`) + - 리프레시 토큰 JWT TTL: 14일 (`1209600000ms`) + - 리프레시 토큰 쿠키 Max-Age: 7일 + + 그래도... + - 쿠키로 처리하기 귀찮거나...번거로울 경우를 대비해 Refresh Token을 Response Body에도 담아 보냅니다...😎 + """) public ApiResponse login( @Valid @RequestBody AuthRequest.LoginDTO request, HttpServletResponse httpServletResponse @@ -57,7 +103,29 @@ public ApiResponse login( } @PostMapping("/refresh-token") - @Operation(summary = "액세스 토큰 재발급 API", description = "리프레시 토큰으로 새 액세스 토큰을 발급합니다.") + @Operation(summary = "액세스 토큰 재발급 API", description = """ + + 요청 방식: + - 요청 바디는 사용하지 않습니다. + - 브라우저 또는 클라이언트는 쿠키에 저장된 `refreshToken`을 함께 전송해야 합니다. + - 서버는 `Cookie` 헤더에서 `refreshToken` 값을 읽습니다. + + 요청 예시: + - `POST /api/auth/refresh-token` + - `Cookie: refreshToken={refresh_token}` + + 검증 방식: + - 리프레시 토큰의 서명/형식/만료 여부를 검증합니다. + - 저장소에 보관된 리프레시 토큰과 사용자 식별자가 일치하는지 검증합니다. + + 응답 방식: + - 새 액세스 토큰만 응답 바디로 반환합니다. + - 리프레시 토큰은 이 API에서 재발급하지 않습니다. + + TTL 정책: + - 새 액세스 토큰 TTL: 1시간 (`3600000ms`) + - 기존 리프레시 토큰 JWT TTL: 14일 (`1209600000ms`) + """) public ApiResponse refreshToken( @CookieValue(REFRESH_TOKEN) String refreshToken ) { @@ -68,7 +136,21 @@ public ApiResponse refreshToken( } @PostMapping("/logout") - @Operation(summary = "로그아웃 API", description = "현재 사용자의 리프레시 토큰을 삭제합니다.") + @Operation(summary = "로그아웃 API", description = """ + + 요청 방식: + - `Authorization: Bearer {access_token}` 헤더가 필요합니다. + - 서버는 액세스 토큰에서 현재 사용자 식별자를 추출합니다. + - 요청 바디는 사용하지 않습니다. + + 처리 방식: + - 저장소에 보관 중인 리프레시 토큰을 삭제합니다. + - 응답 시 `refreshToken` 쿠키를 만료 처리합니다. + + 쿠키 처리 방식: + - `Set-Cookie: refreshToken=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax` + - 브라우저 기준으로 refresh token 쿠키가 제거됩니다. + """) public ApiResponse logout( @CurrentMember Long memberId, HttpServletResponse httpServletResponse