Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ dependencies {
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'

// aws s3
implementation platform('software.amazon.awssdk:bom:2.25.35')
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.1.1'
}

Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ services:
- SPRING_REDIS_PORT=6379
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME}
- AWS_S3_SECRET_ACCESS_KEY=${AWS_S3_SECRET_ACCESS_KEY}
- AWS_S3_ACCESS_KEY_ID=${AWS_S3_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
depends_on:
- redis
Expand Down
14 changes: 12 additions & 2 deletions src/main/java/com/daramg/server/auth/application/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.daramg.server.auth.dto.TokenResponseDto;
import com.daramg.server.auth.exception.AuthErrorStatus;
import com.daramg.server.auth.util.JwtUtil;
import com.daramg.server.common.application.S3ImageService;
import com.daramg.server.common.exception.BusinessException;
import com.daramg.server.user.domain.User;
import com.daramg.server.auth.domain.SignupVo;
Expand All @@ -17,6 +18,7 @@
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.util.concurrent.TimeUnit;

Expand All @@ -32,8 +34,9 @@ public class AuthService {
private final JwtUtil jwtTokenProvider;
private final PasswordEncoder passwordEncoder;
private final RedisTemplate<String, String> redisTemplate;
private final S3ImageService s3ImageService;

public void signup(SignupRequestDto dto){
public void signup(SignupRequestDto dto, MultipartFile image){
if (userRepository.existsByEmail(dto.getEmail())) {
throw new BusinessException("중복된 이메일입니다.");
}
Expand All @@ -42,12 +45,19 @@ public void signup(SignupRequestDto dto){
}
// TODO: bio, 닉네임에 금칙어 검사
String encodedPassword = passwordEncoder.encode(dto.getPassword());

// 이미지가 있으면 S3에 업로드하고 URL을 받아옴, 없으면 null (기본 이미지 사용)
String profileImageUrl = null;
if (image != null && !image.isEmpty()) {
profileImageUrl = s3ImageService.uploadImage(image);
}

SignupVo vo = new SignupVo(
dto.getName(),
dto.getBirthdate(),
dto.getEmail(),
encodedPassword,
dto.getProfileImage(),
profileImageUrl,
dto.getNickname(),
dto.getBio()
);
Expand Down
7 changes: 0 additions & 7 deletions src/main/java/com/daramg/server/auth/dto/SignupRequestDto.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,6 @@ class SignupRequestDto(
)
val password: String,

@get:Pattern(
regexp = "^(https?://).+\\.(jpg|jpeg|png|gif|svg)$",
flags = [Pattern.Flag.CASE_INSENSITIVE],
message = "올바른 이미지 URL 형식이 아닙니다"
)
val profileImage: String?,

@get:NotBlank(message = "닉네임은 필수입니다")
@get:Pattern(
regexp = "^[a-zA-Z0-9가-힣._]{2,8}$",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseCookie;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/auth")
Expand Down Expand Up @@ -50,10 +52,13 @@ public void verify(@RequestBody @Valid CodeVerificationRequestDto request) {
mailVerificationService.verifyEmailWithCode(request);
}

@PostMapping("/signup")
@PostMapping(value = "/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public void signup(@Valid @RequestBody SignupRequestDto request) {
authService.signup(request);
public void signup(
@RequestPart("signupRequest") @Valid SignupRequestDto request,
@RequestPart(value = "image", required = false) MultipartFile image
) {
authService.signup(request, image);
}

@PostMapping("/login")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.daramg.server.common.application;

import com.daramg.server.common.exception.BusinessException;
import com.daramg.server.common.exception.ImageErrorStatus;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class S3ImageService {

private static final java.util.Set<String> ALLOWED_CONTENT_TYPES = java.util.Set.of(
"image/jpeg", "image/jpg", "image/png", "image/gif"
);
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
private static final String S3_URL_FORMAT = "https://%s.s3.%s.amazonaws.com/%s";

private final S3Client s3Client;

@Value("${aws.s3.bucket}")
private String bucketName;

@Value("${aws.region.static:ap-northeast-2}")
private String region;

public List<String> uploadImages(List<MultipartFile> images) {
return images.stream()
.map(this::uploadImage)
.toList();
}

public String uploadImage(MultipartFile file) {
validateFile(file);

try {
String fileName = generateFileName(file.getOriginalFilename());
String key = "images/" + fileName;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 S3 객체 키를 images/{UUID}.{확장자} 형식으로 생성하고 있습니다. 파일을 사용자별로 관리하고, 추후 사용자별 접근 정책을 적용하기 용이하도록 키 경로에 사용자 ID를 포함하는 것을 고려해볼 수 있습니다. 예를 들어, images/{userId}/{UUID}.{확장자}와 같은 구조를 사용하면 좋습니다. 이를 위해 uploadImageuploadImages 메소드가 User 객체나 userId를 파라미터로 받도록 수정이 필요합니다.


PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
.contentType(file.getContentType())
.contentLength(file.getSize())
.build();

try (InputStream inputStream = file.getInputStream()) {
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, file.getSize()));
}

String imageUrl = String.format(S3_URL_FORMAT, bucketName, region, key);
log.info("Image uploaded successfully: {}", imageUrl);
return imageUrl;

} catch (S3Exception e) {
log.error("Failed to upload image to S3", e);
throw new BusinessException(ImageErrorStatus.FILE_UPLOAD_FAILED);
} catch (IOException e) {
log.error("Failed to read file input stream", e);
throw new BusinessException(ImageErrorStatus.FILE_UPLOAD_FAILED);
}
}

private void validateFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BusinessException(ImageErrorStatus.EMPTY_FILE);
}

if (file.getSize() > MAX_FILE_SIZE) {
throw new BusinessException(ImageErrorStatus.FILE_TOO_LARGE);
}

String contentType = file.getContentType();
if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType.toLowerCase())) {
throw new BusinessException(ImageErrorStatus.INVALID_FILE_TYPE);
}
}

private String generateFileName(String originalFilename) {
String extension = "";
if (originalFilename != null && originalFilename.contains(".")) {
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
}
return UUID.randomUUID() + extension;
}
Comment on lines +91 to +97

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 확장자 추출 로직은 .bashrc와 같이 점으로 시작하는 파일의 경우 .bashrc 전체를 확장자로 인식하고, myfile.와 같이 점으로 끝나는 파일의 경우 .을 확장자로 인식하는 등 예외적인 케이스에 취약할 수 있습니다. 보다 안전하게 확장자를 추출하도록 로직을 개선하는 것을 제안합니다.

    private String generateFileName(String originalFilename) {
        String extension = "";
        if (originalFilename != null) {
            int dotIndex = originalFilename.lastIndexOf('.');
            if (dotIndex > 0 && dotIndex < originalFilename.length() - 1) {
                extension = originalFilename.substring(dotIndex);
            }
        }
        return UUID.randomUUID().toString() + extension;
    }

}
23 changes: 23 additions & 0 deletions src/main/java/com/daramg/server/common/config/AppConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@

import com.daramg.server.auth.resolver.AuthUserResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;

import java.util.List;

Expand All @@ -17,11 +22,29 @@ public class AppConfig implements WebMvcConfigurer {

private final AuthUserResolver authUserResolver;

@Value("${aws.credentials.access-key}")
private String accessKey;

@Value("${aws.credentials.secret-key}")
private String secretKey;

@Value("${aws.region.static:ap-northeast-2}")
private String region;

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public S3Client s3Client() {
AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey);
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
.build();
}
Comment on lines +40 to +46

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

StaticCredentialsProvider를 사용하여 자격 증명을 명시적으로 설정하는 것보다 DefaultCredentialsProvider.create()를 사용하는 것이 더 유연하고 안전합니다. DefaultCredentialsProvider는 환경 변수, 시스템 속성, 자격 증명 파일, EC2/ECS 인스턴스 프로필 등 표준적인 위치에서 자동으로 자격 증명을 찾아 사용합니다. 이렇게 하면 로컬 개발 환경과 실제 배포 환경(e.g., EC2)에서 코드를 변경하지 않고도 각 환경에 맞는 자격 증명을 사용할 수 있습니다. 이 변경을 적용하면 accessKey, secretKey 필드와 관련 @Value 어노테이션, 그리고 AwsBasicCredentials, StaticCredentialsProvider import를 제거할 수 있습니다.

Suggested change
public S3Client s3Client() {
AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey);
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
.build();
}
@Bean
public S3Client s3Client() {
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider.create())
.build();
}


@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(authUserResolver);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.daramg.server.common.dto

data class ImageUploadResponseDto(
val imageUrls: List<String>
)

Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ public enum ErrorCategory {
COMMON("COMMON_"),
AUTH("AUTH_"),
USER("USER_"),
POST("POST_");
POST("POST_"),
IMAGE("IMAGE_");

private final String prefix;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
Expand All @@ -19,7 +19,7 @@

@Slf4j
@RequiredArgsConstructor
@RestControllerAdvice(annotations = {RestController.class})
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

private final ErrorCodeRegistry errorCodeRegistry;
Expand All @@ -34,6 +34,59 @@ public ResponseEntity<ErrorResponse> handleGeneralException(BusinessException e)
.body(ErrorResponse.of(errorCode));
}

@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationException(AuthenticationException e) {
// AuthenticationException의 원인 예외에서 BusinessException 확인
Throwable cause = e.getCause();
while (cause != null) {
if (cause instanceof BusinessException businessException) {
BaseErrorCode errorCode = businessException.getErrorCode();
log.warn("BusinessException (from AuthenticationException): {}", errorCode.getMessage());
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ErrorResponse.of(errorCode));
}
cause = cause.getCause();
}

// 기본 인증 실패 응답
log.warn("AuthenticationException: {}", e.getMessage());
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(ErrorResponse.of(CommonErrorStatus.UNAUTHORIZED));
}


@Override
protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatusCode statusCode, WebRequest request) {
// 예외 자체가 BusinessException인지 확인
if (ex instanceof BusinessException businessException) {
BaseErrorCode errorCode = businessException.getErrorCode();
log.warn("BusinessException (from handleExceptionInternal, direct): {}", errorCode.getMessage());
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ErrorResponse.of(errorCode));
}

// HandlerMethodArgumentResolver에서 발생한 BusinessException을 확인
Throwable current = ex;
int depth = 0;
while (current != null && depth < 10) {
if (current instanceof BusinessException businessException) {
BaseErrorCode errorCode = businessException.getErrorCode();
log.warn("BusinessException (from handleExceptionInternal, depth={}): {}", depth, errorCode.getMessage());
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ErrorResponse.of(errorCode));
}
current = current.getCause();
depth++;
}

log.warn("handleExceptionInternal: {} - {}", ex.getClass().getName(), ex.getMessage());
return super.handleExceptionInternal(ex, body, headers, statusCode, request);
}

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
log.warn("MethodArgumentNotValidException: {}", e.getMessage());
Expand All @@ -60,7 +113,31 @@ protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotV

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpectedException(Exception e) {
log.error("Unexpected Exception: ", e);
// 예외 자체가 BusinessException인지 확인
if (e instanceof BusinessException businessException) {
BaseErrorCode errorCode = businessException.getErrorCode();
log.warn("BusinessException (direct): {}", errorCode.getMessage());
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ErrorResponse.of(errorCode));
}

// HandlerMethodArgumentResolver에서 발생한 BusinessException을 확인
Throwable current = e;
int depth = 0;
while (current != null && depth < 10) {
if (current instanceof BusinessException businessException) {
BaseErrorCode errorCode = businessException.getErrorCode();
log.warn("BusinessException (in chain, depth={}): {}", depth, errorCode.getMessage());
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ErrorResponse.of(errorCode));
}
current = current.getCause();
depth++;
}

log.error("Unexpected Exception: {} - {}", e.getClass().getName(), e.getMessage(), e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of(CommonErrorStatus.INTERNAL_SERVER_ERROR));
Expand Down
Loading
Loading