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
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,9 @@ out/

### yml ###
application-dev.properties
application-infrastructure.properties
application-infrastructure.properties

### Claude Code ###
CLAUDE.md
.claude/
.clinerules
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
* JWT 기반 인증을 처리하는 Spring Security 필터
* 모든 HTTP 요청에 대해 JWT 토큰을 검증하고 인증 처리
* Access Token 만료 시 Refresh Token을 사용한 자동 갱신 기능 포함
*/
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
Expand All @@ -34,13 +39,22 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final AuthService authService;
private final CookieUtils cookieUtils;

/**
* JWT 토큰 검증 및 인증 처리
* 쿠키 또는 Authorization 헤더에서 토큰을 추출하고 검증
*
* @param req HTTP 요청
* @param res HTTP 응답
* @param chain 필터 체인
*/
@Override
protected void doFilterInternal(HttpServletRequest req,
@NonNull HttpServletResponse res,
@NonNull FilterChain chain)
throws ServletException, IOException {
log.info("JwtAuthenticationFilter: {} {}", req.getMethod(), req.getRequestURI());

// 쿠키 또는 Authorization 헤더에서 토큰 추출
String accessToken = cookieUtils
.extractTokenFromCookieInRequest(req, ACCESS_TOKEN_COOKIE_NAME)
.or(() -> Optional.ofNullable(req.getHeader("Authorization"))
Expand All @@ -51,6 +65,7 @@ protected void doFilterInternal(HttpServletRequest req,
.orElse(null);
log.info("Access token: {}, Refresh token: {}", accessToken, refreshToken);

// 토큰 존재 여부에 따른 처리
if (accessToken != null) {
log.debug("Access token found - attempting authentication");
authenticateOrRefresh(accessToken, refreshToken, res);
Expand All @@ -67,6 +82,14 @@ protected void doFilterInternal(HttpServletRequest req,
log.debug("JwtAuthenticationFilter completed");
}

/**
* Access Token 검증 및 만료 시 Refresh Token으로 갱신
*
* @param accessToken 검증할 Access Token
* @param refreshToken 갱신에 사용할 Refresh Token
* @param res HTTP 응답 (새 쿠키 설정용)
* @throws JwtAuthenticationException JWT 인증 실패 시
*/
private void authenticateOrRefresh(String accessToken,
String refreshToken,
HttpServletResponse res) throws JwtAuthenticationException {
Expand All @@ -76,6 +99,7 @@ private void authenticateOrRefresh(String accessToken,
SecurityContextHolder.getContext().setAuthentication(auth);
log.info("Authentication successful: user={}, authorities={}", auth.getName(), auth.getAuthorities());
} catch (ExpiredJwtException e) {
// Access Token 만료 시 Refresh Token으로 갱신 시도
log.warn("Access token expired: {}", e.getMessage());
if (refreshToken == null) {
throw new JwtAuthenticationException("Refresh token missing");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,10 @@ public class JwtTokenProviderImpl implements JwtTokenProvider {
private final JwtParser provideJwtParser;
private final JwtParser fontCreateJwtParser;

private SecretKey getSigningKey(String key) {
byte[] keyBytes = Decoders.BASE64.decode(key);
return Keys.hmacShaKeyFor(keyBytes);
}

public JwtTokenProviderImpl(
JwtProperties props) {
public JwtTokenProviderImpl(JwtProperties props) {
log.info("Initializing JWT token provider");

this.props = props;
this.accessSecretKey = getSigningKey(props.getAccessSecretKey());
this.refreshSecretKey = getSigningKey(props.getRefreshSecretKey());
this.provideSecretKey = getSigningKey(props.getProvideSecretKey());
Expand All @@ -49,25 +44,20 @@ public JwtTokenProviderImpl(
this.refreshJwtParser = Jwts.parserBuilder().setSigningKey(refreshSecretKey).build();
this.provideJwtParser = Jwts.parserBuilder().setSigningKey(provideSecretKey).build();
this.fontCreateJwtParser = Jwts.parserBuilder().setSigningKey(fontCreateSecretKey).build();
this.props = props;

log.debug("JWT token provider initialized with token validities - access: {}ms, refresh: {}ms",
props.getAccessTokenValidityMs(), props.getRefreshTokenValidityMs());
}

private SecretKey getSigningKey(String key) {
byte[] keyBytes = Decoders.BASE64.decode(key);
return Keys.hmacShaKeyFor(keyBytes);
}

public String generateTemporalProvideToken(String id) {
log.debug("Generating temporal provide token for id: {}", id);

Date now = new Date();
Date expiryDate = new Date(now.getTime() + props.getTempTokenValidityMs());
String token = Jwts.builder()
.setSubject(String.valueOf(id))
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(this.provideSecretKey)
.compact();

log.info("Temporal provide token generated: id={}, expiresAt={}", id, expiryDate);
String token = generateToken(id, props.getTempTokenValidityMs(), provideSecretKey);
log.info("Temporal provide token generated for id: {}", id);
return token;
}

Expand All @@ -85,56 +75,28 @@ public Long getProvideId(String token) {

public String generateAccessToken(UserPrincipal user) {
log.debug("Generating access token for user: {}", user.getId());

Date now = new Date();
Date expiryDate = new Date(now.getTime() + props.getAccessTokenValidityMs());
String token = Jwts.builder()
.setSubject(String.valueOf(user.getId()))
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(this.accessSecretKey)
.compact();

log.info("Access token generated: userId={}, expiresAt={}", user.getId(), expiryDate);
String token = generateToken(String.valueOf(user.getId()), props.getAccessTokenValidityMs(), accessSecretKey);
log.info("Access token generated for user: {}", user.getId());
return token;
}

public String generateRefreshToken(UserPrincipal user) {
log.debug("Generating refresh token for user: {}", user.getId());

Date now = new Date();
Date expiryDate = new Date(now.getTime() + props.getRefreshTokenValidityMs());
String token = Jwts.builder()
.setSubject(String.valueOf(user.getId()))
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(refreshSecretKey)
.compact();

log.info("Refresh token generated: userId={}, expiresAt={}", user.getId(), expiryDate);
String token = generateToken(String.valueOf(user.getId()), props.getRefreshTokenValidityMs(), refreshSecretKey);
log.info("Refresh token generated for user: {}", user.getId());
return token;
}

public Long getMemberIdFromAccessToken(String token) {
log.debug("Extracting member ID from access token");

Claims claims = accessJwtParser
.parseClaimsJws(token)
.getBody();
Long memberId = Long.valueOf(claims.getSubject());

Long memberId = extractMemberIdFromToken(token, accessJwtParser);
log.debug("Member ID extracted from access token: {}", memberId);
return memberId;
}

public Long getMemberIdFromRefreshToken(String token) {
log.debug("Extracting member ID from refresh token");

Claims claims = refreshJwtParser
.parseClaimsJws(token)
.getBody();
Long memberId = Long.valueOf(claims.getSubject());

Long memberId = extractMemberIdFromToken(token, refreshJwtParser);
log.debug("Member ID extracted from refresh token: {}", memberId);
return memberId;
}
Expand All @@ -156,13 +118,25 @@ public Authentication getAuthenticationFromAccessToken(String token) {

public String getFontCreateServer(String token) {
log.debug("Validating font create server token");

Claims claims = fontCreateJwtParser
.parseClaimsJws(token)
.getBody();
Claims claims = fontCreateJwtParser.parseClaimsJws(token).getBody();
String subject = claims.getSubject();

log.debug("Font create server token validated: subject={}", subject);
return subject;
}

private String generateToken(String subject, long validityMs, SecretKey key) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + validityMs);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(key)
.compact();
}

private Long extractMemberIdFromToken(String token, JwtParser parser) {
Claims claims = parser.parseClaimsJws(token).getBody();
return Long.valueOf(claims.getSubject());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ public class AuthService {
private final JwtTokenProvider jwtTokenProvider;

/**
* 새롭게 토큰 발급
* 기존에 토큰이 존재한다면 제거, 기존 토큰이 존재할 필요 X
* 새로운 Access/Refresh 토큰 쌍을 발급
* Refresh Token은 Redis에 저장하여 검증에 사용
*
* @param member 토큰을 발급할 회원 정보
* @return 발급된 토큰 쌍
*/
private TokenResponse issueNewTokens(Member member) {
log.info("Issuing new token pair for member: memberId={}, provideId={}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,21 @@
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Tag(name = "북마크 관리", description = "북마크 API")
@Tag(name = "북마크 관리", description = "폰트 북마크 추가, 삭제, 조회 API")
@RestController
@RequestMapping("/bookmarks")
@RequiredArgsConstructor
public class BookmarkController {
private final BookmarkService bookmarkService;

@Operation(summary = "북마크 추가")
@Operation(
summary = "북마크 추가",
description = "특정 폰트를 북마크에 추가합니다."
)
@PostMapping("/{fontId}")
public ResponseEntity<?> addBookmark(@Login UserPrincipal userPrincipal, @PathVariable Long fontId) {
public ResponseEntity<?> addBookmark(
@Login UserPrincipal userPrincipal,
@PathVariable @Parameter(description = "북마크할 폰트 ID") Long fontId) {
Long memberId = userPrincipal.getId();
log.info("Request received: Add bookmark for font ID: {} by member ID: {}", fontId, memberId);

Expand All @@ -47,9 +52,14 @@ public ResponseEntity<?> addBookmark(@Login UserPrincipal userPrincipal, @PathVa
.body(BookmarkCreateResponse.from(createdBookmark));
}

@Operation(summary = "북마크 삭제")
@Operation(
summary = "북마크 삭제",
description = "특정 폰트를 북마크에서 제거합니다."
)
@DeleteMapping("/{fontId}")
public ResponseEntity<?> deleteBookmark(@Login UserPrincipal userPrincipal, @PathVariable Long fontId) {
public ResponseEntity<?> deleteBookmark(
@Login UserPrincipal userPrincipal,
@PathVariable @Parameter(description = "북마크 삭제할 폰트 ID") Long fontId) {
Long memberId = userPrincipal.getId();
log.info("Request received: Delete bookmark for font ID: {} by member ID: {}", fontId, memberId);

Expand All @@ -62,7 +72,10 @@ public ResponseEntity<?> deleteBookmark(@Login UserPrincipal userPrincipal, @Pat
.body(deletedBookmark);
}

@Operation(summary = "북마크한 폰트 보기")
@Operation(
summary = "북마크한 폰트 보기",
description = "로그인한 사용자가 북마크한 폰트 목록을 조회합니다. 페이지네이션 및 검색을 지원합니다."
)
@GetMapping
public ResponseEntity<?> getBookmarks(
@Parameter(description = "페이지 시작 오프셋 (기본값: 0)", example = "0") @RequestParam(defaultValue = "0") int page,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
package org.fontory.fontorybe.bookmark.infrastructure;

import java.util.List;
import java.util.Optional;
import org.fontory.fontorybe.bookmark.infrastructure.entity.BookmarkEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface BookmarkJpaRepository extends JpaRepository<BookmarkEntity, Long> {
boolean existsByMemberIdAndFontId(Long memberId, Long fontId);
Optional<BookmarkEntity> findByMemberIdAndFontId(Long memberId, Long fontId);
Page<BookmarkEntity> findAllByMemberId(Long memberId, Pageable pageable);


// 성능 최적화를 위한 추가 쿼리
@Query(value = "SELECT COUNT(*) FROM bookmark WHERE member_id = :memberId", nativeQuery = true)
long countByMemberId(@Param("memberId") Long memberId);

@Query(value = "SELECT font_id FROM bookmark WHERE member_id = :memberId ORDER BY created_at DESC LIMIT :limit", nativeQuery = true)
List<Long> findRecentBookmarkedFontIds(@Param("memberId") Long memberId, @Param("limit") int limit);

@Query("SELECT b FROM BookmarkEntity b WHERE b.memberId = :memberId ORDER BY b.createdAt DESC")
List<BookmarkEntity> findAllByMemberIdOrderByCreatedAtDesc(@Param("memberId") Long memberId, Pageable pageable);
}
Loading
Loading