From fa667a6e9e505d4bbc463135b9fa669ff813764e Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Fri, 23 Jan 2026 15:13:08 +0900 Subject: [PATCH 01/28] =?UTF-8?q?feat:=20USER=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20ENUM=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/user/entity/User.java | 19 +++++++++++-------- .../backend/domain/user/enums/Role.java | 7 +++++++ .../backend/domain/user/enums/UserStatus.java | 8 ++++++++ 3 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 src/main/java/app/dearobjet/backend/domain/user/enums/Role.java create mode 100644 src/main/java/app/dearobjet/backend/domain/user/enums/UserStatus.java diff --git a/src/main/java/app/dearobjet/backend/domain/user/entity/User.java b/src/main/java/app/dearobjet/backend/domain/user/entity/User.java index ff8008f..f9dcf01 100644 --- a/src/main/java/app/dearobjet/backend/domain/user/entity/User.java +++ b/src/main/java/app/dearobjet/backend/domain/user/entity/User.java @@ -1,5 +1,7 @@ package app.dearobjet.backend.domain.user.entity; +import app.dearobjet.backend.domain.user.enums.Role; +import app.dearobjet.backend.domain.user.enums.UserStatus; import jakarta.persistence.*; import lombok.*; @@ -35,18 +37,20 @@ public class User { @Column(name = "marketing_agreement") private Boolean marketingAgreement; + @Enumerated(EnumType.STRING) @Column(nullable = false) - private String role; // CUSTOMER, ARTIST, SHOP + private Role role; @Column(name = "profile_url") private String profileUrl; - @Column(name = "user_status") - private String userStatus; + @Enumerated(EnumType.STRING) + @Column(name = "user_status", nullable = false) + private UserStatus userStatus; - - @Column(name = "login_type") - private String loginType; // EMAIL, KAKAO, NAVER, GOOGLE + // 카카오 단일 +// @Column(name = "login_type") +// private String loginType; // EMAIL, KAKAO, NAVER, GOOGLE @Column(name = "social_id") private String socialId; @@ -58,7 +62,6 @@ public void updateProfile(String name, String profileUrl) { } public void deactivate() { - this.userStatus = "INACTIVE"; + this.userStatus = UserStatus.INACTIVE; } - } diff --git a/src/main/java/app/dearobjet/backend/domain/user/enums/Role.java b/src/main/java/app/dearobjet/backend/domain/user/enums/Role.java new file mode 100644 index 0000000..870bd33 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/user/enums/Role.java @@ -0,0 +1,7 @@ +package app.dearobjet.backend.domain.user.enums; + +public enum Role { + CUSTOMER, + ARTIST, + SHOP +} diff --git a/src/main/java/app/dearobjet/backend/domain/user/enums/UserStatus.java b/src/main/java/app/dearobjet/backend/domain/user/enums/UserStatus.java new file mode 100644 index 0000000..d3b7b16 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/user/enums/UserStatus.java @@ -0,0 +1,8 @@ +package app.dearobjet.backend.domain.user.enums; + +public enum UserStatus { + PENDING, // OAuth만 된 상태 + ACTIVE, + INACTIVE +} + From 98950589d9fa5772adfb97a6733d2fadd36bb625 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Fri, 23 Jan 2026 15:13:53 +0900 Subject: [PATCH 02/28] =?UTF-8?q?feat:=20USER=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/repository/UserRepository.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/java/app/dearobjet/backend/domain/user/repository/UserRepository.java diff --git a/src/main/java/app/dearobjet/backend/domain/user/repository/UserRepository.java b/src/main/java/app/dearobjet/backend/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..8d24054 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/user/repository/UserRepository.java @@ -0,0 +1,14 @@ +package app.dearobjet.backend.domain.user.repository; + +import app.dearobjet.backend.domain.user.entity.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + + Optional findBySocialId(String socialId); + + Optional findByEmail(String email); + + boolean existsByEmail(String email); +} \ No newline at end of file From 41284a0d083d42d89b6713de38a663889a0b7d3a Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Fri, 23 Jan 2026 15:14:40 +0900 Subject: [PATCH 03/28] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/user/entity/User.java | 21 +++++ .../domain/user/service/UserService.java | 25 ++++++ .../domain/user/service/UserServiceImpl.java | 78 +++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 src/main/java/app/dearobjet/backend/domain/user/service/UserService.java create mode 100644 src/main/java/app/dearobjet/backend/domain/user/service/UserServiceImpl.java diff --git a/src/main/java/app/dearobjet/backend/domain/user/entity/User.java b/src/main/java/app/dearobjet/backend/domain/user/entity/User.java index f9dcf01..fb0c512 100644 --- a/src/main/java/app/dearobjet/backend/domain/user/entity/User.java +++ b/src/main/java/app/dearobjet/backend/domain/user/entity/User.java @@ -64,4 +64,25 @@ public void updateProfile(String name, String profileUrl) { public void deactivate() { this.userStatus = UserStatus.INACTIVE; } + + public void completeRegistration( + String name, + String email, + String phoneNumber, + Boolean smsAgreement, + Boolean marketingAgreement, + Role role + ) { + if (this.userStatus != UserStatus.PENDING) { + throw new IllegalStateException("User already registered"); + } + + this.name = name; + this.email = email; + this.phoneNumber = phoneNumber; + this.smsAgreement = smsAgreement; + this.marketingAgreement = marketingAgreement; + this.role = role; + this.userStatus = UserStatus.ACTIVE; + } } diff --git a/src/main/java/app/dearobjet/backend/domain/user/service/UserService.java b/src/main/java/app/dearobjet/backend/domain/user/service/UserService.java new file mode 100644 index 0000000..62b11d1 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/user/service/UserService.java @@ -0,0 +1,25 @@ +package app.dearobjet.backend.domain.user.service; + +import app.dearobjet.backend.domain.user.entity.User; +import app.dearobjet.backend.domain.user.enums.Role; + +public interface UserService { + + // 카카오 OAuth 사용자 조회 또는 신규 생성 + User getOrCreateKakaoUser(String socialId); + + // 추가 회원가입 + void completeSignup( + Long userId, + String name, + String email, + String phoneNumber, + Boolean smsAgreement, + Boolean marketingAgreement, + Role role + ); + + // 회원 비활성화 + void deactivateUser(Long userId); +} + diff --git a/src/main/java/app/dearobjet/backend/domain/user/service/UserServiceImpl.java b/src/main/java/app/dearobjet/backend/domain/user/service/UserServiceImpl.java new file mode 100644 index 0000000..3a9d28b --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/user/service/UserServiceImpl.java @@ -0,0 +1,78 @@ +package app.dearobjet.backend.domain.user.service; + +import app.dearobjet.backend.domain.user.entity.User; +import app.dearobjet.backend.domain.user.enums.Role; +import app.dearobjet.backend.domain.user.enums.UserStatus; +import app.dearobjet.backend.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class UserServiceImpl implements UserService { + + private final UserRepository userRepository; + + /** + * 카카오 OAuth 로그인 사용자 조회 or 생성 + */ + @Override + public User getOrCreateKakaoUser(String socialId) { + return userRepository.findBySocialId(socialId) + .orElseGet(() -> createPendingUser(socialId)); + } + + private User createPendingUser(String socialId) { + User user = User.builder() + .socialId(socialId) + .role(Role.CUSTOMER) + .userStatus(UserStatus.PENDING) + .build(); + + return userRepository.save(user); + } + + /** + * 추가 회원가입 완료 + */ + @Override + public void completeSignup( + Long userId, + String name, + String email, + String phoneNumber, + Boolean smsAgreement, + Boolean marketingAgreement, + Role role + ) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + // 이메일 중복 체크 + if (userRepository.existsByEmail(email)) { + throw new IllegalStateException("Email already exists"); + } + + user.completeRegistration( + name, + email, + phoneNumber, + smsAgreement, + marketingAgreement, + role + ); + } + + /** + * 회원 비활성화 + */ + @Override + public void deactivateUser(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + user.deactivate(); + } +} From 09b86fe3a7dbaeaf4f16943ea60bab79465d03a2 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Sat, 24 Jan 2026 23:39:48 +0900 Subject: [PATCH 04/28] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=EB=A7=81=20?= =?UTF-8?q?=EC=8B=9C=ED=81=90=EB=A6=AC=ED=8B=B0=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=9D=B8=EC=A6=9D=20=EC=A0=95=EB=B3=B4=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/security/CustomUserDetails.java | 67 +++++++++++++++++++ .../security/CustomUserDetailsService.java | 28 ++++++++ .../global/auth/security/SecurityConfig.java | 40 +++++++++++ 3 files changed, 135 insertions(+) create mode 100644 src/main/java/app/dearobjet/backend/global/auth/security/CustomUserDetails.java create mode 100644 src/main/java/app/dearobjet/backend/global/auth/security/CustomUserDetailsService.java create mode 100644 src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java diff --git a/src/main/java/app/dearobjet/backend/global/auth/security/CustomUserDetails.java b/src/main/java/app/dearobjet/backend/global/auth/security/CustomUserDetails.java new file mode 100644 index 0000000..57e8c14 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/security/CustomUserDetails.java @@ -0,0 +1,67 @@ +package app.dearobjet.backend.global.auth.security; + +import app.dearobjet.backend.domain.user.entity.User; +import app.dearobjet.backend.domain.user.enums.Role; +import app.dearobjet.backend.domain.user.enums.UserStatus; +import java.util.Collection; +import java.util.List; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +public class CustomUserDetails implements UserDetails { + + private final User user; + + public CustomUserDetails(User user) { + this.user = user; + } + + public Long getUserId() { + return user.getUserId(); + } + + public Role getRole() { + return user.getRole(); + } + + public UserStatus getUserStatus() { + return user.getUserStatus(); + } + + @Override + public Collection getAuthorities() { + return List.of( + new SimpleGrantedAuthority("ROLE_" + user.getRole().name()) + ); + } + + /** + * OAuth/JWT 구조라 비밀번호 사용 안 함 + */ + @Override + public String getPassword() { + return null; + } + + /** + * username = 식별자 + * → JWT subject / OAuth 이후 인증 기준 + */ + @Override + public String getUsername() { + return String.valueOf(user.getUserId()); + } + + /** + * 상태 기반 계정 제어 + */ + @Override + public boolean isEnabled() { + return user.getUserStatus() == UserStatus.ACTIVE; + } + + @Override public boolean isAccountNonExpired() { return true; } + @Override public boolean isAccountNonLocked() { return true; } + @Override public boolean isCredentialsNonExpired() { return true; } +} diff --git a/src/main/java/app/dearobjet/backend/global/auth/security/CustomUserDetailsService.java b/src/main/java/app/dearobjet/backend/global/auth/security/CustomUserDetailsService.java new file mode 100644 index 0000000..d995455 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/security/CustomUserDetailsService.java @@ -0,0 +1,28 @@ +package app.dearobjet.backend.global.auth.security; + +import app.dearobjet.backend.domain.user.entity.User; +import app.dearobjet.backend.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +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; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService + implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String userId) { + User user = userRepository.findById(Long.valueOf(userId)) + .orElseThrow(() -> + new UsernameNotFoundException("User not found") + ); + + return new CustomUserDetails(user); + } +} + diff --git a/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java new file mode 100644 index 0000000..731c630 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java @@ -0,0 +1,40 @@ +package app.dearobjet.backend.global.auth.security; + +import lombok.RequiredArgsConstructor; +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.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + .csrf(csrf -> csrf.disable()) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ); + + http + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/auth/**", + "/oauth/**", + "/health" + ).permitAll() + .anyRequest().authenticated() + ); + + return http.build(); + } +} + From 306159b2c13e71cdca7398330b46521f37f121a0 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Sun, 25 Jan 2026 00:01:05 +0900 Subject: [PATCH 05/28] =?UTF-8?q?feat:=20JWT=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=ED=95=84=ED=84=B0=20=EB=B0=8F=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++ .../auth/jwt/JwtAuthenticationFilter.java | 57 +++++++++++++++++ .../global/auth/jwt/JwtProperties.java | 17 +++++ .../backend/global/auth/jwt/JwtProvider.java | 62 +++++++++++++++++++ .../global/auth/security/SecurityConfig.java | 19 ++++-- 5 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 src/main/java/app/dearobjet/backend/global/auth/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProperties.java create mode 100644 src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProvider.java diff --git a/build.gradle b/build.gradle index 969eab8..08b2963 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,10 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' } tasks.named('test') { diff --git a/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..8c7b3a1 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,57 @@ +package app.dearobjet.backend.global.auth.jwt; + +import app.dearobjet.backend.global.auth.security.CustomUserDetailsService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final CustomUserDetailsService userDetailsService; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + + if (jwtProvider.validateToken(token)) { + Long userId = jwtProvider.getUserId(token); + + UserDetails userDetails = + userDetailsService.loadUserByUsername(String.valueOf(userId)); + + Authentication authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + + SecurityContextHolder.getContext() + .setAuthentication(authentication); + } + } + + filterChain.doFilter(request, response); + } +} + diff --git a/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProperties.java b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProperties.java new file mode 100644 index 0000000..a1f7ec8 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProperties.java @@ -0,0 +1,17 @@ +package app.dearobjet.backend.global.auth.jwt; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@Getter +public class JwtProperties { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access-token-expiration}") + private long accessTokenExpiration; +} + diff --git a/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProvider.java b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProvider.java new file mode 100644 index 0000000..ec4e5f3 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProvider.java @@ -0,0 +1,62 @@ +package app.dearobjet.backend.global.auth.jwt; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JwtProvider { + + private final JwtProperties jwtProperties; + + private Key getSigningKey() { + return Keys.hmacShaKeyFor( + jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8) + ); + } + + /** Access Token 생성 */ + public String createAccessToken(Long userId) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + jwtProperties.getAccessTokenExpiration()); + + return Jwts.builder() + .setSubject(String.valueOf(userId)) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + /** 토큰에서 userId 추출 */ + public Long getUserId(String token) { + return Long.valueOf( + Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject() + ); + } + + /** 토큰 유효성 검증 */ + public boolean validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } +} diff --git a/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java index 731c630..68079d9 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java +++ b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java @@ -1,5 +1,6 @@ package app.dearobjet.backend.global.auth.security; +import app.dearobjet.backend.global.auth.jwt.JwtAuthenticationFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -7,12 +8,15 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +@RequiredArgsConstructor @Configuration @EnableWebSecurity -@RequiredArgsConstructor public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -26,15 +30,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/auth/**", - "/oauth/**", - "/health" - ).permitAll() + .requestMatchers("/auth/**", "/oauth/**").permitAll() .anyRequest().authenticated() ); + http + .addFilterBefore( + jwtAuthenticationFilter, + UsernamePasswordAuthenticationFilter.class + ); + return http.build(); } } + From 3cd7f19dd96182adfe74961f371bbb91f2ec2f7e Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Sun, 25 Jan 2026 01:31:42 +0900 Subject: [PATCH 06/28] =?UTF-8?q?fix:=20=EC=B6=94=EA=B0=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EB=A5=BC=20=EC=9D=B4=ED=9B=84=EC=97=90=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=B5=9C?= =?UTF-8?q?=EC=B4=88=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=EC=8B=9C=20nul?= =?UTF-8?q?lable=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/app/dearobjet/backend/domain/user/entity/User.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/app/dearobjet/backend/domain/user/entity/User.java b/src/main/java/app/dearobjet/backend/domain/user/entity/User.java index fb0c512..ebfe127 100644 --- a/src/main/java/app/dearobjet/backend/domain/user/entity/User.java +++ b/src/main/java/app/dearobjet/backend/domain/user/entity/User.java @@ -19,13 +19,12 @@ public class User { @Column(name = "user_id") private Long userId; - @Column(nullable = false, unique = true) + @Column(unique = true) private String email; - @Column(nullable = false) private String name; - @Column(nullable = false, unique = true) + @Column(unique = true) private String phoneNumber; @Column(name = "profile_image") From 6c43e1982fa3e3980c05d1374811a6096c5cd912 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Sun, 25 Jan 2026 01:32:48 +0900 Subject: [PATCH 07/28] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/jwt/JwtAuthenticationFilter.java | 47 +++++++++------ .../auth/oauth/CustomOAuth2UserService.java | 36 ++++++++++++ .../auth/oauth/OAuthSuccessHandler.java | 57 +++++++++++++++++++ .../auth/security/CustomUserDetails.java | 2 +- .../global/auth/security/SecurityConfig.java | 26 ++++++--- 5 files changed, 142 insertions(+), 26 deletions(-) create mode 100644 src/main/java/app/dearobjet/backend/global/auth/oauth/CustomOAuth2UserService.java create mode 100644 src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java diff --git a/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtAuthenticationFilter.java index 8c7b3a1..ac69c0a 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtAuthenticationFilter.java @@ -21,6 +21,15 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; private final CustomUserDetailsService userDetailsService; + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + + return uri.startsWith("/oauth2") + || uri.startsWith("/login") + || uri.startsWith("/error"); + } + @Override protected void doFilterInternal( HttpServletRequest request, @@ -28,27 +37,33 @@ protected void doFilterInternal( FilterChain filterChain ) throws ServletException, IOException { - String authHeader = request.getHeader("Authorization"); + String token = null; - if (authHeader != null && authHeader.startsWith("Bearer ")) { - String token = authHeader.substring(7); + // 쿠키에서 JWT 추출 + if (token == null && request.getCookies() != null) { + for (var cookie : request.getCookies()) { + if ("ACCESS_TOKEN".equals(cookie.getName())) { + token = cookie.getValue(); + break; + } + } + } - if (jwtProvider.validateToken(token)) { - Long userId = jwtProvider.getUserId(token); + // 토큰이 있을 때만 인증 처리 + if (token != null && jwtProvider.validateToken(token)) { + Long userId = jwtProvider.getUserId(token); - UserDetails userDetails = - userDetailsService.loadUserByUsername(String.valueOf(userId)); + UserDetails userDetails = + userDetailsService.loadUserByUsername(String.valueOf(userId)); - Authentication authentication = - new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); + Authentication authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); - SecurityContextHolder.getContext() - .setAuthentication(authentication); - } + SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); diff --git a/src/main/java/app/dearobjet/backend/global/auth/oauth/CustomOAuth2UserService.java b/src/main/java/app/dearobjet/backend/global/auth/oauth/CustomOAuth2UserService.java new file mode 100644 index 0000000..6bd68da --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/oauth/CustomOAuth2UserService.java @@ -0,0 +1,36 @@ +package app.dearobjet.backend.global.auth.oauth; + +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService + implements OAuth2UserService { + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) + throws OAuth2AuthenticationException { + + DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + OAuth2User oAuth2User = delegate.loadUser(userRequest); + + Map attributes = oAuth2User.getAttributes(); + + // 카카오 고유 ID + String id = attributes.get("id").toString(); + + return new DefaultOAuth2User( + oAuth2User.getAuthorities(), + attributes, + "id" // user-name-attribute + ); + } +} diff --git a/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java b/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java new file mode 100644 index 0000000..9c6b7d7 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java @@ -0,0 +1,57 @@ +package app.dearobjet.backend.global.auth.oauth; + +import app.dearobjet.backend.domain.user.entity.User; +import app.dearobjet.backend.domain.user.service.UserService; +import app.dearobjet.backend.global.auth.jwt.JwtProvider; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OAuthSuccessHandler + extends SimpleUrlAuthenticationSuccessHandler { + + private final UserService userService; + private final JwtProvider jwtProvider; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException { + + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + + // 카카오 고유 ID + String socialId = oAuth2User.getAttribute("id").toString(); + + // 사용자 조회 or 생성 (PENDING) + User user = userService.getOrCreateKakaoUser(socialId); + + // JWT 발급 + String accessToken = jwtProvider.createAccessToken(user.getUserId()); + + Cookie cookie = new Cookie("ACCESS_TOKEN", accessToken); + cookie.setHttpOnly(true); + cookie.setSecure(true); // https면 true + cookie.setPath("/"); + cookie.setMaxAge(60 * 60); // 1시간 + + response.addCookie(cookie); + + // 프론트로 전달 + response.sendRedirect( + "http://localhost:5173/oauth/callback?token=" + accessToken + ); + } +} + diff --git a/src/main/java/app/dearobjet/backend/global/auth/security/CustomUserDetails.java b/src/main/java/app/dearobjet/backend/global/auth/security/CustomUserDetails.java index 57e8c14..dd10d0b 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/security/CustomUserDetails.java +++ b/src/main/java/app/dearobjet/backend/global/auth/security/CustomUserDetails.java @@ -9,7 +9,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -public class CustomUserDetails implements UserDetails { + public class CustomUserDetails implements UserDetails { private final User user; diff --git a/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java index 68079d9..4b159ae 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java +++ b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java @@ -1,12 +1,13 @@ package app.dearobjet.backend.global.auth.security; import app.dearobjet.backend.global.auth.jwt.JwtAuthenticationFilter; +import app.dearobjet.backend.global.auth.oauth.CustomOAuth2UserService; +import app.dearobjet.backend.global.auth.oauth.OAuthSuccessHandler; import lombok.RequiredArgsConstructor; 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.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -16,6 +17,8 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final OAuthSuccessHandler oAuthSuccessHandler; + private final CustomOAuth2UserService customOAuth2UserService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -24,17 +27,23 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(csrf -> csrf.disable()) .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()) - .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ); - http .authorizeHttpRequests(auth -> auth - .requestMatchers("/auth/**", "/oauth/**").permitAll() + .requestMatchers( + "/oauth2/authorization/**", + "/login/oauth2/**", + "/error" + ).permitAll() .anyRequest().authenticated() - ); + ) + + .oauth2Login(oauth -> oauth + .userInfoEndpoint(userInfo -> + userInfo.userService(customOAuth2UserService) + ) + .successHandler(oAuthSuccessHandler) + ) - http .addFilterBefore( jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class @@ -44,4 +53,3 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { } } - From 94ae2c13e5a4d91d0f4f1a1f2c568756041c0c34 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Sun, 25 Jan 2026 01:33:10 +0900 Subject: [PATCH 08/28] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0e71036..0d8e46e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,9 @@ spring: application: name: backend + config: + import: optional:file:.env[.properties] + datasource: url: jdbc:postgresql://localhost:5432/dearobjet username: dearobjet @@ -9,12 +12,34 @@ spring: jpa: hibernate: - ddl-auto: update + ddl-auto: create # update로 수정해야함 properties: hibernate: format_sql: true open-in-view: false + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/kakao" + client-authentication-method: client_secret_post + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + logging: level: - org.hibernate.SQL: debug \ No newline at end of file + org.hibernate.SQL: debug + org.springframework.security: DEBUG + +jwt: + secret: ${JWT_SECRET} + access-token-expiration: 3600000 # 1시간(ms) \ No newline at end of file From ad96743e781e1a4092d4fa188d2650d5f57b5e67 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Sun, 25 Jan 2026 01:33:23 +0900 Subject: [PATCH 09/28] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/main/java/app/dearobjet/backend/global/auth/controller/AuthController.java diff --git a/src/main/java/app/dearobjet/backend/global/auth/controller/AuthController.java b/src/main/java/app/dearobjet/backend/global/auth/controller/AuthController.java new file mode 100644 index 0000000..50ac5a0 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/controller/AuthController.java @@ -0,0 +1,19 @@ +package app.dearobjet.backend.global.auth.controller; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class AuthController { + + @PostMapping("/auth/logout") + public void logout(HttpServletResponse response) { + Cookie cookie = new Cookie("ACCESS_TOKEN", null); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setMaxAge(0); // 즉시 삭제 + response.addCookie(cookie); + } +} From a646df8bca15ffa04efa1f1d78a42d7087094943 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Mon, 26 Jan 2026 00:20:21 +0900 Subject: [PATCH 10/28] =?UTF-8?q?refactor:=20UserStatus=EB=8A=94=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1/=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= =?UTF-8?q?=EB=A7=8C=20=EB=8B=B4=EB=8B=B9=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EA=B3=BC=20Role=EC=97=90=20TEMP=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/dearobjet/backend/domain/user/entity/User.java | 3 +-- .../app/dearobjet/backend/domain/user/enums/Role.java | 1 + .../dearobjet/backend/domain/user/enums/UserStatus.java | 1 - .../backend/domain/user/service/UserServiceImpl.java | 8 ++++---- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/java/app/dearobjet/backend/domain/user/entity/User.java b/src/main/java/app/dearobjet/backend/domain/user/entity/User.java index ebfe127..14ffc5e 100644 --- a/src/main/java/app/dearobjet/backend/domain/user/entity/User.java +++ b/src/main/java/app/dearobjet/backend/domain/user/entity/User.java @@ -72,7 +72,7 @@ public void completeRegistration( Boolean marketingAgreement, Role role ) { - if (this.userStatus != UserStatus.PENDING) { + if (this.role != Role.TEMP) { throw new IllegalStateException("User already registered"); } @@ -82,6 +82,5 @@ public void completeRegistration( this.smsAgreement = smsAgreement; this.marketingAgreement = marketingAgreement; this.role = role; - this.userStatus = UserStatus.ACTIVE; } } diff --git a/src/main/java/app/dearobjet/backend/domain/user/enums/Role.java b/src/main/java/app/dearobjet/backend/domain/user/enums/Role.java index 870bd33..97a8d37 100644 --- a/src/main/java/app/dearobjet/backend/domain/user/enums/Role.java +++ b/src/main/java/app/dearobjet/backend/domain/user/enums/Role.java @@ -1,6 +1,7 @@ package app.dearobjet.backend.domain.user.enums; public enum Role { + TEMP, // OAuth만 완료 (추가회원가입 전) CUSTOMER, ARTIST, SHOP diff --git a/src/main/java/app/dearobjet/backend/domain/user/enums/UserStatus.java b/src/main/java/app/dearobjet/backend/domain/user/enums/UserStatus.java index d3b7b16..0a20be6 100644 --- a/src/main/java/app/dearobjet/backend/domain/user/enums/UserStatus.java +++ b/src/main/java/app/dearobjet/backend/domain/user/enums/UserStatus.java @@ -1,7 +1,6 @@ package app.dearobjet.backend.domain.user.enums; public enum UserStatus { - PENDING, // OAuth만 된 상태 ACTIVE, INACTIVE } diff --git a/src/main/java/app/dearobjet/backend/domain/user/service/UserServiceImpl.java b/src/main/java/app/dearobjet/backend/domain/user/service/UserServiceImpl.java index 3a9d28b..2f23863 100644 --- a/src/main/java/app/dearobjet/backend/domain/user/service/UserServiceImpl.java +++ b/src/main/java/app/dearobjet/backend/domain/user/service/UserServiceImpl.java @@ -21,14 +21,14 @@ public class UserServiceImpl implements UserService { @Override public User getOrCreateKakaoUser(String socialId) { return userRepository.findBySocialId(socialId) - .orElseGet(() -> createPendingUser(socialId)); + .orElseGet(() -> createTempUser(socialId)); } - private User createPendingUser(String socialId) { + private User createTempUser(String socialId) { User user = User.builder() .socialId(socialId) - .role(Role.CUSTOMER) - .userStatus(UserStatus.PENDING) + .role(Role.TEMP) + .userStatus(UserStatus.ACTIVE) .build(); return userRepository.save(user); From 6593aec7014bc0c745ecdb356bfb1355c4d9e80d Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Mon, 26 Jan 2026 00:21:22 +0900 Subject: [PATCH 11/28] =?UTF-8?q?feat:=20JWT=20=ED=86=A0=ED=81=B0=20claim?= =?UTF-8?q?=EC=97=90=20ROLE=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/auth/jwt/JwtProvider.java | 15 ++++++++++++++- .../global/auth/oauth/OAuthSuccessHandler.java | 9 ++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProvider.java b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProvider.java index ec4e5f3..c1472fd 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProvider.java +++ b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProvider.java @@ -1,5 +1,6 @@ package app.dearobjet.backend.global.auth.jwt; +import app.dearobjet.backend.domain.user.enums.Role; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; @@ -23,12 +24,13 @@ private Key getSigningKey() { } /** Access Token 생성 */ - public String createAccessToken(Long userId) { + public String createAccessToken(Long userId, Role role) { Date now = new Date(); Date expiry = new Date(now.getTime() + jwtProperties.getAccessTokenExpiration()); return Jwts.builder() .setSubject(String.valueOf(userId)) + .claim("role", role.name()) .setIssuedAt(now) .setExpiration(expiry) .signWith(getSigningKey(), SignatureAlgorithm.HS256) @@ -59,4 +61,15 @@ public boolean validateToken(String token) { return false; } } + + public Role getRole(String token) { + String role = Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody() + .get("role", String.class); + + return Role.valueOf(role); + } } diff --git a/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java b/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java index 9c6b7d7..eb724c4 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java +++ b/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java @@ -1,6 +1,7 @@ package app.dearobjet.backend.global.auth.oauth; import app.dearobjet.backend.domain.user.entity.User; +import app.dearobjet.backend.domain.user.enums.Role; import app.dearobjet.backend.domain.user.service.UserService; import app.dearobjet.backend.global.auth.jwt.JwtProvider; import jakarta.servlet.http.Cookie; @@ -34,11 +35,11 @@ public void onAuthenticationSuccess( // 카카오 고유 ID String socialId = oAuth2User.getAttribute("id").toString(); - // 사용자 조회 or 생성 (PENDING) + // 사용자 조회 or 생성 User user = userService.getOrCreateKakaoUser(socialId); // JWT 발급 - String accessToken = jwtProvider.createAccessToken(user.getUserId()); + String accessToken = jwtProvider.createAccessToken(user.getUserId(), user.getRole()); Cookie cookie = new Cookie("ACCESS_TOKEN", accessToken); cookie.setHttpOnly(true); @@ -49,9 +50,7 @@ public void onAuthenticationSuccess( response.addCookie(cookie); // 프론트로 전달 - response.sendRedirect( - "http://localhost:5173/oauth/callback?token=" + accessToken - ); + response.sendRedirect("http://localhost:5173/oauth/callback"); } } From fb4236a6e4bc484c8bb47a85506339b8bdaea2ce Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Mon, 26 Jan 2026 00:21:51 +0900 Subject: [PATCH 12/28] =?UTF-8?q?feat:=20=EC=B6=94=EA=B0=80=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 58 +++++++++++++++++++ .../user/dto/CompleteSignupRequest.java | 17 ++++++ 2 files changed, 75 insertions(+) create mode 100644 src/main/java/app/dearobjet/backend/domain/user/controller/UserController.java create mode 100644 src/main/java/app/dearobjet/backend/domain/user/dto/CompleteSignupRequest.java diff --git a/src/main/java/app/dearobjet/backend/domain/user/controller/UserController.java b/src/main/java/app/dearobjet/backend/domain/user/controller/UserController.java new file mode 100644 index 0000000..cb6368f --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/user/controller/UserController.java @@ -0,0 +1,58 @@ +package app.dearobjet.backend.domain.user.controller; + +import app.dearobjet.backend.domain.user.dto.CompleteSignupRequest; +import app.dearobjet.backend.domain.user.service.UserService; +import app.dearobjet.backend.global.auth.jwt.JwtProvider; +import app.dearobjet.backend.global.auth.security.CustomUserDetails; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/users") +public class UserController { + + private final UserService userService; + private final JwtProvider jwtProvider; + + /** + * 추가 회원가입 (TEMP → CUSTOMER / ARTIST / SHOP) + */ + @PostMapping("/complete") + public ResponseEntity completeSignup( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody CompleteSignupRequest request, + HttpServletResponse response + ) { + Long userId = userDetails.getUserId(); + + // 추가 회원가입 + userService.completeSignup( + userId, + request.getName(), + request.getEmail(), + request.getPhoneNumber(), + request.getSmsAgreement(), + request.getMarketingAgreement(), + request.getRole() + ); + + // role 변경되었으므로 JWT 재발급 + String newAccessToken = + jwtProvider.createAccessToken(userId, request.getRole()); + + Cookie cookie = new Cookie("ACCESS_TOKEN", newAccessToken); + cookie.setHttpOnly(true); + cookie.setSecure(false); // dev 환경 + cookie.setPath("/"); + cookie.setMaxAge(60 * 60); + + response.addCookie(cookie); + + return ResponseEntity.ok().build(); + } +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/user/dto/CompleteSignupRequest.java b/src/main/java/app/dearobjet/backend/domain/user/dto/CompleteSignupRequest.java new file mode 100644 index 0000000..b975636 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/user/dto/CompleteSignupRequest.java @@ -0,0 +1,17 @@ +package app.dearobjet.backend.domain.user.dto; + +import app.dearobjet.backend.domain.user.enums.Role; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class CompleteSignupRequest { + + private String name; + private String email; + private String phoneNumber; + private Boolean smsAgreement; + private Boolean marketingAgreement; + private Role role; // CUSTOMER / ARTIST / SHOP +} From 25e46c16cc2d5d56e63e897d30aba1fe11a1df61 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Mon, 26 Jan 2026 00:23:01 +0900 Subject: [PATCH 13/28] =?UTF-8?q?feat:=20=EB=B9=84=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20API=20=EC=9A=94=EC=B2=AD=20=EC=8B=9C=20=EC=B9=B4?= =?UTF-8?q?=EC=B9=B4=EC=98=A4=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89?= =?UTF-8?q?=ED=8A=B8=20=EB=A7=89=EA=B3=A0=20=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=82=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RestAuthenticationEntryPoint.java | 26 +++++++++++++++++++ .../global/auth/security/SecurityConfig.java | 6 ++++- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/main/java/app/dearobjet/backend/global/auth/entrypoint/RestAuthenticationEntryPoint.java diff --git a/src/main/java/app/dearobjet/backend/global/auth/entrypoint/RestAuthenticationEntryPoint.java b/src/main/java/app/dearobjet/backend/global/auth/entrypoint/RestAuthenticationEntryPoint.java new file mode 100644 index 0000000..7d2de3a --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/entrypoint/RestAuthenticationEntryPoint.java @@ -0,0 +1,26 @@ +package app.dearobjet.backend.global.auth.entrypoint; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write( + "{\"message\": \"로그인이 필요합니다\"}" + ); + } +} diff --git a/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java index 4b159ae..426ec29 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java +++ b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java @@ -1,5 +1,6 @@ package app.dearobjet.backend.global.auth.security; +import app.dearobjet.backend.global.auth.entrypoint.RestAuthenticationEntryPoint; import app.dearobjet.backend.global.auth.jwt.JwtAuthenticationFilter; import app.dearobjet.backend.global.auth.oauth.CustomOAuth2UserService; import app.dearobjet.backend.global.auth.oauth.OAuthSuccessHandler; @@ -19,6 +20,7 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final OAuthSuccessHandler oAuthSuccessHandler; private final CustomOAuth2UserService customOAuth2UserService; + private final RestAuthenticationEntryPoint restAuthenticationEntryPoint; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -27,7 +29,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(csrf -> csrf.disable()) .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()) - + .exceptionHandling(e -> e + .authenticationEntryPoint(restAuthenticationEntryPoint) + ) .authorizeHttpRequests(auth -> auth .requestMatchers( "/oauth2/authorization/**", From 435d9760a2a763252ad41735ba8435a03daf5a43 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 15:49:30 +0900 Subject: [PATCH 14/28] =?UTF-8?q?feat:=20redis=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + docker-compose.yml | 10 +++++++ .../backend/global/config/RedisConfig.java | 26 +++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 src/main/java/app/dearobjet/backend/global/config/RedisConfig.java diff --git a/build.gradle b/build.gradle index 08b2963..f2e3659 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' diff --git a/docker-compose.yml b/docker-compose.yml index 3aa3ddb..67fc14b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,5 +11,15 @@ services: volumes: - pgdata:/var/lib/postgresql/data + redis: + image: redis:7-alpine + container_name: dearobjet-redis + ports: + - "6379:6379" + command: [ "redis-server", "--appendonly", "yes" ] + volumes: + - redisdata:/data + volumes: pgdata: + redisdata: diff --git a/src/main/java/app/dearobjet/backend/global/config/RedisConfig.java b/src/main/java/app/dearobjet/backend/global/config/RedisConfig.java new file mode 100644 index 0000000..d0aef45 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/config/RedisConfig.java @@ -0,0 +1,26 @@ +package app.dearobjet.backend.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate( + RedisConnectionFactory connectionFactory) { + + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + + return template; + } +} From f3ef6a1a96e65bd5e9626d360bd488f79f723f0c Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 15:49:46 +0900 Subject: [PATCH 15/28] =?UTF-8?q?feat:=20redis=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0d8e46e..b07027d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -35,6 +35,11 @@ spring: user-info-uri: https://kapi.kakao.com/v2/user/me user-name-attribute: id + data: + redis: + host: localhost + port: 6379 + logging: level: org.hibernate.SQL: debug @@ -42,4 +47,5 @@ logging: jwt: secret: ${JWT_SECRET} - access-token-expiration: 3600000 # 1시간(ms) \ No newline at end of file + access-token-expiration: 3600000 # 1시간(ms) + refresh-token-expiration: 1209600000 # 14일 (ms) \ No newline at end of file From 96b814c02984f26b8f308114ebb3c613e33c5e7b Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 15:50:38 +0900 Subject: [PATCH 16/28] =?UTF-8?q?refactor:=20=EA=B3=B5=ED=86=B5=20API=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8F=B0=EC=8A=A4=EB=A5=BC=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=ED=95=A0=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/entrypoint/RestAuthenticationEntryPoint.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/app/dearobjet/backend/global/auth/entrypoint/RestAuthenticationEntryPoint.java b/src/main/java/app/dearobjet/backend/global/auth/entrypoint/RestAuthenticationEntryPoint.java index 7d2de3a..3826e94 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/entrypoint/RestAuthenticationEntryPoint.java +++ b/src/main/java/app/dearobjet/backend/global/auth/entrypoint/RestAuthenticationEntryPoint.java @@ -1,5 +1,8 @@ package app.dearobjet.backend.global.auth.entrypoint; +import app.dearobjet.backend.global.api.ApiResponse; +import app.dearobjet.backend.global.api.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @@ -10,6 +13,8 @@ @Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper = new ObjectMapper(); + @Override public void commence( HttpServletRequest request, @@ -19,8 +24,12 @@ public void commence( response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); + + ApiResponse body = + ApiResponse.of(new ErrorResponse("로그인이 필요합니다")); + response.getWriter().write( - "{\"message\": \"로그인이 필요합니다\"}" + objectMapper.writeValueAsString(body) ); } } From b6b4a774b9df136bc25c870e20378979c4d5934b Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 15:52:04 +0900 Subject: [PATCH 17/28] =?UTF-8?q?feat:=20redis=EB=A5=BC=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20refresh=20=ED=86=A0=ED=81=B0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../refresh/RefreshTokenRedisService.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/main/java/app/dearobjet/backend/global/auth/refresh/RefreshTokenRedisService.java diff --git a/src/main/java/app/dearobjet/backend/global/auth/refresh/RefreshTokenRedisService.java b/src/main/java/app/dearobjet/backend/global/auth/refresh/RefreshTokenRedisService.java new file mode 100644 index 0000000..06146c2 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/refresh/RefreshTokenRedisService.java @@ -0,0 +1,36 @@ +package app.dearobjet.backend.global.auth.refresh; + +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RefreshTokenRedisService { + + private final RedisTemplate redisTemplate; + + private static final String PREFIX = "refresh:"; + + public void save(String refreshToken, Long userId, long ttlMillis) { + redisTemplate.opsForValue().set( + PREFIX + refreshToken, + String.valueOf(userId), + ttlMillis, + TimeUnit.MILLISECONDS + ); + } + + public Long getUserId(String refreshToken) { + String value = redisTemplate.opsForValue().get(PREFIX + refreshToken); + if (value == null) { + throw new RuntimeException("refresh token not found"); + } + return Long.valueOf(value); + } + + public void delete(String refreshToken) { + redisTemplate.delete(PREFIX + refreshToken); + } +} From 5de88cb327b7821a041845e64ef5daa8607f4e9d Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 15:53:49 +0900 Subject: [PATCH 18/28] =?UTF-8?q?feat:=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=89=AC=20=ED=86=A0=ED=81=B0=20=EB=B0=9C=EA=B8=89=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/jwt/JwtProperties.java | 3 ++ .../backend/global/auth/jwt/JwtProvider.java | 21 +++++++++++ .../auth/oauth/OAuthSuccessHandler.java | 37 +++++++++++-------- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProperties.java b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProperties.java index a1f7ec8..51a49e8 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProperties.java +++ b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProperties.java @@ -13,5 +13,8 @@ public class JwtProperties { @Value("${jwt.access-token-expiration}") private long accessTokenExpiration; + + @Value("${jwt.refresh-token-expiration}") + private long refreshTokenExpiration; } diff --git a/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProvider.java b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProvider.java index c1472fd..13f9551 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProvider.java +++ b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProvider.java @@ -37,6 +37,19 @@ public String createAccessToken(Long userId, Role role) { .compact(); } + /** Refresh Token 생성 */ + public String createRefreshToken(Long userId) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + jwtProperties.getRefreshTokenExpiration()); + + return Jwts.builder() + .setSubject(String.valueOf(userId)) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + /** 토큰에서 userId 추출 */ public Long getUserId(String token) { return Long.valueOf( @@ -72,4 +85,12 @@ public Role getRole(String token) { return Role.valueOf(role); } + + public long getRefreshTokenExpiration() { + return jwtProperties.getRefreshTokenExpiration(); + } + + public long getAccessTokenExpiration() { + return jwtProperties.getAccessTokenExpiration(); + } } diff --git a/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java b/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java index eb724c4..32fb48f 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java +++ b/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java @@ -1,15 +1,14 @@ package app.dearobjet.backend.global.auth.oauth; import app.dearobjet.backend.domain.user.entity.User; -import app.dearobjet.backend.domain.user.enums.Role; import app.dearobjet.backend.domain.user.service.UserService; import app.dearobjet.backend.global.auth.jwt.JwtProvider; +import app.dearobjet.backend.global.auth.refresh.RefreshTokenRedisService; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; @@ -17,11 +16,11 @@ @Component @RequiredArgsConstructor -public class OAuthSuccessHandler - extends SimpleUrlAuthenticationSuccessHandler { +public class OAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { private final UserService userService; private final JwtProvider jwtProvider; + private final RefreshTokenRedisService refreshTokenRedisService; @Override public void onAuthenticationSuccess( @@ -32,25 +31,31 @@ public void onAuthenticationSuccess( OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); - // 카카오 고유 ID String socialId = oAuth2User.getAttribute("id").toString(); - // 사용자 조회 or 생성 User user = userService.getOrCreateKakaoUser(socialId); - // JWT 발급 - String accessToken = jwtProvider.createAccessToken(user.getUserId(), user.getRole()); + // refresh token 발급 + String refreshToken = jwtProvider.createRefreshToken(user.getUserId()); - Cookie cookie = new Cookie("ACCESS_TOKEN", accessToken); - cookie.setHttpOnly(true); - cookie.setSecure(true); // https면 true - cookie.setPath("/"); - cookie.setMaxAge(60 * 60); // 1시간 + // Redis 저장 + refreshTokenRedisService.save( + refreshToken, + user.getUserId(), + jwtProvider.getRefreshTokenExpiration() + ); - response.addCookie(cookie); + // refresh token을 HttpOnly 쿠키로 저장 + Cookie refreshCookie = new Cookie("refreshToken", refreshToken); + refreshCookie.setHttpOnly(true); + refreshCookie.setSecure(true); // https 환경 + refreshCookie.setPath("/"); + refreshCookie.setMaxAge(14 * 24 * 60 * 60); // 14일 - // 프론트로 전달 + response.addCookie(refreshCookie); + + // access는 여기서 주지 않는다 + // 프론트에서 /auth/token/refresh 호출하게 함 response.sendRedirect("http://localhost:5173/oauth/callback"); } } - From 292b9ef66e0b869e082ec20326f8616c7031ed4e Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 15:54:30 +0900 Subject: [PATCH 19/28] =?UTF-8?q?feat:=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=89=AC=20=ED=86=A0=ED=81=B0=EC=9D=84=20=EA=B0=80=EC=A7=80?= =?UTF-8?q?=EA=B3=A0=20=EC=95=A1=EC=84=B8=EC=8A=A4=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=EC=9D=84=20=EC=9C=84=ED=95=9C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/api/ErrorResponse.java | 10 +++ .../auth/controller/TokenController.java | 72 +++++++++++++++++++ .../global/auth/dto/AccessTokenResponse.java | 10 +++ .../auth/jwt/JwtAuthenticationFilter.java | 49 ++++++------- .../global/auth/security/SecurityConfig.java | 3 + 5 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 src/main/java/app/dearobjet/backend/global/api/ErrorResponse.java create mode 100644 src/main/java/app/dearobjet/backend/global/auth/controller/TokenController.java create mode 100644 src/main/java/app/dearobjet/backend/global/auth/dto/AccessTokenResponse.java diff --git a/src/main/java/app/dearobjet/backend/global/api/ErrorResponse.java b/src/main/java/app/dearobjet/backend/global/api/ErrorResponse.java new file mode 100644 index 0000000..79ce554 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/api/ErrorResponse.java @@ -0,0 +1,10 @@ +package app.dearobjet.backend.global.api; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ErrorResponse { + private String message; +} diff --git a/src/main/java/app/dearobjet/backend/global/auth/controller/TokenController.java b/src/main/java/app/dearobjet/backend/global/auth/controller/TokenController.java new file mode 100644 index 0000000..c0b6958 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/controller/TokenController.java @@ -0,0 +1,72 @@ +package app.dearobjet.backend.global.auth.controller; + +import app.dearobjet.backend.domain.user.entity.User; +import app.dearobjet.backend.domain.user.repository.UserRepository; +import app.dearobjet.backend.global.api.ApiResponse; +import app.dearobjet.backend.global.auth.dto.AccessTokenResponse; +import app.dearobjet.backend.global.auth.jwt.JwtProvider; +import app.dearobjet.backend.global.auth.refresh.RefreshTokenRedisService; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth/token") +@RequiredArgsConstructor +public class TokenController { + + private final JwtProvider jwtProvider; + private final RefreshTokenRedisService refreshTokenRedisService; + private final UserRepository userRepository; + + @PostMapping("/refresh") + public ResponseEntity> refresh(HttpServletRequest request) { + + String refreshToken = extractRefreshToken(request); + if (refreshToken == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + // 1. refresh JWT 자체 검증 + if (!jwtProvider.validateToken(refreshToken)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + // 2. Redis에 존재하는지 확인 + userId 조회 + Long userId; + try { + userId = refreshTokenRedisService.getUserId(refreshToken); + } catch (RuntimeException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + // 3. 사용자 조회 + User user = userRepository.findById(userId) + .orElseThrow(); + + // 4. 새 access token 발급 + String newAccessToken = + jwtProvider.createAccessToken(user.getUserId(), user.getRole()); + + return ResponseEntity.ok( + ApiResponse.of(new AccessTokenResponse(newAccessToken)) + ); + } + + private String extractRefreshToken(HttpServletRequest request) { + if (request.getCookies() == null) return null; + + for (Cookie cookie : request.getCookies()) { + if ("refreshToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } +} + diff --git a/src/main/java/app/dearobjet/backend/global/auth/dto/AccessTokenResponse.java b/src/main/java/app/dearobjet/backend/global/auth/dto/AccessTokenResponse.java new file mode 100644 index 0000000..e6d2508 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/dto/AccessTokenResponse.java @@ -0,0 +1,10 @@ +package app.dearobjet.backend.global.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class AccessTokenResponse { + private String accessToken; +} diff --git a/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtAuthenticationFilter.java index ac69c0a..7ad5dc6 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtAuthenticationFilter.java @@ -27,7 +27,8 @@ protected boolean shouldNotFilter(HttpServletRequest request) { return uri.startsWith("/oauth2") || uri.startsWith("/login") - || uri.startsWith("/error"); + || uri.startsWith("/error") + || uri.startsWith("/auth/token/refresh"); } @Override @@ -37,36 +38,36 @@ protected void doFilterInternal( FilterChain filterChain ) throws ServletException, IOException { - String token = null; + String authHeader = request.getHeader("Authorization"); - // 쿠키에서 JWT 추출 - if (token == null && request.getCookies() != null) { - for (var cookie : request.getCookies()) { - if ("ACCESS_TOKEN".equals(cookie.getName())) { - token = cookie.getValue(); - break; - } - } + // access token 자체가 없는 경우 → 인증 시도 안 함 + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; } - // 토큰이 있을 때만 인증 처리 - if (token != null && jwtProvider.validateToken(token)) { - Long userId = jwtProvider.getUserId(token); + String token = authHeader.substring(7); - UserDetails userDetails = - userDetailsService.loadUserByUsername(String.valueOf(userId)); + // access token이 있는데 유효하지 않으면 → 바로 401 + if (!jwtProvider.validateToken(token)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } - Authentication authentication = - new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); + Long userId = jwtProvider.getUserId(token); - SecurityContextHolder.getContext().setAuthentication(authentication); - } + UserDetails userDetails = + userDetailsService.loadUserByUsername(String.valueOf(userId)); + + Authentication authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); filterChain.doFilter(request, response); } } - diff --git a/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java index 426ec29..aece282 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java +++ b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java @@ -34,6 +34,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { ) .authorizeHttpRequests(auth -> auth .requestMatchers( + "/auth/token/refresh", + "/auth/login", + "/oauth/**", "/oauth2/authorization/**", "/login/oauth2/**", "/error" From c60abf72f8d5686743bab8ee615d7440870def3d Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 15:55:14 +0900 Subject: [PATCH 20/28] =?UTF-8?q?feat:=20=EC=95=A1=EC=84=B8=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=9D=84=20=ED=8F=90=EA=B8=B0=ED=95=B4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=EC=9D=84=20=ED=95=98?= =?UTF-8?q?=EB=8D=98=20=EB=B0=A9=EC=8B=9D=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=89=AC=20=ED=86=A0=ED=81=B0=20=ED=8F=90=EA=B8=B0=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/src/main/java/app/dearobjet/backend/global/auth/controller/AuthController.java b/src/main/java/app/dearobjet/backend/global/auth/controller/AuthController.java index 50ac5a0..015377e 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/controller/AuthController.java +++ b/src/main/java/app/dearobjet/backend/global/auth/controller/AuthController.java @@ -1,19 +1,49 @@ package app.dearobjet.backend.global.auth.controller; +import app.dearobjet.backend.global.auth.refresh.RefreshTokenRedisService; import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; @RestController +@RequiredArgsConstructor public class AuthController { + private final RefreshTokenRedisService refreshTokenRedisService; + @PostMapping("/auth/logout") - public void logout(HttpServletResponse response) { - Cookie cookie = new Cookie("ACCESS_TOKEN", null); - cookie.setPath("/"); - cookie.setHttpOnly(true); - cookie.setMaxAge(0); // 즉시 삭제 - response.addCookie(cookie); + public ResponseEntity logout(HttpServletRequest request, + HttpServletResponse response) { + + // 1. refresh 쿠키에서 값 추출 + String refreshToken = null; + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("refreshToken".equals(cookie.getName())) { + refreshToken = cookie.getValue(); + break; + } + } + } + + // 2. Redis에서 refresh 삭제 + if (refreshToken != null) { + refreshTokenRedisService.delete(refreshToken); + } + + // 3. refresh 쿠키 삭제 + Cookie deleteCookie = new Cookie("refreshToken", null); + deleteCookie.setHttpOnly(true); + deleteCookie.setSecure(true); // https 환경 + deleteCookie.setPath("/"); + deleteCookie.setMaxAge(0); + + response.addCookie(deleteCookie); + + return ResponseEntity.noContent().build(); } } From ef8fc18cf5922f9ae5e2d6c3c09616dbe1c66089 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 16:10:58 +0900 Subject: [PATCH 21/28] =?UTF-8?q?feat:=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=89=AC=20=ED=86=A0=ED=81=B0=20=ED=83=88=EC=B7=A8=20=EB=8B=B9?= =?UTF-8?q?=ED=95=A0=20=EC=8B=9C=20=EA=B3=84=EC=86=8D=20=EC=95=A1=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=ED=81=B0=EC=9D=84=20=EB=B0=9C=EA=B8=89?= =?UTF-8?q?=EB=B0=9B=EC=9D=84=20=EC=88=98=20=EC=97=86=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=A1=9C=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=A0=84=EB=9E=B5=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/TokenController.java | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/main/java/app/dearobjet/backend/global/auth/controller/TokenController.java b/src/main/java/app/dearobjet/backend/global/auth/controller/TokenController.java index c0b6958..7377c08 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/controller/TokenController.java +++ b/src/main/java/app/dearobjet/backend/global/auth/controller/TokenController.java @@ -8,6 +8,7 @@ import app.dearobjet.backend.global.auth.refresh.RefreshTokenRedisService; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -25,19 +26,22 @@ public class TokenController { private final UserRepository userRepository; @PostMapping("/refresh") - public ResponseEntity> refresh(HttpServletRequest request) { + public ResponseEntity> refresh( + HttpServletRequest request, + HttpServletResponse response + ) { String refreshToken = extractRefreshToken(request); if (refreshToken == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - // 1. refresh JWT 자체 검증 + // 1. refresh JWT 검증 if (!jwtProvider.validateToken(refreshToken)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - // 2. Redis에 존재하는지 확인 + userId 조회 + // 2. Redis 조회 + userId 확보 Long userId; try { userId = refreshTokenRedisService.getUserId(refreshToken); @@ -45,14 +49,36 @@ public ResponseEntity> refresh(HttpServletReque return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - // 3. 사용자 조회 + // 3. 기존 refresh 즉시 폐기 (RTR 핵심) + refreshTokenRedisService.delete(refreshToken); + + // 4. 사용자 조회 User user = userRepository.findById(userId) .orElseThrow(); - // 4. 새 access token 발급 + // 5. 새 토큰 발급 String newAccessToken = jwtProvider.createAccessToken(user.getUserId(), user.getRole()); + String newRefreshToken = + jwtProvider.createRefreshToken(user.getUserId()); + + // 6. 새 refresh Redis 저장 + long refreshTtlMillis = jwtProvider.getRefreshTokenExpiration(); + refreshTokenRedisService.save( + newRefreshToken, + user.getUserId(), + refreshTtlMillis + ); + + // 7. refresh 쿠키 교체 + Cookie refreshCookie = new Cookie("refreshToken", newRefreshToken); + refreshCookie.setHttpOnly(true); + refreshCookie.setSecure(true); + refreshCookie.setPath("/"); + refreshCookie.setMaxAge((int) (refreshTtlMillis / 1000)); + response.addCookie(refreshCookie); + return ResponseEntity.ok( ApiResponse.of(new AccessTokenResponse(newAccessToken)) ); From e5034b01b98d6fdcede1ea4419c73c0d0b546a73 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 16:19:23 +0900 Subject: [PATCH 22/28] =?UTF-8?q?chore:=20=EA=B0=9C=EB=B0=9C=20=ED=8E=B8?= =?UTF-8?q?=EC=9D=98=EC=84=B1=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EC=8B=9C?= =?UTF-8?q?=ED=81=90=EB=A6=AC=ED=8B=B0=20=EA=B1=B0=EC=B9=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=84=A4=EC=A0=95=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/security/DevSecurityConfig.java | 24 +++++++++++++++++++ .../global/auth/security/SecurityConfig.java | 2 ++ src/main/resources/application.yml | 3 +++ 3 files changed, 29 insertions(+) create mode 100644 src/main/java/app/dearobjet/backend/global/auth/security/DevSecurityConfig.java diff --git a/src/main/java/app/dearobjet/backend/global/auth/security/DevSecurityConfig.java b/src/main/java/app/dearobjet/backend/global/auth/security/DevSecurityConfig.java new file mode 100644 index 0000000..97d4911 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/security/DevSecurityConfig.java @@ -0,0 +1,24 @@ +package app.dearobjet.backend.global.auth.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@Profile({"dev"}) +public class DevSecurityConfig { + + @Bean + public SecurityFilterChain devFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ); + + return http.build(); + } +} + diff --git a/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java index aece282..47dd371 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java +++ b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; @@ -15,6 +16,7 @@ @RequiredArgsConstructor @Configuration @EnableWebSecurity +@Profile("prod") public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b07027d..1aa00d0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,9 @@ spring: application: name: backend + profiles: + active: local # 인증인가가 필요하다면 prod로 변경 + config: import: optional:file:.env[.properties] From 5418a808350095b28e49adcd27b925a4c058cae3 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 17:36:40 +0900 Subject: [PATCH 23/28] =?UTF-8?q?refactor:=20=EA=B3=BC=EB=8F=84=ED=95=9C?= =?UTF-8?q?=20=EC=B1=85=EC=9E=84=EC=9D=84=20=EA=B0=80=EC=A7=80=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20Controller=EC=97=90=EC=84=9C=20Service=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/TokenController.java | 53 ++++--------------- .../global/auth/dto/RefreshResult.java | 12 +++++ .../global/auth/service/TokenService.java | 51 ++++++++++++++++++ 3 files changed, 74 insertions(+), 42 deletions(-) create mode 100644 src/main/java/app/dearobjet/backend/global/auth/dto/RefreshResult.java create mode 100644 src/main/java/app/dearobjet/backend/global/auth/service/TokenService.java diff --git a/src/main/java/app/dearobjet/backend/global/auth/controller/TokenController.java b/src/main/java/app/dearobjet/backend/global/auth/controller/TokenController.java index 7377c08..ec49859 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/controller/TokenController.java +++ b/src/main/java/app/dearobjet/backend/global/auth/controller/TokenController.java @@ -1,11 +1,9 @@ package app.dearobjet.backend.global.auth.controller; -import app.dearobjet.backend.domain.user.entity.User; -import app.dearobjet.backend.domain.user.repository.UserRepository; import app.dearobjet.backend.global.api.ApiResponse; import app.dearobjet.backend.global.auth.dto.AccessTokenResponse; -import app.dearobjet.backend.global.auth.jwt.JwtProvider; -import app.dearobjet.backend.global.auth.refresh.RefreshTokenRedisService; +import app.dearobjet.backend.global.auth.dto.RefreshResult; +import app.dearobjet.backend.global.auth.service.TokenService; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -21,9 +19,7 @@ @RequiredArgsConstructor public class TokenController { - private final JwtProvider jwtProvider; - private final RefreshTokenRedisService refreshTokenRedisService; - private final UserRepository userRepository; + private final TokenService tokenService; @PostMapping("/refresh") public ResponseEntity> refresh( @@ -36,51 +32,24 @@ public ResponseEntity> refresh( return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - // 1. refresh JWT 검증 - if (!jwtProvider.validateToken(refreshToken)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - - // 2. Redis 조회 + userId 확보 - Long userId; + RefreshResult result; try { - userId = refreshTokenRedisService.getUserId(refreshToken); - } catch (RuntimeException e) { + result = tokenService.refresh(refreshToken); + } catch (Exception e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - // 3. 기존 refresh 즉시 폐기 (RTR 핵심) - refreshTokenRedisService.delete(refreshToken); - - // 4. 사용자 조회 - User user = userRepository.findById(userId) - .orElseThrow(); - - // 5. 새 토큰 발급 - String newAccessToken = - jwtProvider.createAccessToken(user.getUserId(), user.getRole()); - - String newRefreshToken = - jwtProvider.createRefreshToken(user.getUserId()); - - // 6. 새 refresh Redis 저장 - long refreshTtlMillis = jwtProvider.getRefreshTokenExpiration(); - refreshTokenRedisService.save( - newRefreshToken, - user.getUserId(), - refreshTtlMillis - ); - - // 7. refresh 쿠키 교체 - Cookie refreshCookie = new Cookie("refreshToken", newRefreshToken); + // refresh 쿠키 설정 + Cookie refreshCookie = new Cookie("refreshToken", result.getRefreshToken()); refreshCookie.setHttpOnly(true); refreshCookie.setSecure(true); refreshCookie.setPath("/"); - refreshCookie.setMaxAge((int) (refreshTtlMillis / 1000)); response.addCookie(refreshCookie); return ResponseEntity.ok( - ApiResponse.of(new AccessTokenResponse(newAccessToken)) + ApiResponse.of( + new AccessTokenResponse(result.getAccessToken()) + ) ); } diff --git a/src/main/java/app/dearobjet/backend/global/auth/dto/RefreshResult.java b/src/main/java/app/dearobjet/backend/global/auth/dto/RefreshResult.java new file mode 100644 index 0000000..ca06395 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/dto/RefreshResult.java @@ -0,0 +1,12 @@ +package app.dearobjet.backend.global.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class RefreshResult { + private String accessToken; + private String refreshToken; +} + diff --git a/src/main/java/app/dearobjet/backend/global/auth/service/TokenService.java b/src/main/java/app/dearobjet/backend/global/auth/service/TokenService.java new file mode 100644 index 0000000..3fac585 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/service/TokenService.java @@ -0,0 +1,51 @@ +package app.dearobjet.backend.global.auth.service; + +import app.dearobjet.backend.domain.user.entity.User; +import app.dearobjet.backend.domain.user.repository.UserRepository; +import app.dearobjet.backend.global.auth.dto.RefreshResult; +import app.dearobjet.backend.global.auth.jwt.JwtProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TokenService { + + private final JwtProvider jwtProvider; + private final RefreshTokenRedisService refreshTokenRedisService; + private final UserRepository userRepository; + + public RefreshResult refresh(String refreshToken) { + + // 1. refresh token 검증 + if (!jwtProvider.validateToken(refreshToken)) { + throw new IllegalStateException("INVALID_REFRESH_TOKEN"); + } + + // 2. Redis에서 userId 조회 + Long userId = refreshTokenRedisService.getUserId(refreshToken); + + // 3. RTR: 기존 refresh 폐기 + refreshTokenRedisService.delete(refreshToken); + + // 4. 사용자 조회 + User user = userRepository.findById(userId) + .orElseThrow(); + + // 5. 새 토큰 발급 + String newAccessToken = + jwtProvider.createAccessToken(user.getUserId(), user.getRole()); + + String newRefreshToken = + jwtProvider.createRefreshToken(user.getUserId()); + + // 6. 새 refresh Redis 저장 + refreshTokenRedisService.save( + newRefreshToken, + user.getUserId(), + jwtProvider.getRefreshTokenExpiration() + ); + + return new RefreshResult(newAccessToken, newRefreshToken); + } +} From b28ad9ce4025ab9b1920807f70a05ba61ce55f48 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 17:37:10 +0900 Subject: [PATCH 24/28] =?UTF-8?q?feat:=20=EC=B6=94=EA=B0=80=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EB=B6=84=EA=B8=B0=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/oauth/OAuthSuccessHandler.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java b/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java index 32fb48f..9c912ce 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java +++ b/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java @@ -1,14 +1,16 @@ package app.dearobjet.backend.global.auth.oauth; import app.dearobjet.backend.domain.user.entity.User; +import app.dearobjet.backend.domain.user.enums.Role; import app.dearobjet.backend.domain.user.service.UserService; import app.dearobjet.backend.global.auth.jwt.JwtProvider; -import app.dearobjet.backend.global.auth.refresh.RefreshTokenRedisService; +import app.dearobjet.backend.global.auth.service.RefreshTokenRedisService; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; @@ -21,6 +23,8 @@ public class OAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { private final UserService userService; private final JwtProvider jwtProvider; private final RefreshTokenRedisService refreshTokenRedisService; + @Value("${app.frontend.base-url}") + private String frontendBaseUrl; @Override public void onAuthenticationSuccess( @@ -54,8 +58,13 @@ public void onAuthenticationSuccess( response.addCookie(refreshCookie); - // access는 여기서 주지 않는다 - // 프론트에서 /auth/token/refresh 호출하게 함 - response.sendRedirect("http://localhost:5173/oauth/callback"); + // 추가 회원가입 필요 여부 판단 + boolean signupRequired = user.getRole() == Role.TEMP; + + String redirectUrl = frontendBaseUrl + + "/oauth/callback" + + (signupRequired ? "?signup=required" : "?signup=completed"); + + response.sendRedirect(redirectUrl); } } From bc5f635284a7aab775d66450eb763e33e345dc1c Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 17:37:25 +0900 Subject: [PATCH 25/28] =?UTF-8?q?chore:=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/{refresh => service}/RefreshTokenRedisService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/java/app/dearobjet/backend/global/auth/{refresh => service}/RefreshTokenRedisService.java (95%) diff --git a/src/main/java/app/dearobjet/backend/global/auth/refresh/RefreshTokenRedisService.java b/src/main/java/app/dearobjet/backend/global/auth/service/RefreshTokenRedisService.java similarity index 95% rename from src/main/java/app/dearobjet/backend/global/auth/refresh/RefreshTokenRedisService.java rename to src/main/java/app/dearobjet/backend/global/auth/service/RefreshTokenRedisService.java index 06146c2..9d2aabe 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/refresh/RefreshTokenRedisService.java +++ b/src/main/java/app/dearobjet/backend/global/auth/service/RefreshTokenRedisService.java @@ -1,4 +1,4 @@ -package app.dearobjet.backend.global.auth.refresh; +package app.dearobjet.backend.global.auth.service; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; From 27c5a09aa7d3aafcd14ee5144016a8d1c8eb5ab2 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 17:37:37 +0900 Subject: [PATCH 26/28] =?UTF-8?q?chore:=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/auth/controller/AuthController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/app/dearobjet/backend/global/auth/controller/AuthController.java b/src/main/java/app/dearobjet/backend/global/auth/controller/AuthController.java index 015377e..419e9b4 100644 --- a/src/main/java/app/dearobjet/backend/global/auth/controller/AuthController.java +++ b/src/main/java/app/dearobjet/backend/global/auth/controller/AuthController.java @@ -1,6 +1,6 @@ package app.dearobjet.backend.global.auth.controller; -import app.dearobjet.backend.global.auth.refresh.RefreshTokenRedisService; +import app.dearobjet.backend.global.auth.service.RefreshTokenRedisService; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; From 9cc6ca4b4ce1f72be57c25e93e39d85c34ece67a Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 17:38:23 +0900 Subject: [PATCH 27/28] =?UTF-8?q?conf:=20Securiy=20=EC=9A=B4=EC=98=81/?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=20=ED=99=98=EA=B2=BD=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1aa00d0..864079b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,7 +3,7 @@ spring: name: backend profiles: - active: local # 인증인가가 필요하다면 prod로 변경 + active: prod # 인증인가가 필요하다면 prod로 변경 config: import: optional:file:.env[.properties] @@ -51,4 +51,8 @@ logging: jwt: secret: ${JWT_SECRET} access-token-expiration: 3600000 # 1시간(ms) - refresh-token-expiration: 1209600000 # 14일 (ms) \ No newline at end of file + refresh-token-expiration: 1209600000 # 14일 (ms) + +app: + frontend: + base-url: ${FRONTEND_BASE_URL} \ No newline at end of file From b0690efb3b30e5bf0e2fb706e9dd22bd111c0bf7 Mon Sep 17 00:00:00 2001 From: ruudska6 Date: Tue, 3 Feb 2026 17:53:52 +0900 Subject: [PATCH 28/28] =?UTF-8?q?fix:=20=EC=84=A4=EA=B3=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20users/comple?= =?UTF-8?q?te=20API=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/src/main/java/app/dearobjet/backend/domain/user/controller/UserController.java b/src/main/java/app/dearobjet/backend/domain/user/controller/UserController.java index cb6368f..1166bb7 100644 --- a/src/main/java/app/dearobjet/backend/domain/user/controller/UserController.java +++ b/src/main/java/app/dearobjet/backend/domain/user/controller/UserController.java @@ -2,10 +2,8 @@ import app.dearobjet.backend.domain.user.dto.CompleteSignupRequest; import app.dearobjet.backend.domain.user.service.UserService; -import app.dearobjet.backend.global.auth.jwt.JwtProvider; +import app.dearobjet.backend.global.api.ApiResponse; import app.dearobjet.backend.global.auth.security.CustomUserDetails; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -15,22 +13,18 @@ @RequiredArgsConstructor @RequestMapping("/users") public class UserController { - private final UserService userService; - private final JwtProvider jwtProvider; /** * 추가 회원가입 (TEMP → CUSTOMER / ARTIST / SHOP) */ @PostMapping("/complete") - public ResponseEntity completeSignup( + public ResponseEntity> completeSignup( @AuthenticationPrincipal CustomUserDetails userDetails, - @RequestBody CompleteSignupRequest request, - HttpServletResponse response + @RequestBody CompleteSignupRequest request ) { Long userId = userDetails.getUserId(); - // 추가 회원가입 userService.completeSignup( userId, request.getName(), @@ -41,18 +35,6 @@ public ResponseEntity completeSignup( request.getRole() ); - // role 변경되었으므로 JWT 재발급 - String newAccessToken = - jwtProvider.createAccessToken(userId, request.getRole()); - - Cookie cookie = new Cookie("ACCESS_TOKEN", newAccessToken); - cookie.setHttpOnly(true); - cookie.setSecure(false); // dev 환경 - cookie.setPath("/"); - cookie.setMaxAge(60 * 60); - - response.addCookie(cookie); - - return ResponseEntity.ok().build(); + return ResponseEntity.ok(ApiResponse.of(null)); } } \ No newline at end of file