diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/code/AuthExceptionCode.java b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/code/AuthExceptionCode.java index f538830..2536293 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/code/AuthExceptionCode.java +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/code/AuthExceptionCode.java @@ -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; diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthService.java b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthService.java index ee4dd47..64d8652 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthService.java +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthService.java @@ -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); diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/MemberController.java b/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/MemberController.java index 68e1023..ce1a57e 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/MemberController.java +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/member/controller/MemberController.java @@ -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; diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/member/dto/request/MemberCreateRequest.java b/src/main/java/kr/ac/ks/cs_web_back/domain/member/dto/request/MemberCreateRequest.java index f7e35fa..daaa0b0 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/domain/member/dto/request/MemberCreateRequest.java +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/member/dto/request/MemberCreateRequest.java @@ -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 ) { } diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/member/model/Member.java b/src/main/java/kr/ac/ks/cs_web_back/domain/member/model/Member.java index ce27c0a..ac4c508 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/domain/member/model/Member.java +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/member/model/Member.java @@ -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; @@ -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 getAuthorities() { + return Collections.emptyList(); + } + + @Override + public String getUsername() { + return email; + } } diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/member/service/MemberService.java b/src/main/java/kr/ac/ks/cs_web_back/domain/member/service/MemberService.java index 21bef06..bacb9a8 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/domain/member/service/MemberService.java +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/member/service/MemberService.java @@ -2,14 +2,13 @@ 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; @@ -17,7 +16,7 @@ @Service @RequiredArgsConstructor @Transactional -public class MemberService { +public class MemberService implements UserDetailsService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; @@ -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")); + } } diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/annotation/IdentifiedUser.java b/src/main/java/kr/ac/ks/cs_web_back/global/annotation/IdentifiedUser.java new file mode 100644 index 0000000..34bbec5 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/annotation/IdentifiedUser.java @@ -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 { +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/annotation/ResolvedUser.java b/src/main/java/kr/ac/ks/cs_web_back/global/annotation/ResolvedUser.java new file mode 100644 index 0000000..4608dc8 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/annotation/ResolvedUser.java @@ -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 { +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/config/AppConfig.java b/src/main/java/kr/ac/ks/cs_web_back/global/config/AppConfig.java new file mode 100644 index 0000000..ddddd78 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/config/AppConfig.java @@ -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(); + } +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/config/SecurityConfig.java b/src/main/java/kr/ac/ks/cs_web_back/global/config/SecurityConfig.java index f0e2c3a..2b93c99 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/global/config/SecurityConfig.java +++ b/src/main/java/kr/ac/ks/cs_web_back/global/config/SecurityConfig.java @@ -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(); } diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/config/WebConfig.java b/src/main/java/kr/ac/ks/cs_web_back/global/config/WebConfig.java new file mode 100644 index 0000000..655ad1e --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/config/WebConfig.java @@ -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 resolvers) { + resolvers.add(identifiedUserArgumentResolver); + resolvers.add(resolvedUserArgumentResolver); + } +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/exeption/GlobalExceptionHandler.java b/src/main/java/kr/ac/ks/cs_web_back/global/exeption/GlobalExceptionHandler.java index d86aa75..e415c4a 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/global/exeption/GlobalExceptionHandler.java +++ b/src/main/java/kr/ac/ks/cs_web_back/global/exeption/GlobalExceptionHandler.java @@ -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 { diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/exeption/domain/InvalidTokenException.java b/src/main/java/kr/ac/ks/cs_web_back/global/exeption/domain/InvalidTokenException.java new file mode 100644 index 0000000..9e4bde0 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/exeption/domain/InvalidTokenException.java @@ -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); + } +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/jwt/CustomAuthenticationEntryPoint.java b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..c14a581 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/CustomAuthenticationEntryPoint.java @@ -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)); + } +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtAuthenticationFilter.java b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..0e44c28 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtAuthenticationFilter.java @@ -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); + } + +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtTokenResolver.java b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtTokenResolver.java new file mode 100644 index 0000000..b715d3f --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtTokenResolver.java @@ -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; + } + +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtUtil.java b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtUtil.java index adb5c8d..1c1d0df 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtUtil.java +++ b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtUtil.java @@ -1,8 +1,9 @@ package kr.ac.ks.cs_web_back.global.jwt; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.*; import jakarta.annotation.PostConstruct; +import kr.ac.ks.cs_web_back.domain.auth.controller.code.AuthExceptionCode; +import kr.ac.ks.cs_web_back.global.exeption.domain.InvalidTokenException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -37,7 +38,7 @@ public String generateRefreshToken(String email) { return generateToken(email, refreshTokenExpireTime); } - public String generateToken(String email, Long expireTime) { + private String generateToken(String email, Long expireTime) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expireTime); return Jwts.builder().setSubject(email) @@ -46,4 +47,27 @@ public String generateToken(String email, Long expireTime) { .signWith(hmacKey) .compact(); } + + public String getEmailFromToken(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(hmacKey) + .build() + .parseClaimsJws(token) + .getBody(); + + return claims.getSubject(); + } + + public void validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(hmacKey) + .build() + .parseClaimsJws(token); + } catch (ExpiredJwtException e) { + throw new InvalidTokenException(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION); + } catch (JwtException | IllegalArgumentException e) { + throw new InvalidTokenException(AuthExceptionCode.UNAUTHORIZED_INVALID_TOKEN); + } + } } \ No newline at end of file diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/resolver/IdentifiedUserArgumentResolver.java b/src/main/java/kr/ac/ks/cs_web_back/global/resolver/IdentifiedUserArgumentResolver.java new file mode 100644 index 0000000..af42990 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/resolver/IdentifiedUserArgumentResolver.java @@ -0,0 +1,41 @@ +package kr.ac.ks.cs_web_back.global.resolver; + +import kr.ac.ks.cs_web_back.domain.auth.controller.code.AuthExceptionCode; +import kr.ac.ks.cs_web_back.domain.member.model.Member; +import kr.ac.ks.cs_web_back.global.annotation.IdentifiedUser; +import kr.ac.ks.cs_web_back.global.exeption.domain.InvalidTokenException; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class IdentifiedUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(IdentifiedUser.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null) { + throw new InvalidTokenException(AuthExceptionCode.UNAUTHORIZED_INVALID_TOKEN); + } + + final Object principal = authentication.getPrincipal(); + + if (!(principal instanceof Member)) { + throw new InvalidTokenException(AuthExceptionCode.UNAUTHORIZED_INVALID_TOKEN); + } + + return principal; + } +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/resolver/ResolvedUserArgumentResolver.java b/src/main/java/kr/ac/ks/cs_web_back/global/resolver/ResolvedUserArgumentResolver.java new file mode 100644 index 0000000..adaf4b5 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/resolver/ResolvedUserArgumentResolver.java @@ -0,0 +1,38 @@ +package kr.ac.ks.cs_web_back.global.resolver; + +import kr.ac.ks.cs_web_back.domain.member.model.Member; +import kr.ac.ks.cs_web_back.global.annotation.ResolvedUser; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class ResolvedUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(ResolvedUser.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || "anonymousUser".equals(authentication.getPrincipal())) + return null; + + final Object principal = authentication.getPrincipal(); + + if (!(principal instanceof Member)) { + return null; + } + + return principal; + } +} diff --git a/src/test/java/kr/ac/ks/cs_web_back/domain/auth/controller/AuthControllerTest.java b/src/test/java/kr/ac/ks/cs_web_back/domain/auth/controller/AuthControllerTest.java index 815661a..f245399 100644 --- a/src/test/java/kr/ac/ks/cs_web_back/domain/auth/controller/AuthControllerTest.java +++ b/src/test/java/kr/ac/ks/cs_web_back/domain/auth/controller/AuthControllerTest.java @@ -9,12 +9,15 @@ import kr.ac.ks.cs_web_back.domain.auth.service.AuthService; 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.JwtTokenResolver; +import kr.ac.ks.cs_web_back.global.jwt.JwtUtil; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.MediaType; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -35,6 +38,15 @@ public class AuthControllerTest { @MockitoBean private AuthService authService; + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private JwtTokenResolver jwtTokenResolver; + + @MockitoBean + private UserDetailsService userDetailsService; + @Test @DisplayName("로그인 성공: 올바른 이메일과 비밀번호로 로그인 시 200 OK와 토큰을 반환한다.") void loginSuccessReturns200OkWithToken() throws Exception { @@ -110,14 +122,14 @@ void loginWithNonExistentEmailReturns404NotFound() throws Exception { AuthLoginRequest request = AuthFixture.NonExistentEmailLoginRequest(); when(authService.loginMember(any(AuthLoginRequest.class))) - .thenThrow(new NotFoundException(AuthExceptionCode.NOT_FOUND_USER)); + .thenThrow(new NotFoundException(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION)); // when & then mockMvc.perform(post("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(AuthExceptionCode.NOT_FOUND_USER.getCode())) - .andExpect(jsonPath("$.message").value(AuthExceptionCode.NOT_FOUND_USER.getMessage())); + .andExpect(jsonPath("$.code").value(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION.getCode())) + .andExpect(jsonPath("$.message").value(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION.getMessage())); } } diff --git a/src/test/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthServiceTest.java b/src/test/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthServiceTest.java index 1828974..a83b10a 100644 --- a/src/test/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthServiceTest.java @@ -86,7 +86,7 @@ void shouldThrowExceptionWhenLoginWithNonExistentEmail() { .isInstanceOf(NotFoundException.class) .satisfies(e -> { NotFoundException exception = (NotFoundException) e; - assertThat(exception.getExceptionCode()).isEqualTo(AuthExceptionCode.NOT_FOUND_USER); + assertThat(exception.getExceptionCode()).isEqualTo(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION); }); } } diff --git a/src/test/java/kr/ac/ks/cs_web_back/domain/member/controller/MemberControllerTest.java b/src/test/java/kr/ac/ks/cs_web_back/domain/member/controller/MemberControllerTest.java index 6220cfe..6e1ab09 100644 --- a/src/test/java/kr/ac/ks/cs_web_back/domain/member/controller/MemberControllerTest.java +++ b/src/test/java/kr/ac/ks/cs_web_back/domain/member/controller/MemberControllerTest.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import kr.ac.ks.cs_web_back.domain.member.dto.request.MemberCreateRequest; import kr.ac.ks.cs_web_back.domain.member.service.MemberService; +import kr.ac.ks.cs_web_back.global.jwt.JwtTokenResolver; +import kr.ac.ks.cs_web_back.global.jwt.JwtUtil; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -22,6 +24,12 @@ public class MemberControllerTest { @MockitoBean private MemberService memberService; + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private JwtTokenResolver jwtTokenResolver; + @Autowired private MockMvc mockMvc; @@ -114,12 +122,12 @@ public void registeredWithInvalidEmailReturns400BadRequest() throws Exception { } @Test - @DisplayName("회원가입 실패: 비밀번호가 24글자를 초과하면 400 BadRequest를 반환한다.") + @DisplayName("회원가입 실패: 비밀번호가 48글자 이상이면 400 BadRequest를 반환한다.") public void tooLongPasswordReturns400BadRequest() throws Exception { // given MemberCreateRequest request = new MemberCreateRequest( "invalidEmail", - "password".repeat(10), + "a".repeat(48), "exampleUser" ); diff --git a/src/test/java/kr/ac/ks/cs_web_back/domain/member/service/MemberServiceTest.java b/src/test/java/kr/ac/ks/cs_web_back/domain/member/service/MemberServiceTest.java index 226e7a2..926fdc0 100644 --- a/src/test/java/kr/ac/ks/cs_web_back/domain/member/service/MemberServiceTest.java +++ b/src/test/java/kr/ac/ks/cs_web_back/domain/member/service/MemberServiceTest.java @@ -48,7 +48,7 @@ void shouldCreateMemberAndReturnsCreatedId() { assertThat(createdId).isEqualTo(1L); assertThat(savedMember.getEmail()).isEqualTo(request.email()); - assertThat(savedMember.getUsername()).isEqualTo(request.username()); + assertThat(savedMember.getNickname()).isEqualTo(request.username()); assertThat(passwordEncoder.matches(request.password(), savedMember.getPassword())).isTrue(); } @@ -102,4 +102,16 @@ void registeredFailedByConflictUsername() { assertThat(e.getExceptionCode()).isEqualTo(MemberExceptionCode.CONFLICT_USERNAME); }); } + + @Test + @DisplayName("loadUserByUsername 실패: 존재하지 않는 이메일로 사용자 로드 시 UsernameNotFoundException이 발생한다.") + void shouldThrowUsernameNotFoundExceptionWhenLoadingNonExistentUser() { + // given + String nonExistentEmail = "nonexistent@ks.ac.kr"; + + // when & then + assertThatThrownBy(() -> memberService.loadUserByUsername(nonExistentEmail)) + .isInstanceOf(org.springframework.security.core.userdetails.UsernameNotFoundException.class) + .hasMessage("User not found"); + } } diff --git a/src/test/java/kr/ac/ks/cs_web_back/global/jwt/CustomAuthenticationEntryPointTest.java b/src/test/java/kr/ac/ks/cs_web_back/global/jwt/CustomAuthenticationEntryPointTest.java new file mode 100644 index 0000000..27baae8 --- /dev/null +++ b/src/test/java/kr/ac/ks/cs_web_back/global/jwt/CustomAuthenticationEntryPointTest.java @@ -0,0 +1,73 @@ +package kr.ac.ks.cs_web_back.global.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.ac.ks.cs_web_back.domain.auth.controller.code.AuthExceptionCode; +import kr.ac.ks.cs_web_back.global.exeption.dto.ExceptionResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; + +import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class CustomAuthenticationEntryPointTest { + + @InjectMocks + private CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private AuthenticationException authException; + + @Spy + private ObjectMapper objectMapper = new ObjectMapper(); + private StringWriter stringWriter; + private PrintWriter printWriter; + + @BeforeEach + void setUp() throws IOException { + stringWriter = new StringWriter(); + printWriter = new PrintWriter(stringWriter); + when(response.getWriter()).thenReturn(printWriter); + + } + + @Test + @DisplayName("commence 메서드 호출 시 401 Unauthorized 응답과 JSON 에러 메시지를 반환한다.") + void commenceReturns401UnauthorizedAndJsonError() throws IOException { + // Given + AuthExceptionCode expectedCode = AuthExceptionCode.UNAUTHORIZED_INVALID_TOKEN; + ExceptionResponse expectedResponse = new ExceptionResponse(expectedCode.getCode(), expectedCode.getMessage()); + String expectedJson = objectMapper.writeValueAsString(expectedResponse); + + // When + customAuthenticationEntryPoint.commence(request, response, authException); + + // Then + verify(response).setCharacterEncoding("UTF-8"); + verify(response).setStatus(HttpStatus.UNAUTHORIZED.value()); + verify(response).setContentType("application/json;charset=UTF-8"); + verify(response).getWriter(); + printWriter.flush(); + assertThat(stringWriter.toString()).isEqualTo(expectedJson); + } +} diff --git a/src/test/java/kr/ac/ks/cs_web_back/global/jwt/JwtAuthenticationFilterTest.java b/src/test/java/kr/ac/ks/cs_web_back/global/jwt/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..3e3e364 --- /dev/null +++ b/src/test/java/kr/ac/ks/cs_web_back/global/jwt/JwtAuthenticationFilterTest.java @@ -0,0 +1,154 @@ +package kr.ac.ks.cs_web_back.global.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.transaction.Transactional; +import kr.ac.ks.cs_web_back.domain.auth.controller.code.AuthExceptionCode; +import kr.ac.ks.cs_web_back.domain.member.dto.request.MemberCreateRequest; +import kr.ac.ks.cs_web_back.domain.member.fixture.MemberFixture; +import kr.ac.ks.cs_web_back.domain.member.model.Member; +import kr.ac.ks.cs_web_back.domain.member.service.MemberService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import java.io.PrintWriter; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@Transactional +@AutoConfigureMockMvc +public class JwtAuthenticationFilterTest { + + @Value("${jwt.secret}") + private String secret; + + @Autowired + JwtAuthenticationFilter jwtAuthenticationFilter; + + @Autowired + MemberService memberService; + + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private JwtTokenResolver jwtTokenResolver; + + @MockitoBean + private HttpServletRequest request; + + @MockitoBean + private HttpServletResponse response; + + @MockitoBean + private FilterChain filterChain; + + @Autowired + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + Member member = MemberFixture.memberFixture(); + + memberService.createMember(new MemberCreateRequest( + member.getEmail(), member.getPassword(), member.getUsername() + )); + + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("토큰이 유효한 경우 요청이 필터 체인을 통과하고 SecurityContext에 인증 정보가 저장된다.") + void shouldProceedWithFilterChainWhenTokenIsValid() throws Exception { + // Given + String accessToken = TokenFixture.createAccessToken(jwtUtil); + when(request.getHeader("Authorization")).thenReturn("Bearer " + accessToken); + + // When + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // Then + verify(filterChain, times(1)).doFilter(request, response); + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + assertNotNull(authentication); + assertTrue(authentication.isAuthenticated()); + assertEquals(TokenFixture.TEST_EMAIL, ((UserDetails) authentication.getPrincipal()).getUsername()); + } + + @Test + @DisplayName("만료된 토큰인 경우 필터 체인 과정에서 401 Unauthorized 예외가 발생한다.") + void shouldBe401UnauthorizedWithFilterChainWhenTokenIsExpired() throws Exception { + // Given + String expiredAccessToken = TokenFixture.createExpiredAccessToken(secret); + when(request.getHeader("Authorization")).thenReturn("Bearer " + expiredAccessToken); + when(response.getWriter()).thenReturn(mock(PrintWriter.class)); + + // When + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // Then + verify(filterChain, never()).doFilter(request, response); + verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + verify(response).setContentType("application/json;charset=UTF-8"); + verify(response, times(1)).getWriter(); + } + + @Test + @DisplayName("토큰 형식이 잘못된 경우 필터 체인 과정에서 401 Unauthorized 예외가 발생한다.") + void shouldBe401UnauthorizedWithFilterChainWhenTokenIsInvalid() throws Exception { + // Given + String malformedAccessToken = "Bearer MalformedAccessToken"; + when(request.getHeader("Authorization")).thenReturn(malformedAccessToken); + when(response.getWriter()).thenReturn(mock(PrintWriter.class)); + + // When + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // Then + verify(filterChain, never()).doFilter(request, response); + verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + verify(response).setContentType("application/json;charset=UTF-8"); + verify(response, times(1)).getWriter(); + } + + @Test + @DisplayName("Authorization 헤더가 없거나 'Bearer '로 시작하지 않는 경우 필터 체인이 진행되고 인증 정보가 설정되지 않는다.") + void shouldProceedWithFilterChainWhenTokenIsNotPresentOrInvalidFormat() throws Exception { + when(request.getHeader("Authorization")).thenReturn(null); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain, times(1)).doFilter(request, response); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + + reset(request, filterChain); + SecurityContextHolder.clearContext(); + + when(request.getHeader("Authorization")).thenReturn("Basic somecredentials"); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain, times(1)).doFilter(request, response); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } +} diff --git a/src/test/java/kr/ac/ks/cs_web_back/global/jwt/TokenFixture.java b/src/test/java/kr/ac/ks/cs_web_back/global/jwt/TokenFixture.java new file mode 100644 index 0000000..b6b32ec --- /dev/null +++ b/src/test/java/kr/ac/ks/cs_web_back/global/jwt/TokenFixture.java @@ -0,0 +1,53 @@ +package kr.ac.ks.cs_web_back.global.jwt; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; + +import javax.crypto.spec.SecretKeySpec; +import java.security.Key; +import java.util.Base64; +import java.util.Date; + +public class TokenFixture { + + public static final String TEST_EMAIL = "example@ks.ac.kr"; + + /** + * 테스트용 Access Token 생성 + * @param jwtUtil JwtUtil 인스턴스 + * @return 생성된 Access Token + */ + public static String createAccessToken(JwtUtil jwtUtil) { + return jwtUtil.generateAccessToken(TEST_EMAIL); + } + + public static String createAccessToken(JwtUtil jwtUtil, String email) { + return jwtUtil.generateAccessToken(email); + } + + /** + * 테스트용 Refresh Token 생성 + * @param jwtUtil JwtUtil 인스턴스 + * @return 생성된 Refresh Token + */ + public static String createRefreshToken(JwtUtil jwtUtil) { + return jwtUtil.generateRefreshToken(TEST_EMAIL); + } + + public static String createRefreshToken(JwtUtil jwtUtil, String email) { + return jwtUtil.generateRefreshToken(email); + } + + public static String createExpiredAccessToken(String secret) { + byte[] decodedKey = Base64.getDecoder().decode(secret); + Key hmacKey = new SecretKeySpec(decodedKey, SignatureAlgorithm.HS256.getJcaName()); + + Date now = new Date(); + Date expiryDate = new Date(now.getTime() - 1); + return Jwts.builder().setSubject(TEST_EMAIL) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(hmacKey) + .compact(); + } +} diff --git a/src/test/java/kr/ac/ks/cs_web_back/global/resolver/UserArgumentResolverTest.java b/src/test/java/kr/ac/ks/cs_web_back/global/resolver/UserArgumentResolverTest.java new file mode 100644 index 0000000..2626720 --- /dev/null +++ b/src/test/java/kr/ac/ks/cs_web_back/global/resolver/UserArgumentResolverTest.java @@ -0,0 +1,163 @@ +package kr.ac.ks.cs_web_back.global.resolver; + +import kr.ac.ks.cs_web_back.domain.member.fixture.MemberFixture; +import kr.ac.ks.cs_web_back.domain.member.model.Member; +import kr.ac.ks.cs_web_back.global.annotation.IdentifiedUser; +import kr.ac.ks.cs_web_back.global.annotation.ResolvedUser; +import kr.ac.ks.cs_web_back.global.exeption.domain.InvalidTokenException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserArgumentResolverTest { + + @InjectMocks + private IdentifiedUserArgumentResolver identifiedUserArgumentResolver; + + @InjectMocks + private ResolvedUserArgumentResolver resolvedUserArgumentResolver; + + @Mock + private MethodParameter methodParameter; + + @Mock + private NativeWebRequest nativeWebRequest; + + @Mock + private WebDataBinderFactory webDataBinderFactory; + + @Mock + private ModelAndViewContainer modelAndViewContainer; + + @Mock + private SecurityContext securityContext; + + @Mock + private Authentication authentication; + + private Member testMember; + + @BeforeEach + void setUp() { + testMember = MemberFixture.memberFixture(); + SecurityContextHolder.setContext(securityContext); + } + + // IdentifiedUserArgumentResolver Tests + @Test + @DisplayName("IdentifiedUserArgumentResolver: @IdentifiedUser 어노테이션이 있는 파라미터를 지원한다.") + void identifiedUserSupportsParameterWithAnnotation() { + when(methodParameter.hasParameterAnnotation(IdentifiedUser.class)).thenReturn(true); + assertThat(identifiedUserArgumentResolver.supportsParameter(methodParameter)).isTrue(); + } + + @Test + @DisplayName("IdentifiedUserArgumentResolver: @IdentifiedUser 어노테이션이 없는 파라미터를 지원하지 않는다.") + void identifiedUserSupportsParameterWithoutAnnotation() { + when(methodParameter.hasParameterAnnotation(IdentifiedUser.class)).thenReturn(false); + assertThat(identifiedUserArgumentResolver.supportsParameter(methodParameter)).isFalse(); + } + + @Test + @DisplayName("IdentifiedUserArgumentResolver: 인증된 Member 객체를 반환한다.") + void identifiedUserResolveArgumentReturnsMember() { + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(testMember); + + Object result = identifiedUserArgumentResolver.resolveArgument(methodParameter, modelAndViewContainer, nativeWebRequest, webDataBinderFactory); + + assertThat(result).isEqualTo(testMember); + } + + @Test + @DisplayName("IdentifiedUserArgumentResolver: 인증 정보가 없으면 InvalidTokenException을 발생시킨다.") + void identifiedUserResolveArgumentThrowsExceptionWhenNoAuthentication() { + when(securityContext.getAuthentication()).thenReturn(null); + + assertThatThrownBy(() -> identifiedUserArgumentResolver.resolveArgument(methodParameter, modelAndViewContainer, nativeWebRequest, webDataBinderFactory)) + .isInstanceOf(InvalidTokenException.class); + } + + @Test + @DisplayName("IdentifiedUserArgumentResolver: Principal이 Member 타입이 아니면 InvalidTokenException을 발생시킨다.") + void identifiedUserResolveArgumentThrowsExceptionWhenPrincipalIsNotMember() { + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn("notAMember"); + + assertThatThrownBy(() -> identifiedUserArgumentResolver.resolveArgument(methodParameter, modelAndViewContainer, nativeWebRequest, webDataBinderFactory)) + .isInstanceOf(InvalidTokenException.class); + } + + @Test + @DisplayName("ResolvedUserArgumentResolver: @ResolvedUser 어노테이션이 있는 파라미터를 지원한다.") + void resolvedUserSupportsParameterWithAnnotation() { + when(methodParameter.hasParameterAnnotation(ResolvedUser.class)).thenReturn(true); + assertThat(resolvedUserArgumentResolver.supportsParameter(methodParameter)).isTrue(); + } + + @Test + @DisplayName("ResolvedUserArgumentResolver: @ResolvedUser 어노테이션이 없는 파라미터를 지원하지 않는다.") + void resolvedUserSupportsParameterWithoutAnnotation() { + when(methodParameter.hasParameterAnnotation(ResolvedUser.class)).thenReturn(false); + assertThat(resolvedUserArgumentResolver.supportsParameter(methodParameter)).isFalse(); + } + + @Test + @DisplayName("ResolvedUserArgumentResolver: 인증된 Member 객체를 반환한다.") + void resolvedUserResolveArgumentReturnsMember() { + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(testMember); + + Object result = resolvedUserArgumentResolver.resolveArgument(methodParameter, modelAndViewContainer, nativeWebRequest, webDataBinderFactory); + + assertThat(result).isEqualTo(testMember); + } + + @Test + @DisplayName("ResolvedUserArgumentResolver: 인증 정보가 없으면 null을 반환한다.") + void resolvedUserResolveArgumentReturnsNullWhenNoAuthentication() { + when(securityContext.getAuthentication()).thenReturn(null); + + Object result = resolvedUserArgumentResolver.resolveArgument(methodParameter, modelAndViewContainer, nativeWebRequest, webDataBinderFactory); + + assertThat(result).isNull(); + } + + @Test + @DisplayName("ResolvedUserArgumentResolver: Principal이 'anonymousUser'이면 null을 반환한다.") + void resolvedUserResolveArgumentReturnsNullWhenPrincipalIsAnonymousUser() { + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn("anonymousUser"); + + Object result = resolvedUserArgumentResolver.resolveArgument(methodParameter, modelAndViewContainer, nativeWebRequest, webDataBinderFactory); + + assertThat(result).isNull(); + } + + @Test + @DisplayName("ResolvedUserArgumentResolver: Principal이 Member 타입이 아니면 null을 반환한다.") + void resolvedUserResolveArgumentReturnsNullWhenPrincipalIsNotMember() { + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn("notAMember"); + + Object result = resolvedUserArgumentResolver.resolveArgument(methodParameter, modelAndViewContainer, nativeWebRequest, webDataBinderFactory); + + assertThat(result).isNull(); + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index f075f76..cebffda 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -3,4 +3,6 @@ spring.datasource.driverClassName=org.h2.Driver spring.datasource.username=sa spring.datasource.password= spring.jpa.hibernate.ddl-auto=update -spring.datasource.generate-unique-name=false \ No newline at end of file +spring.datasource.generate-unique-name=false + +jwt.secret=very-long-and-random-secret-key-for-testing-purpose-only-DO-NOT-USE-IN-PRODUCTION \ No newline at end of file