Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a11171b
feat(#20): 요청 헤더에서 토큰 추출하는 클래스 작성
02-wooki Sep 10, 2025
1e90f64
feat(#20): 토큰 추출, 검증 메서드 작성
02-wooki Sep 10, 2025
90df8e9
feat(#20): JWT 인증 필터 작성
02-wooki Sep 10, 2025
9f289b1
feat(#20): 인터셉터 Spring Security에 등록
02-wooki Sep 10, 2025
8c0cb50
feat(#20): 유효하지 않은 토큰 예외 작성
02-wooki Sep 10, 2025
6696c89
feat(#20): 멤버 객체에 UserDetailService 적용
02-wooki Sep 10, 2025
fa6bca7
feat(#20): 예외 코드 수정
02-wooki Sep 10, 2025
da242c7
feat(#20): 예외 코드 수정
02-wooki Sep 10, 2025
28c8e1a
feat(#20): JWT 인증 체인 엔트리 커스텀
02-wooki Sep 10, 2025
9b0aebc
feat(#20): 토큰 체인 등록
02-wooki Sep 10, 2025
6cbd53a
feat(#20): UserDetailsService 상속
02-wooki Sep 10, 2025
6169019
refactor(#20): import 정리
02-wooki Sep 16, 2025
407f85b
fix(#20): PasswordEncoder 순환 참조 해결
02-wooki Sep 16, 2025
bd7cf20
feat(#20): jwt 인증 필터 체인 커스텀 예외 구현
02-wooki Sep 16, 2025
613cd5a
refactor(#20): 로그인 예외 코드 수정
02-wooki Sep 16, 2025
36695dd
style(#20): import 정리
02-wooki Sep 17, 2025
0b6d6c8
refactor(#20): import 정리
02-wooki Sep 17, 2025
392d590
refactor(#20): import 정리
02-wooki Sep 17, 2025
c54dd82
feat(#20): 만료된 토큰과 나머지 상황에 대한 예외 분기 처리
02-wooki Sep 17, 2025
64534bd
refactor(#20): 토큰 관련 예외코드 Auth 도메인으로 통일
02-wooki Sep 17, 2025
b6db653
feat(#20): 토큰 인증 여부에 따른 인증 어노테이션 구현
02-wooki Sep 17, 2025
33c307a
fix(#20): 명세서에 맞게 통일
02-wooki Sep 17, 2025
5361cdf
refactor(#20): 빈 줄 삭제
02-wooki Sep 17, 2025
5cee264
fix(#20): Spring Security에서 이메일을 식별자로 받아갈 수 있도록 조정
02-wooki Sep 17, 2025
66cb90b
test(#20): getUsername 메서드 변경으로 인한 조정
02-wooki Sep 17, 2025
9b869a7
test(#20): 토큰필터체인 관련 테스트를 위한 토큰 생성자 세팅
02-wooki Sep 17, 2025
753d65b
test(#20): 오타 수정
02-wooki Sep 17, 2025
613d1dc
refactor(#20): 사용되지 않는 예외 메시지 깔끔하게 정리
02-wooki Sep 17, 2025
bbb6fb9
test(#20): 토큰 필터 체인 테스트 커버리지
02-wooki Sep 17, 2025
6668021
feat(#20): 토큰 필터체인에 필요한 Mock 주입
02-wooki Sep 17, 2025
f474ea8
test(#20): loadUserByUserName 메서드 커버리지
02-wooki Sep 17, 2025
ddaa534
fix(#20): 인증 정보가 없는 경우 예외처리
02-wooki Sep 17, 2025
b26aa2c
test(#20): 인증 관련 어노테이션 리졸버 단위 테스트
02-wooki Sep 17, 2025
6114a87
test(#20): 테스트 통과 기원
02-wooki Sep 17, 2025
735f3cb
test(#20): 테스트 성공 기원
02-wooki Sep 17, 2025
e8d5d4f
test(#20): 테스트 통과 기원
02-wooki Sep 17, 2025
b5d1b0b
test(#20): 길고 긴 싸움 끝에 테스트 통과 기원
02-wooki Sep 17, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ public enum AuthExceptionCode implements ExceptionCode {

BAD_REQUEST_NO_EMAIL(9010, "이메일이 입력되지 않았습니다."),
BAD_REQUEST_NO_PASSWORD(9010, "비밀번호가 입력되지 않았습니다."),
UNAUTHORIZED_INVALID_TOKEN(8001, "유효하지 않은 토큰입니다."),
UNAUTHORIZED_FAILED_VALIDATION(8002, "인증에 실패했습니다."),
UNAUTHORIZED_PASSWORD(8011, "비밀번호가 일치하지 않습니다."),
NOT_FOUND_USER(6001, "일치하는 사용자가 존재하지 않습니다."),
;

private final int code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public class AuthService {

public AuthLoginResponse loginMember(AuthLoginRequest request) {
Member member = memberRepository.findByEmail(request.email())
.orElseThrow(() -> new NotFoundException(AuthExceptionCode.NOT_FOUND_USER));
.orElseThrow(() -> new NotFoundException(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION));

if(!passwordEncoder.matches(request.password(), member.getPassword()))
throw new UnauthorizedException(AuthExceptionCode.UNAUTHORIZED_PASSWORD);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import jakarta.validation.Valid;
import kr.ac.ks.cs_web_back.domain.member.controller.code.MemberSuccessCode;
import kr.ac.ks.cs_web_back.domain.member.dto.request.MemberCreateRequest;
import kr.ac.ks.cs_web_back.domain.auth.dto.request.AuthLoginRequest;
import kr.ac.ks.cs_web_back.domain.member.service.MemberService;
import kr.ac.ks.cs_web_back.global.response.CsResponse;
import lombok.RequiredArgsConstructor;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ public record MemberCreateRequest(

@Schema(description = "비밀번호", example = "examplePassword123")
@NotBlank(message = "비밀번호가 입력되지 않았습니다.")
@Size(max = 24, message = "비밀번호는 24자 내로 입력해 주세요.")
@Size(max = 48, message = "비밀번호는 48자 이상일 수 없습니다.")
String password,

@Schema(description = "사용자명", example = "userNickName")
@NotBlank(message = "사용자명이 입력되지 않았습니다.")
@Size(max = 12, message = "유저명은 12자 내로 입력해 주세요.")
@Size(max = 12, message = "사용자명은 12자 이상일 수 없습니다.")
String username
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.time.LocalDate;
import java.util.Collection;
import java.util.Collections;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseEntity {
public class Member extends BaseEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
Expand Down Expand Up @@ -40,4 +44,18 @@ private Member(String email, String password, String username, LocalDate birthd
this.birthdate = birthdate;
this.number = number;
}

public String getNickname() {
return username;
}

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

@Override
public String getUsername() {
return email;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,21 @@

import kr.ac.ks.cs_web_back.domain.member.controller.code.MemberExceptionCode;
import kr.ac.ks.cs_web_back.domain.member.dto.request.MemberCreateRequest;
import kr.ac.ks.cs_web_back.domain.auth.dto.request.AuthLoginRequest;
import kr.ac.ks.cs_web_back.domain.member.model.Member;
import kr.ac.ks.cs_web_back.domain.member.repository.MemberRepository;
import kr.ac.ks.cs_web_back.global.exeption.domain.ConflictException;
import kr.ac.ks.cs_web_back.global.exeption.domain.NotFoundException;
import kr.ac.ks.cs_web_back.global.exeption.domain.UnauthorizedException;
import kr.ac.ks.cs_web_back.global.jwt.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {
public class MemberService implements UserDetailsService {

private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
Expand All @@ -39,4 +38,11 @@ public Long createMember(MemberCreateRequest request) {

return memberRepository.save(member).getId();
}

@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return memberRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.ac.ks.cs_web_back.global.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface IdentifiedUser {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.ac.ks.cs_web_back.global.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface ResolvedUser {
}
14 changes: 14 additions & 0 deletions src/main/java/kr/ac/ks/cs_web_back/global/config/AppConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package kr.ac.ks.cs_web_back.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class AppConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Original file line number Diff line number Diff line change
@@ -1,37 +1,39 @@
package kr.ac.ks.cs_web_back.global.config;

import kr.ac.ks.cs_web_back.global.jwt.CustomAuthenticationEntryPoint;
import kr.ac.ks.cs_web_back.global.jwt.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
// H2 콘솔을 위한 설정
.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
.requestMatchers(PathRequest.toH2Console()).permitAll() // H2 콘솔 경로 허용
.requestMatchers("/member/register", "/auth/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated()
);
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(customAuthenticationEntryPoint));

return http.build();
}
Expand Down
24 changes: 24 additions & 0 deletions src/main/java/kr/ac/ks/cs_web_back/global/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kr.ac.ks.cs_web_back.global.config;

import kr.ac.ks.cs_web_back.global.resolver.IdentifiedUserArgumentResolver;
import kr.ac.ks.cs_web_back.global.resolver.ResolvedUserArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

private final IdentifiedUserArgumentResolver identifiedUserArgumentResolver;
private final ResolvedUserArgumentResolver resolvedUserArgumentResolver;

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(identifiedUserArgumentResolver);
resolvers.add(resolvedUserArgumentResolver);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,6 @@
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import kr.ac.ks.cs_web_back.global.exeption.dto.ValidationExceptionCode;
import org.springframework.validation.FieldError;

import java.util.List;

@RestControllerAdvice
public class GlobalExceptionHandler {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.ac.ks.cs_web_back.global.exeption.domain;

import kr.ac.ks.cs_web_back.global.exeption.dto.ExceptionCode;

public class InvalidTokenException extends UnauthorizedException {
public InvalidTokenException(final ExceptionCode exception) {
super(exception);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package kr.ac.ks.cs_web_back.global.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kr.ac.ks.cs_web_back.domain.auth.controller.code.AuthExceptionCode;
import kr.ac.ks.cs_web_back.global.exeption.dto.ExceptionResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

private final ObjectMapper objectMapper;

@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException {
final AuthExceptionCode responseCode = AuthExceptionCode.UNAUTHORIZED_INVALID_TOKEN;

response.setCharacterEncoding("UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json;charset=UTF-8");

ExceptionResponse exceptionResponse = new ExceptionResponse(responseCode.getCode(), responseCode.getMessage());
response.getWriter().write(objectMapper.writeValueAsString(exceptionResponse));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package kr.ac.ks.cs_web_back.global.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kr.ac.ks.cs_web_back.global.exeption.domain.InvalidTokenException;
import kr.ac.ks.cs_web_back.global.exeption.dto.ExceptionResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtUtil jwtUtil;
private final JwtTokenResolver jwtTokenResolver;
private final UserDetailsService userDetailsService;
private final ObjectMapper objectMapper;

@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
try {
String token = jwtTokenResolver.resolveToken(request);

if (token != null) {
jwtUtil.validateToken(token);

String email = jwtUtil.getEmailFromToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (InvalidTokenException e) {
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json;charset=UTF-8");

ExceptionResponse exceptionResponse = new ExceptionResponse(
e.getExceptionCode().getCode(),
e.getExceptionCode().getMessage()
);

response.getWriter().write(objectMapper.writeValueAsString(exceptionResponse));
return;
}

filterChain.doFilter(request, response);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package kr.ac.ks.cs_web_back.global.jwt;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

@Component
public class JwtTokenResolver {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String HEADER_PREFIX = "Bearer ";

public String resolveToken(HttpServletRequest request) {
String token = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(token) && token.startsWith(HEADER_PREFIX))
return token.substring(HEADER_PREFIX.length());

return null;
}

}
Loading
Loading