diff --git a/src/main/java/com/api/trip/common/exception/ErrorCode.java b/src/main/java/com/api/trip/common/exception/ErrorCode.java index 9cd543e..98dadb8 100644 --- a/src/main/java/com/api/trip/common/exception/ErrorCode.java +++ b/src/main/java/com/api/trip/common/exception/ErrorCode.java @@ -6,6 +6,20 @@ @Getter public enum ErrorCode { + // 400 + EMPTY_REFRESH_TOKEN("RefreshToken이 필요합니다.", HttpStatus.BAD_REQUEST), + + // 401 + LOGOUTED_TOKEN("이미 로그아웃 처리된 토큰입니다.", HttpStatus.UNAUTHORIZED), + SNATCH_TOKEN("Refresh Token 탈취를 감지하여 로그아웃 처리됩니다.", HttpStatus.UNAUTHORIZED), + INVALID_TYPE_TOKEN("Token의 타입은 Bearer입니다.", HttpStatus.UNAUTHORIZED), + EXPIRED_PERIOD_ACCESS_TOKEN("기한이 만료된 AccessToken입니다.", HttpStatus.UNAUTHORIZED), + EXPIRED_PERIOD_REFRESH_TOKEN("기한이 만료된 RefreshToken입니다.", HttpStatus.UNAUTHORIZED), + EMPTY_AUTHORITY("권한 정보가 필요합니다.", HttpStatus.UNAUTHORIZED), + INVALID_ACCESS_TOKEN("유효하지 않은 AccessToken입니다.", HttpStatus.UNAUTHORIZED), + INVALID_REFRESH_TOKEN("유효하지 않은 RefreshToken입니다.", HttpStatus.UNAUTHORIZED), + + // 404 NOT_FOUND_MEMBER("회원이 존재하지 않습니다.", HttpStatus.NOT_FOUND); private final String message; diff --git a/src/main/java/com/api/trip/common/exception/custom_exception/BadRequestException.java b/src/main/java/com/api/trip/common/exception/custom_exception/BadRequestException.java new file mode 100644 index 0000000..a6cf5ff --- /dev/null +++ b/src/main/java/com/api/trip/common/exception/custom_exception/BadRequestException.java @@ -0,0 +1,10 @@ +package com.api.trip.common.exception.custom_exception; + +import com.api.trip.common.exception.CustomException; +import com.api.trip.common.exception.ErrorCode; + +public class BadRequestException extends CustomException { + public BadRequestException(ErrorCode errorCode) { + super(errorCode); + } +} \ No newline at end of file diff --git a/src/main/java/com/api/trip/common/exception/custom_exception/InvalidException.java b/src/main/java/com/api/trip/common/exception/custom_exception/InvalidException.java new file mode 100644 index 0000000..bbc4547 --- /dev/null +++ b/src/main/java/com/api/trip/common/exception/custom_exception/InvalidException.java @@ -0,0 +1,10 @@ +package com.api.trip.common.exception.custom_exception; + +import com.api.trip.common.exception.CustomException; +import com.api.trip.common.exception.ErrorCode; + +public class InvalidException extends CustomException { + public InvalidException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/api/trip/common/security/jwt/JwtTokenFilter.java b/src/main/java/com/api/trip/common/security/jwt/JwtTokenFilter.java index 1d88834..8d07acd 100644 --- a/src/main/java/com/api/trip/common/security/jwt/JwtTokenFilter.java +++ b/src/main/java/com/api/trip/common/security/jwt/JwtTokenFilter.java @@ -1,5 +1,6 @@ package com.api.trip.common.security.jwt; +import com.api.trip.common.security.util.JwtTokenUtils; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -21,19 +22,20 @@ public class JwtTokenFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String header = request.getHeader("accessToken"); - if (header == null || !header.startsWith("Bearer ")) { - log.error("Error occurs while getting header. header is null or invalid {}", request.getRequestURL()); - filterChain.doFilter(request, response); - return; - } - String accessToken = header.split(" ")[1].trim(); + String accessToken = JwtTokenUtils.extractBearerToken(request.getHeader("accessToken")); + + - if (jwtTokenProvider.validateAccessToken(accessToken)) { + if (!request.getRequestURI().equals("/api/members/rotate") && accessToken != null) { // 토큰 재발급의 요청이 아니면서 accessToken이 존재할 때 + + // 토큰이 유효한 경우 and 로그인 상태 Authentication authentication = jwtTokenProvider.getAuthenticationByAccessToken(accessToken); + jwtTokenProvider.checkLogin(authentication.getName()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } filterChain.doFilter(request, response); diff --git a/src/main/java/com/api/trip/common/security/jwt/JwtTokenProvider.java b/src/main/java/com/api/trip/common/security/jwt/JwtTokenProvider.java index 5f288ac..60bd238 100644 --- a/src/main/java/com/api/trip/common/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/api/trip/common/security/jwt/JwtTokenProvider.java @@ -1,10 +1,10 @@ package com.api.trip.common.security.jwt; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwtException; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; +import com.api.trip.common.exception.ErrorCode; +import com.api.trip.common.exception.custom_exception.InvalidException; +import com.api.trip.common.security.util.JwtTokenUtils; +import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import lombok.RequiredArgsConstructor; @@ -30,52 +30,100 @@ @Slf4j public class JwtTokenProvider { + private final JwtTokenUtils jwtTokenUtils; + @Value("${custom.jwt.token.access-expiration-time}") private long accessExpirationTime; + + @Value("${custom.jwt.token.refresh-expiration-time}") + private long refreshExpirationTime; + private final Key key; @Autowired - public JwtTokenProvider(@Value("${custom.jwt.token.secret}") String secretKey) { + public JwtTokenProvider(@Value("${custom.jwt.token.secret}") String secretKey, JwtTokenUtils jwtTokenUtils) { + this.jwtTokenUtils = jwtTokenUtils; byte[] keyBytes = Decoders.BASE64.decode(secretKey); this.key = Keys.hmacShaKeyFor(keyBytes); } public JwtToken createJwtToken(String email, String authorities) { + Date now = new Date(); Claims claims = Jwts.claims().setSubject(email); claims.put("roles", authorities); - String accessToken = Jwts.builder() + + String accessToken = createAccessToken(claims, new Date(now.getTime() + accessExpirationTime)); + String refreshToken = createRefreshToken(claims, new Date(now.getTime() + refreshExpirationTime)); + + + return new JwtToken(accessToken, refreshToken); + } + public JwtToken refreshJwtToken(Authentication authentication){ + String authorities = authentication.getAuthorities().stream() + .map(a -> a.getAuthority()) + .collect(Collectors.joining(",")); + + JwtToken jwtToken = createJwtToken(authentication.getName(), authorities); + + jwtTokenUtils.updateRefreshToken(authentication.getName(), jwtToken.getRefreshToken()); + return jwtToken; + } + + + private String createAccessToken(Claims claims, Date expiredDate) { + return Jwts.builder() + .setClaims(claims) // 아이디, 권한정보 + .setIssuedAt(new Date(System.currentTimeMillis())) // 생성일 설정 + .setExpiration(expiredDate) // 만료일 설정 + .signWith(SignatureAlgorithm.HS256, key) + .compact(); + } + + private String createRefreshToken(Claims claims, Date expiredDate) { + String refreshToken = Jwts.builder() .setClaims(claims) .setIssuedAt(new Date(System.currentTimeMillis())) - .setExpiration(new Date(System.currentTimeMillis() + accessExpirationTime)) + .setExpiration(expiredDate) .signWith(key, SignatureAlgorithm.HS256) .compact(); + jwtTokenUtils.setRefreshToken(claims.getSubject(), refreshToken); - return new JwtToken(accessToken, "refreshToken"); + return refreshToken; } - public boolean validateAccessToken(String accessToken) { - try { - parseToken(accessToken); - return true; - } catch (final JwtException | IllegalArgumentException exception) { - return false; + /** + * @Description + * AccessToken 검증 + Return 인증객체 + */ + public Authentication getAuthenticationByAccessToken(String accessToken) { + + Claims claims = validateAccessToken(accessToken); + + if (claims.get("roles") == null){ + throw new InvalidException(ErrorCode.EMPTY_AUTHORITY); } - } - private Claims parseToken(final String token) { - return Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody(); - } + Collection authorities = + Arrays.stream(claims.get("roles").toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); - public Authentication getAuthenticationByAccessToken(String accessToken) { + UserDetails principal = new User(claims.getSubject(), "", authorities); + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + } - Claims claims = parseToken(accessToken); + /** + * @Description + * RefreshToken 검증 + Return 인증객체 + */ + public Authentication getAuthenticationByRefreshToken(String refreshToken){ + Claims claims = validateRefreshToken(refreshToken); + if (claims.get("roles") == null){ + throw new InvalidException(ErrorCode.EMPTY_AUTHORITY); + } Collection authorities = Arrays.stream(claims.get("roles").toString().split(",")) .map(SimpleGrantedAuthority::new) @@ -84,4 +132,42 @@ public Authentication getAuthenticationByAccessToken(String accessToken) { UserDetails principal = new User(claims.getSubject(), "", authorities); return new UsernamePasswordAuthenticationToken(principal, "", authorities); } + + + /** + * @Description + * 토큰의 만료여부와 유효성에 대해 검증합니다. + */ + private Claims validateAccessToken(String accessToken) { + try { + return parseToken(accessToken); + + } catch (ExpiredJwtException e) { + throw new InvalidException(ErrorCode.EXPIRED_PERIOD_ACCESS_TOKEN); + } catch (final JwtException | IllegalArgumentException e) { + throw new InvalidException(ErrorCode.INVALID_ACCESS_TOKEN); + } + } + private Claims validateRefreshToken(String refreshToken) { + try { + return parseToken(refreshToken); + } catch (ExpiredJwtException e) { + throw new InvalidException(ErrorCode.EXPIRED_PERIOD_REFRESH_TOKEN); + } catch (final JwtException | IllegalArgumentException e) { + throw new InvalidException(ErrorCode.INVALID_REFRESH_TOKEN); + } + } + + private Claims parseToken(final String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } + + public void checkLogin(String email) { + if (jwtTokenUtils.isLogin(email) == false) + throw new InvalidException(ErrorCode.LOGOUTED_TOKEN); + } } diff --git a/src/main/java/com/api/trip/common/security/util/JwtTokenUtils.java b/src/main/java/com/api/trip/common/security/util/JwtTokenUtils.java new file mode 100644 index 0000000..c21506e --- /dev/null +++ b/src/main/java/com/api/trip/common/security/util/JwtTokenUtils.java @@ -0,0 +1,53 @@ +package com.api.trip.common.security.util; + + + +import com.api.trip.common.exception.ErrorCode; +import com.api.trip.common.exception.custom_exception.BadRequestException; +import com.api.trip.common.redis.RedisService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Component +@RequiredArgsConstructor +public class JwtTokenUtils { + + private final RedisService redisService; + + @Value("${custom.jwt.token.refresh-expiration-time}") + private long refreshExpirationTime; + /** + * @Description + * Bearer토큰의 여부에 대해 검증한 뒤 토큰을 반환합니다. + */ + public static String extractBearerToken(String token) { + if(token != null){ + if(!token.startsWith("Bearer")) + throw new BadRequestException(ErrorCode.INVALID_TYPE_TOKEN); + return token.split(" ")[1].trim(); + } + return null; + } + + public boolean isLogin(String email){ + return redisService.getData("Login_" + email) != null; + } + public void setRefreshToken(String username, String refreshToken){ + redisService.setData("Login_" + username, refreshToken, refreshExpirationTime, TimeUnit.SECONDS); + } + public void updateRefreshToken(String name, String refreshToken) { + setRefreshToken(name, refreshToken); + } + + public void deleteRefreshToken(String name) { + redisService.deleteData("Login_" + name); + } + + public String getRefreshToken(String name) { + return redisService.getData("Login_" + name).toString(); + } + +} diff --git a/src/main/java/com/api/trip/domain/article/controller/ArticleController.java b/src/main/java/com/api/trip/domain/article/controller/ArticleController.java index 0bcb45d..cb24888 100644 --- a/src/main/java/com/api/trip/domain/article/controller/ArticleController.java +++ b/src/main/java/com/api/trip/domain/article/controller/ArticleController.java @@ -45,9 +45,12 @@ public ResponseEntity readArticle(@PathVariable Long articl @GetMapping public ResponseEntity getArticles( @PageableDefault(size = 8) Pageable pageable, - @RequestParam(value = "filter", required = false) String filter + @RequestParam(value = "sortCode", defaultValue = "0") int sortCode, + @RequestParam(value = "category", required = false) String category, + @RequestParam(value = "title", required = false) String title, + @RequestParam(value = "tag", required = false) String tagName ) { - return ResponseEntity.ok(articleService.getArticles(pageable, filter)); + return ResponseEntity.ok(articleService.getArticles(pageable, sortCode, category, title, tagName)); } @GetMapping("/me") diff --git a/src/main/java/com/api/trip/domain/article/controller/dto/CreateArticleRequest.java b/src/main/java/com/api/trip/domain/article/controller/dto/CreateArticleRequest.java index da050a4..f68a70e 100644 --- a/src/main/java/com/api/trip/domain/article/controller/dto/CreateArticleRequest.java +++ b/src/main/java/com/api/trip/domain/article/controller/dto/CreateArticleRequest.java @@ -4,10 +4,13 @@ import com.api.trip.domain.member.model.Member; import lombok.Getter; +import java.util.List; + @Getter public class CreateArticleRequest { private String title; + private List tags; private String content; public Article toEntity(Member writer) { diff --git a/src/main/java/com/api/trip/domain/article/controller/dto/ReadArticleResponse.java b/src/main/java/com/api/trip/domain/article/controller/dto/ReadArticleResponse.java index f853c14..ffdbc5e 100644 --- a/src/main/java/com/api/trip/domain/article/controller/dto/ReadArticleResponse.java +++ b/src/main/java/com/api/trip/domain/article/controller/dto/ReadArticleResponse.java @@ -1,12 +1,14 @@ package com.api.trip.domain.article.controller.dto; import com.api.trip.domain.article.model.Article; +import com.api.trip.domain.articletag.model.ArticleTag; import com.api.trip.domain.interestarticle.model.InterestArticle; import com.api.trip.domain.member.model.Member; import lombok.Builder; import lombok.Getter; import java.time.LocalDateTime; +import java.util.List; @Getter @Builder @@ -18,13 +20,14 @@ public class ReadArticleResponse { private String writerNickname; private String writerRole; private String writerProfileImg; + private List tags; private String content; private long viewCount; private long likeCount; private LocalDateTime createdAt; private Long interestArticleId; - public static ReadArticleResponse of(Article article, InterestArticle interestArticle) { + public static ReadArticleResponse of(Article article, List articleTags, InterestArticle interestArticle) { Member writer = article.getWriter(); return builder() .articleId(article.getId()) @@ -33,6 +36,7 @@ public static ReadArticleResponse of(Article article, InterestArticle interestAr .writerNickname(writer.getNickname()) .writerProfileImg(writer.getProfileImg()) .writerRole(writer.getRole().name()) + .tags(articleTags.stream().map(articleTag -> articleTag.getTag().getName()).toList()) .content(article.getContent()) .viewCount(article.getViewCount() + 1) .likeCount(article.getLikeCount()) diff --git a/src/main/java/com/api/trip/domain/article/controller/dto/UpdateArticleRequest.java b/src/main/java/com/api/trip/domain/article/controller/dto/UpdateArticleRequest.java index 1eceb9d..b73d709 100644 --- a/src/main/java/com/api/trip/domain/article/controller/dto/UpdateArticleRequest.java +++ b/src/main/java/com/api/trip/domain/article/controller/dto/UpdateArticleRequest.java @@ -2,9 +2,12 @@ import lombok.Getter; +import java.util.List; + @Getter public class UpdateArticleRequest { private String title; + private List tags; private String content; } diff --git a/src/main/java/com/api/trip/domain/article/repository/ArticleRepositoryCustom.java b/src/main/java/com/api/trip/domain/article/repository/ArticleRepositoryCustom.java index fbdbe4f..bfda2af 100644 --- a/src/main/java/com/api/trip/domain/article/repository/ArticleRepositoryCustom.java +++ b/src/main/java/com/api/trip/domain/article/repository/ArticleRepositoryCustom.java @@ -10,5 +10,7 @@ public interface ArticleRepositoryCustom { Optional
findArticle(Long articleId); - Page
findArticles(Pageable pageable, String filter); + Page
findArticles(Pageable pageable, int sortCode, String category, String title); + + Page
findArticlesByTagName(Pageable pageable, int sortCode, String category, String tagName); } diff --git a/src/main/java/com/api/trip/domain/article/repository/ArticleRepositoryCustomImpl.java b/src/main/java/com/api/trip/domain/article/repository/ArticleRepositoryCustomImpl.java index aff3711..802a916 100644 --- a/src/main/java/com/api/trip/domain/article/repository/ArticleRepositoryCustomImpl.java +++ b/src/main/java/com/api/trip/domain/article/repository/ArticleRepositoryCustomImpl.java @@ -1,6 +1,7 @@ package com.api.trip.domain.article.repository; import com.api.trip.domain.article.model.Article; +import com.api.trip.domain.articletag.model.ArticleTag; import com.api.trip.domain.member.model.MemberRole; import com.querydsl.core.types.Order; import com.querydsl.core.types.OrderSpecifier; @@ -10,14 +11,15 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import java.util.ArrayList; import java.util.List; import java.util.Optional; import static com.api.trip.domain.article.model.QArticle.article; +import static com.api.trip.domain.articletag.model.QArticleTag.articleTag; import static com.api.trip.domain.member.model.QMember.member; +import static com.api.trip.domain.tag.model.QTag.tag; public class ArticleRepositoryCustomImpl implements ArticleRepositoryCustom { @@ -39,12 +41,44 @@ public Optional
findArticle(Long articleId) { } @Override - public Page
findArticles(Pageable pageable, String filter) { + public Page
findArticles(Pageable pageable, int sortCode, String category, String title) { List
content = jpaQueryFactory - .selectFrom(article) + .select(article) + .from(article) + .innerJoin(article.writer, member).fetchJoin() + .where(eqCategory(category), containsTitle(title)) + .orderBy(getOrderSpecifiers(sortCode)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = jpaQueryFactory + .select(article.count()) + .from(article) + .innerJoin(article.writer, member) + .where(eqCategory(category), containsTitle(title)) + .fetchOne(); + + return new PageImpl<>(content, pageable, total); + } + + @Override + public Page
findArticlesByTagName(Pageable pageable, int sortCode, String category, String tagName) { + List articleTags = jpaQueryFactory + .select(articleTag) + .from(articleTag) + .innerJoin(articleTag.tag, tag) + .where(tag.name.eq(tagName)) + .fetch(); + + List
articles = articleTags.stream().map(ArticleTag::getArticle).toList(); + + List
content = jpaQueryFactory + .select(article) + .from(article) .innerJoin(article.writer, member).fetchJoin() - .where(eqFilter(filter)) - .orderBy(getOrderSpecifiers(pageable)) + .where(eqCategory(category), article.in(articles)) + .orderBy(getOrderSpecifiers(sortCode)) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); @@ -53,27 +87,31 @@ public Page
findArticles(Pageable pageable, String filter) { .select(article.count()) .from(article) .innerJoin(article.writer, member) - .where(eqFilter(filter)) + .where(eqCategory(category), article.in(articles)) .fetchOne(); return new PageImpl<>(content, pageable, total); } - private BooleanExpression eqFilter(String filter) { + private BooleanExpression eqCategory(String category) { for (MemberRole role : MemberRole.values()) { - if (role.name().equals(filter)) { + if (role.name().equals(category)) { return member.role.eq(role); } } return null; } - private OrderSpecifier[] getOrderSpecifiers(Pageable pageable) { + private BooleanExpression containsTitle(String title) { + return title != null ? article.title.contains(title) : null; + } + + private OrderSpecifier[] getOrderSpecifiers(int sortCode) { List> orderSpecifierList = new ArrayList<>(); - for (Sort.Order order : pageable.getSort()) { - if ("popular".equals(order.getProperty())) { - orderSpecifierList.add(new OrderSpecifier<>(Order.DESC, article.likeCount)); - } + switch (sortCode) { + case 1 -> orderSpecifierList.add(new OrderSpecifier<>(Order.ASC, article.id)); + case 2 -> orderSpecifierList.add(new OrderSpecifier<>(Order.DESC, article.likeCount)); + case 3 -> orderSpecifierList.add(new OrderSpecifier<>(Order.ASC, article.likeCount)); } orderSpecifierList.add(new OrderSpecifier<>(Order.DESC, article.id)); return orderSpecifierList.toArray(OrderSpecifier[]::new); diff --git a/src/main/java/com/api/trip/domain/article/service/ArticleService.java b/src/main/java/com/api/trip/domain/article/service/ArticleService.java index fe61dba..a85bd20 100644 --- a/src/main/java/com/api/trip/domain/article/service/ArticleService.java +++ b/src/main/java/com/api/trip/domain/article/service/ArticleService.java @@ -5,10 +5,13 @@ import com.api.trip.domain.article.repository.ArticleRepository; import com.api.trip.domain.article.util.UrlExtractor; import com.api.trip.domain.articlefile.repository.ArticleFileRepository; +import com.api.trip.domain.articletag.model.ArticleTag; +import com.api.trip.domain.articletag.repository.ArticleTagRepository; import com.api.trip.domain.interestarticle.model.InterestArticle; import com.api.trip.domain.interestarticle.repository.InterestArticleRepository; import com.api.trip.domain.member.model.Member; import com.api.trip.domain.member.repository.MemberRepository; +import com.api.trip.domain.tag.repository.TagRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -25,18 +28,31 @@ public class ArticleService { private final ArticleRepository articleRepository; private final MemberRepository memberRepository; + private final ArticleTagRepository articleTagRepository; + private final TagRepository tagRepository; private final InterestArticleRepository interestArticleRepository; private final ArticleFileRepository articleFileRepository; public Long createArticle(CreateArticleRequest request, String email) { Member member = memberRepository.findByEmail(email).orElseThrow(); - Article article = request.toEntity(member); + final Article article = request.toEntity(member); - article = articleRepository.save(article); + articleRepository.save(article); + + tagRepository.findByNameIn(request.getTags()) + .forEach(tag -> { + ArticleTag articleTag = ArticleTag.builder() + .article(article) + .tag(tag) + .build(); + articleTagRepository.save(articleTag); + }); List urls = UrlExtractor.extractAll(request.getContent()); - articleFileRepository.setArticleWhereArticleNullAndUrlIn(article, urls); + if (urls.size() > 0) { + articleFileRepository.setArticleWhereArticleNullAndUrlIn(article, urls); + } return article.getId(); } @@ -49,9 +65,21 @@ public void updateArticle(Long articleId, UpdateArticleRequest request, String e throw new RuntimeException("수정 권한이 없습니다."); } + articleTagRepository.deleteAllByArticle(article); + tagRepository.findByNameIn(request.getTags()) + .forEach(tag -> { + ArticleTag articleTag = ArticleTag.builder() + .article(article) + .tag(tag) + .build(); + articleTagRepository.save(articleTag); + }); + List urls = UrlExtractor.extractAll(request.getContent()); - articleFileRepository.setArticleNullWhereArticleAndUrlNotIn(article, urls); - articleFileRepository.setArticleWhereArticleNullAndUrlIn(article, urls); + if (urls.size() > 0) { + articleFileRepository.setArticleNullWhereArticleAndUrlNotIn(article, urls); + articleFileRepository.setArticleWhereArticleNullAndUrlIn(article, urls); + } article.modify(request.getTitle(), request.getContent()); } @@ -64,6 +92,8 @@ public void deleteArticle(Long articleId, String email) { throw new RuntimeException("삭제 권한이 없습니다."); } + articleTagRepository.deleteAllByArticle(article); + articleFileRepository.setArticleNullWhereArticle(article); articleRepository.delete(article); @@ -72,7 +102,7 @@ public void deleteArticle(Long articleId, String email) { public ReadArticleResponse readArticle(Long articleId, String email) { Article article = articleRepository.findArticle(articleId).orElseThrow(); - articleRepository.increaseViewCount(article); + List articleTags = articleTagRepository.findArticleTags(article); InterestArticle interestArticle = null; if (!Objects.equals(email, "anonymousUser")) { @@ -80,12 +110,22 @@ public ReadArticleResponse readArticle(Long articleId, String email) { interestArticle = interestArticleRepository.findByMemberAndArticle(member, article); } - return ReadArticleResponse.of(article, interestArticle); + articleRepository.increaseViewCount(article); + + return ReadArticleResponse.of(article, articleTags, interestArticle); } @Transactional(readOnly = true) - public GetArticlesResponse getArticles(Pageable pageable, String filter) { - Page
articlePage = articleRepository.findArticles(pageable, filter); + public GetArticlesResponse getArticles(Pageable pageable, int sortCode, String category, String title, String tagName) { + Page
articlePage = null; + + if (tagName == null) { + articlePage = articleRepository.findArticles(pageable, sortCode, category, title); + } + + if (tagName != null) { + articlePage = articleRepository.findArticlesByTagName(pageable, sortCode, category, tagName); + } return GetArticlesResponse.of(articlePage); } diff --git a/src/main/java/com/api/trip/domain/articletag/model/ArticleTag.java b/src/main/java/com/api/trip/domain/articletag/model/ArticleTag.java new file mode 100644 index 0000000..a67c554 --- /dev/null +++ b/src/main/java/com/api/trip/domain/articletag/model/ArticleTag.java @@ -0,0 +1,33 @@ +package com.api.trip.domain.articletag.model; + +import com.api.trip.common.auditing.entity.BaseTimeEntity; +import com.api.trip.domain.article.model.Article; +import com.api.trip.domain.tag.model.Tag; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"article_id", "tag_id"})}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ArticleTag extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + private Article article; + + @ManyToOne(fetch = FetchType.LAZY) + private Tag tag; + + @Builder + private ArticleTag(Article article, Tag tag) { + this.article = article; + this.tag = tag; + } +} diff --git a/src/main/java/com/api/trip/domain/articletag/repository/ArticleTagRepository.java b/src/main/java/com/api/trip/domain/articletag/repository/ArticleTagRepository.java new file mode 100644 index 0000000..37fe6f3 --- /dev/null +++ b/src/main/java/com/api/trip/domain/articletag/repository/ArticleTagRepository.java @@ -0,0 +1,15 @@ +package com.api.trip.domain.articletag.repository; + +import com.api.trip.domain.article.model.Article; +import com.api.trip.domain.articletag.model.ArticleTag; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ArticleTagRepository extends JpaRepository, ArticleTagRepositoryCustom { + + @Modifying + @Query("DELETE FROM ArticleTag at WHERE at.article = :article") + void deleteAllByArticle(@Param("article") Article article); +} diff --git a/src/main/java/com/api/trip/domain/articletag/repository/ArticleTagRepositoryCustom.java b/src/main/java/com/api/trip/domain/articletag/repository/ArticleTagRepositoryCustom.java new file mode 100644 index 0000000..be9b6d0 --- /dev/null +++ b/src/main/java/com/api/trip/domain/articletag/repository/ArticleTagRepositoryCustom.java @@ -0,0 +1,11 @@ +package com.api.trip.domain.articletag.repository; + +import com.api.trip.domain.article.model.Article; +import com.api.trip.domain.articletag.model.ArticleTag; + +import java.util.List; + +public interface ArticleTagRepositoryCustom { + + List findArticleTags(Article article); +} diff --git a/src/main/java/com/api/trip/domain/articletag/repository/ArticleTagRepositoryCustomImpl.java b/src/main/java/com/api/trip/domain/articletag/repository/ArticleTagRepositoryCustomImpl.java new file mode 100644 index 0000000..bf1c5c6 --- /dev/null +++ b/src/main/java/com/api/trip/domain/articletag/repository/ArticleTagRepositoryCustomImpl.java @@ -0,0 +1,29 @@ +package com.api.trip.domain.articletag.repository; + +import com.api.trip.domain.article.model.Article; +import com.api.trip.domain.articletag.model.ArticleTag; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; + +import java.util.List; + +import static com.api.trip.domain.articletag.model.QArticleTag.articleTag; +import static com.api.trip.domain.tag.model.QTag.tag; + +public class ArticleTagRepositoryCustomImpl implements ArticleTagRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + public ArticleTagRepositoryCustomImpl(EntityManager em) { + this.jpaQueryFactory = new JPAQueryFactory(em); + } + + @Override + public List findArticleTags(Article article) { + return jpaQueryFactory + .selectFrom(articleTag) + .innerJoin(articleTag.tag, tag).fetchJoin() + .where(articleTag.article.eq(article)) + .fetch(); + } +} diff --git a/src/main/java/com/api/trip/domain/member/controller/MemberController.java b/src/main/java/com/api/trip/domain/member/controller/MemberController.java index 81726e1..80b34bd 100644 --- a/src/main/java/com/api/trip/domain/member/controller/MemberController.java +++ b/src/main/java/com/api/trip/domain/member/controller/MemberController.java @@ -1,13 +1,20 @@ package com.api.trip.domain.member.controller; +import com.api.trip.common.exception.ErrorCode; +import com.api.trip.common.exception.custom_exception.BadRequestException; +import com.api.trip.common.security.jwt.JwtToken; +import com.api.trip.common.security.util.JwtTokenUtils; import com.api.trip.domain.email.service.EmailService; import com.api.trip.domain.member.controller.dto.*; import com.api.trip.domain.member.service.MemberService; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -75,4 +82,22 @@ public ResponseEntity deleteMember(@RequestBody DeleteRequest deleteReques return ResponseEntity.ok().build(); } + @PreAuthorize("isAuthenticated()") + @Operation(summary = "로그아웃") + @PostMapping("/logout") + public ResponseEntity logoutMember() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + return ResponseEntity.ok().body(memberService.logout(username)); + } + + @GetMapping("/rotate") + public JwtToken rotateToken(HttpServletRequest request){ + String refreshToken = JwtTokenUtils.extractBearerToken(request.getHeader("refreshToken")); + + if(refreshToken.isBlank()) + throw new BadRequestException(ErrorCode.EMPTY_REFRESH_TOKEN); + + + return memberService.rotateToken(refreshToken); + } } diff --git a/src/main/java/com/api/trip/domain/member/service/MemberService.java b/src/main/java/com/api/trip/domain/member/service/MemberService.java index 65ebae6..0138032 100644 --- a/src/main/java/com/api/trip/domain/member/service/MemberService.java +++ b/src/main/java/com/api/trip/domain/member/service/MemberService.java @@ -1,7 +1,9 @@ package com.api.trip.domain.member.service; +import com.api.trip.common.exception.custom_exception.InvalidException; import com.api.trip.common.security.jwt.JwtToken; import com.api.trip.common.security.jwt.JwtTokenProvider; +import com.api.trip.common.security.util.JwtTokenUtils; import com.api.trip.common.security.util.SecurityUtils; import com.api.trip.domain.aws.util.MultipartFileUtils; import com.api.trip.domain.aws.service.AmazonS3Service; @@ -21,8 +23,11 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.io.InvalidClassException; import java.util.stream.Collectors; +import static com.api.trip.common.exception.ErrorCode.SNATCH_TOKEN; + @Service @Transactional @RequiredArgsConstructor @@ -34,6 +39,7 @@ public class MemberService { private final AmazonS3Service amazonS3Service; private final AuthenticationManagerBuilder authenticationManagerBuilder; private final JwtTokenProvider jwtTokenProvider; + private final JwtTokenUtils jwtTokenUtils; public void join(JoinRequest joinRequest) throws IOException { @@ -125,4 +131,43 @@ public Member getMemberByEmail(String email) { public Member getAuthenticationMember() { return memberRepository.findByEmail(SecurityUtils.getCurrentUsername()).orElseThrow(() -> new UsernameNotFoundException("가입된 회원이 아닙니다!")); } + + /** + * @Description + * 1. 요청으로 들어온 RT검증 + 로그인 여부 체킹 + * 2. 현재 RT와 요청으로 들어온 RT비교 + * 3. 다르다면 토큰 탈취로 간주 후 로그아웃 처리 + 예외 처리 + */ + public JwtToken rotateToken(String requestRefreshToken) { + Authentication authentication = validateAndGetAuthentication(requestRefreshToken); + String userEmail = authentication.getName(); + + checkLogin(userEmail); + + String currentRefreshToken = jwtTokenUtils.getRefreshToken(userEmail); + + if(isSnatch(requestRefreshToken, currentRefreshToken) == true) { + logout(authentication.getName()); + throw new InvalidException(SNATCH_TOKEN); + } + + return jwtTokenProvider.refreshJwtToken(authentication); + } + + private boolean isSnatch(String requestRefreshToken, String currentRefreshToken) { + return !currentRefreshToken.equals(requestRefreshToken); + } + + private void checkLogin(String email) { + jwtTokenProvider.checkLogin(email); + } + + private Authentication validateAndGetAuthentication(String requestRefreshToken){ + return jwtTokenProvider.getAuthenticationByRefreshToken(requestRefreshToken); + } + + public String logout(String email){ + jwtTokenUtils.deleteRefreshToken(email); + return "로그아웃 처리 되었습니다."; + } }