diff --git a/build.gradle b/build.gradle index d73e76c..5f0d61b 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' runtimeOnly 'com.h2database:h2' @@ -37,6 +38,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/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/domain/user/controller/UserController.java b/src/main/java/app/dearobjet/backend/domain/user/controller/UserController.java new file mode 100644 index 0000000..1166bb7 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/user/controller/UserController.java @@ -0,0 +1,40 @@ +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.api.ApiResponse; +import app.dearobjet.backend.global.auth.security.CustomUserDetails; +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; + + /** + * 추가 회원가입 (TEMP → CUSTOMER / ARTIST / SHOP) + */ + @PostMapping("/complete") + public ResponseEntity> completeSignup( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody CompleteSignupRequest request + ) { + Long userId = userDetails.getUserId(); + + userService.completeSignup( + userId, + request.getName(), + request.getEmail(), + request.getPhoneNumber(), + request.getSmsAgreement(), + request.getMarketingAgreement(), + request.getRole() + ); + + return ResponseEntity.ok(ApiResponse.of(null)); + } +} \ 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 +} 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 ea5aa22..e4c9e40 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,6 +1,7 @@ package app.dearobjet.backend.domain.user.entity; -import app.dearobjet.backend.global.common.entity.BaseTimeEntity; +import app.dearobjet.backend.domain.user.enums.Role; +import app.dearobjet.backend.domain.user.enums.UserStatus; import jakarta.persistence.*; import lombok.*; @@ -18,13 +19,12 @@ public class User extends BaseTimeEntity { @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") @@ -36,18 +36,20 @@ public class User extends BaseTimeEntity { @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; @@ -59,7 +61,26 @@ public void updateProfile(String name, String profileUrl) { } public void deactivate() { - this.userStatus = "INACTIVE"; + this.userStatus = UserStatus.INACTIVE; } + public void completeRegistration( + String name, + String email, + String phoneNumber, + Boolean smsAgreement, + Boolean marketingAgreement, + Role role + ) { + if (this.role != Role.TEMP) { + throw new IllegalStateException("User already registered"); + } + + this.name = name; + this.email = email; + this.phoneNumber = phoneNumber; + this.smsAgreement = smsAgreement; + this.marketingAgreement = marketingAgreement; + this.role = role; + } } 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..97a8d37 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/user/enums/Role.java @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..0a20be6 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/user/enums/UserStatus.java @@ -0,0 +1,7 @@ +package app.dearobjet.backend.domain.user.enums; + +public enum UserStatus { + ACTIVE, + INACTIVE +} + 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 index 70d89aa..8d24054 100644 --- a/src/main/java/app/dearobjet/backend/domain/user/repository/UserRepository.java +++ b/src/main/java/app/dearobjet/backend/domain/user/repository/UserRepository.java @@ -1,7 +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 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..2f23863 --- /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(() -> createTempUser(socialId)); + } + + private User createTempUser(String socialId) { + User user = User.builder() + .socialId(socialId) + .role(Role.TEMP) + .userStatus(UserStatus.ACTIVE) + .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(); + } +} 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/AuthController.java b/src/main/java/app/dearobjet/backend/global/auth/controller/AuthController.java new file mode 100644 index 0000000..419e9b4 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/controller/AuthController.java @@ -0,0 +1,49 @@ +package app.dearobjet.backend.global.auth.controller; + +import app.dearobjet.backend.global.auth.service.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 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(); + } +} 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..ec49859 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/controller/TokenController.java @@ -0,0 +1,67 @@ +package app.dearobjet.backend.global.auth.controller; + +import app.dearobjet.backend.global.api.ApiResponse; +import app.dearobjet.backend.global.auth.dto.AccessTokenResponse; +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; +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 TokenService tokenService; + + @PostMapping("/refresh") + public ResponseEntity> refresh( + HttpServletRequest request, + HttpServletResponse response + ) { + + String refreshToken = extractRefreshToken(request); + if (refreshToken == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + RefreshResult result; + try { + result = tokenService.refresh(refreshToken); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + // refresh 쿠키 설정 + Cookie refreshCookie = new Cookie("refreshToken", result.getRefreshToken()); + refreshCookie.setHttpOnly(true); + refreshCookie.setSecure(true); + refreshCookie.setPath("/"); + response.addCookie(refreshCookie); + + return ResponseEntity.ok( + ApiResponse.of( + new AccessTokenResponse(result.getAccessToken()) + ) + ); + } + + 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/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/entrypoint/RestAuthenticationEntryPoint.java b/src/main/java/app/dearobjet/backend/global/auth/entrypoint/RestAuthenticationEntryPoint.java new file mode 100644 index 0000000..3826e94 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/entrypoint/RestAuthenticationEntryPoint.java @@ -0,0 +1,35 @@ +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; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + + ApiResponse body = + ApiResponse.of(new ErrorResponse("로그인이 필요합니다")); + + response.getWriter().write( + objectMapper.writeValueAsString(body) + ); + } +} 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..7ad5dc6 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,73 @@ +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 boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + + return uri.startsWith("/oauth2") + || uri.startsWith("/login") + || uri.startsWith("/error") + || uri.startsWith("/auth/token/refresh"); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + // access token 자체가 없는 경우 → 인증 시도 안 함 + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = authHeader.substring(7); + + // access token이 있는데 유효하지 않으면 → 바로 401 + if (!jwtProvider.validateToken(token)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + 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..51a49e8 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProperties.java @@ -0,0 +1,20 @@ +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; + + @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 new file mode 100644 index 0000000..13f9551 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/jwt/JwtProvider.java @@ -0,0 +1,96 @@ +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; +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, 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) + .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( + 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; + } + } + + public Role getRole(String token) { + String role = Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody() + .get("role", String.class); + + 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/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..9c912ce --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/oauth/OAuthSuccessHandler.java @@ -0,0 +1,70 @@ +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.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; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +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( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException { + + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + + String socialId = oAuth2User.getAttribute("id").toString(); + + User user = userService.getOrCreateKakaoUser(socialId); + + // refresh token 발급 + String refreshToken = jwtProvider.createRefreshToken(user.getUserId()); + + // Redis 저장 + refreshTokenRedisService.save( + refreshToken, + user.getUserId(), + jwtProvider.getRefreshTokenExpiration() + ); + + // 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); + + // 추가 회원가입 필요 여부 판단 + boolean signupRequired = user.getRole() == Role.TEMP; + + String redirectUrl = frontendBaseUrl + + "/oauth/callback" + + (signupRequired ? "?signup=required" : "?signup=completed"); + + response.sendRedirect(redirectUrl); + } +} 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..dd10d0b --- /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/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 new file mode 100644 index 0000000..47dd371 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/security/SecurityConfig.java @@ -0,0 +1,64 @@ +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; +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; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@RequiredArgsConstructor +@Configuration +@EnableWebSecurity +@Profile("prod") +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 { + + http + .csrf(csrf -> csrf.disable()) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .exceptionHandling(e -> e + .authenticationEntryPoint(restAuthenticationEntryPoint) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/auth/token/refresh", + "/auth/login", + "/oauth/**", + "/oauth2/authorization/**", + "/login/oauth2/**", + "/error" + ).permitAll() + .anyRequest().authenticated() + ) + + .oauth2Login(oauth -> oauth + .userInfoEndpoint(userInfo -> + userInfo.userService(customOAuth2UserService) + ) + .successHandler(oAuthSuccessHandler) + ) + + .addFilterBefore( + jwtAuthenticationFilter, + UsernamePasswordAuthenticationFilter.class + ); + + return http.build(); + } +} + diff --git a/src/main/java/app/dearobjet/backend/global/auth/service/RefreshTokenRedisService.java b/src/main/java/app/dearobjet/backend/global/auth/service/RefreshTokenRedisService.java new file mode 100644 index 0000000..9d2aabe --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/auth/service/RefreshTokenRedisService.java @@ -0,0 +1,36 @@ +package app.dearobjet.backend.global.auth.service; + +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); + } +} 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); + } +} 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; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0e71036..864079b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,12 @@ spring: application: name: backend + profiles: + active: prod # 인증인가가 필요하다면 prod로 변경 + + config: + import: optional:file:.env[.properties] + datasource: url: jdbc:postgresql://localhost:5432/dearobjet username: dearobjet @@ -9,12 +15,44 @@ 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 + + data: + redis: + host: localhost + port: 6379 + 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) + refresh-token-expiration: 1209600000 # 14일 (ms) + +app: + frontend: + base-url: ${FRONTEND_BASE_URL} \ No newline at end of file