From cb788ed385e2dc95af01a70f5ed3460f8e0c468d Mon Sep 17 00:00:00 2001 From: ahnbs Date: Wed, 10 Dec 2025 12:02:43 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat(auth)=20:=20entity=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1,=20jwt=20=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db_dev.mv.db | Bin 24576 -> 0 bytes .../back/kalender/domain/auth/entity/.gitkeep | 0 .../domain/auth/entity/EmailVerification.java | 46 ++++++++++++++++++ .../auth/entity/PasswordResetToken.java | 46 ++++++++++++++++++ .../domain/auth/entity/RefreshToken.java | 40 +++++++++++++++ .../kalender/domain/auth/repository/.gitkeep | 0 .../EmailVerificationRepository.java | 12 +++++ .../PasswordResetTokenRepository.java | 11 +++++ .../repository/RefreshTokenRepository.java | 13 +++++ .../back/kalender/global/security/.gitkeep | 0 .../security/jwt/JwtAuthEntryPoint.java | 4 ++ .../global/security/jwt/JwtAuthFilter.java | 4 ++ .../global/security/jwt/JwtProperties.java | 8 +++ .../global/security/jwt/JwtTokenProvider.java | 4 ++ src/main/resources/application.yml | 5 +- 15 files changed, 192 insertions(+), 1 deletion(-) delete mode 100644 db_dev.mv.db delete mode 100644 src/main/java/back/kalender/domain/auth/entity/.gitkeep create mode 100644 src/main/java/back/kalender/domain/auth/entity/EmailVerification.java create mode 100644 src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java create mode 100644 src/main/java/back/kalender/domain/auth/entity/RefreshToken.java delete mode 100644 src/main/java/back/kalender/domain/auth/repository/.gitkeep create mode 100644 src/main/java/back/kalender/domain/auth/repository/EmailVerificationRepository.java create mode 100644 src/main/java/back/kalender/domain/auth/repository/PasswordResetTokenRepository.java create mode 100644 src/main/java/back/kalender/domain/auth/repository/RefreshTokenRepository.java delete mode 100644 src/main/java/back/kalender/global/security/.gitkeep create mode 100644 src/main/java/back/kalender/global/security/jwt/JwtAuthEntryPoint.java create mode 100644 src/main/java/back/kalender/global/security/jwt/JwtAuthFilter.java create mode 100644 src/main/java/back/kalender/global/security/jwt/JwtProperties.java create mode 100644 src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java diff --git a/db_dev.mv.db b/db_dev.mv.db deleted file mode 100644 index f03abc54bd1c7d33c8c895449b0f6031eee958ae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24576 zcmeHP&2QVt6({A7c%67Ro1(X(m{mS(wOTDXBt=rEr9{f+Mxq>&a^e$9QB-0?mRwm* z;}!)Li=sXBQlN(dMfaXd(Oc1L3-nej_Rt=B=(&GE?|mGSvLs82yl}Eu>`wn&U(*FLO$q*ey_ymgF)yI!*+>RTO1Zt zPE}g@PJeLh4q_i~-qS+`5tPV=CtJfx;tzxOoFfTuEm zV6}eB0hvK(b^qjEx!Z5qVJ94fy&$|7N&De4Me5u!WjrPP%2FZ^yPHM#pF8%PeVUCx!-S}v|^B-iDf zspXF9gAS8isoj!RQ?rhROYoXs8H-t!EwTzLuo5e?BIxpymCsk0Rp!Pq=(ljxUXKPb z07Eu7-bgIJ84qivrE@WhA60)XJJI_Ce`4NY)-nv6nd_QnQFiMWZ0xY2 zreoO1tRk!7BIAkaxDLuXS{6B4%|+XMASnSB+JPjulu+ci(Vph)p>3>+VHgJ3mzAQ( z;f|UG$0>97+`z@Q=K>QxYq%OpK_vl5qPvSDMUZS{6bgm9D8bL{Qltf_Wg=)@vXQyFX z8zzAd2=%182YubCvaR?#YZ zhK^jVTr*I1zge!Cm24ra*)GJ{ylSIXYdm(cJDXALFKg;qR5o`_%SF3}W!S(b(NXyj z=|)v+LM%>mkf|G%Yq}Tb8WysewHk7aM@_@3j5p#OI<8Uo(kq@U^RmRtWsg%h-4pey z;VD&7_u#K<6}c!1qL3v~z^KN2EK}SM-P7DGa?QHoxLSRGr zD+)1=Ri3~wBg1Cy9=f}AoS>L?VGO~q);W;gcF|+g-9tPo8?}bD>v(R%Bl@|IKG?F7 z7WZwluGxp^p>a6YXmZwrvmVYOz<$0;BW>m$p!-`thcGGxSYKlT5DnE)rC~X)twjcK zQSrsesLe6Kc1RP*v~=UiSpG>Nx4{~}SjrmTmKhqVE>x9r(c=uA^TeV8W~fwTuPTX} zpbBNFD$3VvhM>z`!)w~LEUN5jwpNK0asc`@t=%0gNvSnkTHTnJWi&80b30cv&E-10 z&@^*kn!8JzW_6}%f_{m?`~Yn7#Uh(@vP&5wv~o3LtY&`*#`wq8q#tvpMkksun>8by z6w~AJY2;1JjunGf%4)gl$z>VLQW3CME^^od^U!&{B8jS|UNX!16Ie1o)=cZ+Jc~-Q z>>GBqVb?X7yvO>OTpabj0@Lhn!#>O+!)n%dwl&K#4-DG@Bi_!VZSApU>z=dMcpM#- zP21hmHFBkFH#EJXIc{_T=8}qGlU>8DL}#j+<<$+tg^`dRI_9oL_F=L&542jeUxUUx zn41i(9vwC;=$PTzM$OP1V>>q*9MfV)w~U!L4!DVdj^_-=QFWWS{ME+Va#~zC);@%> z_Gsy`CQXgCRADgK(%nd_xdM#3PubbSF3q^yxPula#$8&PG_&w-AZ0&-zWpreThlV( zk%8Qf9i+qKh-p>aOXZsn9o)1Uyj^`%b}Xgh6lHr)e_Y&mERWoIy!o?DJe@}Odk0lb z+i`QNw*iJ%b6|)E)1ujDfiPJ#yFWWwB1P^ib!_#0!0X2eUeov4>O--1sMQ2suR3C* zY&I)Tq()Pze8QOtGOv9^7@IyOP`U#s{c9mgqesX2Q}qJ*vIu4z1BQ9@e6y22jYejA z38n?LT=dd5s)ijseZtD|)H7~+*MT7o6MN+$!>s)17Rs>SyD}-7ESdTwZILt}X`7@W zNjnK~dW#U8k9zI!Wr3F_j^yhiRmZi)OlY1T=UWTo2l8vk*OOdq{B7f6f1gmmQ+hd0 zKAV^>yyIfb5GOQt__iYXrsC-0WrlYe%or~wFJDX^YmHAo;kcl=G`N#dDmxny4Sx3>d4*ZO;8VoB3cQe4@rR{(6;YTg ze%~eKRa}Wx0k6hL`Dua$9>vCH5-d8OC(@-~-;5)0=^Hq}0U5DAV(B6_&P?gVI660r zo)gRT8u4&S+8O762*Cd!@jju=wZ(Xs!zz!H-L|TNTB0`ta(7}L$iIIN#~(8*U;Y!e z472*`OR}X2MSvne5ugZA1SkR&0g3=cfFdx4K)n8+uD_@2|KnfLkJkU?H@5!&e}2x8 A+5i9m 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..f887afc --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/entity/EmailVerification.java @@ -0,0 +1,46 @@ +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 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) { + EmailVerification ev = new EmailVerification(); + ev.userId = userId; + ev.code = code; + ev.used = false; + ev.expiredAt = LocalDateTime.now().plusMinutes(5); + return ev; + } + + public void markUsed() { + this.used = true; + } +} 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..b23251a --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java @@ -0,0 +1,46 @@ +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_user_id", columnList = "userId"), + @Index(name = "idx_token", columnList = "token") + } +) +public class PasswordResetToken extends BaseEntity { + + private Long userId; + + @Column(nullable = false, length = 100) + private String token; + + private boolean used; + + @Column(nullable = false) + private LocalDateTime expiredAt; + + public static PasswordResetToken create(Long userId, String code) { + PasswordResetToken token = new PasswordResetToken(); + token.userId = userId; + token.token = code; + token.used = false; + token.expiredAt = LocalDateTime.now().plusMinutes(5); + return token; + } + + public void markUsed() { + this.used = true; + } +} \ 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..c5edeac --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java @@ -0,0 +1,40 @@ +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_user_id", columnList = "userId"), + @Index(name = "idx_token", columnList = "token") + } +) +public class RefreshToken extends BaseEntity { + + private Long userId; + + @Column(nullable = false, length = 100) + 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; + } + +} \ 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..b0addbc --- /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 findByCode(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..c736708 --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,13 @@ +package back.kalender.domain.auth.repository; + +import back.kalender.domain.auth.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByToken(String token); + List findAllByUserId(Long userId); + void deleteAllByUserId(Long userId); +} \ No newline at end of file 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/jwt/JwtAuthEntryPoint.java b/src/main/java/back/kalender/global/security/jwt/JwtAuthEntryPoint.java new file mode 100644 index 0000000..ce90517 --- /dev/null +++ b/src/main/java/back/kalender/global/security/jwt/JwtAuthEntryPoint.java @@ -0,0 +1,4 @@ +package back.kalender.global.security.jwt; + +public class JwtAuthEntryPoint { +} 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..a88139f --- /dev/null +++ b/src/main/java/back/kalender/global/security/jwt/JwtAuthFilter.java @@ -0,0 +1,4 @@ +package back.kalender.global.security.jwt; + +public class JwtAuthFilter { +} 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..5761735 --- /dev/null +++ b/src/main/java/back/kalender/global/security/jwt/JwtProperties.java @@ -0,0 +1,8 @@ +package back.kalender.global.security.jwt; + +import lombok.Getter; + +@Getter +public class JwtProperties { + +} 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..758e257 --- /dev/null +++ b/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,4 @@ +package back.kalender.global.security.jwt; + +public class JwtTokenProvider { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5a69115..3a1cdd1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -54,5 +54,8 @@ custom: frontUrl: "${custom.dev.frontUrl}" jwt: - expireSeconds: "#{30 * 60}" + accessToken: + expireSeconds: 1800 + refreshToken: + expireDays: 14 secretPattern: ${custom.jwt.secretPattern} \ No newline at end of file From f9d5a1d48b4321d9d61b12a4bc4ec130b482057e Mon Sep 17 00:00:00 2001 From: ahnbs Date: Wed, 10 Dec 2025 17:05:51 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat(auth)=20:=20jwt=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 + .../back/kalender/KalenderApplication.java | 2 + .../global/security/SecurityConfig.java | 11 +- .../security/jwt/JwtAuthEntryPoint.java | 41 ++++++- .../global/security/jwt/JwtAuthFilter.java | 47 ++++++- .../global/security/jwt/JwtProperties.java | 20 +++ .../global/security/jwt/JwtTokenProvider.java | 115 ++++++++++++++++++ src/main/resources/application.yml | 9 +- 8 files changed, 242 insertions(+), 8 deletions(-) diff --git a/build.gradle b/build.gradle index 675faa6..20084ea 100644 --- a/build.gradle +++ b/build.gradle @@ -41,6 +41,11 @@ 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.7' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.7' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.7' } 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/global/security/SecurityConfig.java b/src/main/java/back/kalender/global/security/SecurityConfig.java index bbd958c..b8b8615 100644 --- a/src/main/java/back/kalender/global/security/SecurityConfig.java +++ b/src/main/java/back/kalender/global/security/SecurityConfig.java @@ -1,10 +1,13 @@ 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.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; /** * Spring Security 설정 @@ -15,9 +18,15 @@ public class SecurityConfig { @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + 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 비활성화 + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .authorizeHttpRequests(auth -> auth .anyRequest().permitAll() // 모든 요청 허용 (개발용) ); diff --git a/src/main/java/back/kalender/global/security/jwt/JwtAuthEntryPoint.java b/src/main/java/back/kalender/global/security/jwt/JwtAuthEntryPoint.java index ce90517..4b5d934 100644 --- a/src/main/java/back/kalender/global/security/jwt/JwtAuthEntryPoint.java +++ b/src/main/java/back/kalender/global/security/jwt/JwtAuthEntryPoint.java @@ -1,4 +1,43 @@ package back.kalender.global.security.jwt; -public class JwtAuthEntryPoint { +import com.fasterxml.jackson.databind.ObjectMapper; +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 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 index a88139f..449d63f 100644 --- a/src/main/java/back/kalender/global/security/jwt/JwtAuthFilter.java +++ b/src/main/java/back/kalender/global/security/jwt/JwtAuthFilter.java @@ -1,4 +1,49 @@ package back.kalender.global.security.jwt; -public class JwtAuthFilter { +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 index 5761735..1230cd4 100644 --- a/src/main/java/back/kalender/global/security/jwt/JwtProperties.java +++ b/src/main/java/back/kalender/global/security/jwt/JwtProperties.java @@ -1,8 +1,28 @@ 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; + + public JwtProperties(String secret, TokenExpiration tokenExpiration) { + this.secret = secret; + this.tokenExpiration = tokenExpiration; + } + + @Getter + public static class TokenExpiration { + private final long access; // custom.jwt.tokenExpiration.access + private final long refresh; // custom.jwt.tokenExpiration.refresh + + public TokenExpiration(long access, long refresh) { + this.access = access; + this.refresh = refresh; + } + } } diff --git a/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java b/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java index 758e257..c69bbb6 100644 --- a/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java @@ -1,4 +1,119 @@ package back.kalender.global.security.jwt; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Map; + +@Component +@RequiredArgsConstructor public class JwtTokenProvider { + + private final JwtProperties jwtProperties; + private final UserDetailsService userDetailsService; + + private SecretKey signingKey; + private long accessTokenValidityInMillis; + private long refreshTokenValidityInMillis; + + @PostConstruct + public void init() { + // secret 문자열로 HMAC 키 생성 + this.signingKey = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)); + + // access: 초 단위 → 밀리초 변환 + this.accessTokenValidityInMillis = jwtProperties.getTokenExpiration().getAccess() * 1000; + // refresh: 일 단위 → 밀리초 변환 + this.refreshTokenValidityInMillis = jwtProperties.getTokenExpiration().getRefresh() * 24 * 60 * 60 * 1000; + } + + public String createAccessToken(String subject, Map additionalClaims) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + accessTokenValidityInMillis); + + JwtBuilder builder = Jwts.builder() + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(signingKey); + + if (additionalClaims != null && !additionalClaims.isEmpty()) { + builder.addClaims(additionalClaims); + } + + return builder.compact(); + } + + public String createRefreshToken(String subject, Map additionalClaims) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + refreshTokenValidityInMillis); + + 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 (SecurityException | MalformedJwtException e) { + // 잘못된 서명 or 토큰 형식 + return false; + } catch (ExpiredJwtException e) { + // 만료 + return false; + } catch (UnsupportedJwtException e) { + return false; + } catch (IllegalArgumentException e) { + 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 username = getSubject(token); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + return new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3a1cdd1..8c6832c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -54,8 +54,7 @@ custom: frontUrl: "${custom.dev.frontUrl}" jwt: - accessToken: - expireSeconds: 1800 - refreshToken: - expireDays: 14 - secretPattern: ${custom.jwt.secretPattern} \ No newline at end of file + tokenExpiration: + access: 1800 + refresh: 14 + secret: ${custom.jwt.secretPattern} \ No newline at end of file From c7213beea4497829de652f77944ac558dc0d4c71 Mon Sep 17 00:00:00 2001 From: ahnbs Date: Thu, 11 Dec 2025 10:49:42 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(auth)=20:=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC,=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20securityContext=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + build.gradle | 8 +- .../auth/controller/UserAuthController.java | 326 ++++++++++++++---- .../auth/entity/PasswordResetToken.java | 4 +- .../domain/auth/entity/RefreshToken.java | 4 +- .../kalender/domain/auth/service/.gitkeep | 0 .../domain/auth/service/AuthService.java | 19 + .../domain/auth/service/AuthServiceImpl.java | 288 ++++++++++++++++ .../service/CustomUserDetailsService.java | 44 +++ .../kalender/domain/user/entity/User.java | 15 +- .../user/repository/UserRepository.java | 1 + .../kalender/global/exception/ErrorCode.java | 17 +- .../global/security/SecurityConfig.java | 7 + .../security/jwt/JwtAuthEntryPoint.java | 2 +- .../global/security/jwt/JwtProperties.java | 4 +- .../global/security/jwt/JwtTokenProvider.java | 23 +- .../security/user/CustomUserDetails.java | 59 ++++ .../global/security/util/SecurityUtil.java | 67 ++++ src/main/resources/application.yml | 2 +- 19 files changed, 813 insertions(+), 78 deletions(-) delete mode 100644 src/main/java/back/kalender/domain/auth/service/.gitkeep create mode 100644 src/main/java/back/kalender/domain/auth/service/AuthService.java create mode 100644 src/main/java/back/kalender/domain/auth/service/AuthServiceImpl.java create mode 100644 src/main/java/back/kalender/domain/auth/service/CustomUserDetailsService.java create mode 100644 src/main/java/back/kalender/global/security/user/CustomUserDetails.java create mode 100644 src/main/java/back/kalender/global/security/util/SecurityUtil.java 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 20084ea..d35c4aa 100644 --- a/build.gradle +++ b/build.gradle @@ -43,9 +43,11 @@ dependencies { runtimeOnly("com.mysql:mysql-connector-j") // JWT 라이브러리 - implementation 'io.jsonwebtoken:jjwt-api:0.12.7' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.7' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.7' + 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/domain/auth/controller/UserAuthController.java b/src/main/java/back/kalender/domain/auth/controller/UserAuthController.java index c389041..b100667 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,95 @@ 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.user.CustomUserDetails; +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); + + // Refresh Token 쿠키 삭제 + jakarta.servlet.http.Cookie cookie = new jakarta.servlet.http.Cookie("refreshToken", null); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + return ResponseEntity.ok().build(); } @@ -59,14 +98,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 +133,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 +165,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 +227,142 @@ 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 + // @Parameter(hidden = true) + // @org.springframework.security.core.annotation.AuthenticationPrincipal CustomUserDetails userDetails ) { - // 항상 성공 반환 - // 실제 구현 시 -> Authorization 헤더에서 Bearer 토큰 추출하여 JWT 파싱, 유저 정보 추출 후 DB에서 인증 상태 조회 - EmailStatusResponse response = new EmailStatusResponse( - 1L, - "user@example.com", - true, - java.time.LocalDateTime.now() - ); + Long userId = SecurityUtil.getCurrentUserIdOrThrow(); + + EmailStatusResponse response = authService.getEmailStatus(userId); return ResponseEntity.ok(response); } } diff --git a/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java b/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java index b23251a..790036c 100644 --- a/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java +++ b/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java @@ -15,8 +15,8 @@ @Table( name = "password_reset_tokens", indexes = { - @Index(name = "idx_user_id", columnList = "userId"), - @Index(name = "idx_token", columnList = "token") + @Index(name = "idx_password_user_id", columnList = "userId"), + @Index(name = "idx_password_token", columnList = "token") } ) public class PasswordResetToken extends BaseEntity { diff --git a/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java b/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java index c5edeac..3767013 100644 --- a/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java +++ b/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java @@ -15,8 +15,8 @@ @Table( name = "refresh_tokens", indexes = { - @Index(name = "idx_user_id", columnList = "userId"), - @Index(name = "idx_token", columnList = "token") + @Index(name = "idx_refresh_user_id", columnList = "userId"), + @Index(name = "idx_refresh_token", columnList = "token") } ) public class RefreshToken extends BaseEntity { 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..b59667e --- /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); + 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..88dfedb --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/service/AuthServiceImpl.java @@ -0,0 +1,288 @@ +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.HashMap; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthServiceImpl implements AuthService { + + 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); + } + + // 토큰 생성 + Map claims = new HashMap<>(); + claims.put("userId", user.getId()); + claims.put("email", user.getEmail()); + + String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), claims); + String refreshToken = jwtTokenProvider.createRefreshToken(user.getEmail(), claims); + + // Refresh Token DB 저장 + RefreshToken refreshTokenEntity = RefreshToken.create( + user.getId(), + refreshToken, + jwtProperties.getTokenExpiration().getRefresh() + ); + refreshTokenRepository.save(refreshTokenEntity); + + // Access Token을 Response Header에 설정 + response.setHeader("Authorization", "Bearer " + accessToken); + + // Refresh Token을 httpOnly secure 쿠키로 설정 + Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken); + refreshTokenCookie.setHttpOnly(true); + refreshTokenCookie.setSecure(true); + refreshTokenCookie.setPath("/"); + refreshTokenCookie.setMaxAge((int) (jwtProperties.getTokenExpiration().getRefresh() * 24 * 60 * 60)); + response.addCookie(refreshTokenCookie); + + return new UserLoginResponse( + user.getId(), + user.getNickname(), + user.getEmail(), + user.getProfileImage(), + user.getEmailVerified() != null ? user.getEmailVerified() : false + ); + } + + @Override + @Transactional + public void logout(String refreshToken) { + if (refreshToken != null) { + refreshTokenRepository.findByToken(refreshToken) + .ifPresent(refreshTokenRepository::delete); + } + } + + @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.getExpiredAt().isBefore(LocalDateTime.now())) { + refreshTokenRepository.delete(refreshTokenEntity); + throw new ServiceException(ErrorCode.EXPIRED_REFRESH_TOKEN); + } + + // 유저 조회 + User user = userRepository.findById(refreshTokenEntity.getUserId()) + .orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); + + // 새 토큰 생성 + Map claims = new HashMap<>(); + claims.put("userId", user.getId()); + claims.put("email", user.getEmail()); + + String newAccessToken = jwtTokenProvider.createAccessToken(user.getEmail(), claims); + String newRefreshToken = jwtTokenProvider.createRefreshToken(user.getEmail(), claims); + + // 기존 Refresh Token 삭제 + refreshTokenRepository.delete(refreshTokenEntity); + + // 새 Refresh Token 저장 + RefreshToken newRefreshTokenEntity = RefreshToken.create( + user.getId(), + newRefreshToken, + jwtProperties.getTokenExpiration().getRefresh() + ); + refreshTokenRepository.save(newRefreshTokenEntity); + + // Access Token을 Response Header에 설정 + response.setHeader("Authorization", "Bearer " + newAccessToken); + + // Refresh Token을 httpOnly secure 쿠키로 설정 + Cookie refreshTokenCookie = new Cookie("refreshToken", newRefreshToken); + refreshTokenCookie.setHttpOnly(true); + refreshTokenCookie.setSecure(true); + refreshTokenCookie.setPath("/"); + refreshTokenCookie.setMaxAge((int) (jwtProperties.getTokenExpiration().getRefresh() * 24 * 60 * 60)); + response.addCookie(refreshTokenCookie); + } + + @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.getExpiredAt().isBefore(LocalDateTime.now())) { + 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); + } + + // 최근 5분 이내 발송된 인증 코드 확인 (재발송 제한) + emailVerificationRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId()) + .ifPresent(verification -> { + if (verification.getCreatedAt().isAfter(LocalDateTime.now().minusMinutes(1))) { + 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.findByCode(request.code()) + .orElseThrow(() -> new ServiceException(ErrorCode.EMAIL_VERIFICATION_CODE_NOT_FOUND)); + + // 유저 ID 일치 확인 + if (!verification.getUserId().equals(user.getId())) { + throw new ServiceException(ErrorCode.INVALID_EMAIL_VERIFICATION_CODE); + } + + // 사용 여부 확인 + if (verification.isUsed()) { + throw new ServiceException(ErrorCode.INVALID_EMAIL_VERIFICATION_CODE); + } + + // 만료 확인 + if (verification.getExpiredAt().isBefore(LocalDateTime.now())) { + 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 + ); + } +} + 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..a803308 --- /dev/null +++ b/src/main/java/back/kalender/domain/auth/service/CustomUserDetailsService.java @@ -0,0 +1,44 @@ +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 = Collections.singletonList( + new SimpleGrantedAuthority("ROLE_USER") + ); + + return new CustomUserDetails( + user.getId(), + user.getEmail(), + user.getPassword(), + authorities + ); + } +} + 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/global/exception/ErrorCode.java b/src/main/java/back/kalender/global/exception/ErrorCode.java index d15cf97..c3dc030 100644 --- a/src/main/java/back/kalender/global/exception/ErrorCode.java +++ b/src/main/java/back/kalender/global/exception/ErrorCode.java @@ -46,10 +46,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/SecurityConfig.java b/src/main/java/back/kalender/global/security/SecurityConfig.java index b8b8615..5c46785 100644 --- a/src/main/java/back/kalender/global/security/SecurityConfig.java +++ b/src/main/java/back/kalender/global/security/SecurityConfig.java @@ -6,6 +6,8 @@ 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; @@ -17,6 +19,11 @@ @EnableWebSecurity public class SecurityConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean public JwtAuthFilter jwtAuthFilter(JwtTokenProvider jwtTokenProvider) { return new JwtAuthFilter(jwtTokenProvider); diff --git a/src/main/java/back/kalender/global/security/jwt/JwtAuthEntryPoint.java b/src/main/java/back/kalender/global/security/jwt/JwtAuthEntryPoint.java index 4b5d934..3d6279d 100644 --- a/src/main/java/back/kalender/global/security/jwt/JwtAuthEntryPoint.java +++ b/src/main/java/back/kalender/global/security/jwt/JwtAuthEntryPoint.java @@ -1,6 +1,5 @@ package back.kalender.global.security.jwt; -import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -8,6 +7,7 @@ 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; diff --git a/src/main/java/back/kalender/global/security/jwt/JwtProperties.java b/src/main/java/back/kalender/global/security/jwt/JwtProperties.java index 1230cd4..4a56fb6 100644 --- a/src/main/java/back/kalender/global/security/jwt/JwtProperties.java +++ b/src/main/java/back/kalender/global/security/jwt/JwtProperties.java @@ -17,8 +17,8 @@ public JwtProperties(String secret, TokenExpiration tokenExpiration) { @Getter public static class TokenExpiration { - private final long access; // custom.jwt.tokenExpiration.access - private final long refresh; // custom.jwt.tokenExpiration.refresh + private final long access; + private final long refresh; public TokenExpiration(long access, long refresh) { this.access = access; diff --git a/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java b/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java index c69bbb6..224d535 100644 --- a/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java @@ -31,10 +31,7 @@ public class JwtTokenProvider { public void init() { // secret 문자열로 HMAC 키 생성 this.signingKey = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)); - - // access: 초 단위 → 밀리초 변환 this.accessTokenValidityInMillis = jwtProperties.getTokenExpiration().getAccess() * 1000; - // refresh: 일 단위 → 밀리초 변환 this.refreshTokenValidityInMillis = jwtProperties.getTokenExpiration().getRefresh() * 24 * 60 * 60 * 1000; } @@ -84,10 +81,8 @@ public boolean validateToken(String token) { .parseClaimsJws(token); return true; } catch (SecurityException | MalformedJwtException e) { - // 잘못된 서명 or 토큰 형식 return false; } catch (ExpiredJwtException e) { - // 만료 return false; } catch (UnsupportedJwtException e) { return false; @@ -116,4 +111,22 @@ public Authentication getAuthentication(String token) { userDetails.getAuthorities() ); } + + public Long getUserId(String token) { + Claims claims = Jwts.parser() + .verifyWith(signingKey) + .build() + .parseClaimsJws(token) + .getBody(); + + Object userIdObj = claims.get("userId"); + if (userIdObj instanceof Integer) { + return ((Integer) userIdObj).longValue(); + } else if (userIdObj instanceof Long) { + return (Long) userIdObj; + } else if (userIdObj instanceof Number) { + return ((Number) userIdObj).longValue(); + } + 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..90cd910 --- /dev/null +++ b/src/main/java/back/kalender/global/security/util/SecurityUtil.java @@ -0,0 +1,67 @@ +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 { + + /** + * SecurityContext에서 현재 인증된 사용자의 ID를 가져옵니다. + * @return 사용자 ID, 인증되지 않은 경우 null + */ + public static Long getCurrentUserId() { + Authentication authentication = SecurityContextHolder.getContext().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; + } + + /** + * SecurityContext에서 현재 인증된 사용자의 이메일을 가져옵니다. + * @return 사용자 이메일, 인증되지 않은 경우 null + */ + public static String getCurrentUserEmail() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated()) { + return authentication.getName(); + } + return null; + } + + /** + * SecurityContext에서 현재 인증된 사용자의 CustomUserDetails를 가져옵니다. + * @return CustomUserDetails, 인증되지 않은 경우 null + */ + public static CustomUserDetails getCurrentUserDetails() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated()) { + Object principal = authentication.getPrincipal(); + if (principal instanceof CustomUserDetails) { + return (CustomUserDetails) principal; + } + } + return null; + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8c6832c..4800bb8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -57,4 +57,4 @@ custom: tokenExpiration: access: 1800 refresh: 14 - secret: ${custom.jwt.secretPattern} \ No newline at end of file + secret: ${JWT_SECRET} \ No newline at end of file From 7a1d0ca40e8e2fe0bb7e51494234672aefb068dc Mon Sep 17 00:00:00 2001 From: ahnbs Date: Thu, 11 Dec 2025 12:02:26 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat(user)=20:=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/entity/PasswordResetToken.java | 2 +- .../domain/auth/entity/RefreshToken.java | 2 +- .../user/controller/UserController.java | 16 +------ .../domain/user/service/UserService.java | 3 ++ .../domain/user/service/UserServiceImpl.java | 47 +++++++++++++++++++ .../kalender/global/exception/ErrorCode.java | 1 + 6 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java b/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java index 790036c..5507735 100644 --- a/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java +++ b/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java @@ -23,7 +23,7 @@ public class PasswordResetToken extends BaseEntity { private Long userId; - @Column(nullable = false, length = 100) + @Column(nullable = false, length = 500) private String token; private boolean used; diff --git a/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java b/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java index 3767013..20fb007 100644 --- a/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java +++ b/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java @@ -23,7 +23,7 @@ public class RefreshToken extends BaseEntity { private Long userId; - @Column(nullable = false, length = 100) + @Column(nullable = false, length = 1000) private String token; @Column(nullable = false) 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/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 c3dc030..93c33fa 100644 --- a/src/main/java/back/kalender/global/exception/ErrorCode.java +++ b/src/main/java/back/kalender/global/exception/ErrorCode.java @@ -12,6 +12,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,"아티스트를 찾을 수 없습니다."), From e62be103c0ad1e23b927598f072f4e52a8990a42 Mon Sep 17 00:00:00 2001 From: ahnbs Date: Thu, 11 Dec 2025 12:12:41 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix(auth)=20:=20test.yml=EC=97=90=20jwt?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=EA=B0=80=20=EC=97=86=EC=96=B4=EC=84=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=86=B5=EA=B3=BC=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8->=20=EC=B6=94=EA=B0=80=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-test.yml | 7 +++++++ .../back/kalender/KalenderApplicationTests.java | 13 ------------- 2 files changed, 7 insertions(+), 13 deletions(-) delete mode 100644 src/test/java/back/kalender/KalenderApplicationTests.java 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/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() { - } - -} From faa7d2ca7e306b20ca8d553fceaacb1c11ffed55 Mon Sep 17 00:00:00 2001 From: ahnbs Date: Thu, 11 Dec 2025 12:44:17 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix=20:=20ci=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) 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 From f86a814ae5f1aa2e9452184ef3c7a9aebb3e8950 Mon Sep 17 00:00:00 2001 From: ahnbs Date: Thu, 11 Dec 2025 14:38:23 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor(auth)=20:=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0,=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95,=20secure=EC=84=A4=EC=A0=95=20yml?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=9C=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/AuthServiceImpl.java | 78 ++++++++++++------- .../global/security/jwt/JwtProperties.java | 13 +++- src/main/resources/application-prop.yml | 6 +- src/main/resources/application.yml | 4 +- 4 files changed, 68 insertions(+), 33 deletions(-) diff --git a/src/main/java/back/kalender/domain/auth/service/AuthServiceImpl.java b/src/main/java/back/kalender/domain/auth/service/AuthServiceImpl.java index 88dfedb..c2c5e24 100644 --- a/src/main/java/back/kalender/domain/auth/service/AuthServiceImpl.java +++ b/src/main/java/back/kalender/domain/auth/service/AuthServiceImpl.java @@ -23,6 +23,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; @@ -54,12 +55,8 @@ public UserLoginResponse login(UserLoginRequest request, HttpServletResponse res } // 토큰 생성 - Map claims = new HashMap<>(); - claims.put("userId", user.getId()); - claims.put("email", user.getEmail()); - - String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), claims); - String refreshToken = jwtTokenProvider.createRefreshToken(user.getEmail(), claims); + String accessToken = createAccessToken(user); + String refreshToken = createRefreshToken(user); // Refresh Token DB 저장 RefreshToken refreshTokenEntity = RefreshToken.create( @@ -70,15 +67,10 @@ public UserLoginResponse login(UserLoginRequest request, HttpServletResponse res refreshTokenRepository.save(refreshTokenEntity); // Access Token을 Response Header에 설정 - response.setHeader("Authorization", "Bearer " + accessToken); + applyAccessTokenHeader(response, accessToken); // Refresh Token을 httpOnly secure 쿠키로 설정 - Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken); - refreshTokenCookie.setHttpOnly(true); - refreshTokenCookie.setSecure(true); - refreshTokenCookie.setPath("/"); - refreshTokenCookie.setMaxAge((int) (jwtProperties.getTokenExpiration().getRefresh() * 24 * 60 * 60)); - response.addCookie(refreshTokenCookie); + response.addCookie(buildRefreshTokenCookie(refreshToken)); return new UserLoginResponse( user.getId(), @@ -110,7 +102,7 @@ public void refreshToken(String refreshToken, HttpServletResponse response) { .orElseThrow(() -> new ServiceException(ErrorCode.INVALID_REFRESH_TOKEN)); // 만료 확인 - if (refreshTokenEntity.getExpiredAt().isBefore(LocalDateTime.now())) { + if (isExpired(refreshTokenEntity.getExpiredAt())) { refreshTokenRepository.delete(refreshTokenEntity); throw new ServiceException(ErrorCode.EXPIRED_REFRESH_TOKEN); } @@ -120,12 +112,8 @@ public void refreshToken(String refreshToken, HttpServletResponse response) { .orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); // 새 토큰 생성 - Map claims = new HashMap<>(); - claims.put("userId", user.getId()); - claims.put("email", user.getEmail()); - - String newAccessToken = jwtTokenProvider.createAccessToken(user.getEmail(), claims); - String newRefreshToken = jwtTokenProvider.createRefreshToken(user.getEmail(), claims); + String newAccessToken = createAccessToken(user); + String newRefreshToken = createRefreshToken(user); // 기존 Refresh Token 삭제 refreshTokenRepository.delete(refreshTokenEntity); @@ -139,15 +127,10 @@ public void refreshToken(String refreshToken, HttpServletResponse response) { refreshTokenRepository.save(newRefreshTokenEntity); // Access Token을 Response Header에 설정 - response.setHeader("Authorization", "Bearer " + newAccessToken); + applyAccessTokenHeader(response, newAccessToken); // Refresh Token을 httpOnly secure 쿠키로 설정 - Cookie refreshTokenCookie = new Cookie("refreshToken", newRefreshToken); - refreshTokenCookie.setHttpOnly(true); - refreshTokenCookie.setSecure(true); - refreshTokenCookie.setPath("/"); - refreshTokenCookie.setMaxAge((int) (jwtProperties.getTokenExpiration().getRefresh() * 24 * 60 * 60)); - response.addCookie(refreshTokenCookie); + response.addCookie(buildRefreshTokenCookie(newRefreshToken)); } @Override @@ -186,7 +169,7 @@ public void resetPassword(UserPasswordResetRequest request) { } // 만료 확인 - if (resetToken.getExpiredAt().isBefore(LocalDateTime.now())) { + if (isExpired(resetToken.getExpiredAt())) { throw new ServiceException(ErrorCode.EXPIRED_PASSWORD_RESET_TOKEN); } @@ -214,7 +197,7 @@ public void sendVerifyEmail(VerifyEmailSendRequest request) { // 최근 5분 이내 발송된 인증 코드 확인 (재발송 제한) emailVerificationRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId()) .ifPresent(verification -> { - if (verification.getCreatedAt().isAfter(LocalDateTime.now().minusMinutes(1))) { + if (verification.getCreatedAt().isAfter(LocalDateTime.now().minusMinutes(5))) { throw new ServiceException(ErrorCode.EMAIL_VERIFICATION_LIMIT_EXCEEDED); } }); @@ -254,7 +237,7 @@ public VerifyEmailResponse verifyEmail(VerifyEmailRequest request) { } // 만료 확인 - if (verification.getExpiredAt().isBefore(LocalDateTime.now())) { + if (isExpired(verification.getExpiredAt())) { throw new ServiceException(ErrorCode.EXPIRED_EMAIL_VERIFICATION_CODE); } @@ -284,5 +267,40 @@ public EmailStatusResponse getEmailStatus(Long userId) { verifiedAt ); } + + // ------------------------------ HELPERS ------------------------------------------ + private Map buildUserClaims(User user) { + Map claims = new HashMap<>(); + claims.put("userId", user.getId()); + claims.put("email", user.getEmail()); + return claims; + } + + private String createAccessToken(User user) { + return jwtTokenProvider.createAccessToken(user.getEmail(), buildUserClaims(user)); + } + private String createRefreshToken(User user) { + return jwtTokenProvider.createRefreshToken(user.getEmail(), buildUserClaims(user)); + } + + private void applyAccessTokenHeader(HttpServletResponse response, String accessToken) { + response.setHeader("Authorization", "Bearer " + accessToken); + } + + 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().getRefresh() * 24 * 60 * 60)); + return cookie; + } + + private boolean isExpired(LocalDateTime expiredAt) { + LocalDateTime now = LocalDateTime.now(); + Duration remaining = Duration.between(now, expiredAt); + // 만료 시간이 지금보다 과거면 음수 or 0 + return !remaining.isPositive(); // 0 또는 음수면 만료로 봄 + } } diff --git a/src/main/java/back/kalender/global/security/jwt/JwtProperties.java b/src/main/java/back/kalender/global/security/jwt/JwtProperties.java index 4a56fb6..feb4083 100644 --- a/src/main/java/back/kalender/global/security/jwt/JwtProperties.java +++ b/src/main/java/back/kalender/global/security/jwt/JwtProperties.java @@ -9,10 +9,12 @@ public class JwtProperties { private final String secret; private final TokenExpiration tokenExpiration; + private final CookieProperties cookie; - public JwtProperties(String secret, TokenExpiration tokenExpiration) { + public JwtProperties(String secret, TokenExpiration tokenExpiration, CookieProperties cookie) { this.secret = secret; this.tokenExpiration = tokenExpiration; + this.cookie = cookie; } @Getter @@ -25,4 +27,13 @@ public TokenExpiration(long access, long refresh) { this.refresh = refresh; } } + + @Getter + public static class CookieProperties { + private final boolean secure; + + public CookieProperties(boolean secure) { + this.secure = secure; + } + } } 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.yml b/src/main/resources/application.yml index 4800bb8..e152ea7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -57,4 +57,6 @@ custom: tokenExpiration: access: 1800 refresh: 14 - secret: ${JWT_SECRET} \ No newline at end of file + secret: ${JWT_SECRET} + cookie: + secure: false \ No newline at end of file From 36ff244808f2d841311a4e20da82bcd206fa55ed Mon Sep 17 00:00:00 2001 From: ahnbs Date: Thu, 11 Dec 2025 15:02:20 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor(auth)=20:=20userid=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=EC=97=90=20nullable=20=EC=84=A4=EC=A0=95,=20=EB=A6=AC?= =?UTF-8?q?=ED=94=84=EB=A0=88=EC=8B=9C=ED=86=A0=ED=81=B0=20=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=EC=97=90=20=ED=95=84=EC=9A=94=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../back/kalender/domain/auth/entity/EmailVerification.java | 1 + .../back/kalender/domain/auth/entity/PasswordResetToken.java | 1 + .../java/back/kalender/domain/auth/entity/RefreshToken.java | 1 + .../domain/auth/repository/RefreshTokenRepository.java | 3 --- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/back/kalender/domain/auth/entity/EmailVerification.java b/src/main/java/back/kalender/domain/auth/entity/EmailVerification.java index f887afc..b18d381 100644 --- a/src/main/java/back/kalender/domain/auth/entity/EmailVerification.java +++ b/src/main/java/back/kalender/domain/auth/entity/EmailVerification.java @@ -21,6 +21,7 @@ ) public class EmailVerification extends BaseEntity { + @Column(nullable = false) private Long userId; @Column(nullable = false, length = 50) diff --git a/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java b/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java index 5507735..8deb816 100644 --- a/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java +++ b/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java @@ -21,6 +21,7 @@ ) public class PasswordResetToken extends BaseEntity { + @Column(nullable = false) private Long userId; @Column(nullable = false, length = 500) diff --git a/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java b/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java index 20fb007..e4f7331 100644 --- a/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java +++ b/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java @@ -21,6 +21,7 @@ ) public class RefreshToken extends BaseEntity { + @Column(nullable = false) private Long userId; @Column(nullable = false, length = 1000) diff --git a/src/main/java/back/kalender/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/back/kalender/domain/auth/repository/RefreshTokenRepository.java index c736708..448ac24 100644 --- a/src/main/java/back/kalender/domain/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/back/kalender/domain/auth/repository/RefreshTokenRepository.java @@ -3,11 +3,8 @@ import back.kalender.domain.auth.entity.RefreshToken; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; import java.util.Optional; public interface RefreshTokenRepository extends JpaRepository { Optional findByToken(String token); - List findAllByUserId(Long userId); - void deleteAllByUserId(Long userId); } \ No newline at end of file From b1e08b534dd2b4b3d1f26fec76c7de8ac1e63e38 Mon Sep 17 00:00:00 2001 From: ahnbs Date: Thu, 11 Dec 2025 17:25:26 +0900 Subject: [PATCH 09/10] =?UTF-8?q?refactor(auth)=20:=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9,=20=EB=A7=A4=EC=A7=81=EB=84=98=EB=B2=84?= =?UTF-8?q?=20=EC=83=81=EC=88=98=ED=99=94,=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0=20=EB=93=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/UserAuthController.java | 17 +-- .../domain/auth/entity/EmailVerification.java | 12 +- .../auth/entity/PasswordResetToken.java | 12 +- .../domain/auth/entity/RefreshToken.java | 4 + .../EmailVerificationRepository.java | 2 +- .../domain/auth/service/AuthService.java | 2 +- .../domain/auth/service/AuthServiceImpl.java | 114 ++++++++---------- .../service/CustomUserDetailsService.java | 8 +- .../global/security/jwt/JwtProperties.java | 22 +++- .../global/security/jwt/JwtTokenProvider.java | 86 ++++++------- .../global/security/util/SecurityUtil.java | 25 ++-- 11 files changed, 158 insertions(+), 146 deletions(-) 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 b100667..0d49f58 100644 --- a/src/main/java/back/kalender/domain/auth/controller/UserAuthController.java +++ b/src/main/java/back/kalender/domain/auth/controller/UserAuthController.java @@ -3,7 +3,6 @@ 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.user.CustomUserDetails; import back.kalender.global.security.util.SecurityUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -81,16 +80,7 @@ public ResponseEntity logout( @CookieValue(value = "refreshToken", required = false) String refreshToken, HttpServletResponse response ) { - authService.logout(refreshToken); - - // Refresh Token 쿠키 삭제 - jakarta.servlet.http.Cookie cookie = new jakarta.servlet.http.Cookie("refreshToken", null); - cookie.setHttpOnly(true); - cookie.setSecure(true); - cookie.setPath("/"); - cookie.setMaxAge(0); - response.addCookie(cookie); - + authService.logout(refreshToken, response); return ResponseEntity.ok().build(); } @@ -356,10 +346,7 @@ public ResponseEntity verifyEmail( } """))) }) - public ResponseEntity getEmailStatus( - // @Parameter(hidden = true) - // @org.springframework.security.core.annotation.AuthenticationPrincipal CustomUserDetails userDetails - ) { + public ResponseEntity getEmailStatus() { Long userId = SecurityUtil.getCurrentUserIdOrThrow(); EmailStatusResponse response = authService.getEmailStatus(userId); diff --git a/src/main/java/back/kalender/domain/auth/entity/EmailVerification.java b/src/main/java/back/kalender/domain/auth/entity/EmailVerification.java index b18d381..77b222b 100644 --- a/src/main/java/back/kalender/domain/auth/entity/EmailVerification.java +++ b/src/main/java/back/kalender/domain/auth/entity/EmailVerification.java @@ -21,6 +21,8 @@ ) public class EmailVerification extends BaseEntity { + private static final int DEFAULT_EXPIRY_MINUTES = 5; + @Column(nullable = false) private Long userId; @@ -33,15 +35,23 @@ public class EmailVerification extends BaseEntity { 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(5); + 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 index 8deb816..6bb7cba 100644 --- a/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java +++ b/src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java @@ -21,6 +21,8 @@ ) public class PasswordResetToken extends BaseEntity { + private static final int DEFAULT_EXPIRY_MINUTES = 5; + @Column(nullable = false) private Long userId; @@ -33,15 +35,23 @@ public class PasswordResetToken extends BaseEntity { 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(5); + 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 index e4f7331..0a84dba 100644 --- a/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java +++ b/src/main/java/back/kalender/domain/auth/entity/RefreshToken.java @@ -38,4 +38,8 @@ public static RefreshToken create(Long userId, String token, long 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/EmailVerificationRepository.java b/src/main/java/back/kalender/domain/auth/repository/EmailVerificationRepository.java index b0addbc..8a85cca 100644 --- a/src/main/java/back/kalender/domain/auth/repository/EmailVerificationRepository.java +++ b/src/main/java/back/kalender/domain/auth/repository/EmailVerificationRepository.java @@ -7,6 +7,6 @@ public interface EmailVerificationRepository extends JpaRepository { Optional findTopByUserIdOrderByCreatedAtDesc(Long userId); - Optional findByCode(String code); + 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/service/AuthService.java b/src/main/java/back/kalender/domain/auth/service/AuthService.java index b59667e..d07b67c 100644 --- a/src/main/java/back/kalender/domain/auth/service/AuthService.java +++ b/src/main/java/back/kalender/domain/auth/service/AuthService.java @@ -8,7 +8,7 @@ public interface AuthService { UserLoginResponse login(UserLoginRequest request, HttpServletResponse response); - void logout(String refreshToken); + void logout(String refreshToken, HttpServletResponse response); void refreshToken(String refreshToken, HttpServletResponse response); void sendPasswordResetEmail(UserPasswordResetSendRequest request); void resetPassword(UserPasswordResetRequest request); diff --git a/src/main/java/back/kalender/domain/auth/service/AuthServiceImpl.java b/src/main/java/back/kalender/domain/auth/service/AuthServiceImpl.java index c2c5e24..e1cdcfe 100644 --- a/src/main/java/back/kalender/domain/auth/service/AuthServiceImpl.java +++ b/src/main/java/back/kalender/domain/auth/service/AuthServiceImpl.java @@ -23,10 +23,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.Duration; import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.Map; import java.util.UUID; @Service @@ -34,6 +31,8 @@ @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; @@ -54,23 +53,8 @@ public UserLoginResponse login(UserLoginRequest request, HttpServletResponse res throw new ServiceException(ErrorCode.INVALID_CREDENTIALS); } - // 토큰 생성 - String accessToken = createAccessToken(user); - String refreshToken = createRefreshToken(user); - - // Refresh Token DB 저장 - RefreshToken refreshTokenEntity = RefreshToken.create( - user.getId(), - refreshToken, - jwtProperties.getTokenExpiration().getRefresh() - ); - refreshTokenRepository.save(refreshTokenEntity); - - // Access Token을 Response Header에 설정 - applyAccessTokenHeader(response, accessToken); - - // Refresh Token을 httpOnly secure 쿠키로 설정 - response.addCookie(buildRefreshTokenCookie(refreshToken)); + // 토큰 생성, 저장 및 응답 설정 + createAndSaveTokens(user, response); return new UserLoginResponse( user.getId(), @@ -83,11 +67,14 @@ public UserLoginResponse login(UserLoginRequest request, HttpServletResponse res @Override @Transactional - public void logout(String refreshToken) { + public void logout(String refreshToken, HttpServletResponse response) { if (refreshToken != null) { refreshTokenRepository.findByToken(refreshToken) .ifPresent(refreshTokenRepository::delete); } + + // Refresh Token 쿠키 삭제 + clearRefreshTokenCookie(response); } @Override @@ -102,7 +89,7 @@ public void refreshToken(String refreshToken, HttpServletResponse response) { .orElseThrow(() -> new ServiceException(ErrorCode.INVALID_REFRESH_TOKEN)); // 만료 확인 - if (isExpired(refreshTokenEntity.getExpiredAt())) { + if (refreshTokenEntity.isExpired()) { refreshTokenRepository.delete(refreshTokenEntity); throw new ServiceException(ErrorCode.EXPIRED_REFRESH_TOKEN); } @@ -111,26 +98,11 @@ public void refreshToken(String refreshToken, HttpServletResponse response) { User user = userRepository.findById(refreshTokenEntity.getUserId()) .orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); - // 새 토큰 생성 - String newAccessToken = createAccessToken(user); - String newRefreshToken = createRefreshToken(user); - // 기존 Refresh Token 삭제 refreshTokenRepository.delete(refreshTokenEntity); - // 새 Refresh Token 저장 - RefreshToken newRefreshTokenEntity = RefreshToken.create( - user.getId(), - newRefreshToken, - jwtProperties.getTokenExpiration().getRefresh() - ); - refreshTokenRepository.save(newRefreshTokenEntity); - - // Access Token을 Response Header에 설정 - applyAccessTokenHeader(response, newAccessToken); - - // Refresh Token을 httpOnly secure 쿠키로 설정 - response.addCookie(buildRefreshTokenCookie(newRefreshToken)); + // 새 토큰 생성, 저장 및 응답 설정 + createAndSaveTokens(user, response); } @Override @@ -169,7 +141,7 @@ public void resetPassword(UserPasswordResetRequest request) { } // 만료 확인 - if (isExpired(resetToken.getExpiredAt())) { + if (resetToken.isExpired()) { throw new ServiceException(ErrorCode.EXPIRED_PASSWORD_RESET_TOKEN); } @@ -194,10 +166,10 @@ public void sendVerifyEmail(VerifyEmailSendRequest request) { throw new ServiceException(ErrorCode.EMAIL_ALREADY_VERIFIED); } - // 최근 5분 이내 발송된 인증 코드 확인 (재발송 제한) + // 최근 N분 이내 발송된 인증 코드 확인 (재발송 제한) emailVerificationRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId()) .ifPresent(verification -> { - if (verification.getCreatedAt().isAfter(LocalDateTime.now().minusMinutes(5))) { + if (verification.getCreatedAt().isAfter(LocalDateTime.now().minusMinutes(EMAIL_VERIFICATION_RESEND_LIMIT_MINUTES))) { throw new ServiceException(ErrorCode.EMAIL_VERIFICATION_LIMIT_EXCEEDED); } }); @@ -222,22 +194,18 @@ public VerifyEmailResponse verifyEmail(VerifyEmailRequest request) { User user = userRepository.findByEmail(request.email()) .orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); - // 인증 코드 조회 - EmailVerification verification = emailVerificationRepository.findByCode(request.code()) + // 인증 코드 조회 + EmailVerification verification = emailVerificationRepository + .findByUserIdAndCode(user.getId(), request.code()) .orElseThrow(() -> new ServiceException(ErrorCode.EMAIL_VERIFICATION_CODE_NOT_FOUND)); - // 유저 ID 일치 확인 - if (!verification.getUserId().equals(user.getId())) { - throw new ServiceException(ErrorCode.INVALID_EMAIL_VERIFICATION_CODE); - } - // 사용 여부 확인 if (verification.isUsed()) { throw new ServiceException(ErrorCode.INVALID_EMAIL_VERIFICATION_CODE); } // 만료 확인 - if (isExpired(verification.getExpiredAt())) { + if (verification.isExpired()) { throw new ServiceException(ErrorCode.EXPIRED_EMAIL_VERIFICATION_CODE); } @@ -269,22 +237,36 @@ public EmailStatusResponse getEmailStatus(Long userId) { } // ------------------------------ HELPERS ------------------------------------------ - private Map buildUserClaims(User user) { - Map claims = new HashMap<>(); - claims.put("userId", user.getId()); - claims.put("email", user.getEmail()); - return claims; + 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) { - return jwtTokenProvider.createAccessToken(user.getEmail(), buildUserClaims(user)); + long validityInMillis = jwtProperties.getTokenExpiration().getAccessInMillis(); + return jwtTokenProvider.createToken(String.valueOf(user.getId()), validityInMillis); } + private String createRefreshToken(User user) { - return jwtTokenProvider.createRefreshToken(user.getEmail(), buildUserClaims(user)); + long validityInMillis = jwtProperties.getTokenExpiration().getRefreshInMillis(); + return jwtTokenProvider.createToken(String.valueOf(user.getId()), validityInMillis); } - private void applyAccessTokenHeader(HttpServletResponse response, String accessToken) { + private void setTokensToResponse(HttpServletResponse response, String accessToken, String refreshToken) { response.setHeader("Authorization", "Bearer " + accessToken); + response.addCookie(buildRefreshTokenCookie(refreshToken)); } private Cookie buildRefreshTokenCookie(String token) { @@ -292,15 +274,19 @@ private Cookie buildRefreshTokenCookie(String token) { cookie.setHttpOnly(true); cookie.setSecure(jwtProperties.getCookie().isSecure()); cookie.setPath("/"); - cookie.setMaxAge((int) (jwtProperties.getTokenExpiration().getRefresh() * 24 * 60 * 60)); + cookie.setMaxAge((int) jwtProperties.getTokenExpiration().getRefreshInSeconds()); return cookie; } - private boolean isExpired(LocalDateTime expiredAt) { - LocalDateTime now = LocalDateTime.now(); - Duration remaining = Duration.between(now, expiredAt); - // 만료 시간이 지금보다 과거면 음수 or 0 - return !remaining.isPositive(); // 0 또는 음수면 만료로 봄 + 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 index a803308..ceee165 100644 --- a/src/main/java/back/kalender/domain/auth/service/CustomUserDetailsService.java +++ b/src/main/java/back/kalender/domain/auth/service/CustomUserDetailsService.java @@ -29,9 +29,7 @@ public UserDetails loadUserByUsername(String email) throws UsernameNotFoundExcep User user = userRepository.findByEmail(email) .orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND)); - List authorities = Collections.singletonList( - new SimpleGrantedAuthority("ROLE_USER") - ); + List authorities = createAuthorities("ROLE_USER"); return new CustomUserDetails( user.getId(), @@ -40,5 +38,9 @@ public UserDetails loadUserByUsername(String email) throws UsernameNotFoundExcep authorities ); } + + private List createAuthorities(String roleName) { + return Collections.singletonList(new SimpleGrantedAuthority(roleName)); + } } diff --git a/src/main/java/back/kalender/global/security/jwt/JwtProperties.java b/src/main/java/back/kalender/global/security/jwt/JwtProperties.java index feb4083..cc72e5d 100644 --- a/src/main/java/back/kalender/global/security/jwt/JwtProperties.java +++ b/src/main/java/back/kalender/global/security/jwt/JwtProperties.java @@ -19,13 +19,31 @@ public JwtProperties(String secret, TokenExpiration tokenExpiration, CookiePrope @Getter public static class TokenExpiration { - private final long access; - private final long refresh; + 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 diff --git a/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java b/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java index 224d535..b6940f7 100644 --- a/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/back/kalender/global/security/jwt/JwtTokenProvider.java @@ -1,60 +1,52 @@ 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.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; +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 UserDetailsService userDetailsService; + private final UserRepository userRepository; private SecretKey signingKey; - private long accessTokenValidityInMillis; - private long refreshTokenValidityInMillis; @PostConstruct public void init() { // secret 문자열로 HMAC 키 생성 this.signingKey = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)); - this.accessTokenValidityInMillis = jwtProperties.getTokenExpiration().getAccess() * 1000; - this.refreshTokenValidityInMillis = jwtProperties.getTokenExpiration().getRefresh() * 24 * 60 * 60 * 1000; } - public String createAccessToken(String subject, Map additionalClaims) { - Date now = new Date(); - Date expiry = new Date(now.getTime() + accessTokenValidityInMillis); - - JwtBuilder builder = Jwts.builder() - .setSubject(subject) - .setIssuedAt(now) - .setExpiration(expiry) - .signWith(signingKey); - - if (additionalClaims != null && !additionalClaims.isEmpty()) { - builder.addClaims(additionalClaims); - } - - return builder.compact(); + public String createToken(String subject, long validityInMillis) { + return createToken(subject, null, validityInMillis); } - public String createRefreshToken(String subject, Map additionalClaims) { + public String createToken(String subject, Map additionalClaims, long validityInMillis) { Date now = new Date(); - Date expiry = new Date(now.getTime() + refreshTokenValidityInMillis); + Date expiry = new Date(now.getTime() + validityInMillis); JwtBuilder builder = Jwts.builder() .setSubject(subject) @@ -80,13 +72,14 @@ public boolean validateToken(String token) { .build() .parseClaimsJws(token); return true; - } catch (SecurityException | MalformedJwtException e) { - return false; } catch (ExpiredJwtException e) { + // 만료된 토큰은 정상적인 케이스 return false; - } catch (UnsupportedJwtException e) { + } 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; } } @@ -102,8 +95,20 @@ public String getSubject(String token) { } public Authentication getAuthentication(String token) { - String username = getSubject(token); - UserDetails userDetails = userDetailsService.loadUserByUsername(username); + 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, @@ -112,21 +117,16 @@ public Authentication getAuthentication(String token) { ); } - public Long getUserId(String token) { - Claims claims = Jwts.parser() - .verifyWith(signingKey) - .build() - .parseClaimsJws(token) - .getBody(); + private List createAuthorities(String roleName) { + return Collections.singletonList(new SimpleGrantedAuthority(roleName)); + } - Object userIdObj = claims.get("userId"); - if (userIdObj instanceof Integer) { - return ((Integer) userIdObj).longValue(); - } else if (userIdObj instanceof Long) { - return (Long) userIdObj; - } else if (userIdObj instanceof Number) { - return ((Number) userIdObj).longValue(); + public Long getUserId(String token) { + String userIdStr = getSubject(token); + try { + return Long.parseLong(userIdStr); + } catch (NumberFormatException e) { + return null; } - return null; } } diff --git a/src/main/java/back/kalender/global/security/util/SecurityUtil.java b/src/main/java/back/kalender/global/security/util/SecurityUtil.java index 90cd910..3b9e804 100644 --- a/src/main/java/back/kalender/global/security/util/SecurityUtil.java +++ b/src/main/java/back/kalender/global/security/util/SecurityUtil.java @@ -8,12 +8,9 @@ public class SecurityUtil { - /** - * SecurityContext에서 현재 인증된 사용자의 ID를 가져옵니다. - * @return 사용자 ID, 인증되지 않은 경우 null - */ + public static Long getCurrentUserId() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Authentication authentication = getAuthentication(); if (authentication != null && authentication.isAuthenticated()) { Object principal = authentication.getPrincipal(); if (principal instanceof CustomUserDetails) { @@ -37,24 +34,18 @@ public static Long getCurrentUserIdOrThrow() { return userId; } - /** - * SecurityContext에서 현재 인증된 사용자의 이메일을 가져옵니다. - * @return 사용자 이메일, 인증되지 않은 경우 null - */ + public static String getCurrentUserEmail() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Authentication authentication = getAuthentication(); if (authentication != null && authentication.isAuthenticated()) { return authentication.getName(); } return null; } - /** - * SecurityContext에서 현재 인증된 사용자의 CustomUserDetails를 가져옵니다. - * @return CustomUserDetails, 인증되지 않은 경우 null - */ + public static CustomUserDetails getCurrentUserDetails() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Authentication authentication = getAuthentication(); if (authentication != null && authentication.isAuthenticated()) { Object principal = authentication.getPrincipal(); if (principal instanceof CustomUserDetails) { @@ -63,5 +54,9 @@ public static CustomUserDetails getCurrentUserDetails() { } return null; } + + private static Authentication getAuthentication() { + return SecurityContextHolder.getContext().getAuthentication(); + } } From 54e59365026e8bbcd9ff4bce79073ff5098b2a10 Mon Sep 17 00:00:00 2001 From: ahnbs Date: Thu, 11 Dec 2025 17:44:57 +0900 Subject: [PATCH 10/10] =?UTF-8?q?refactor(auth)=20:=20securityConfig=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/SecurityConfig.java | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/java/back/kalender/global/security/SecurityConfig.java b/src/main/java/back/kalender/global/security/SecurityConfig.java index 5c46785..e16ae4f 100644 --- a/src/main/java/back/kalender/global/security/SecurityConfig.java +++ b/src/main/java/back/kalender/global/security/SecurityConfig.java @@ -13,7 +13,6 @@ /** * Spring Security 설정 - * 개발 단계에서 모든 경로 허용 (추후 인증/인가 추가 예정) */ @Configuration @EnableWebSecurity @@ -32,10 +31,28 @@ public JwtAuthFilter jwtAuthFilter(JwtTokenProvider 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();