diff --git a/.env.example b/.env.example index 47ebcc1a..250156da 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,17 @@ DB_NAME=cherrish DB_USERNAME=postgres DB_PASSWORD=postgres +# Redis Configuration (로컬 개발 환경 - 인증 없음) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# JWT Configuration (Base64-encoded 32+ bytes; e.g., openssl rand -base64 32) +JWT_SECRET_KEY=MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE= + +# Social Login Configuration +APPLE_CLIENT_ID=your-apple-bundle-id + # OpenAI Configuration OPENAI_API_KEY=sk-proj-your-api-key-here OPENAI_MODEL=gpt-3.5-turbo diff --git a/build.gradle b/build.gradle index af69a1c6..40f2b32d 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,17 @@ dependencies { annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.7' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.7' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.7' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + //swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14' implementation 'io.swagger.core.v3:swagger-annotations-jakarta:2.2.41' diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 099001c7..de3beab7 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -16,5 +16,19 @@ services: timeout: 5s retries: 5 + redis: + image: redis:7-alpine + container_name: cherrish-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + volumes: postgres_data: + redis_data: diff --git a/scripts/deploy-dev.sh b/scripts/deploy-dev.sh index a48c1480..f3c1eaa4 100644 --- a/scripts/deploy-dev.sh +++ b/scripts/deploy-dev.sh @@ -32,6 +32,11 @@ OPENAI_API_KEY=$(aws ssm get-parameter --name "/cherrish/OPENAI_API_KEY" --regio OPENAI_MODEL=$(aws ssm get-parameter --name "/cherrish/OPENAI_MODEL" --region "${AWS_REGION}" --query "Parameter.Value" --output text) SERVER_URL=$(aws ssm get-parameter --name "/cherrish/SERVER_URL" --region "${AWS_REGION}" --query "Parameter.Value" --output text) DISCORD_ERROR_WEBHOOK_URL=$(aws ssm get-parameter --name "/cherrish/DISCORD_ERROR_WEBHOOK_URL" --region "${AWS_REGION}" --with-decryption --query "Parameter.Value" --output text) +REDIS_HOST=$(aws ssm get-parameter --name "/cherrish/REDIS_HOST" --region "${AWS_REGION}" --query "Parameter.Value" --output text) +REDIS_PORT=$(aws ssm get-parameter --name "/cherrish/REDIS_PORT" --region "${AWS_REGION}" --query "Parameter.Value" --output text) +REDIS_PASSWORD=$(aws ssm get-parameter --name "/cherrish/REDIS_PASSWORD" --region "${AWS_REGION}" --with-decryption --query "Parameter.Value" --output text) +JWT_SECRET_KEY=$(aws ssm get-parameter --name "/cherrish/JWT_SECRET_KEY" --region "${AWS_REGION}" --with-decryption --query "Parameter.Value" --output text) +APPLE_CLIENT_ID=$(aws ssm get-parameter --name "/cherrish/APPLE_CLIENT_ID" --region "${AWS_REGION}" --query "Parameter.Value" --output text) # Stop and remove existing container echo "Stopping existing container..." @@ -53,6 +58,11 @@ docker run -d \ -e OPENAI_MODEL="${OPENAI_MODEL}" \ -e SERVER_URL="${SERVER_URL}" \ -e DISCORD_ERROR_WEBHOOK_URL="${DISCORD_ERROR_WEBHOOK_URL}" \ + -e REDIS_HOST="${REDIS_HOST}" \ + -e REDIS_PORT="${REDIS_PORT}" \ + -e REDIS_PASSWORD="${REDIS_PASSWORD}" \ + -e JWT_SECRET_KEY="${JWT_SECRET_KEY}" \ + -e APPLE_CLIENT_ID="${APPLE_CLIENT_ID}" \ "${IMAGE}" # Cleanup old images diff --git a/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java b/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java new file mode 100644 index 00000000..0ec0ee5a --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java @@ -0,0 +1,164 @@ +package com.sopt.cherrish.domain.auth.application.service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sopt.cherrish.domain.auth.domain.repository.AccessTokenBlacklistRepository; +import com.sopt.cherrish.domain.auth.domain.repository.RefreshTokenRepository; +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.domain.auth.exception.AuthException; +import com.sopt.cherrish.domain.auth.infrastructure.jwt.JwtTokenProvider; +import com.sopt.cherrish.domain.auth.infrastructure.social.SocialUserInfo; +import com.sopt.cherrish.domain.auth.presentation.dto.request.SocialLoginRequestDto; +import com.sopt.cherrish.domain.auth.presentation.dto.request.TokenRefreshRequestDto; +import com.sopt.cherrish.domain.auth.presentation.dto.response.LoginResponseDto; +import com.sopt.cherrish.domain.auth.presentation.dto.response.TokenResponseDto; +import com.sopt.cherrish.domain.user.domain.model.User; +import com.sopt.cherrish.domain.user.domain.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 인증 관련 비즈니스 로직을 처리하는 서비스. + * + *

소셜 로그인, 토큰 재발급, 로그아웃 기능을 제공합니다.

+ */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private static final String DEFAULT_NAME = "사용자"; + private static final int DEFAULT_AGE = 0; + + private final SocialLoginService socialLoginService; + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final AccessTokenBlacklistRepository accessTokenBlacklistRepository; + + /** + * 소셜 로그인을 처리합니다. + * + *

소셜 토큰을 검증하고, 신규 사용자인 경우 회원가입을 진행합니다. + * 이후 Access Token과 Refresh Token을 발급합니다.

+ * + * @param request 소셜 로그인 요청 (provider, token) + * @return 로그인 응답 (userId, isNewUser, accessToken, refreshToken) + * @throws AuthException 소셜 토큰이 유효하지 않은 경우 + */ + @Transactional + public LoginResponseDto login(SocialLoginRequestDto request) { + SocialUserInfo socialUserInfo = socialLoginService.authenticate( + request.provider(), + request.token() + ); + + Optional existingUser = userRepository.findBySocialProviderAndSocialId( + request.provider(), + socialUserInfo.socialId() + ); + + boolean isNewUser = existingUser.isEmpty(); + User user; + + if (isNewUser) { + user = User.builder() + .name(DEFAULT_NAME) + .age(DEFAULT_AGE) + .socialProvider(request.provider()) + .socialId(socialUserInfo.socialId()) + .email(socialUserInfo.email()) + .build(); + user = userRepository.save(user); + } else { + user = existingUser.get(); + } + + TokenResponseDto tokens = issueTokenPair(user.getId()); + + return new LoginResponseDto(user.getId(), isNewUser, tokens.accessToken(), tokens.refreshToken()); + } + + /** + * Refresh Token으로 새로운 토큰 쌍을 발급합니다. + * + *

Refresh Token Rotation(RTR) 방식을 적용하여 재발급시 새로운 Refresh Token도 함께 발급합니다.

+ * + * @param request 토큰 재발급 요청 (refreshToken) + * @return 새로운 Access Token과 Refresh Token + * @throws AuthException Refresh Token이 유효하지 않거나 만료된 경우 + */ + @Transactional + public TokenResponseDto refresh(TokenRefreshRequestDto request) { + String refreshToken = request.refreshToken(); + + jwtTokenProvider.validateToken(refreshToken); + + if (!jwtTokenProvider.isRefreshToken(refreshToken)) { + throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + + Long userId = jwtTokenProvider.getUserId(refreshToken); + + if (!userRepository.existsById(userId)) { + throw new AuthException(AuthErrorCode.USER_NOT_FOUND); + } + + String storedToken = refreshTokenRepository.findByUserId(userId) + .orElseThrow(() -> new AuthException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND)); + + if (!constantTimeEquals(storedToken, refreshToken)) { + throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + + return issueTokenPair(userId); + } + + /** + * 로그아웃을 처리합니다. + * + *

Redis에 저장된 Refresh Token을 삭제하고, Access Token을 블랙리스트에 추가하여 + * 해당 토큰들로 더 이상 인증이 불가능하게 합니다.

+ * + * @param userId 로그아웃할 사용자 ID + * @param authorizationHeader Authorization 헤더 값 (Bearer 토큰) + */ + @Transactional + public void logout(Long userId, String authorizationHeader) { + refreshTokenRepository.deleteByUserId(userId); + + String accessToken = jwtTokenProvider.extractToken(authorizationHeader); + if (accessToken != null) { + long remainingExpiration = jwtTokenProvider.getRemainingExpiration(accessToken); + accessTokenBlacklistRepository.add(accessToken, remainingExpiration); + } + } + + private TokenResponseDto issueTokenPair(Long userId) { + String accessToken = jwtTokenProvider.createAccessToken(userId); + String refreshToken = jwtTokenProvider.createRefreshToken(userId); + + refreshTokenRepository.save( + userId, + refreshToken, + jwtTokenProvider.getRefreshTokenExpiration() + ); + + return new TokenResponseDto(accessToken, refreshToken); + } + + private boolean constantTimeEquals(String a, String b) { + if (a == null || b == null) { + return false; + } + return MessageDigest.isEqual( + a.getBytes(StandardCharsets.UTF_8), + b.getBytes(StandardCharsets.UTF_8) + ); + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/application/service/SocialLoginService.java b/src/main/java/com/sopt/cherrish/domain/auth/application/service/SocialLoginService.java new file mode 100644 index 00000000..43a115c5 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/application/service/SocialLoginService.java @@ -0,0 +1,38 @@ +package com.sopt.cherrish.domain.auth.application.service; + +import org.springframework.stereotype.Service; + +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; +import com.sopt.cherrish.domain.auth.infrastructure.social.AppleAuthClient; +import com.sopt.cherrish.domain.auth.infrastructure.social.KakaoAuthClient; +import com.sopt.cherrish.domain.auth.infrastructure.social.SocialUserInfo; + +import lombok.RequiredArgsConstructor; + +/** + * 소셜 로그인 인증을 처리하는 서비스. + * + *

소셜 플랫폼에 따라 적절한 인증 클라이언트를 선택하여 토큰을 검증합니다.

+ */ +@Service +@RequiredArgsConstructor +public class SocialLoginService { + + private final KakaoAuthClient kakaoAuthClient; + private final AppleAuthClient appleAuthClient; + + /** + * 소셜 토큰을 검증하고 사용자 정보를 반환합니다. + * + * @param provider 소셜 플랫폼 (KAKAO, APPLE) + * @param token 소셜 플랫폼에서 발급한 토큰 + * @return 소셜 사용자 정보 + * @throws com.sopt.cherrish.domain.auth.exception.AuthException 토큰이 유효하지 않은 경우 + */ + public SocialUserInfo authenticate(SocialProvider provider, String token) { + return switch (provider) { + case KAKAO -> kakaoAuthClient.getUserInfo(token); + case APPLE -> appleAuthClient.getUserInfo(token); + }; + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/domain/model/SocialProvider.java b/src/main/java/com/sopt/cherrish/domain/auth/domain/model/SocialProvider.java new file mode 100644 index 00000000..cf15900b --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/domain/model/SocialProvider.java @@ -0,0 +1,6 @@ +package com.sopt.cherrish.domain.auth.domain.model; + +public enum SocialProvider { + KAKAO, + APPLE +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java new file mode 100644 index 00000000..4fc4999f --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java @@ -0,0 +1,50 @@ +package com.sopt.cherrish.domain.auth.domain.repository; + +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +/** + * Access Token 블랙리스트를 Redis에 관리하는 저장소. + * + *

로그아웃된 Access Token을 저장하여 만료 전까지 재사용을 방지합니다. + * TTL은 토큰의 남은 만료 시간으로 설정되어 자동으로 정리됩니다.

+ */ +@Repository +@RequiredArgsConstructor +public class AccessTokenBlacklistRepository { + + private static final String KEY_PREFIX = "blacklist:"; + + private final RedisTemplate redisTemplate; + + /** + * Access Token을 블랙리스트에 추가합니다. + * + * @param token 블랙리스트에 추가할 Access Token + * @param expirationMillis 토큰의 남은 만료 시간 (밀리초) + */ + public void add(String token, long expirationMillis) { + if (expirationMillis <= 0) { + return; + } + String key = KEY_PREFIX + token; + redisTemplate.opsForValue().set(key, "blacklisted", expirationMillis, TimeUnit.MILLISECONDS); + } + + /** + * Access Token이 블랙리스트에 있는지 확인합니다. + * + *

Redis 연결 문제 등으로 null이 반환될 수 있으므로 null-safe 비교를 수행합니다.

+ * + * @param token 확인할 Access Token + * @return 블랙리스트에 있으면 true, 없거나 확인 불가 시 false + */ + public boolean isBlacklisted(String token) { + String key = KEY_PREFIX + token; + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..dce02682 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java @@ -0,0 +1,79 @@ +package com.sopt.cherrish.domain.auth.domain.repository; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +/** + * Refresh Token을 Redis에 저장하고 관리하는 저장소. + * + *

사용자별로 하나의 Refresh Token만 유효하도록 관리합니다. + * TTL이 설정되어 만료된 토큰은 자동으로 삭제됩니다.

+ */ +@Repository +@RequiredArgsConstructor +public class RefreshTokenRepository { + + private static final String KEY_PREFIX = "refresh_token:"; + + private final RedisTemplate redisTemplate; + + /** + * Refresh Token을 저장합니다. + * + *

기존에 저장된 토큰이 있으면 덮어씁니다. + * 만료 시간이 0 이하인 경우 저장하지 않습니다.

+ * + * @param userId 사용자 ID + * @param refreshToken 저장할 Refresh Token + * @param expirationMillis 만료 시간 (밀리초) + */ + public void save(Long userId, String refreshToken, long expirationMillis) { + if (expirationMillis <= 0) { + return; + } + String key = KEY_PREFIX + userId; + redisTemplate.opsForValue().set(key, refreshToken, expirationMillis, TimeUnit.MILLISECONDS); + } + + /** + * 사용자의 Refresh Token을 조회합니다. + * + * @param userId 사용자 ID + * @return 저장된 Refresh Token, 없으면 빈 Optional + */ + public Optional findByUserId(Long userId) { + String key = KEY_PREFIX + userId; + String token = redisTemplate.opsForValue().get(key); + return Optional.ofNullable(token); + } + + /** + * 사용자의 Refresh Token을 삭제합니다. + * + *

로그아웃 시 호출되어 해당 토큰을 무효화합니다.

+ * + * @param userId 사용자 ID + */ + public void deleteByUserId(Long userId) { + String key = KEY_PREFIX + userId; + redisTemplate.delete(key); + } + + /** + * 사용자의 Refresh Token 존재 여부를 확인합니다. + * + *

Redis 연결 문제 등으로 null이 반환될 수 있으므로 null-safe 비교를 수행합니다.

+ * + * @param userId 사용자 ID + * @return 토큰이 존재하면 true, 없거나 확인 불가 시 false + */ + public boolean existsByUserId(Long userId) { + String key = KEY_PREFIX + userId; + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/sopt/cherrish/domain/auth/exception/AuthErrorCode.java new file mode 100644 index 00000000..9f2d23a3 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/exception/AuthErrorCode.java @@ -0,0 +1,28 @@ +package com.sopt.cherrish.domain.auth.exception; + +import com.sopt.cherrish.global.response.error.ErrorType; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorType { + + UNAUTHORIZED("A001", "인증이 필요합니다", 401), + INVALID_TOKEN("A002", "유효하지 않은 토큰입니다", 401), + TOKEN_EXPIRED("A003", "만료된 토큰입니다", 401), + ACCESS_DENIED("A004", "접근 권한이 없습니다", 403), + USER_NOT_FOUND("A005", "사용자를 찾을 수 없습니다", 404), + + INVALID_SOCIAL_TOKEN("A010", "유효하지 않은 소셜 토큰입니다", 401), + SOCIAL_AUTH_FAILED("A011", "소셜 인증에 실패했습니다", 401), + UNSUPPORTED_SOCIAL_PROVIDER("A012", "지원하지 않는 소셜 로그인 제공자입니다", 400), + + INVALID_REFRESH_TOKEN("A020", "유효하지 않은 리프레시 토큰입니다", 401), + REFRESH_TOKEN_NOT_FOUND("A021", "리프레시 토큰이 존재하지 않습니다", 401); + + private final String code; + private final String message; + private final int status; +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/exception/AuthException.java b/src/main/java/com/sopt/cherrish/domain/auth/exception/AuthException.java new file mode 100644 index 00000000..49e4b1fa --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/exception/AuthException.java @@ -0,0 +1,10 @@ +package com.sopt.cherrish.domain.auth.exception; + +import com.sopt.cherrish.global.exception.BaseException; + +public class AuthException extends BaseException { + + public AuthException(AuthErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..08d4508a --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,89 @@ +package com.sopt.cherrish.domain.auth.infrastructure.jwt; + +import java.io.IOException; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.sopt.cherrish.domain.auth.domain.repository.AccessTokenBlacklistRepository; +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.domain.auth.exception.AuthException; +import com.sopt.cherrish.domain.user.domain.model.User; +import com.sopt.cherrish.domain.user.domain.repository.UserRepository; +import com.sopt.cherrish.global.security.UserPrincipal; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * JWT 기반 인증을 처리하는 필터. + * + *

모든 요청에서 Authorization 헤더의 Bearer 토큰을 추출하여 검증합니다. + * 토큰이 유효하면 SecurityContext에 인증 정보를 설정합니다.

+ * + *

토큰이 없거나 유효하지 않은 경우 인증을 설정하지 않고 다음 필터로 진행합니다. + * 보호된 리소스 접근시 {@link com.sopt.cherrish.global.security.JwtAuthenticationEntryPoint}에서 + * 401 응답을 반환합니다.

+ */ +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + private final AccessTokenBlacklistRepository accessTokenBlacklistRepository; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER); + String token = jwtTokenProvider.extractToken(authorizationHeader); + + if (token != null) { + try { + jwtTokenProvider.validateToken(token); + + if (!jwtTokenProvider.isAccessToken(token)) { + log.debug("Token is not an access token"); + filterChain.doFilter(request, response); + return; + } + + if (accessTokenBlacklistRepository.isBlacklisted(token)) { + log.debug("Token is blacklisted"); + filterChain.doFilter(request, response); + return; + } + + Long userId = jwtTokenProvider.getUserId(token); + User user = userRepository.findById(userId) + .orElseThrow(() -> new AuthException(AuthErrorCode.USER_NOT_FOUND)); + + UserPrincipal userPrincipal = UserPrincipal.from(user); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userPrincipal, + null, + userPrincipal.getAuthorities() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (AuthException e) { + log.debug("JWT authentication failed: {}", e.getMessage()); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtProperties.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtProperties.java new file mode 100644 index 00000000..cb9abcb6 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtProperties.java @@ -0,0 +1,27 @@ +package com.sopt.cherrish.domain.auth.infrastructure.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Component +@Validated +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + + @NotBlank + private String secretKey; + + @Positive + private long accessTokenExpiration; + + @Positive + private long refreshTokenExpiration; +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..293a2a17 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java @@ -0,0 +1,205 @@ +package com.sopt.cherrish.domain.auth.infrastructure.jwt; + +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.stereotype.Component; + +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.domain.auth.exception.AuthException; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * JWT 토큰 생성 및 검증을 담당하는 컴포넌트. + * + *

Access Token과 Refresh Token을 생성하고, 토큰의 유효성을 검증합니다. + * 토큰에는 사용자 ID와 토큰 타입(access/refresh)이 포함됩니다.

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private static final String TOKEN_TYPE_CLAIM = "type"; + private static final String ACCESS_TOKEN_TYPE = "access"; + private static final String REFRESH_TOKEN_TYPE = "refresh"; + private static final int MIN_SECRET_KEY_BYTES = 32; + + private final JwtProperties jwtProperties; + private SecretKey secretKey; + + @PostConstruct + protected void init() { + byte[] keyBytes; + try { + keyBytes = Decoders.BASE64.decode(jwtProperties.getSecretKey()); + } catch (Exception e) { + throw new IllegalStateException( + "JWT secret key is not valid Base64 encoded. Please provide a valid Base64 encoded key.", e); + } + + if (keyBytes.length < MIN_SECRET_KEY_BYTES) { + throw new IllegalStateException(String.format( + "JWT secret key must be at least %d bytes (256 bits) for HMAC-SHA. Current key is %d bytes. " + + "Generate a new key with: openssl rand -base64 32", + MIN_SECRET_KEY_BYTES, keyBytes.length)); + } + + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + /** + * Access Token을 생성합니다. + * + * @param userId 사용자 ID + * @return 생성된 Access Token 문자열 + */ + public String createAccessToken(Long userId) { + return createToken(userId, ACCESS_TOKEN_TYPE, jwtProperties.getAccessTokenExpiration()); + } + + /** + * Refresh Token을 생성합니다. + * + * @param userId 사용자 ID + * @return 생성된 Refresh Token 문자열 + */ + public String createRefreshToken(Long userId) { + return createToken(userId, REFRESH_TOKEN_TYPE, jwtProperties.getRefreshTokenExpiration()); + } + + private String createToken(Long userId, String tokenType, long expirationTime) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + expirationTime); + + return Jwts.builder() + .subject(String.valueOf(userId)) + .claim(TOKEN_TYPE_CLAIM, tokenType) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey) + .compact(); + } + + /** + * 토큰에서 사용자 ID를 추출합니다. + * + * @param token JWT 토큰 + * @return 사용자 ID + * @throws AuthException subject가 유효한 사용자 ID가 아닌 경우 + */ + public Long getUserId(String token) { + try { + Claims claims = parseClaims(token); + return Long.parseLong(claims.getSubject()); + } catch (NumberFormatException | NullPointerException e) { + log.debug("Invalid user ID in token: {}", e.getMessage()); + throw new AuthException(AuthErrorCode.INVALID_TOKEN); + } + } + + /** + * 토큰의 유효성을 검증합니다. + * + *

토큰이 유효하면 정상 반환되고, 유효하지 않으면 예외가 발생합니다.

+ * + * @param token 검증할 JWT 토큰 + * @throws AuthException 토큰이 만료되었거나 유효하지 않은 경우 + */ + public void validateToken(String token) { + try { + parseClaims(token); + } catch (ExpiredJwtException e) { + log.debug("Expired JWT token: {}", e.getMessage()); + throw new AuthException(AuthErrorCode.TOKEN_EXPIRED); + } catch (UnsupportedJwtException | MalformedJwtException | SecurityException | IllegalArgumentException e) { + log.debug("Invalid JWT token: {}", e.getMessage()); + throw new AuthException(AuthErrorCode.INVALID_TOKEN); + } + } + + /** + * 토큰이 Access Token인지 확인합니다. + * + * @param token JWT 토큰 + * @return Access Token이면 true, Refresh Token이면 false + */ + public boolean isAccessToken(String token) { + Claims claims = parseClaims(token); + return ACCESS_TOKEN_TYPE.equals(claims.get(TOKEN_TYPE_CLAIM, String.class)); + } + + /** + * 토큰이 Refresh Token인지 확인합니다. + * + * @param token JWT 토큰 + * @return Refresh Token이면 true, Access Token이면 false + */ + public boolean isRefreshToken(String token) { + Claims claims = parseClaims(token); + return REFRESH_TOKEN_TYPE.equals(claims.get(TOKEN_TYPE_CLAIM, String.class)); + } + + /** + * Refresh Token의 만료 시간(밀리초)을 반환합니다. + * + * @return Refresh Token 만료 시간 (밀리초) + */ + public long getRefreshTokenExpiration() { + return jwtProperties.getRefreshTokenExpiration(); + } + + /** + * 토큰의 남은 만료 시간(밀리초)을 반환합니다. + * + * @param token JWT 토큰 + * @return 남은 만료 시간 (밀리초), 이미 만료되었거나 유효하지 않은 경우 0 + */ + public long getRemainingExpiration(String token) { + try { + Claims claims = parseClaims(token); + Date expiration = claims.getExpiration(); + long remaining = expiration.getTime() - System.currentTimeMillis(); + return Math.max(0, remaining); + } catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException + | SecurityException | IllegalArgumentException e) { + log.debug("Expected JWT exception in getRemainingExpiration: {}", e.getMessage()); + return 0; + } catch (Exception e) { + log.warn("Unexpected exception in getRemainingExpiration: {}", e.getMessage(), e); + return 0; + } + } + + /** + * Authorization 헤더에서 Bearer 토큰을 추출합니다. + * + * @param authorizationHeader Authorization 헤더 값 + * @return 추출된 토큰, 유효하지 않은 형식이면 null + */ + public String extractToken(String authorizationHeader) { + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + return authorizationHeader.substring(7); + } + return null; + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java new file mode 100644 index 00000000..761b2eb3 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java @@ -0,0 +1,202 @@ +package com.sopt.cherrish.domain.auth.infrastructure.social; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.domain.auth.exception.AuthException; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import lombok.extern.slf4j.Slf4j; + +/** + * Apple Sign In 토큰 검증 클라이언트. + * + *

Apple Identity Token을 검증하고 사용자 정보를 추출합니다. + * Apple 공개키는 24시간 동안 캐싱되어 성능을 최적화합니다.

+ * + * @see Apple Sign In Documentation + */ +@Slf4j +@Component +public class AppleAuthClient implements SocialAuthClient { + + private static final String APPLE_PUBLIC_KEY_URL = "https://appleid.apple.com/auth/keys"; + private static final String APPLE_ISSUER = "https://appleid.apple.com"; + private static final long CACHE_DURATION_HOURS = 24; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Value("${social.apple.client-id}") + private String clientId; + + private volatile ApplePublicKeys cachedKeys; + private volatile long cacheExpireTime; + + public AppleAuthClient(RestTemplate restTemplate, ObjectMapper objectMapper) { + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + } + + /** + * Apple Identity Token을 검증하고 사용자 정보를 추출합니다. + * + *

토큰의 서명을 Apple 공개키로 검증하고, issuer와 audience를 확인합니다.

+ * + * @param identityToken Apple에서 발급한 Identity Token (JWT) + * @return 소셜 사용자 정보 (socialId, email) + * @throws AuthException 토큰이 유효하지 않거나 검증에 실패한 경우 + */ + @Override + public SocialUserInfo getUserInfo(String identityToken) { + try { + ApplePublicKeys applePublicKeys = getApplePublicKeys(); + + String[] tokenParts = identityToken.split("\\."); + if (tokenParts.length != 3) { + throw new AuthException(AuthErrorCode.INVALID_SOCIAL_TOKEN); + } + + String headerJson = new String(Base64.getUrlDecoder().decode(tokenParts[0])); + JsonNode headerNode = objectMapper.readTree(headerJson); + String kid = headerNode.get("kid").asText(); + String alg = headerNode.get("alg").asText(); + + ApplePublicKey matchedKey = findMatchingKey(applePublicKeys, kid, alg); + + if (matchedKey == null) { + log.info("Apple public key not found in cache, refreshing keys for kid: {}", kid); + applePublicKeys = refreshApplePublicKeys(); + matchedKey = findMatchingKey(applePublicKeys, kid, alg); + + if (matchedKey == null) { + throw new AuthException(AuthErrorCode.INVALID_SOCIAL_TOKEN); + } + } + + PublicKey publicKey = generatePublicKey(matchedKey); + + Claims claims = Jwts.parser() + .verifyWith(publicKey) + .requireIssuer(APPLE_ISSUER) + .requireAudience(clientId) + .build() + .parseSignedClaims(identityToken) + .getPayload(); + + return new SocialUserInfo( + claims.getSubject(), + claims.get("email", String.class), + null + ); + } catch (ExpiredJwtException e) { + log.error("Apple identity token expired"); + throw new AuthException(AuthErrorCode.TOKEN_EXPIRED); + } catch (AuthException e) { + throw e; + } catch (Exception e) { + log.error("Apple auth error: {}", e.getMessage(), e); + throw new AuthException(AuthErrorCode.SOCIAL_AUTH_FAILED); + } + } + + private PublicKey generatePublicKey(ApplePublicKey appleKey) { + try { + byte[] nBytes = Base64.getUrlDecoder().decode(appleKey.n()); + byte[] eBytes = Base64.getUrlDecoder().decode(appleKey.e()); + + BigInteger modulus = new BigInteger(1, nBytes); + BigInteger exponent = new BigInteger(1, eBytes); + + RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); + KeyFactory factory = KeyFactory.getInstance("RSA"); + return factory.generatePublic(spec); + } catch (Exception e) { + log.error("Failed to generate Apple public key: {}", e.getMessage(), e); + throw new AuthException(AuthErrorCode.SOCIAL_AUTH_FAILED); + } + } + + private ApplePublicKeys getApplePublicKeys() { + long now = System.currentTimeMillis(); + + if (cachedKeys != null && now < cacheExpireTime) { + return cachedKeys; + } + + return refreshApplePublicKeys(); + } + + private synchronized ApplePublicKeys refreshApplePublicKeys() { + long now = System.currentTimeMillis(); + + if (cachedKeys != null && now < cacheExpireTime) { + return cachedKeys; + } + + try { + ApplePublicKeys keys = restTemplate.getForObject( + APPLE_PUBLIC_KEY_URL, + ApplePublicKeys.class + ); + + if (keys == null || keys.keys() == null) { + if (cachedKeys != null) { + log.warn("Failed to fetch Apple public keys, using cached keys"); + return cachedKeys; + } + throw new AuthException(AuthErrorCode.SOCIAL_AUTH_FAILED); + } + + cachedKeys = keys; + cacheExpireTime = now + TimeUnit.HOURS.toMillis(CACHE_DURATION_HOURS); + log.info("Apple public keys cached successfully, {} keys loaded", keys.keys().size()); + + return cachedKeys; + } catch (RestClientException e) { + if (cachedKeys != null) { + log.warn("Failed to refresh Apple public keys: {}, using cached keys", e.getMessage()); + return cachedKeys; + } + log.error("Failed to fetch Apple public keys and no cache available: {}", e.getMessage()); + throw new AuthException(AuthErrorCode.SOCIAL_AUTH_FAILED); + } + } + + private ApplePublicKey findMatchingKey(ApplePublicKeys keys, String kid, String alg) { + return keys.keys().stream() + .filter(key -> key.kid().equals(kid) && key.alg().equals(alg)) + .findFirst() + .orElse(null); + } + + private record ApplePublicKeys( + List keys + ) { + } + + private record ApplePublicKey( + String kty, + String kid, + String use, + String alg, + String n, + String e + ) { + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/KakaoAuthClient.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/KakaoAuthClient.java new file mode 100644 index 00000000..17d2c1a7 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/KakaoAuthClient.java @@ -0,0 +1,98 @@ +package com.sopt.cherrish.domain.auth.infrastructure.social; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.domain.auth.exception.AuthException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 카카오 로그인 토큰 검증 클라이언트. + * + *

카카오 Access Token을 사용하여 카카오 API에서 사용자 정보를 조회합니다.

+ * + * @see 카카오 로그인 API 문서 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KakaoAuthClient implements SocialAuthClient { + + private static final String KAKAO_USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"; + + private final RestTemplate restTemplate; + + /** + * 카카오 Access Token으로 사용자 정보를 조회합니다. + * + *

카카오 사용자 정보 API(/v2/user/me)를 호출하여 사용자 정보를 가져옵니다.

+ * + * @param accessToken 카카오에서 발급한 Access Token + * @return 소셜 사용자 정보 (socialId, email, nickname) + * @throws AuthException 토큰이 유효하지 않거나 API 호출에 실패한 경우 + */ + @Override + public SocialUserInfo getUserInfo(String accessToken) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + KAKAO_USER_INFO_URL, + HttpMethod.GET, + entity, + KakaoUserResponse.class + ); + + KakaoUserResponse body = response.getBody(); + if (body == null) { + throw new AuthException(AuthErrorCode.SOCIAL_AUTH_FAILED); + } + + return new SocialUserInfo( + String.valueOf(body.id()), + body.kakaoAccount() != null ? body.kakaoAccount().email() : null, + body.properties() != null ? body.properties().nickname() : null + ); + } catch (HttpClientErrorException e) { + log.error("Kakao token validation failed: status={}, body={}", + e.getStatusCode(), e.getResponseBodyAsString()); + throw new AuthException(AuthErrorCode.INVALID_SOCIAL_TOKEN); + } catch (AuthException e) { + throw e; + } catch (Exception e) { + log.error("Kakao auth error: {}", e.getMessage(), e); + throw new AuthException(AuthErrorCode.SOCIAL_AUTH_FAILED); + } + } + + private record KakaoUserResponse( + Long id, + @JsonProperty("kakao_account") KakaoAccount kakaoAccount, + KakaoProperties properties + ) { + } + + private record KakaoAccount( + String email + ) { + } + + private record KakaoProperties( + String nickname + ) { + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialAuthClient.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialAuthClient.java new file mode 100644 index 00000000..eebf2ea2 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialAuthClient.java @@ -0,0 +1,18 @@ +package com.sopt.cherrish.domain.auth.infrastructure.social; + +/** + * 소셜 로그인 인증 클라이언트 인터페이스. + * + *

각 소셜 플랫폼(카카오, 애플 등)별로 구현체를 제공합니다.

+ */ +public interface SocialAuthClient { + + /** + * 소셜 토큰을 검증하고 사용자 정보를 조회합니다. + * + * @param token 소셜 플랫폼에서 발급한 토큰 + * @return 소셜 사용자 정보 + * @throws com.sopt.cherrish.domain.auth.exception.AuthException 토큰이 유효하지 않은 경우 + */ + SocialUserInfo getUserInfo(String token); +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialUserInfo.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialUserInfo.java new file mode 100644 index 00000000..8590d767 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialUserInfo.java @@ -0,0 +1,15 @@ +package com.sopt.cherrish.domain.auth.infrastructure.social; + +/** + * 소셜 플랫폼에서 조회한 사용자 정보. + * + * @param socialId 소셜 플랫폼의 고유 사용자 ID + * @param email 사용자 이메일 (없을 수 있음) + * @param nickname 사용자 닉네임 (없을 수 있음) + */ +public record SocialUserInfo( + String socialId, + String email, + String nickname +) { +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java b/src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java new file mode 100644 index 00000000..445c1478 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java @@ -0,0 +1,78 @@ +package com.sopt.cherrish.domain.auth.presentation; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.sopt.cherrish.domain.auth.application.service.AuthService; +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.domain.auth.presentation.dto.request.SocialLoginRequestDto; +import com.sopt.cherrish.domain.auth.presentation.dto.request.TokenRefreshRequestDto; +import com.sopt.cherrish.domain.auth.presentation.dto.response.LoginResponseDto; +import com.sopt.cherrish.domain.auth.presentation.dto.response.TokenResponseDto; +import com.sopt.cherrish.domain.auth.response.success.AuthSuccessCode; +import com.sopt.cherrish.global.annotation.ApiExceptions; +import com.sopt.cherrish.global.response.CommonApiResponse; +import com.sopt.cherrish.global.response.error.ErrorCode; +import com.sopt.cherrish.global.security.CurrentUser; +import com.sopt.cherrish.global.security.UserPrincipal; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +@Tag(name = "Auth", description = "인증 관련 API") +public class AuthController { + + private final AuthService authService; + + @Operation( + summary = "소셜 로그인", + description = "카카오 또는 애플 소셜 토큰으로 로그인합니다. 신규 사용자는 isNewUser: true를 반환합니다." + ) + @SecurityRequirements + @ApiExceptions({AuthErrorCode.class, ErrorCode.class}) + @PostMapping("/login") + public CommonApiResponse login( + @Valid @RequestBody SocialLoginRequestDto request + ) { + LoginResponseDto response = authService.login(request); + return CommonApiResponse.success(AuthSuccessCode.LOGIN_SUCCESS, response); + } + + @Operation( + summary = "토큰 재발급", + description = "Refresh Token으로 새로운 Access Token과 Refresh Token을 발급받습니다." + ) + @SecurityRequirements + @ApiExceptions({AuthErrorCode.class, ErrorCode.class}) + @PostMapping("/refresh") + public CommonApiResponse refresh( + @Valid @RequestBody TokenRefreshRequestDto request + ) { + TokenResponseDto response = authService.refresh(request); + return CommonApiResponse.success(AuthSuccessCode.TOKEN_REFRESHED, response); + } + + @Operation( + summary = "로그아웃", + description = "현재 사용자의 Refresh Token을 무효화하고 Access Token을 블랙리스트에 추가합니다." + ) + @ApiExceptions({AuthErrorCode.class, ErrorCode.class}) + @PostMapping("/logout") + public CommonApiResponse logout( + @CurrentUser UserPrincipal userPrincipal, + @Parameter(hidden = true) @RequestHeader("Authorization") String authorizationHeader + ) { + authService.logout(userPrincipal.getUserId(), authorizationHeader); + return CommonApiResponse.success(AuthSuccessCode.LOGOUT_SUCCESS); + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/request/SocialLoginRequestDto.java b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/request/SocialLoginRequestDto.java new file mode 100644 index 00000000..f314f13a --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/request/SocialLoginRequestDto.java @@ -0,0 +1,22 @@ +package com.sopt.cherrish.domain.auth.presentation.dto.request; + +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "소셜 로그인 요청") +public record SocialLoginRequestDto( + + @Schema(description = "소셜 로그인 제공자", example = "KAKAO", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "소셜 로그인 제공자는 필수입니다") + SocialProvider provider, + + @Schema(description = "소셜 토큰 (카카오: Access Token, 애플: Identity Token)", + example = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "소셜 토큰은 필수입니다") + String token +) { +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/request/TokenRefreshRequestDto.java b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/request/TokenRefreshRequestDto.java new file mode 100644 index 00000000..68b7c967 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/request/TokenRefreshRequestDto.java @@ -0,0 +1,15 @@ +package com.sopt.cherrish.domain.auth.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "토큰 재발급 요청") +public record TokenRefreshRequestDto( + + @Schema(description = "Refresh Token", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "Refresh Token은 필수입니다") + String refreshToken +) { +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/response/LoginResponseDto.java b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/response/LoginResponseDto.java new file mode 100644 index 00000000..40ccf2da --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/response/LoginResponseDto.java @@ -0,0 +1,20 @@ +package com.sopt.cherrish.domain.auth.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "로그인 응답") +public record LoginResponseDto( + + @Schema(description = "사용자 ID", example = "1") + Long userId, + + @Schema(description = "신규 사용자 여부 (true인 경우 온보딩 필요)", example = "false") + boolean isNewUser, + + @Schema(description = "Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + String accessToken, + + @Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + String refreshToken +) { +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/response/TokenResponseDto.java b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/response/TokenResponseDto.java new file mode 100644 index 00000000..7d0307fc --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/response/TokenResponseDto.java @@ -0,0 +1,14 @@ +package com.sopt.cherrish.domain.auth.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "토큰 재발급 응답") +public record TokenResponseDto( + + @Schema(description = "Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + String accessToken, + + @Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + String refreshToken +) { +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/response/success/AuthSuccessCode.java b/src/main/java/com/sopt/cherrish/domain/auth/response/success/AuthSuccessCode.java new file mode 100644 index 00000000..088f7ac6 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/response/success/AuthSuccessCode.java @@ -0,0 +1,18 @@ +package com.sopt.cherrish.domain.auth.response.success; + +import com.sopt.cherrish.global.response.success.SuccessType; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AuthSuccessCode implements SuccessType { + + LOGIN_SUCCESS("A200", "로그인 성공"), + TOKEN_REFRESHED("A201", "토큰 재발급 성공"), + LOGOUT_SUCCESS("A202", "로그아웃 성공"); + + private final String code; + private final String message; +} diff --git a/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java b/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java index 765f814c..70479608 100644 --- a/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java +++ b/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java @@ -1,20 +1,27 @@ package com.sopt.cherrish.domain.user.domain.model; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.global.entity.BaseTimeEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Entity -@Table(name = "users") +@Table( + name = "users", + uniqueConstraints = @UniqueConstraint(columnNames = {"social_provider", "social_id"}) +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User extends BaseTimeEntity { @@ -29,10 +36,22 @@ public class User extends BaseTimeEntity { @Column(nullable = false) private int age; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private SocialProvider socialProvider; + + @Column(nullable = false) + private String socialId; + + private String email; + @Builder - private User(String name, int age) { + private User(String name, int age, SocialProvider socialProvider, String socialId, String email) { this.name = name; this.age = age; + this.socialProvider = socialProvider; + this.socialId = socialId; + this.email = email; } public void update(String name, Integer age) { diff --git a/src/main/java/com/sopt/cherrish/domain/user/domain/repository/UserRepository.java b/src/main/java/com/sopt/cherrish/domain/user/domain/repository/UserRepository.java index 470f9233..91a383d6 100644 --- a/src/main/java/com/sopt/cherrish/domain/user/domain/repository/UserRepository.java +++ b/src/main/java/com/sopt/cherrish/domain/user/domain/repository/UserRepository.java @@ -1,8 +1,15 @@ package com.sopt.cherrish.domain.user.domain.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.user.domain.model.User; public interface UserRepository extends JpaRepository { + + Optional findBySocialProviderAndSocialId(SocialProvider socialProvider, String socialId); + + boolean existsBySocialProviderAndSocialId(SocialProvider socialProvider, String socialId); } diff --git a/src/main/java/com/sopt/cherrish/global/config/JpaAuditConfig.java b/src/main/java/com/sopt/cherrish/global/config/JpaAuditConfig.java index 565d3f3f..ba295abb 100644 --- a/src/main/java/com/sopt/cherrish/global/config/JpaAuditConfig.java +++ b/src/main/java/com/sopt/cherrish/global/config/JpaAuditConfig.java @@ -2,8 +2,10 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; -@EnableJpaAuditing @Configuration +@EnableJpaAuditing +@EnableJpaRepositories(basePackages = "com.sopt.cherrish.domain") public class JpaAuditConfig { } diff --git a/src/main/java/com/sopt/cherrish/global/config/RedisConfig.java b/src/main/java/com/sopt/cherrish/global/config/RedisConfig.java new file mode 100644 index 00000000..f44f3e23 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/config/RedisConfig.java @@ -0,0 +1,22 @@ +package com.sopt.cherrish.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/java/com/sopt/cherrish/global/config/RestTemplateConfig.java b/src/main/java/com/sopt/cherrish/global/config/RestTemplateConfig.java new file mode 100644 index 00000000..45acc05d --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/config/RestTemplateConfig.java @@ -0,0 +1,20 @@ +package com.sopt.cherrish.global.config; + +import java.time.Duration; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .connectTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(5)) + .build(); + } +} diff --git a/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java b/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java new file mode 100644 index 00000000..ce612861 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java @@ -0,0 +1,86 @@ +package com.sopt.cherrish.global.config; + +import java.util.List; + +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.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import com.sopt.cherrish.domain.auth.domain.repository.AccessTokenBlacklistRepository; +import com.sopt.cherrish.domain.auth.infrastructure.jwt.JwtAuthenticationFilter; +import com.sopt.cherrish.domain.auth.infrastructure.jwt.JwtTokenProvider; +import com.sopt.cherrish.domain.user.domain.repository.UserRepository; +import com.sopt.cherrish.global.security.JwtAccessDeniedHandler; +import com.sopt.cherrish.global.security.JwtAuthenticationEntryPoint; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + private final AccessTokenBlacklistRepository accessTokenBlacklistRepository; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + /** + * Security Filter Chain을 구성합니다. + * + * @param http HttpSecurity 설정 객체 + * @return 구성된 SecurityFilterChain + * @throws Exception 설정 중 발생할 수 있는 예외 + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/auth/login", + "/api/auth/refresh", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + "/actuator/health" + ).permitAll() + .anyRequest().authenticated()) + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter(jwtTokenProvider, userRepository, accessTokenBlacklistRepository); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/src/main/java/com/sopt/cherrish/global/security/CurrentUser.java b/src/main/java/com/sopt/cherrish/global/security/CurrentUser.java new file mode 100644 index 00000000..4064043e --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/security/CurrentUser.java @@ -0,0 +1,27 @@ +package com.sopt.cherrish.global.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +/** + * 현재 인증된 사용자 정보를 주입받기 위한 커스텀 어노테이션. + * + *

컨트롤러 메서드 파라미터에 사용하여 {@link UserPrincipal}을 주입받습니다.

+ * + *
{@code
+ * @GetMapping("/me")
+ * public ResponseEntity getMe(@CurrentUser UserPrincipal userPrincipal) {
+ *     Long userId = userPrincipal.getUserId();
+ *     // ...
+ * }
+ * }
+ */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@AuthenticationPrincipal +public @interface CurrentUser { +} diff --git a/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java b/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..f5c8a529 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java @@ -0,0 +1,34 @@ +package com.sopt.cherrish.global.security; + +import java.io.IOException; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +/** + * 권한이 없는 요청에 대한 처리를 담당하는 Handler. + * + *

인증은 되었으나 해당 리소스에 대한 권한이 없는 경우 403 Forbidden 응답을 반환합니다.

+ */ +@Component +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final SecurityErrorResponseWriter errorResponseWriter; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + errorResponseWriter.writeErrorResponse(response, AuthErrorCode.ACCESS_DENIED); + } +} diff --git a/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..5eb0a098 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,35 @@ +package com.sopt.cherrish.global.security; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +/** + * 인증되지 않은 요청에 대한 처리를 담당하는 EntryPoint. + * + *

JWT 토큰이 없거나 유효하지 않은 경우 401 Unauthorized 응답을 반환합니다. + * Spring Security 필터 체인에서 발생하는 인증 예외를 처리합니다.

+ */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final SecurityErrorResponseWriter errorResponseWriter; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + errorResponseWriter.writeErrorResponse(response, AuthErrorCode.UNAUTHORIZED); + } +} diff --git a/src/main/java/com/sopt/cherrish/global/security/SecurityErrorResponseWriter.java b/src/main/java/com/sopt/cherrish/global/security/SecurityErrorResponseWriter.java new file mode 100644 index 00000000..a9d2ad43 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/security/SecurityErrorResponseWriter.java @@ -0,0 +1,47 @@ +package com.sopt.cherrish.global.security; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.global.response.CommonApiResponse; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +/** + * Security 관련 에러 응답을 작성하는 유틸리티 클래스. + * + *

인증/인가 실패 시 일관된 JSON 응답을 생성합니다.

+ */ +@Component +@RequiredArgsConstructor +public class SecurityErrorResponseWriter { + + private final ObjectMapper objectMapper; + + /** + * 에러 응답을 HTTP Response에 작성합니다. + * + *

HTTP 상태 코드는 AuthErrorCode에서 가져옵니다.

+ * + * @param response HTTP 응답 객체 + * @param errorCode 에러 코드 + * @throws IOException 응답 작성 중 예외 발생 시 + */ + public void writeErrorResponse( + HttpServletResponse response, + AuthErrorCode errorCode + ) throws IOException { + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setStatus(errorCode.getStatus()); + + CommonApiResponse errorResponse = CommonApiResponse.fail(errorCode); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/sopt/cherrish/global/security/UserPrincipal.java b/src/main/java/com/sopt/cherrish/global/security/UserPrincipal.java new file mode 100644 index 00000000..c13f534a --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/security/UserPrincipal.java @@ -0,0 +1,77 @@ +package com.sopt.cherrish.global.security; + +import java.util.Collection; +import java.util.Collections; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import com.sopt.cherrish.domain.user.domain.model.User; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Spring Security의 UserDetails 구현체. + * + *

JWT 인증 후 SecurityContext에 저장되어 현재 인증된 사용자 정보를 제공합니다. + * {@link CurrentUser} 어노테이션을 통해 컨트롤러에서 주입받을 수 있습니다.

+ */ +@Getter +@RequiredArgsConstructor +public class UserPrincipal implements UserDetails { + + private final Long userId; + private final String name; + private final Collection authorities; + + /** + * User 엔티티로부터 UserPrincipal을 생성합니다. + * + * @param user 사용자 엔티티 + * @return UserPrincipal 인스턴스 + */ + public static UserPrincipal from(User user) { + return new UserPrincipal( + user.getId(), + user.getName(), + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return String.valueOf(userId); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/sopt/cherrish/global/swagger/OpenApiConfigurer.java b/src/main/java/com/sopt/cherrish/global/swagger/OpenApiConfigurer.java index 0cd82135..cea15663 100644 --- a/src/main/java/com/sopt/cherrish/global/swagger/OpenApiConfigurer.java +++ b/src/main/java/com/sopt/cherrish/global/swagger/OpenApiConfigurer.java @@ -2,8 +2,11 @@ import java.util.List; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; public class OpenApiConfigurer { @@ -11,6 +14,7 @@ public class OpenApiConfigurer { private static final String API_TITLE = "Cherrish API"; private static final String API_VERSION = "v1.0.0"; private static final String API_DESCRIPTION = "Cherrish API 문서"; + private static final String SECURITY_SCHEME_NAME = "Bearer Authentication"; private final String baseUrl; @@ -21,7 +25,27 @@ public OpenApiConfigurer(String baseUrl) { public OpenAPI createOpenAPI() { return new OpenAPI() .info(createApiInfo()) - .servers(createServerList()); + .servers(createServerList()) + .components(createComponents()) + .addSecurityItem(createSecurityRequirement()); + } + + private Components createComponents() { + return new Components() + .addSecuritySchemes(SECURITY_SCHEME_NAME, createSecurityScheme()); + } + + private SecurityScheme createSecurityScheme() { + return new SecurityScheme() + .name(SECURITY_SCHEME_NAME) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT Access Token을 입력하세요. (Bearer 접두사 불필요)"); + } + + private SecurityRequirement createSecurityRequirement() { + return new SecurityRequirement().addList(SECURITY_SCHEME_NAME); } private Info createApiInfo() { diff --git a/src/main/resources/application-auth.yaml b/src/main/resources/application-auth.yaml new file mode 100644 index 00000000..05977cb2 --- /dev/null +++ b/src/main/resources/application-auth.yaml @@ -0,0 +1,8 @@ +jwt: + secret-key: ${JWT_SECRET_KEY} + access-token-expiration: 1800000 # 30분 (밀리초) + refresh-token-expiration: 1209600000 # 14일 (밀리초) + +social: + apple: + client-id: ${APPLE_CLIENT_ID:com.sopt.cherrish} # iOS Bundle ID diff --git a/src/main/resources/application-redis.yaml b/src/main/resources/application-redis.yaml new file mode 100644 index 00000000..bd0b4c8c --- /dev/null +++ b/src/main/resources/application-redis.yaml @@ -0,0 +1,8 @@ +spring: + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + repositories: + enabled: false diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e5483e7a..4b267f4d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -7,6 +7,8 @@ spring: include: - db - openai + - auth + - redis config: import: optional:file:.env[.properties] diff --git a/src/test/java/com/sopt/cherrish/domain/calendar/fixture/CalendarTestFixture.java b/src/test/java/com/sopt/cherrish/domain/calendar/fixture/CalendarTestFixture.java index 547d258b..5415042d 100644 --- a/src/test/java/com/sopt/cherrish/domain/calendar/fixture/CalendarTestFixture.java +++ b/src/test/java/com/sopt/cherrish/domain/calendar/fixture/CalendarTestFixture.java @@ -8,6 +8,7 @@ import com.sopt.cherrish.domain.calendar.presentation.dto.response.CalendarDailyResponseDto; import com.sopt.cherrish.domain.calendar.presentation.dto.response.ProcedureEventDowntimeResponseDto; import com.sopt.cherrish.domain.calendar.presentation.dto.response.ProcedureEventResponseDto; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.procedure.domain.model.Procedure; import com.sopt.cherrish.domain.user.domain.model.User; import com.sopt.cherrish.domain.userprocedure.domain.model.UserProcedure; @@ -21,6 +22,8 @@ public static User createMockUser(String name, int age) { return User.builder() .name(name) .age(age) + .socialProvider(SocialProvider.KAKAO) + .socialId("test_social_id") .build(); } diff --git a/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCreationFacadeIntegrationTest.java b/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCreationFacadeIntegrationTest.java index 73ee6d7d..0d7d3c5e 100644 --- a/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCreationFacadeIntegrationTest.java +++ b/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCreationFacadeIntegrationTest.java @@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -25,6 +26,7 @@ import com.sopt.cherrish.domain.challenge.core.exception.ChallengeException; import com.sopt.cherrish.domain.challenge.core.presentation.dto.request.ChallengeCreateRequestDto; import com.sopt.cherrish.domain.challenge.core.presentation.dto.response.ChallengeCreateResponseDto; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.user.domain.model.User; import com.sopt.cherrish.domain.user.domain.repository.UserRepository; import com.sopt.cherrish.domain.user.exception.UserErrorCode; @@ -65,6 +67,8 @@ private User createTestUser() { return userRepository.save(User.builder() .name("테스트 유저") .age(25) + .socialProvider(SocialProvider.KAKAO) + .socialId(UUID.randomUUID().toString()) .build()); } diff --git a/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCustomRoutineFacadeIntegrationTest.java b/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCustomRoutineFacadeIntegrationTest.java index 8a568f9d..123e9992 100644 --- a/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCustomRoutineFacadeIntegrationTest.java +++ b/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCustomRoutineFacadeIntegrationTest.java @@ -7,6 +7,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -27,6 +28,7 @@ import com.sopt.cherrish.domain.challenge.core.exception.ChallengeException; import com.sopt.cherrish.domain.challenge.core.presentation.dto.request.CustomRoutineAddRequestDto; import com.sopt.cherrish.domain.challenge.core.presentation.dto.response.CustomRoutineAddResponseDto; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.user.application.service.UserService; import com.sopt.cherrish.domain.user.domain.model.User; import com.sopt.cherrish.domain.user.domain.repository.UserRepository; @@ -72,6 +74,8 @@ private User createTestUser() { return userRepository.save(User.builder() .name("테스트 유저") .age(25) + .socialProvider(SocialProvider.KAKAO) + .socialId(UUID.randomUUID().toString()) .build()); } @@ -206,6 +210,8 @@ void addCustomRoutineUnauthorizedAccessThrowsException() { User other = userRepository.save(User.builder() .name("다른 유저") .age(30) + .socialProvider(SocialProvider.KAKAO) + .socialId(UUID.randomUUID().toString()) .build()); createActiveChallengeWithRoutines(owner, 3); diff --git a/src/test/java/com/sopt/cherrish/domain/challenge/core/fixture/ChallengeIntegrationTestFixture.java b/src/test/java/com/sopt/cherrish/domain/challenge/core/fixture/ChallengeIntegrationTestFixture.java index d03fc7d5..74d4d6b4 100644 --- a/src/test/java/com/sopt/cherrish/domain/challenge/core/fixture/ChallengeIntegrationTestFixture.java +++ b/src/test/java/com/sopt/cherrish/domain/challenge/core/fixture/ChallengeIntegrationTestFixture.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.util.List; +import java.util.UUID; import org.springframework.stereotype.Component; @@ -12,6 +13,7 @@ import com.sopt.cherrish.domain.challenge.core.domain.repository.ChallengeRepository; import com.sopt.cherrish.domain.challenge.core.domain.repository.ChallengeRoutineRepository; import com.sopt.cherrish.domain.challenge.core.domain.repository.ChallengeStatisticsRepository; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.user.domain.model.User; import static com.sopt.cherrish.domain.challenge.core.fixture.ChallengeTestConstants.FIXED_START_DATE; @@ -73,6 +75,8 @@ public User createUser(String name, int age) { return userRepository.save(User.builder() .name(name) .age(age) + .socialProvider(SocialProvider.KAKAO) + .socialId(UUID.randomUUID().toString()) .build()); } diff --git a/src/test/java/com/sopt/cherrish/domain/user/fixture/UserFixture.java b/src/test/java/com/sopt/cherrish/domain/user/fixture/UserFixture.java index 66796809..c2b67112 100644 --- a/src/test/java/com/sopt/cherrish/domain/user/fixture/UserFixture.java +++ b/src/test/java/com/sopt/cherrish/domain/user/fixture/UserFixture.java @@ -2,7 +2,9 @@ import java.lang.reflect.Field; import java.time.LocalDateTime; +import java.util.concurrent.atomic.AtomicLong; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.user.domain.model.User; import com.sopt.cherrish.global.entity.BaseTimeEntity; @@ -10,7 +12,10 @@ public class UserFixture { private static final String DEFAULT_NAME = "홍길동"; private static final int DEFAULT_AGE = 25; - private static final Long DEFAULT_ID = 1L; + private static final SocialProvider DEFAULT_SOCIAL_PROVIDER = SocialProvider.KAKAO; + + private static final AtomicLong ID_COUNTER = new AtomicLong(1); + private static final AtomicLong SOCIAL_ID_COUNTER = new AtomicLong(1); private UserFixture() { // Utility class @@ -24,8 +29,10 @@ public static User createUser(String name, int age) { User user = User.builder() .name(name) .age(age) + .socialProvider(DEFAULT_SOCIAL_PROVIDER) + .socialId(generateSocialId()) .build(); - setField(user, User.class, "id", DEFAULT_ID); + setField(user, User.class, "id", ID_COUNTER.getAndIncrement()); setField(user, BaseTimeEntity.class, "createdAt", LocalDateTime.now()); return user; } @@ -34,12 +41,18 @@ public static User createUser(String name, int age, LocalDateTime createdAt) { User user = User.builder() .name(name) .age(age) + .socialProvider(DEFAULT_SOCIAL_PROVIDER) + .socialId(generateSocialId()) .build(); - setField(user, User.class, "id", DEFAULT_ID); + setField(user, User.class, "id", ID_COUNTER.getAndIncrement()); setField(user, BaseTimeEntity.class, "createdAt", createdAt); return user; } + private static String generateSocialId() { + return "test_social_id_" + SOCIAL_ID_COUNTER.getAndIncrement(); + } + private static void setField(Object target, Class clazz, String fieldName, Object value) { try { Field field = clazz.getDeclaredField(fieldName); diff --git a/src/test/java/com/sopt/cherrish/domain/userprocedure/domain/repository/UserProcedureRepositoryTest.java b/src/test/java/com/sopt/cherrish/domain/userprocedure/domain/repository/UserProcedureRepositoryTest.java index 76471fdf..d822d3db 100644 --- a/src/test/java/com/sopt/cherrish/domain/userprocedure/domain/repository/UserProcedureRepositoryTest.java +++ b/src/test/java/com/sopt/cherrish/domain/userprocedure/domain/repository/UserProcedureRepositoryTest.java @@ -6,6 +6,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,6 +17,7 @@ import org.springframework.context.annotation.Import; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.procedure.domain.model.Procedure; import com.sopt.cherrish.domain.user.domain.model.User; import com.sopt.cherrish.domain.userprocedure.domain.model.UserProcedure; @@ -183,6 +185,8 @@ private User createAndPersistUser(String name, int age) { User user = User.builder() .name(name) .age(age) + .socialProvider(SocialProvider.KAKAO) + .socialId(UUID.randomUUID().toString()) .build(); return entityManager.persist(user); } diff --git a/src/test/java/com/sopt/cherrish/global/config/TestAuthConfig.java b/src/test/java/com/sopt/cherrish/global/config/TestAuthConfig.java new file mode 100644 index 00000000..e4c76d11 --- /dev/null +++ b/src/test/java/com/sopt/cherrish/global/config/TestAuthConfig.java @@ -0,0 +1,27 @@ +package com.sopt.cherrish.global.config; + +import org.mockito.Mockito; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; + +import com.sopt.cherrish.domain.auth.domain.repository.AccessTokenBlacklistRepository; +import com.sopt.cherrish.domain.auth.infrastructure.jwt.JwtTokenProvider; + +@Configuration +@Profile("test") +public class TestAuthConfig { + + @Bean + @Primary + public JwtTokenProvider jwtTokenProvider() { + return Mockito.mock(JwtTokenProvider.class); + } + + @Bean + @Primary + public AccessTokenBlacklistRepository accessTokenBlacklistRepository() { + return Mockito.mock(AccessTokenBlacklistRepository.class); + } +} diff --git a/src/test/java/com/sopt/cherrish/global/config/TestRedisConfig.java b/src/test/java/com/sopt/cherrish/global/config/TestRedisConfig.java new file mode 100644 index 00000000..824c8c28 --- /dev/null +++ b/src/test/java/com/sopt/cherrish/global/config/TestRedisConfig.java @@ -0,0 +1,36 @@ +package com.sopt.cherrish.global.config; + +import org.mockito.Mockito; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; + +@Configuration +@Profile("test") +public class TestRedisConfig { + + @Bean + @Primary + @SuppressWarnings("unchecked") + public RedisTemplate redisTemplate() { + RedisTemplate mockTemplate = Mockito.mock(RedisTemplate.class); + ValueOperations mockValueOps = Mockito.mock(ValueOperations.class); + + when(mockTemplate.opsForValue()).thenReturn(mockValueOps); + when(mockTemplate.hasKey(anyString())).thenReturn(false); + when(mockTemplate.delete(anyString())).thenReturn(true); + doNothing().when(mockValueOps).set(anyString(), anyString(), anyLong(), any()); + when(mockValueOps.get(anyString())).thenReturn(null); + + return mockTemplate; + } +} diff --git a/src/test/java/com/sopt/cherrish/global/config/TestSecurityConfig.java b/src/test/java/com/sopt/cherrish/global/config/TestSecurityConfig.java new file mode 100644 index 00000000..be7a1bd5 --- /dev/null +++ b/src/test/java/com/sopt/cherrish/global/config/TestSecurityConfig.java @@ -0,0 +1,21 @@ +package com.sopt.cherrish.global.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@TestConfiguration +public class TestSecurityConfig { + + @Bean + @Primary + public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .build(); + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 70200a53..ca53fc85 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -1,5 +1,16 @@ spring: + profiles: + active: test + + main: + allow-bean-definition-overriding: true + + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration + - org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration + datasource: url: jdbc:h2:mem:testdb driver-class-name: org.h2.Driver @@ -25,3 +36,12 @@ spring: ai: openai: api-key: test-dummy-api-key-for-unit-tests + +jwt: + secret-key: dGVzdC1zZWNyZXQta2V5LWZvci11bml0LXRlc3RzLW11c3QtYmUtYXQtbGVhc3QtMjU2LWJpdHMtbG9uZw== + access-token-expiration: 1800000 + refresh-token-expiration: 1209600000 + +social: + apple: + client-id: com.test.app