Skip to content

Commit 95b305e

Browse files
authored
Feat: JWT 인증/인가 인프라 초기 세팅 (#32) (#43)
* Feat: 기본 환경 변수 및 설정 추가 * Feat: 인증 관련 에러 코드 추가 * Feat: JWT 기반 인증 기능 추가 * Feate: 인증/인가 실패 공통 핸들러 구현 * Feat: Spring Security 설정 * Test: JWT 인증 인프라 통합 테스트 * Test: 테스트 환경변수 설정 및 프로파일
1 parent a6a3cdc commit 95b305e

File tree

13 files changed

+484
-7
lines changed

13 files changed

+484
-7
lines changed

.env.default

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
JWT_SECRET=your-secret-key

build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ dependencies {
3939
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
4040
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
4141
implementation ("io.github.cdimascio:dotenv-java:3.0.0")
42+
43+
// JWT
44+
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
45+
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
46+
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
4247
}
4348

4449
tasks.withType<Test> {

src/main/java/com/back/global/exception/ErrorCode.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@ public enum ErrorCode {
1010
// ======================== 공통 에러 ========================
1111
BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."),
1212
FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_403", "접근 권한이 없습니다."),
13-
NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_404", "요청하신 리소스를 찾을 수 없습니다.");
13+
NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_404", "요청하신 리소스를 찾을 수 없습니다."),
14+
15+
// ======================== 인증/인가 에러 ========================
16+
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "AUTH_401", "인증이 필요합니다."),
17+
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401", "유효하지 않은 토큰입니다."),
18+
EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401", "만료된 액세스 토큰입니다."),
19+
EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401", "만료된 리프레시 토큰입니다."),
20+
REFRESH_TOKEN_REUSE(HttpStatus.FORBIDDEN, "AUTH_403", "재사용된 리프레시 토큰입니다."),
21+
ACCESS_DENIED(HttpStatus.FORBIDDEN, "AUTH_403", "권한이 없습니다.");
22+
1423

1524
private final HttpStatus status;
1625
private final String code;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.back.global.security;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
6+
import org.springframework.security.core.userdetails.UserDetails;
7+
8+
import java.util.Collection;
9+
import java.util.List;
10+
11+
/**
12+
* Spring Security에서 사용하는 사용자 인증 정보 클래스
13+
* - JWT에서 파싱한 사용자 정보를 담고 있음
14+
*/
15+
@Getter
16+
@AllArgsConstructor
17+
public class CustomUserDetails implements UserDetails {
18+
private Long userId;
19+
private String username;
20+
private String role;
21+
22+
@Override
23+
public Collection<SimpleGrantedAuthority> getAuthorities() {
24+
// Spring Security 권한 체크는 "ROLE_" prefix 필요
25+
return List.of(new SimpleGrantedAuthority("ROLE_" + role));
26+
}
27+
28+
@Override
29+
public String getPassword() {
30+
// JWT 인증에서는 비밀번호를 사용하지 않음
31+
return null;
32+
}
33+
34+
@Override
35+
public String getUsername() {
36+
return username;
37+
}
38+
39+
@Override
40+
public boolean isAccountNonExpired() {
41+
return true;
42+
}
43+
44+
@Override
45+
public boolean isAccountNonLocked() {
46+
return true;
47+
}
48+
49+
@Override
50+
public boolean isCredentialsNonExpired() {
51+
return true;
52+
}
53+
54+
@Override
55+
public boolean isEnabled() {
56+
return true;
57+
}
58+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.back.global.security;
2+
3+
import com.back.global.common.dto.RsData;
4+
import com.back.global.exception.ErrorCode;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import jakarta.servlet.http.HttpServletRequest;
7+
import jakarta.servlet.http.HttpServletResponse;
8+
import org.springframework.security.access.AccessDeniedException;
9+
import org.springframework.security.web.access.AccessDeniedHandler;
10+
import org.springframework.stereotype.Component;
11+
12+
import java.io.IOException;
13+
14+
/**
15+
* 인가 실패(403 Forbidden) 처리 클래스
16+
* - 인증은 되었으나, 권한(Role)이 부족한 경우
17+
* - Json 형태로 에러 응답을 반환
18+
*/
19+
@Component
20+
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
21+
private final ObjectMapper objectMapper = new ObjectMapper();
22+
23+
@Override
24+
public void handle(
25+
HttpServletRequest request,
26+
HttpServletResponse response,
27+
AccessDeniedException accessDeniedException
28+
) throws IOException {
29+
30+
response.setContentType("application/json;charset=UTF-8");
31+
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
32+
33+
RsData<Void> body = RsData.fail(ErrorCode.ACCESS_DENIED);
34+
35+
response.getWriter().write(objectMapper.writeValueAsString(body));
36+
}
37+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.back.global.security;
2+
3+
import com.back.global.common.dto.RsData;
4+
import com.back.global.exception.ErrorCode;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import jakarta.servlet.http.HttpServletRequest;
7+
import jakarta.servlet.http.HttpServletResponse;
8+
import org.springframework.security.core.AuthenticationException;
9+
import org.springframework.security.web.AuthenticationEntryPoint;
10+
import org.springframework.stereotype.Component;
11+
12+
import java.io.IOException;
13+
14+
/**
15+
* 인증 실패(401 Unauthorized) 처리 클래스
16+
* - JwtAuthenticationFilter에서 토큰이 없거나 잘못된 경우
17+
* - 인증되지 않은 사용자가 보호된 API에 접근하려는 경우
18+
* - Json 형태로 에러 응답을 반환
19+
*/
20+
@Component
21+
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
22+
private final ObjectMapper objectMapper = new ObjectMapper();
23+
24+
@Override
25+
public void commence(
26+
HttpServletRequest request,
27+
HttpServletResponse response,
28+
AuthenticationException authException
29+
) throws IOException {
30+
31+
response.setContentType("application/json;charset=UTF-8");
32+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
33+
34+
RsData<Void> body = RsData.fail(ErrorCode.UNAUTHORIZED);
35+
36+
response.getWriter().write(objectMapper.writeValueAsString(body));
37+
}
38+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.back.global.security;
2+
3+
import jakarta.servlet.FilterChain;
4+
import jakarta.servlet.ServletException;
5+
import jakarta.servlet.http.HttpServletRequest;
6+
import jakarta.servlet.http.HttpServletResponse;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.security.core.Authentication;
9+
import org.springframework.security.core.context.SecurityContextHolder;
10+
import org.springframework.stereotype.Component;
11+
import org.springframework.web.filter.OncePerRequestFilter;
12+
13+
import java.io.IOException;
14+
15+
/**
16+
* JWT 인증을 처리하는 필터
17+
* - 모든 요청에 대해 JWT 토큰을 검사
18+
* - 토큰이 유효하면 Authentication 객체를 생성하여 SecurityContext에 저장
19+
*/
20+
@Component
21+
@RequiredArgsConstructor
22+
public class JwtAuthenticationFilter extends OncePerRequestFilter {
23+
private final JwtTokenProvider jwtTokenProvider;
24+
25+
@Override
26+
protected void doFilterInternal(
27+
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain
28+
) throws ServletException, IOException {
29+
30+
// Request Header에서 토큰 추출
31+
String token = resolveToken(request);
32+
33+
// 토큰이 유효한 경우에만 Authentication 객체 생성 및 SecurityContext에 저장
34+
if (token != null && jwtTokenProvider.validateToken(token)) {
35+
Authentication authentication = jwtTokenProvider.getAuthentication(token);
36+
SecurityContextHolder.getContext().setAuthentication(authentication);
37+
}
38+
39+
// 다음 필터로 요청 전달
40+
filterChain.doFilter(request, response);
41+
}
42+
43+
/**
44+
* Request의 Authorization 헤더에서 JWT 토큰을 추출
45+
*
46+
* @param request HTTP 요청 객체
47+
* @return JWT 토큰 문자열 또는 null
48+
*/
49+
private String resolveToken(HttpServletRequest request) {
50+
String bearerToken = request.getHeader("Authorization");
51+
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
52+
return bearerToken.substring(7);
53+
}
54+
return null;
55+
}
56+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package com.back.global.security;
2+
3+
import com.back.global.exception.CustomException;
4+
import com.back.global.exception.ErrorCode;
5+
import io.jsonwebtoken.Claims;
6+
import io.jsonwebtoken.ExpiredJwtException;
7+
import io.jsonwebtoken.JwtException;
8+
import io.jsonwebtoken.Jwts;
9+
import io.jsonwebtoken.security.Keys;
10+
import jakarta.annotation.PostConstruct;
11+
import org.springframework.beans.factory.annotation.Value;
12+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
13+
import org.springframework.security.core.Authentication;
14+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
15+
import org.springframework.stereotype.Component;
16+
17+
import javax.crypto.SecretKey;
18+
import java.util.Date;
19+
import java.util.List;
20+
21+
/**
22+
* JWT 생성 및 검증을 담당하는 Provider 클래스
23+
* - Access Token, Refresh Token 생성
24+
* - 토큰 검증 및 파싱
25+
* - Authentication 객체 생성 (Spring Security 연동)
26+
*/
27+
@Component
28+
public class JwtTokenProvider {
29+
30+
@Value("${jwt.secret}")
31+
private String secretKey;
32+
33+
@Value("${jwt.access-token-expiration}")
34+
private long accessTokenExpirationInSeconds;
35+
36+
@Value("${jwt.refresh-token-expiration}")
37+
private long refreshTokenExpirationInSeconds;
38+
39+
private SecretKey key;
40+
41+
@PostConstruct
42+
public void init() {
43+
this.key = Keys.hmacShaKeyFor(secretKey.getBytes());
44+
}
45+
46+
/**
47+
* Access Token 생성
48+
*
49+
* @param userId 사용자 PK
50+
* @param username 로그인 ID
51+
* @param role 권한
52+
* @return JWT Access Token 문자열
53+
*/
54+
public String createAccessToken(Long userId, String username, String role) {
55+
Date now = new Date();
56+
Date expiryDate = new Date(now.getTime() + accessTokenExpirationInSeconds * 1000);
57+
58+
return Jwts.builder()
59+
.subject(username)
60+
.claim("userId", userId)
61+
.claim("role", role)
62+
.issuedAt(now)
63+
.expiration(expiryDate)
64+
.signWith(key)
65+
.compact();
66+
}
67+
68+
/**
69+
* Refresh Token 생성
70+
*
71+
* @param userId 사용자 PK
72+
* @return JWT Refresh Token 문자열
73+
*/
74+
public String createRefreshToken(Long userId) {
75+
Date now = new Date();
76+
Date expiryDate = new Date(now.getTime() + refreshTokenExpirationInSeconds * 1000);
77+
78+
return Jwts.builder()
79+
.subject(String.valueOf(userId))
80+
.issuedAt(now)
81+
.expiration(expiryDate)
82+
.signWith(key)
83+
.compact();
84+
}
85+
86+
/**
87+
* JWT 토큰에서 인증 정보 추출
88+
*
89+
* @param token JWT Access Token
90+
* @return 인증 정보가 담긴 Authentication 객체
91+
*/
92+
public Authentication getAuthentication(String token) {
93+
Claims claims = parseClaims(token);
94+
Long userId = claims.get("userId", Long.class);
95+
String username = claims.getSubject();
96+
String role = claims.get("role", String.class);
97+
98+
SimpleGrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + role);
99+
CustomUserDetails principal = new CustomUserDetails(userId, username, role);
100+
101+
return new UsernamePasswordAuthenticationToken(principal, token, List.of(authority));
102+
}
103+
104+
/**
105+
* JWT 토큰 검증
106+
*
107+
* @param token JWT Access Token
108+
* @return 유효한 토큰이면 true, 그렇지 않으면 false
109+
*/
110+
public boolean validateToken(String token) {
111+
try {
112+
Jwts.parser()
113+
.verifyWith(key)
114+
.build()
115+
.parseSignedClaims(token);
116+
return true;
117+
} catch (JwtException e) {
118+
return false;
119+
}
120+
}
121+
122+
/**
123+
* JWT 파싱
124+
*
125+
* @param token JWT 토큰
126+
* @return 토큰의 Claims
127+
* @throws CustomException 토큰이 유효하지 않은 경우
128+
*/
129+
private Claims parseClaims(String token) {
130+
try {
131+
return Jwts.parser()
132+
.verifyWith(key)
133+
.build()
134+
.parseSignedClaims(token)
135+
.getPayload();
136+
} catch (ExpiredJwtException e) {
137+
return e.getClaims();
138+
} catch (JwtException e) {
139+
throw new CustomException(ErrorCode.INVALID_TOKEN);
140+
}
141+
}
142+
}

0 commit comments

Comments
 (0)