Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: JWT 기반 인증 및 Spring Security 설정 구현 #23 #26

Merged
merged 23 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9b65ad6
rename: BaseEntity entity 패키지로 이동 (#5)
medoeun Aug 23, 2024
eb5b981
feat: jwt 관련 의존성 설정 (#23)
medoeun Aug 23, 2024
eb23ac5
feat: `JwtUtil` 기본틀 작성 (#23)
medoeun Aug 23, 2024
be5ce80
Merge remote-tracking branch 'origin/main' into feature/jwt-auth
medoeun Aug 23, 2024
938d8e8
feat: jwt 필터 구현 (#23)
medoeun Aug 23, 2024
d0d5795
feat: jwt 필터 구현 (#23)
medoeun Aug 23, 2024
bc5db59
rename: `jwtUtil` auth.jwt로 이동 (#23)
medoeun Aug 23, 2024
20ca514
feat: application.yml에 jwt 설정 추가 (#23)
medoeun Aug 23, 2024
ea88983
feat: CustomUserDetails 추가 (#23)
medoeun Aug 23, 2024
35d94a7
rename: UserDetails 파일 auth/security로 이동 (#23)
medoeun Aug 23, 2024
7b19943
Merge remote-tracking branch 'origin/main' into feature/jwt-auth
medoeun Aug 23, 2024
4a12c87
feat: 시큐리티 관련 에러 설정 추가 (#23)
medoeun Aug 23, 2024
1d4990a
feat: CustomUserDetailsService 구현, SecurityConfig 수정 (#23)
medoeun Aug 23, 2024
666e435
Merge remote-tracking branch 'origin/main' into feature/jwt-auth
medoeun Aug 24, 2024
74fa689
style: 코딩 컨벤션 적용
medoeun Aug 24, 2024
b69b667
refactor: 어노테이션 적용 후 로그 추가 및 불필요한 코드 삭제 (#23)
medoeun Aug 25, 2024
df88534
Merge remote-tracking branch 'origin/main' into feature/jwt-auth
medoeun Aug 25, 2024
ee79c9c
fix: swagger ui 필터 제외 및 필터 메소드 수정 (#23)
medoeun Aug 25, 2024
8d5f488
fix: StatusCode 관련 import 일괄 수정, `UsernameNotFoundException` 401 UNAU…
medoeun Aug 26, 2024
b848cb5
refactor: boolean타입 validateToken() -> isValidToken() 변경 (#23)
medoeun Aug 26, 2024
85b7ff6
refactor: claim key 상수화, test yml에 jwt 설정 추가 (#23)
medoeun Aug 26, 2024
c91c9ab
rename: JwtUtil 클래스 -> JwtManager로 변경 (#23)
medoeun Aug 26, 2024
a0de780
chore: PostController import 수정 (#23)
medoeun Aug 26, 2024
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
8 changes: 8 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ dependencies {
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

//jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

//security
implementation 'org.springframework.boot:spring-boot-starter-security'

// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import team05.integrated_feed_backend.exception.code.StatusCode;
import team05.integrated_feed_backend.common.code.StatusCode;

/**
* code, status, message 기본 응답 형식
* code, status, message 기본 응답 형식
**/
@Getter
@Schema(description = "기본 응답 형식")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package team05.integrated_feed_backend.exception.code;
package team05.integrated_feed_backend.common.code;

import org.springframework.http.HttpStatus;

Expand All @@ -18,6 +18,8 @@ public enum StatusCode {
* 400 번대 CODE
**/
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "요청 경로가 지원되지 않습니다."),
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "요청된 사용자를 찾을 수 없습니다."),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증 오류가 발생했습니다."),

/**
* 500 번대 CODE
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package team05.integrated_feed_backend.common;
package team05.integrated_feed_backend.common.entity;

import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.persistence.Column;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package team05.integrated_feed_backend.core.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import lombok.RequiredArgsConstructor;
import team05.integrated_feed_backend.module.auth.jwt.JwtAuthenticationFilter;
import team05.integrated_feed_backend.module.auth.jwt.JwtManager;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtManager jwtManager;
private final UserDetailsService userDetailsService;

@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtManager, userDetailsService);
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

http
.csrf(csrf -> csrf.disable()) // CSRF 보호를 비활성화 (JWT를 사용하기 때문에)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/swagger-ui/**",
"/v3/api-docs/**",
"/swagger-ui.html",
"/api-docs/**",
"/webjars/**"
).permitAll() // Swagger UI 관련 경로에 인증 없이 접근 허용
.requestMatchers("/auth/**").permitAll() // 인증 없이 접근할 수 있는 경로 설정 (회원가입, 로그인 등)
.requestMatchers(HttpMethod.POST, "/api/members").permitAll()
.anyRequest().authenticated() // 그 외의 모든 요청 인증 필요
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용 X
.addFilterBefore(jwtAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class); // JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가

return http.build();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헉 요부분은 BCryptPasswordEncoder 말고 Argon2PasswordEncoder를 쓸 것 같아요!
얘가 조금 더 보안이 좋다고 하더라구요! 근데 이건 제가 작업하면서 바꿔놓겠습니당😉

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그렇군요!! 저도 더 알아보겠습니당

}

@Bean
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
AuthenticationManagerBuilder authManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);
authManagerBuilder
.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
return authManagerBuilder.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
Expand All @@ -18,7 +19,7 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import team05.integrated_feed_backend.common.BaseApiResponse;
import team05.integrated_feed_backend.exception.code.StatusCode;
import team05.integrated_feed_backend.common.code.StatusCode;
import team05.integrated_feed_backend.exception.custom.BadRequestException;
import team05.integrated_feed_backend.exception.custom.BusinessException;
import team05.integrated_feed_backend.exception.custom.DataNotFoundException;
Expand Down Expand Up @@ -147,6 +148,18 @@ public BaseApiResponse<Void> handleUnknownException(Exception e) {
return BaseApiResponse.of(statusCode);
}

/**
* 인증 과정에서 사용자를 찾을 수 없는 경우 발생
* ex) JWT 토큰에 포함된 사용자 이름으로 사용자를 로드할 때 해당 사용자가 존재하지 않는 경우
**/
@ResponseStatus(UNAUTHORIZED)
@ExceptionHandler({UsernameNotFoundException.class})
public BaseApiResponse<Void> handleUsernameNotFoundException(UsernameNotFoundException e) {
log.warn(e.getMessage(), e);

return BaseApiResponse.of(StatusCode.UNAUTHORIZED);
}

/**
* validation 검사 실패한 항목 에러 메세지 만드는 메서드
*/
Expand Down Expand Up @@ -192,4 +205,5 @@ private static StatusCode getStatusCodeFromException(BusinessException e) {

return e.getStatusCode();
}

}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package team05.integrated_feed_backend.exception.custom;

import lombok.Getter;
import team05.integrated_feed_backend.exception.code.StatusCode;
import team05.integrated_feed_backend.common.code.StatusCode;

/**
* 요청이 잘못된 경우
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import lombok.AllArgsConstructor;
import lombok.Getter;
import team05.integrated_feed_backend.exception.code.StatusCode;
import team05.integrated_feed_backend.common.code.StatusCode;

/**
* 비즈니스 로직 중에서 나는 에러 정의
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package team05.integrated_feed_backend.exception.custom;

import lombok.Getter;
import team05.integrated_feed_backend.exception.code.StatusCode;
import team05.integrated_feed_backend.common.code.StatusCode;

/**
* 요청 결과가 없는 경우
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package team05.integrated_feed_backend.exception.custom;

import lombok.Getter;
import team05.integrated_feed_backend.exception.code.StatusCode;
import team05.integrated_feed_backend.common.code.StatusCode;

/**
* 생성하고자 요청하는 데이터가 이미 있는 경우
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package team05.integrated_feed_backend.exception.custom;

import lombok.Getter;
import team05.integrated_feed_backend.exception.code.StatusCode;
import team05.integrated_feed_backend.common.code.StatusCode;

/**
* 권한이 없는 곳에 접근하고자 하는 경우
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import jakarta.persistence.*;
import lombok.*;
import team05.integrated_feed_backend.common.BaseEntity;
import team05.integrated_feed_backend.module.user.entity.Member;
import team05.integrated_feed_backend.common.entity.BaseEntity;
import team05.integrated_feed_backend.module.member.entity.Member;

import java.time.LocalDateTime;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package team05.integrated_feed_backend.module.auth.jwt;

import java.io.IOException;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtManager jwtManager;
private final UserDetailsService userDetailsService;

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

// Swagger ui 관련 요청은 필터링하지 않음
String path = request.getRequestURI();
if (path.startsWith("/swagger-ui") || path.startsWith("/webjars/") || path.startsWith("/v3/api-docs")) {
filterChain.doFilter(request, response);
return;
}
// request에서 토큰 파싱, 토큰 문자열 반환해 이후 인증 확인
String token = jwtManager.resolveToken(request);

if (token != null && jwtManager.isValidToken(token)) {
String account = jwtManager.getAccount(token);

// UserDetailsService를 통해 UserDetails를 로드
var userDetails = userDetailsService.loadUserByUsername(account);
medoeun marked this conversation as resolved.
Show resolved Hide resolved

// 토큰이 유효한 경우 Authentication 생성
UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken)jwtManager.getAuthentication(
token, userDetails);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

// SecurityContext에 객체 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 다음 필터로 요청 전달
filterChain.doFilter(request, response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package team05.integrated_feed_backend.module.auth.jwt;

import java.security.Key;
import java.util.Base64;
import java.util.Date;

import javax.crypto.spec.SecretKeySpec;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import team05.integrated_feed_backend.module.auth.security.CustomUserDetails;

@Slf4j
@Component
public class JwtManager {
private static final String CLAIM_MEMBER_ID = "memberId";
private static final String CLAIM_ACCOUNT = "account";

private final Key secretKey;

@Value("${jwt.token-validity-in-seconds}")
private long tokenValidityInseconds;

//jjwt: String secretKey -> Key 객체 방식으로 대체됨
public JwtManager(@Value("${jwt.secret}") String secret) {
byte[] keyBytes = Base64.getDecoder().decode(secret);
this.secretKey = new SecretKeySpec(keyBytes, SignatureAlgorithm.HS256.getJcaName());
}

// JWT 토큰 생성
public String generateToken(Authentication authentication) {
CustomUserDetails userDetails = (CustomUserDetails)authentication.getPrincipal();
Long memberId = userDetails.getMemberId();
String account = userDetails.getUsername();

// JWT 토큰에 포함되는 정보(페이로드): jwt의 주체 memberId로
Claims claims = Jwts.claims().setSubject(String.valueOf(memberId));
claims.put(CLAIM_MEMBER_ID, memberId);
claims.put(CLAIM_ACCOUNT, account);

Date now = new Date();
Date validity = new Date(now.getTime() + tokenValidityInseconds * 1000);

String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now) // 발급 시간
.setExpiration(validity) // 만료 시간
.signWith(secretKey, SignatureAlgorithm.HS256) // 서명
.compact();

log.info("JWT 토큰 생성: 사용자 계정 = {}, 만료시간 = {}", account, validity);
return token;
}

// HTTP 요청 헤더에서 JWT 토큰을 추출
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // "Bearer " 이후의 토큰 실제부분만 추출
}
return null;
}

public Authentication getAuthentication(String token, UserDetails userDetails) {
return new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
}

// jjwt : parser() 메서드가 parserBuilder()로 대체됨
// JWT 토큰에서 memberId 추출 (Body: 페이로드)
public Long getMemberId(String token) {
return Long.parseLong(Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject());
}

// JWT 토큰에서 account 추출
public String getAccount(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody()
.get(CLAIM_ACCOUNT, String.class);
}

// JWT 토큰 유효성 검증
public boolean isValidToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token);
log.debug("JWT 토큰 유효성 검사 통과: {}", token);
return true;
} catch (Exception e) {
log.error("JWT 토큰 유효성 검사 실패: {}", token, e);
return false;
}
}
}
Loading
Loading