diff --git a/springFirstSeminar/build.gradle b/springFirstSeminar/build.gradle index d4be4cd..543eb90 100644 --- a/springFirstSeminar/build.gradle +++ b/springFirstSeminar/build.gradle @@ -30,6 +30,19 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.rest-assured:rest-assured' implementation group: 'org.postgresql', name: 'postgresql', version: '42.7.3' + implementation 'org.springframework.boot:spring-boot-starter-data-redis:2.3.1.RELEASE' + //Multipart file + implementation("software.amazon.awssdk:bom:2.21.0") + implementation("software.amazon.awssdk:s3:2.21.0") + + //JWT + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + + //Security + implementation 'org.springframework.boot:spring-boot-starter-security' } tasks.named('test') { diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/GlobalExceptionHandler.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/GlobalExceptionHandler.java index bf791ee..3f88b83 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/GlobalExceptionHandler.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import org.sopt.springFirstSeminar.common.dto.ErrorMessage; import org.sopt.springFirstSeminar.common.dto.ErrorResponse; import org.sopt.springFirstSeminar.exception.NotFoundException; +import org.sopt.springFirstSeminar.exception.UnauthorizedException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -24,4 +25,10 @@ protected ResponseEntity> handleMethodArgumentNotValidException( protected ResponseEntity> handleNotFoundException(NotFoundException e) { return ApiResponseUtil.fail(e.getErrorMessage()); } + + @ExceptionHandler(UnauthorizedException.class) + protected ResponseEntity handlerUnauthorizedException(UnauthorizedException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ErrorResponse.of(e.getErrorMessage().getStatus(), e.getErrorMessage().getMessage())); + } } diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/dto/ErrorMessage.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/dto/ErrorMessage.java index 6e6e1bc..966fae2 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/dto/ErrorMessage.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/dto/ErrorMessage.java @@ -12,7 +12,8 @@ public enum ErrorMessage { BLOG_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "BLOG ID에 해당하는 블로그가 존재하지 않습니다."), BLOG_NOT_MATCH_MEMBER(HttpStatus.NOT_FOUND.value(), "해당 멤버 ID에 해당하는 블로그ID가 아닙니다."), CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "POST ID에 해당하는 글이 존재하지 않습니다"), - MAX_BLOG_CONTENT(HttpStatus.BAD_REQUEST.value(), "블로그 글이 최대 글자 수(20)를 초과했습니다") + MAX_BLOG_CONTENT(HttpStatus.BAD_REQUEST.value(), "블로그 글이 최대 글자 수(20)를 초과했습니다"), + JWT_UNAUTHORIZED_EXCEPTION(HttpStatus.UNAUTHORIZED.value(), "사용자의 로그인 검증을 실패했습니다.") ; private final int status; diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtTokenProvider.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..ed633d4 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtTokenProvider.java @@ -0,0 +1,84 @@ +package org.sopt.springFirstSeminar.common.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Base64; +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private static final String USER_ID = "userId"; + + private static final Long ACCESS_TOKEN_EXPIRATION_TIME = 24 * 60 * 60 * 100L * 14; + private static final Long REFRESH_TOKEN_EXPIRATION_TIME = 24 * 60 * 60 * 1000L * 14; + + + @Value("${jwt.secret}") + private String JWT_SECRET; + + + public String issueAccessToken(final Authentication authentication) { + return generateToken(authentication, ACCESS_TOKEN_EXPIRATION_TIME); + } + +// public String issueRefreshToken(final Authentication authentication) { +// return issueToken(authentication, REFRESH_TOKEN_EXPIRATION_TIME); +// } + + + public String generateToken(Authentication authentication, Long tokenExpirationTime) { + final Date now = new Date(); + final Claims claims = Jwts.claims() + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + tokenExpirationTime)); // 만료 시간 + + claims.put(USER_ID, authentication.getPrincipal()); + + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // Header + .setClaims(claims) // Claim + .signWith(getSigningKey()) // Signature + .compact(); + } + + private SecretKey getSigningKey() { + String encodedKey = Base64.getEncoder().encodeToString(JWT_SECRET.getBytes()); //SecretKey 통해 서명 생성 + return Keys.hmacShaKeyFor(encodedKey.getBytes()); //일반적으로 HMAC (Hash-based Message Authentication Code) 알고리즘 사용 + } + + public JwtValidationType validateToken(String token) { + try { + final Claims claims = getBody(token); + return JwtValidationType.VALID_JWT; + } catch (MalformedJwtException ex) { + return JwtValidationType.INVALID_JWT_TOKEN; + } catch (ExpiredJwtException ex) { + return JwtValidationType.EXPIRED_JWT_TOKEN; + } catch (UnsupportedJwtException ex) { + return JwtValidationType.UNSUPPORTED_JWT_TOKEN; + } catch (IllegalArgumentException ex) { + return JwtValidationType.EMPTY_JWT; + } + } + + private Claims getBody(final String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + public Long getUserFromJwt(String token) { + Claims claims = getBody(token); + return Long.valueOf(claims.get(USER_ID).toString()); + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtValidationType.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtValidationType.java new file mode 100644 index 0000000..e2b98b4 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/JwtValidationType.java @@ -0,0 +1,10 @@ +package org.sopt.springFirstSeminar.common.jwt; + +public enum JwtValidationType { + VALID_JWT, // 유효한 JWT + INVALID_JWT_SIGNATURE, // 유효하지 않은 서명 + INVALID_JWT_TOKEN, // 유효하지 않은 토큰 + EXPIRED_JWT_TOKEN, // 만료된 토큰 + UNSUPPORTED_JWT_TOKEN, // 지원하지 않는 형식의 토큰 + EMPTY_JWT // 빈 JWT +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/UserAuthentication.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/UserAuthentication.java new file mode 100644 index 0000000..d4d86ed --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/UserAuthentication.java @@ -0,0 +1,18 @@ +package org.sopt.springFirstSeminar.common.jwt; + + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class UserAuthentication extends UsernamePasswordAuthenticationToken { + + public UserAuthentication(Object principal, Object credentials, Collection authorities) { + super(principal, credentials, authorities); + } + + public static UserAuthentication createUserAuthentication(Long userId) { + return new UserAuthentication(userId, null, null); + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/CustomAccessDeniedHandler.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..3ae38c9 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/CustomAccessDeniedHandler.java @@ -0,0 +1,23 @@ +package org.sopt.springFirstSeminar.common.jwt.auth; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + setResponse(response); + } + + private void setResponse(HttpServletResponse response) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + } +} + diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/CustomJwtAuthenticationEntryPoint.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/CustomJwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..8db6af5 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/CustomJwtAuthenticationEntryPoint.java @@ -0,0 +1,36 @@ +package org.sopt.springFirstSeminar.common.jwt.auth.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.sopt.springFirstSeminar.common.dto.ErrorMessage; +import org.sopt.springFirstSeminar.common.dto.ErrorResponse; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomJwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + setResponse(response); + } + + private void setResponse(HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter() + .write(objectMapper.writeValueAsString( + ErrorResponse.of(ErrorMessage.JWT_UNAUTHORIZED_EXCEPTION.getStatus(), + ErrorMessage.JWT_UNAUTHORIZED_EXCEPTION.getMessage()))); + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/JwtAuthenticationFilter.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..9e50059 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/JwtAuthenticationFilter.java @@ -0,0 +1,54 @@ +package org.sopt.springFirstSeminar.common.jwt.auth.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.sopt.springFirstSeminar.common.dto.ErrorMessage; +import org.sopt.springFirstSeminar.common.jwt.JwtTokenProvider; +import org.sopt.springFirstSeminar.common.jwt.UserAuthentication; +import org.sopt.springFirstSeminar.exception.UnauthorizedException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static org.sopt.springFirstSeminar.common.jwt.JwtValidationType.VALID_JWT; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + try { + final String token = getJwtFromRequest(request); + if (jwtTokenProvider.validateToken(token) == VALID_JWT) { + Long memberId = jwtTokenProvider.getUserFromJwt(token); + UserAuthentication authentication = UserAuthentication.createUserAuthentication(memberId); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception exception) { + throw new UnauthorizedException(ErrorMessage.JWT_UNAUTHORIZED_EXCEPTION); + } + filterChain.doFilter(request, response); + } + + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring("Bearer ".length()); + } + return null; + } +} \ No newline at end of file diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/PrincipalHandler.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/PrincipalHandler.java new file mode 100644 index 0000000..223b1c9 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/PrincipalHandler.java @@ -0,0 +1,26 @@ +package org.sopt.springFirstSeminar.common.jwt.auth.filter; + +import org.sopt.springFirstSeminar.common.dto.ErrorMessage; +import org.sopt.springFirstSeminar.exception.UnauthorizedException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class PrincipalHandler { + + private static final String ANONYMOUS_USER = "anonymousUser"; + + public Long getUserIdFromPrincipal() { + Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + isPrincipalNull(principal); + return Long.valueOf(principal.toString()); + } + + public void isPrincipalNull( + final Object principal + ) { + if (principal.toString().equals(ANONYMOUS_USER)) { + throw new UnauthorizedException(ErrorMessage.JWT_UNAUTHORIZED_EXCEPTION); + } + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/SecurityConfig.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/SecurityConfig.java new file mode 100644 index 0000000..b8c4b2b --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/filter/SecurityConfig.java @@ -0,0 +1,46 @@ +package org.sopt.springFirstSeminar.common.jwt.auth.filter; + +import lombok.RequiredArgsConstructor; +import org.sopt.springFirstSeminar.common.jwt.auth.CustomAccessDeniedHandler; +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.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.RequestCacheConfigurer; + +@Configuration +@RequiredArgsConstructor +@EnableWebSecurity //web Security를 사용할 수 있게 +public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + + private static final String[] AUTH_WHITE_LIST = {"/api/v1/member"}; + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .requestCache(RequestCacheConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .exceptionHandling(exception -> + { + exception.authenticationEntryPoint(customJwtAuthenticationEntryPoint); + exception.accessDeniedHandler(customAccessDeniedHandler); + }); + + + http.authorizeHttpRequests(auth -> { + auth.requestMatchers(AUTH_WHITE_LIST).permitAll(); + auth.anyRequest().authenticated(); + }) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/Token.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/Token.java new file mode 100644 index 0000000..a97c803 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/Token.java @@ -0,0 +1,29 @@ +package org.sopt.springFirstSeminar.common.jwt.auth.redis; + + +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@RedisHash(value = "", timeToLive = 60 * 60 * 24 * 1000L * 1) +@AllArgsConstructor +@Getter +@Builder +public class Token { + + @Id + private Long id; + + @Indexed + private String refreshToken; + + public static Token of(final Long id, final String refreshToken) { + return Token.builder() + .id(id) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/repository/TokenRepository.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/repository/TokenRepository.java new file mode 100644 index 0000000..9f61d70 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/auth/redis/repository/TokenRepository.java @@ -0,0 +1,11 @@ +package org.sopt.springFirstSeminar.common.jwt.auth.redis.repository; + +import org.sopt.springFirstSeminar.common.jwt.auth.redis.Token; +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface TokenRepository extends CrudRepository { + Optional findByRefreshToken(final String refreshToken); + Optional findById(final Long id); +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/dto/UserJoinResponse.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/dto/UserJoinResponse.java new file mode 100644 index 0000000..812649d --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/common/jwt/dto/UserJoinResponse.java @@ -0,0 +1,16 @@ +package org.sopt.springFirstSeminar.common.jwt.dto; + +public record UserJoinResponse( + String accessToken, + String userId +) { + + public static UserJoinResponse of( + String accessToken, + String userId + ) { + return new UserJoinResponse(accessToken, userId); + } +} + + diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/controller/BlogController.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/controller/BlogController.java index 87895da..dae490d 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/controller/BlogController.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/controller/BlogController.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.sopt.springFirstSeminar.common.dto.SuccessMessage; import org.sopt.springFirstSeminar.common.dto.SuccessStatusResponse; +import org.sopt.springFirstSeminar.common.jwt.auth.filter.PrincipalHandler; import org.sopt.springFirstSeminar.service.BlogService; import org.sopt.springFirstSeminar.service.dto.BlogCreateRequest; import org.sopt.springFirstSeminar.service.dto.BlogTitleUpdateRequest; @@ -12,22 +13,22 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.net.URI; + @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor public class BlogController { private final BlogService blogService; + private final PrincipalHandler principalHandler; @PostMapping("/blog") - public ResponseEntity> createBlog( - @RequestHeader(name = "memberId") final Long memberId, - @RequestBody final BlogCreateRequest blogCreateRequest + public ResponseEntity createBlog( + @ModelAttribute BlogCreateRequest blogCreateRequest ) { - return ResponseEntity - .status(HttpStatus.CREATED) - .header("Location", blogService.create(memberId, blogCreateRequest)) - .body(SuccessStatusResponse.of(SuccessMessage.BLOG_CREATE_SUCCESS)); + return ResponseEntity.created(URI.create(blogService.create( + principalHandler.getUserIdFromPrincipal(), blogCreateRequest))).build(); } @PatchMapping("/blog/{blogId}/title") diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/controller/MemberController.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/controller/MemberController.java index 89a8d15..15b2dbe 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/controller/MemberController.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/controller/MemberController.java @@ -2,10 +2,12 @@ import lombok.RequiredArgsConstructor; +import org.sopt.springFirstSeminar.common.jwt.dto.UserJoinResponse; import org.sopt.springFirstSeminar.service.MemberService; import org.sopt.springFirstSeminar.service.dto.MemberCreateDTO; import org.sopt.springFirstSeminar.service.dto.MemberFindDTO; import org.sopt.springFirstSeminar.service.dto.MemberDataDTO; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -20,9 +22,15 @@ public class MemberController { private final MemberService memberService; @PostMapping - public ResponseEntity createMember(@RequestBody final MemberCreateDTO memberCreate) { - return ResponseEntity.created(URI.create(memberService.createMember(memberCreate))) - .build(); + public ResponseEntity postMember( + @RequestBody MemberCreateDTO memberCreate + ) { + UserJoinResponse userJoinResponse = memberService.createMember(memberCreate); + return ResponseEntity.status(HttpStatus.CREATED) + .header("Location", userJoinResponse.userId()) + .body( + userJoinResponse + ); } @GetMapping("/{memberId}") diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/domain/Blog.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/domain/Blog.java index d04411a..40993f7 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/domain/Blog.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/domain/Blog.java @@ -24,6 +24,24 @@ public class Blog extends BaseTimeEntity { private String description; + private String imageUrl; + + private Blog(Member member, String title, String imageUrl, String description) { + this.member = member; + this.title = title; + this.imageUrl = imageUrl; + this.description = description; + } + + public static Blog create( + Member member, + String title, + String description, + String imageUrl + ) { + return new Blog(member, title, imageUrl, description); + } + private Blog(Member member, String title, String description) { this.member = member; this.title = title; diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/exception/UnauthorizedException.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/exception/UnauthorizedException.java new file mode 100644 index 0000000..f04251f --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/exception/UnauthorizedException.java @@ -0,0 +1,9 @@ +package org.sopt.springFirstSeminar.exception; + +import org.sopt.springFirstSeminar.common.dto.ErrorMessage; + +public class UnauthorizedException extends BusinessException { + public UnauthorizedException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/external/AwsConfig.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/external/AwsConfig.java new file mode 100644 index 0000000..eb8f31a --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/external/AwsConfig.java @@ -0,0 +1,48 @@ +package org.sopt.springFirstSeminar.external; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class AwsConfig { + + private static final String AWS_ACCESS_KEY_ID = "aws.accessKeyId"; + private static final String AWS_SECRET_ACCESS_KEY = "aws.secretAccessKey"; + + private final String accessKey; + private final String secretKey; + private final String regionString; + + public AwsConfig(@Value("${aws-property.access-key}") final String accessKey, + @Value("${aws-property.secret-key}") final String secretKey, + @Value("${aws-property.aws-region}") final String regionString) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.regionString = regionString; + } + + + @Bean + public SystemPropertyCredentialsProvider systemPropertyCredentialsProvider() { + System.setProperty(AWS_ACCESS_KEY_ID, accessKey); + System.setProperty(AWS_SECRET_ACCESS_KEY, secretKey); + return SystemPropertyCredentialsProvider.create(); + } + + @Bean + public Region getRegion() { + return Region.of(regionString); + } + + @Bean + public S3Client getS3Client() { + return S3Client.builder() + .region(getRegion()) + .credentialsProvider(systemPropertyCredentialsProvider()) + .build(); + } +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/external/S3Service.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/external/S3Service.java new file mode 100644 index 0000000..d584b72 --- /dev/null +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/external/S3Service.java @@ -0,0 +1,80 @@ +package org.sopt.springFirstSeminar.external; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +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.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@Component +public class S3Service { + + private final String bucketName; + private final AwsConfig awsConfig; + private static final List IMAGE_EXTENSIONS = Arrays.asList("image/jpeg", "image/png", "image/jpg", "image/webp"); + + + public S3Service(@Value("${aws-property.s3-bucket-name}") final String bucketName, AwsConfig awsConfig) { + this.bucketName = bucketName; + this.awsConfig = awsConfig; + } + + + public String uploadImage(String directoryPath, MultipartFile image) throws IOException { + final String key = directoryPath + generateImageFileName(); + final S3Client s3Client = awsConfig.getS3Client(); + + validateExtension(image); + validateFileSize(image); + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentType(image.getContentType()) + .contentDisposition("inline") + .build(); + + RequestBody requestBody = RequestBody.fromBytes(image.getBytes()); + s3Client.putObject(request, requestBody); + return key; + } + + public void deleteImage(String key) throws IOException { + final S3Client s3Client = awsConfig.getS3Client(); + + s3Client.deleteObject((DeleteObjectRequest.Builder builder) -> + builder.bucket(bucketName) + .key(key) + .build() + ); + } + + + private String generateImageFileName() { + return UUID.randomUUID() + ".jpg"; + } + + + private void validateExtension(MultipartFile image) { + String contentType = image.getContentType(); + if (!IMAGE_EXTENSIONS.contains(contentType)) { + throw new RuntimeException("이미지 확장자는 jpg, png, webp만 가능합니다."); + } + } + + private static final Long MAX_FILE_SIZE = 5 * 1024 * 1024L; + + private void validateFileSize(MultipartFile image) { + if (image.getSize() > MAX_FILE_SIZE) { + throw new RuntimeException("이미지 사이즈는 5MB를 넘을 수 없습니다."); + } + } + +} diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/BlogService.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/BlogService.java index 4b555f6..715b3a2 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/BlogService.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/BlogService.java @@ -7,6 +7,7 @@ import org.sopt.springFirstSeminar.domain.Member; import org.sopt.springFirstSeminar.domain.Post; import org.sopt.springFirstSeminar.exception.NotFoundException; +import org.sopt.springFirstSeminar.external.S3Service; import org.sopt.springFirstSeminar.repository.BlogRepository; import org.sopt.springFirstSeminar.repository.MemberRepository; import org.sopt.springFirstSeminar.repository.PostRepository; @@ -17,6 +18,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; + @Service @RequiredArgsConstructor public class BlogService { @@ -24,10 +27,21 @@ public class BlogService { private final BlogRepository blogRepository; private final MemberRepository memberRepository; - public String create(final Long memberId, final BlogCreateRequest blogCreateRequest) { + private final S3Service s3Service; + private static final String BLOG_S3_UPLOAD_FOLER = "blog/"; + + + @Transactional + public String create(Long memberId, BlogCreateRequest createRequest) { + //member찾기 Member member = findMemberById(memberId); - Blog blog = blogRepository.save(Blog.create(member, blogCreateRequest)); - return blog.getId().toString(); + try { + Blog blog = blogRepository.save(Blog.create(member, createRequest.title(), createRequest.description(), + s3Service.uploadImage(BLOG_S3_UPLOAD_FOLER, createRequest.image()))); + return blog.getId().toString(); + } catch (RuntimeException | IOException e) { + throw new RuntimeException(e.getMessage()); + } } @Transactional //이거를 적거나 이 메서드 아래에 blogRepository.save()를 해도 됨. diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/MemberService.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/MemberService.java index 7cdf230..f70035a 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/MemberService.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/MemberService.java @@ -2,6 +2,9 @@ import lombok.RequiredArgsConstructor; import org.sopt.springFirstSeminar.common.dto.ErrorMessage; +import org.sopt.springFirstSeminar.common.jwt.JwtTokenProvider; +import org.sopt.springFirstSeminar.common.jwt.UserAuthentication; +import org.sopt.springFirstSeminar.common.jwt.dto.UserJoinResponse; import org.sopt.springFirstSeminar.domain.Member; import org.sopt.springFirstSeminar.exception.NotFoundException; import org.sopt.springFirstSeminar.repository.MemberRepository; @@ -20,11 +23,20 @@ public class MemberService { private final MemberRepository memberRepository; - @Transactional //데이터베이스의 변경사항을 적용하는(있을때) 어노테이션 - public String createMember(final MemberCreateDTO memberCreateDTO) { //final 이유 : 인자의 불변성보장 - Member member = Member.create(memberCreateDTO.name(), memberCreateDTO.part(), memberCreateDTO.age()); - memberRepository.save(member); - return member.getId().toString(); + private final JwtTokenProvider jwtTokenProvider; + + @Transactional + public UserJoinResponse createMember( + MemberCreateDTO memberCreate + ) { + Member member = memberRepository.save( + Member.create(memberCreate.name(), memberCreate.part(), memberCreate.age()) + ); + Long memberId = member.getId(); + String accessToken = jwtTokenProvider.issueAccessToken( + UserAuthentication.createUserAuthentication(memberId) + ); + return UserJoinResponse.of(accessToken, memberId.toString()); } public void findById(final Long memberId) { diff --git a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/dto/BlogCreateRequest.java b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/dto/BlogCreateRequest.java index b4ed7b8..b415cc8 100644 --- a/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/dto/BlogCreateRequest.java +++ b/springFirstSeminar/src/main/java/org/sopt/springFirstSeminar/service/dto/BlogCreateRequest.java @@ -1,8 +1,11 @@ package org.sopt.springFirstSeminar.service.dto; +import org.springframework.web.multipart.MultipartFile; + public record BlogCreateRequest( String title, - String description + String description, + MultipartFile image ) { - } +