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 16 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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
implementation 'org.springframework.boot:spring-boot-starter-security'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
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,61 @@
package team05.integrated_feed_backend.core.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.JwtUtil;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;

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

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

http
.csrf(csrf -> csrf.disable()) // CSRF 보호를 비활성화 (JWT를 사용하기 때문에)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용X
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/auth/**").permitAll() // 인증 없이 접근할 수 있는 경로 설정하기 (회원가입, 로그인 등)
medoeun marked this conversation as resolved.
Show resolved Hide resolved
.anyRequest().authenticated() // 그 외의 모든 요청 인증 필요
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); // JWT 필터 추가

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 Down Expand Up @@ -147,6 +148,18 @@ public BaseApiResponse<Void> handleUnknownException(Exception e) {
return BaseApiResponse.of(statusCode);
}

/**
* 요청된 사용자를 찾을 수 없는 경우
* ex) 로그인 시 존재하지 않는 사용자를 입력한 경우
**/
@ResponseStatus(NOT_FOUND)
@ExceptionHandler({UsernameNotFoundException.class})
public BaseApiResponse<Void> handleUsernameNotFoundException(UsernameNotFoundException e) {
log.warn(e.getMessage(), e);

return BaseApiResponse.of(StatusCode.USER_NOT_FOUND);
Copy link
Collaborator

Choose a reason for hiding this comment

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

💭 요건 유저가 없는 거긴 한데, 토큰이 잘못된거니까 404에러 보다는 401 Unauthorized 에러는 어떠세용?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

그게 맞는 것 같아 반영하면서 import도 수정해두었습니당! 8d5f488

}

/**
* 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
Expand Up @@ -17,6 +17,7 @@ public enum StatusCode {
* 400 번대 CODE
**/
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "요청 경로가 지원되지 않습니다."),
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "요청된 사용자를 찾을 수 없습니다."),

/**
* 500 번대 CODE
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,49 @@
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 JwtUtil jwtUtil;
Copy link
Collaborator

Choose a reason for hiding this comment

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

💭 요것도 정말 사소한데.. 인스턴스화 해서 사용하면 Util이라는 네이밍보단 JwtManager, JwtHandler 같은 네이밍은 어떠세욤?? 그리고 JwtUtil 클래스를 보면 HttpServletRequest, UserDetails 등 웹 계층이랑 의존성이 있어서 순수 유틸클래스는 아닌 것 같아서요! 근데 개인적인 의견이고 안바꾸셔도 됩니다!!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

JwtManager 좋은 것 같아요! 반영했습니다 c91c9ab

private final UserDetailsService userDetailsService;

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

String token = jwtUtil.resolveToken(request);

if (token != null && jwtUtil.validateToken(token)) {
String account = jwtUtil.getAccount(token);

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

// 토큰이 유효한 경우 Authentication 생성
UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken)jwtUtil.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,111 @@
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 JwtUtil {

private final Key secretKey;

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

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

Choose a reason for hiding this comment

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

🙋🏻‍♀️ 필드는 필드끼리 모여 있으면 좋을 것 같습니당!

Suggested change
private final Key secretKey;
//jjwt: String secretKey -> Key 객체 방식으로 대체됨
public JwtUtil(@Value("${jwt.secret}") String secret) {
byte[] keyBytes = Base64.getDecoder().decode(secret);
this.secretKey = new SecretKeySpec(keyBytes, SignatureAlgorithm.HS256.getJcaName());
}
@Value("${jwt.token-validity-in-seconds}")
private long tokenValidityInseconds;
private final Key secretKey;
@Value("${jwt.token-validity-in-seconds}")
private long tokenValidityInseconds;
//jjwt: String secretKey -> Key 객체 방식으로 대체됨
public JwtUtil(@Value("${jwt.secret}") String secret) {
byte[] keyBytes = Base64.getDecoder().decode(secret);
this.secretKey = new SecretKeySpec(keyBytes, SignatureAlgorithm.HS256.getJcaName());
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

그리고 @Value로 yml에 있는 환경변수를 받아 오고 계신데,
@ConfigurationProperties() 애너테이션을 사용해서 해당 설정들을 가진 객체(ex. JWTProperties)로 만들어서 주입할수도 있어요! 요건 그냥 지식 공유였습니다😉


// 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("memberId", memberId);
claims.put("account", account);
Copy link
Collaborator

Choose a reason for hiding this comment

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

💭 요런 "memberId", "account" 같은 string은 private static final String 으로 상수화 해도 될 것 같아요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

반영했습니다! 85b7ff6


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) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

❗️호옥시 token을 인자로 받는 이유가 있을까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

어디까지 token이 활용되는지 잘 몰라서 일단 받아오게 써둔 것 같아요...! 지켜보고 안쓰이면 빼겠습니당

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()
Copy link
Collaborator

Choose a reason for hiding this comment

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

💭 요부분 밑에 getAccount()랑 반복되는 로직이 있어서, 메서드 분리해서 재사용하는 것도 좋을 것 같아요!

.getSubject());
}

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

// JWT 토큰 유효성 검증
public boolean validateToken(String token) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

💭 사소한 내용이긴한데, validateToken()은 반환값이 없을 것 같은 네이밍이라, isValidToken(String token) 같은 네이밍은 어떠세욤?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

반영했습니다! b848cb5

try {
Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token);
log.debug("JWT 토큰 유효성 검사 통과: {}", token);
return true;
} catch (Exception e) {
log.error("JWT 토큰 유효성 검사 실패: {}", token, e);
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package team05.integrated_feed_backend.module.auth.security;

import java.util.Collection;
import java.util.Collections;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import lombok.Getter;
import team05.integrated_feed_backend.module.member.entity.Member;

@Getter
public class CustomUserDetails implements UserDetails {

private final Member member;

public CustomUserDetails(Member member) {
this.member = member;
}

@Override
public String getUsername() {
return member.getAccount();
}
hye-on marked this conversation as resolved.
Show resolved Hide resolved

@Override
public String getPassword() {
return member.getPassword();
}

// 계정 만료 여부 확인 로직
@Override
public boolean isAccountNonExpired() {
return true;
}

// 계정 잠김 여부 확인 로직
@Override
public boolean isAccountNonLocked() {
return true;
}

// 자격 증명(비밀번호) 만료 여부 확인 로직
@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return "verified".equals(member.getStatus()); // 인증된 상태만 활성화
}
medoeun marked this conversation as resolved.
Show resolved Hide resolved

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.emptyList();
}

public Long getMemberId() {
return member.getMemberId();
}

public String getEmail() {
return member.getEmail();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package team05.integrated_feed_backend.module.auth.security;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import team05.integrated_feed_backend.exception.code.StatusCode;
import team05.integrated_feed_backend.module.member.entity.Member;
import team05.integrated_feed_backend.module.member.repository.MemberRepository;

@Slf4j
@Service
Copy link
Collaborator

Choose a reason for hiding this comment

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

🙋‍♀️ 저번에 저희 클래스 영역에서 @Transactional(readOnly = true) 해주기로 했던 것 같은데, 보니까 로그인은 post만 있는 것 같아서 딱히 필요없는 것 같기도 하고.. 다른 분들 의견 궁금합니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

누락인데 필요할 것 같습니다!! 수정할게요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

어노테이션들 추가했습니다 b69b667

@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;

@Override
public UserDetails loadUserByUsername(String account) throws UsernameNotFoundException {
Member member = memberRepository.findByAccount(account)
.orElseThrow(() -> new UsernameNotFoundException(StatusCode.USER_NOT_FOUND.name()));

log.info("유저 조회 성공: {}", member.getAccount());
return new CustomUserDetails(member);
}
}
Loading
Loading