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 .env.default
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
JWT_SECRET=your-secret-key
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ dependencies {
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
implementation ("io.github.cdimascio:dotenv-java:3.0.0")

// JWT
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
}

tasks.withType<Test> {
Expand Down
11 changes: 10 additions & 1 deletion src/main/java/com/back/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@ public enum ErrorCode {
// ======================== 공통 에러 ========================
BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."),
FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_403", "접근 권한이 없습니다."),
NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_404", "요청하신 리소스를 찾을 수 없습니다.");
NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_404", "요청하신 리소스를 찾을 수 없습니다."),

// ======================== 인증/인가 에러 ========================
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "AUTH_401", "인증이 필요합니다."),
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401", "유효하지 않은 토큰입니다."),
EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401", "만료된 액세스 토큰입니다."),
EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401", "만료된 리프레시 토큰입니다."),
REFRESH_TOKEN_REUSE(HttpStatus.FORBIDDEN, "AUTH_403", "재사용된 리프레시 토큰입니다."),
ACCESS_DENIED(HttpStatus.FORBIDDEN, "AUTH_403", "권한이 없습니다.");


private final HttpStatus status;
private final String code;
Expand Down
58 changes: 58 additions & 0 deletions src/main/java/com/back/global/security/CustomUserDetails.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.back.global.security;

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

/**
* Spring Security에서 사용하는 사용자 인증 정보 클래스
* - JWT에서 파싱한 사용자 정보를 담고 있음
*/
@Getter
@AllArgsConstructor
public class CustomUserDetails implements UserDetails {
private Long userId;
private String username;
private String role;

@Override
public Collection<SimpleGrantedAuthority> getAuthorities() {
// Spring Security 권한 체크는 "ROLE_" prefix 필요
return List.of(new SimpleGrantedAuthority("ROLE_" + role));
}

@Override
public String getPassword() {
// JWT 인증에서는 비밀번호를 사용하지 않음
return null;
}

@Override
public String getUsername() {
return username;
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}
}
37 changes: 37 additions & 0 deletions src/main/java/com/back/global/security/JwtAccessDeniedHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.back.global.security;

import com.back.global.common.dto.RsData;
import com.back.global.exception.ErrorCode;
import com.fasterxml.jackson.databind.ObjectMapper;
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;

/**
* 인가 실패(403 Forbidden) 처리 클래스
* - 인증은 되었으나, 권한(Role)이 부족한 경우
* - Json 형태로 에러 응답을 반환
*/
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException
) throws IOException {

response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);

RsData<Void> body = RsData.fail(ErrorCode.ACCESS_DENIED);

response.getWriter().write(objectMapper.writeValueAsString(body));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.back.global.security;

import com.back.global.common.dto.RsData;
import com.back.global.exception.ErrorCode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

/**
* 인증 실패(401 Unauthorized) 처리 클래스
* - JwtAuthenticationFilter에서 토큰이 없거나 잘못된 경우
* - 인증되지 않은 사용자가 보호된 API에 접근하려는 경우
* - Json 형태로 에러 응답을 반환
*/
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException {

response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

RsData<Void> body = RsData.fail(ErrorCode.UNAUTHORIZED);

response.getWriter().write(objectMapper.writeValueAsString(body));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.back.global.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
* JWT 인증을 처리하는 필터
* - 모든 요청에 대해 JWT 토큰을 검사
* - 토큰이 유효하면 Authentication 객체를 생성하여 SecurityContext에 저장
*/
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;

@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain
) throws ServletException, IOException {

// Request Header에서 토큰 추출
String token = resolveToken(request);

// 토큰이 유효한 경우에만 Authentication 객체 생성 및 SecurityContext에 저장
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}

// 다음 필터로 요청 전달
filterChain.doFilter(request, response);
}

/**
* Request의 Authorization 헤더에서 JWT 토큰을 추출
*
* @param request HTTP 요청 객체
* @return JWT 토큰 문자열 또는 null
*/
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
142 changes: 142 additions & 0 deletions src/main/java/com/back/global/security/JwtTokenProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package com.back.global.security;

import com.back.global.exception.CustomException;
import com.back.global.exception.ErrorCode;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.List;

/**
* JWT 생성 및 검증을 담당하는 Provider 클래스
* - Access Token, Refresh Token 생성
* - 토큰 검증 및 파싱
* - Authentication 객체 생성 (Spring Security 연동)
*/
@Component
public class JwtTokenProvider {

@Value("${jwt.secret}")
private String secretKey;

@Value("${jwt.access-token-expiration}")
private long accessTokenExpirationInSeconds;

@Value("${jwt.refresh-token-expiration}")
private long refreshTokenExpirationInSeconds;

private SecretKey key;

@PostConstruct
public void init() {
this.key = Keys.hmacShaKeyFor(secretKey.getBytes());
}

/**
* Access Token 생성
*
* @param userId 사용자 PK
* @param username 로그인 ID
* @param role 권한
* @return JWT Access Token 문자열
*/
public String createAccessToken(Long userId, String username, String role) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenExpirationInSeconds * 1000);

return Jwts.builder()
.subject(username)
.claim("userId", userId)
.claim("role", role)
.issuedAt(now)
.expiration(expiryDate)
.signWith(key)
.compact();
}

/**
* Refresh Token 생성
*
* @param userId 사용자 PK
* @return JWT Refresh Token 문자열
*/
public String createRefreshToken(Long userId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + refreshTokenExpirationInSeconds * 1000);

return Jwts.builder()
.subject(String.valueOf(userId))
.issuedAt(now)
.expiration(expiryDate)
.signWith(key)
.compact();
}

/**
* JWT 토큰에서 인증 정보 추출
*
* @param token JWT Access Token
* @return 인증 정보가 담긴 Authentication 객체
*/
public Authentication getAuthentication(String token) {
Claims claims = parseClaims(token);
Long userId = claims.get("userId", Long.class);
String username = claims.getSubject();
String role = claims.get("role", String.class);

SimpleGrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + role);
CustomUserDetails principal = new CustomUserDetails(userId, username, role);

return new UsernamePasswordAuthenticationToken(principal, token, List.of(authority));
}

/**
* JWT 토큰 검증
*
* @param token JWT Access Token
* @return 유효한 토큰이면 true, 그렇지 않으면 false
*/
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token);
return true;
} catch (JwtException e) {
return false;
}
}

/**
* JWT 파싱
*
* @param token JWT 토큰
* @return 토큰의 Claims
* @throws CustomException 토큰이 유효하지 않은 경우
*/
private Claims parseClaims(String token) {
try {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
return e.getClaims();
} catch (JwtException e) {
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
}
}
Loading
Loading