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() + ); + + OptionalRefresh 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 RedisTemplateRedis 연결 문제 등으로 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기존에 저장된 토큰이 있으면 덮어씁니다. + * 만료 시간이 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로그아웃 시 호출되어 해당 토큰을 무효화합니다.
+ * + * @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카카오 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각 소셜 플랫폼(카카오, 애플 등)별로 구현체를 제공합니다.
+ */ +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컨트롤러 메서드 파라미터에 사용하여 {@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()); + + CommonApiResponseJWT 인증 후 SecurityContext에 저장되어 현재 인증된 사용자 정보를 제공합니다. + * {@link CurrentUser} 어노테이션을 통해 컨트롤러에서 주입받을 수 있습니다.
+ */ +@Getter +@RequiredArgsConstructor +public class UserPrincipal implements UserDetails { + + private final Long userId; + private final String name; + private final Collection extends GrantedAuthority> 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 extends GrantedAuthority> 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