diff --git a/account-service/src/main/java/com/synapse/account_service/config/ObjectMapperConfig.java b/account-service/src/main/java/com/synapse/account_service/config/ObjectMapperConfig.java new file mode 100644 index 0000000..31381fc --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/config/ObjectMapperConfig.java @@ -0,0 +1,17 @@ +package com.synapse.account_service.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +@Configuration +public class ObjectMapperConfig { + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return objectMapper; + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java b/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java index 49d8403..ccab6e4 100644 --- a/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java +++ b/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java @@ -2,26 +2,50 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; 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.authority.mapping.GrantedAuthoritiesMapper; 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.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.synapse.account_service.convert.authority.CustomAuthorityMapper; +import com.synapse.account_service.filter.JwtAuthenticationFilter; +import com.synapse.account_service.service.CustomUserDetailsService; +import com.synapse.account_service.service.handler.LoginFailureHandler; +import com.synapse.account_service.service.handler.LoginSuccessHandler; + +import lombok.RequiredArgsConstructor; @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final CustomUserDetailsService customUserDetailsService; + private final LoginSuccessHandler loginSuccessHandler; + private final LoginFailureHandler loginFailureHandler; + private final ObjectMapper objectMapper; + @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { http.csrf(csrf -> csrf.disable()) .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/accounts/signup").permitAll() + .requestMatchers("/api/accounts/signup", "/api/accounts/login", "/").permitAll() .anyRequest().authenticated() - ); + ) + .addFilterAt(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling( + exceptionHandlingConfigurer -> exceptionHandlingConfigurer.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))); return http.build(); } @@ -30,4 +54,32 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(customUserDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(DaoAuthenticationProvider authenticationProvider) { + return new ProviderManager(authenticationProvider); + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter(AuthenticationManager authenticationManager) { + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(authenticationManager, objectMapper); + + filter.setFilterProcessesUrl("/api/accounts/login"); + filter.setAuthenticationSuccessHandler(loginSuccessHandler); + filter.setAuthenticationFailureHandler(loginFailureHandler); + + return filter; + } + + @Bean + public GrantedAuthoritiesMapper customAuthorityMapper() { + return new CustomAuthorityMapper(); + } } diff --git a/account-service/src/main/java/com/synapse/account_service/convert/ProviderUserRequest.java b/account-service/src/main/java/com/synapse/account_service/convert/ProviderUserRequest.java new file mode 100644 index 0000000..d28dda0 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/convert/ProviderUserRequest.java @@ -0,0 +1,7 @@ +package com.synapse.account_service.convert; + +import com.synapse.account_service.domain.Member; + +public record ProviderUserRequest(Member member) { + +} diff --git a/account-service/src/main/java/com/synapse/account_service/convert/authority/CustomAuthorityMapper.java b/account-service/src/main/java/com/synapse/account_service/convert/authority/CustomAuthorityMapper.java new file mode 100644 index 0000000..7c3dd6f --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/convert/authority/CustomAuthorityMapper.java @@ -0,0 +1,31 @@ +package com.synapse.account_service.convert.authority; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; + +public class CustomAuthorityMapper implements GrantedAuthoritiesMapper { + + private final String PREFIX = "ROLE_"; + + @Override + public Set mapAuthorities(Collection authorities) { + HashSet mapped = new HashSet<>(authorities.size()); + for (GrantedAuthority authority : authorities) { + mapped.add(mapAuthority(authority.getAuthority())); + } + + return mapped; + } + + private GrantedAuthority mapAuthority(String name) { + if (!name.startsWith(PREFIX)) { + name = PREFIX + name; + } + return new SimpleGrantedAuthority(name); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/domain/Member.java b/account-service/src/main/java/com/synapse/account_service/domain/Member.java index 73e6df2..5c86f3a 100644 --- a/account-service/src/main/java/com/synapse/account_service/domain/Member.java +++ b/account-service/src/main/java/com/synapse/account_service/domain/Member.java @@ -1,5 +1,7 @@ package com.synapse.account_service.domain; +import java.util.UUID; + import org.springframework.security.crypto.password.PasswordEncoder; import com.synapse.account_service.common.BaseEntity; @@ -17,9 +19,9 @@ @Table(name = "members") public class Member extends BaseEntity { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue(strategy = GenerationType.UUID) @Column(name = "member_id") - private Long id; + private UUID id; @Column(name = "username", nullable = false) private String username; @@ -47,7 +49,8 @@ public class Member extends BaseEntity { private Subscription subscription; @Builder - public Member(String username, String password, String email, String provider, String picture, String registrationId, MemberRole role) { + public Member(UUID id, String username, String password, String email, String provider, String picture, String registrationId, MemberRole role) { + this.id = id; this.username = username; this.password = password; this.email = email; diff --git a/account-service/src/main/java/com/synapse/account_service/domain/PrincipalUser.java b/account-service/src/main/java/com/synapse/account_service/domain/PrincipalUser.java new file mode 100644 index 0000000..01b25f0 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/domain/PrincipalUser.java @@ -0,0 +1,73 @@ +package com.synapse.account_service.domain; + +import java.util.Collection; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +public record PrincipalUser(ProviderUser providerUser) implements UserDetails, OidcUser { + + @Override + public String getName() { + return providerUser.getUsername(); + } + + @Override + public Map getAttributes() { + return providerUser.getAttributes(); + } + + @Override + public Collection getAuthorities() { + return providerUser.getAuthorities(); + } + + @Override + public String getPassword() { + return providerUser.getPassword(); + } + + @Override + public String getUsername() { + return providerUser.getUsername(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public Map getClaims() { + return null; + } + + @Override + public OidcUserInfo getUserInfo() { + return null; + } + + @Override + public OidcIdToken getIdToken() { + return null; + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/domain/ProviderUser.java b/account-service/src/main/java/com/synapse/account_service/domain/ProviderUser.java new file mode 100644 index 0000000..f9c0afd --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/domain/ProviderUser.java @@ -0,0 +1,28 @@ +package com.synapse.account_service.domain; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +public interface ProviderUser { + UUID getId(); + + String getUsername(); + + String getPassword(); + + String getEmail(); + + String getProvider(); + + String getPicture(); + + List getAuthorities(); + + Map getAttributes(); + + OAuth2User getOAuth2User(); +} diff --git a/account-service/src/main/java/com/synapse/account_service/domain/Subscription.java b/account-service/src/main/java/com/synapse/account_service/domain/Subscription.java index cf705ed..d1abab0 100644 --- a/account-service/src/main/java/com/synapse/account_service/domain/Subscription.java +++ b/account-service/src/main/java/com/synapse/account_service/domain/Subscription.java @@ -1,6 +1,7 @@ package com.synapse.account_service.domain; import java.time.ZonedDateTime; +import java.util.UUID; import com.synapse.account_service.common.BaseTimeEntity; import com.synapse.account_service.domain.enums.SubscriptionTier; @@ -14,9 +15,9 @@ @Table(name = "subscription") public class Subscription extends BaseTimeEntity { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue(strategy = GenerationType.UUID) @Column(name = "subscription_id") - private Long id; + private UUID id; @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) diff --git a/account-service/src/main/java/com/synapse/account_service/domain/enums/MemberRole.java b/account-service/src/main/java/com/synapse/account_service/domain/enums/MemberRole.java index cca74c8..00b7ee8 100644 --- a/account-service/src/main/java/com/synapse/account_service/domain/enums/MemberRole.java +++ b/account-service/src/main/java/com/synapse/account_service/domain/enums/MemberRole.java @@ -1,5 +1,14 @@ package com.synapse.account_service.domain.enums; +import java.util.List; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + public enum MemberRole { - USER, ADMIN + USER, ADMIN; + + public List getAuthorities() { + return List.of(new SimpleGrantedAuthority(this.name())); + } } diff --git a/account-service/src/main/java/com/synapse/account_service/domain/forms/FormUser.java b/account-service/src/main/java/com/synapse/account_service/domain/forms/FormUser.java new file mode 100644 index 0000000..45e535f --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/domain/forms/FormUser.java @@ -0,0 +1,71 @@ +package com.synapse.account_service.domain.forms; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import com.synapse.account_service.domain.ProviderUser; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class FormUser implements ProviderUser { + + private String registrationId; + private UUID id; + private String username; + private String password; + private String email; + private String provider; + private List authorities; + + @Override + public UUID getId() { + return id; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getEmail() { + return email; + } + + @Override + public String getProvider() { + return provider; + } + + @Override + public String getPicture() { + return null; + } + + @Override + public List getAuthorities() { + return authorities; + } + + @Override + public Map getAttributes() { + return null; + } + + @Override + public OAuth2User getOAuth2User() { + return null; + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/dto/TokenResponse.java b/account-service/src/main/java/com/synapse/account_service/dto/TokenResponse.java new file mode 100644 index 0000000..8c609ae --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/dto/TokenResponse.java @@ -0,0 +1,11 @@ +package com.synapse.account_service.dto; + +/** + * JwtService가 최종적으로 생성하여 반환할 인증 토큰 DTO + * + * @param accessToken 액세스 토큰 정보 + * @param refreshToken 리프레시 토큰 정보 + */ +public record TokenResponse(TokenResult accessToken, TokenResult refreshToken) { + +} diff --git a/account-service/src/main/java/com/synapse/account_service/dto/TokenResult.java b/account-service/src/main/java/com/synapse/account_service/dto/TokenResult.java new file mode 100644 index 0000000..0539741 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/dto/TokenResult.java @@ -0,0 +1,13 @@ +package com.synapse.account_service.dto; + +import java.time.Instant; + +/** + * JwtTemplate이 토큰 생성 후 반환할 DTO + * + * @param token 생성된 JWT 문자열 + * @param expiresAt 토큰의 만료 시간 + */ +public record TokenResult(String token, Instant expiresAt) { + +} diff --git a/account-service/src/main/java/com/synapse/account_service/dto/request/LoginRequest.java b/account-service/src/main/java/com/synapse/account_service/dto/request/LoginRequest.java new file mode 100644 index 0000000..03ac247 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/dto/request/LoginRequest.java @@ -0,0 +1,15 @@ +package com.synapse.account_service.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record LoginRequest( + @NotBlank(message = "사용자 이름은 필수 입력 항목입니다.") + String username, + + @NotBlank(message = "비밀번호는 필수 입력 항목입니다.") + @Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.") + String password +) { + +} diff --git a/account-service/src/main/java/com/synapse/account_service/dto/response/SignUpResponse.java b/account-service/src/main/java/com/synapse/account_service/dto/response/SignUpResponse.java index 94691be..46beaeb 100644 --- a/account-service/src/main/java/com/synapse/account_service/dto/response/SignUpResponse.java +++ b/account-service/src/main/java/com/synapse/account_service/dto/response/SignUpResponse.java @@ -1,9 +1,11 @@ package com.synapse.account_service.dto.response; +import java.util.UUID; + import com.synapse.account_service.domain.Member; public record SignUpResponse( - Long id, + UUID id, String email, String username, String role diff --git a/account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java b/account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java index 5d31e1b..3c5fa8c 100644 --- a/account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java +++ b/account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java @@ -8,13 +8,20 @@ import static org.springframework.http.HttpStatus.CONFLICT; import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) public enum ExceptionType { DUPLICATED_EMAIL(CONFLICT, "001", "이미 존재하는 이메일입니다."), DUPLICATED_USERNAME(CONFLICT, "002", "이미 존재하는 사용자 이름입니다."), - EXCEPTION(INTERNAL_SERVER_ERROR, "003", "예상치 못한 오류가 발생했습니다.") + EXCEPTION(INTERNAL_SERVER_ERROR, "003", "예상치 못한 오류가 발생했습니다."), + NOT_FOUND_MEMBER(NOT_FOUND, "004", "존재하지 않는 사용자입니다."), + + INVALID_TOKEN(UNAUTHORIZED, "005", "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(UNAUTHORIZED, "006", "만료된 토큰입니다."), + FAIL_LOGIN(UNAUTHORIZED, "007", "아이디 또는 비밀번호가 일치하지 않습니다.") ; private final HttpStatus status; diff --git a/account-service/src/main/java/com/synapse/account_service/exception/JWTTokenExpiredException.java b/account-service/src/main/java/com/synapse/account_service/exception/JWTTokenExpiredException.java new file mode 100644 index 0000000..9f0128b --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/exception/JWTTokenExpiredException.java @@ -0,0 +1,7 @@ +package com.synapse.account_service.exception; + +public class JWTTokenExpiredException extends AccountServiceException { + public JWTTokenExpiredException(ExceptionType exceptionType) { + super(exceptionType); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/exception/JWTValidationException.java b/account-service/src/main/java/com/synapse/account_service/exception/JWTValidationException.java new file mode 100644 index 0000000..dac4f70 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/exception/JWTValidationException.java @@ -0,0 +1,7 @@ +package com.synapse.account_service.exception; + +public class JWTValidationException extends AccountServiceException { + public JWTValidationException(ExceptionType exceptionType) { + super(exceptionType); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/exception/NotFoundException.java b/account-service/src/main/java/com/synapse/account_service/exception/NotFoundException.java new file mode 100644 index 0000000..44b2415 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package com.synapse.account_service.exception; + +public class NotFoundException extends AccountServiceException { + public NotFoundException(ExceptionType exceptionType) { + super(exceptionType); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/filter/JwtAuthenticationFilter.java b/account-service/src/main/java/com/synapse/account_service/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..46ccc48 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/filter/JwtAuthenticationFilter.java @@ -0,0 +1,41 @@ +package com.synapse.account_service.filter; + +import java.io.IOException; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.synapse.account_service.dto.request.LoginRequest; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + private final ObjectMapper objectMapper; + + public JwtAuthenticationFilter(AuthenticationManager authenticationManager, ObjectMapper objectMapper) { + super(authenticationManager); + this.objectMapper = objectMapper; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + try { + LoginRequest loginRequest = objectMapper.readValue(request.getInputStream(), LoginRequest.class); + + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + loginRequest.username(), + loginRequest.password(), + null); + + return this.getAuthenticationManager().authenticate(authToken); + } catch (IOException e) { + throw new AuthenticationServiceException("로그인 정보 파싱에 실패했습니다.", e); + } + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/repository/MemberRepository.java b/account-service/src/main/java/com/synapse/account_service/repository/MemberRepository.java index 0a3c691..56d6b48 100644 --- a/account-service/src/main/java/com/synapse/account_service/repository/MemberRepository.java +++ b/account-service/src/main/java/com/synapse/account_service/repository/MemberRepository.java @@ -1,12 +1,13 @@ package com.synapse.account_service.repository; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import com.synapse.account_service.domain.Member; -public interface MemberRepository extends JpaRepository { +public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); Optional findByUsername(String username); Optional findByProviderAndRegistrationId(String provider, String registrationId); diff --git a/account-service/src/main/java/com/synapse/account_service/repository/SubscriptionRepository.java b/account-service/src/main/java/com/synapse/account_service/repository/SubscriptionRepository.java index ce1db9c..7a444c0 100644 --- a/account-service/src/main/java/com/synapse/account_service/repository/SubscriptionRepository.java +++ b/account-service/src/main/java/com/synapse/account_service/repository/SubscriptionRepository.java @@ -1,8 +1,10 @@ package com.synapse.account_service.repository; +import java.util.UUID; + import org.springframework.data.jpa.repository.JpaRepository; import com.synapse.account_service.domain.Subscription; -public interface SubscriptionRepository extends JpaRepository { +public interface SubscriptionRepository extends JpaRepository { } diff --git a/account-service/src/main/java/com/synapse/account_service/service/CustomUserDetailsService.java b/account-service/src/main/java/com/synapse/account_service/service/CustomUserDetailsService.java new file mode 100644 index 0000000..20efb07 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/service/CustomUserDetailsService.java @@ -0,0 +1,48 @@ +package com.synapse.account_service.service; + +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 com.synapse.account_service.convert.ProviderUserRequest; +import com.synapse.account_service.domain.Member; +import com.synapse.account_service.domain.PrincipalUser; +import com.synapse.account_service.domain.ProviderUser; +import com.synapse.account_service.domain.forms.FormUser; +import com.synapse.account_service.exception.ExceptionType; +import com.synapse.account_service.exception.NotFoundException; +import com.synapse.account_service.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Member member = memberRepository.findByUsername(username) + .orElseThrow(() -> new NotFoundException(ExceptionType.NOT_FOUND_MEMBER)); + + ProviderUserRequest providerUserRequest = new ProviderUserRequest(member); + ProviderUser providerUser = providerUser(providerUserRequest); + + return new PrincipalUser(providerUser); + } + + private ProviderUser providerUser(ProviderUserRequest providerUserRequest) { + Member member = providerUserRequest.member(); + + return FormUser.builder() + .id(member.getId()) + .username(member.getUsername()) + .password(member.getPassword()) + .email(member.getEmail()) + .provider(member.getProvider()) + .authorities(member.getRole().getAuthorities()) + .build(); + } + +} diff --git a/account-service/src/main/java/com/synapse/account_service/service/JwtTokenService.java b/account-service/src/main/java/com/synapse/account_service/service/JwtTokenService.java new file mode 100644 index 0000000..3388e07 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/service/JwtTokenService.java @@ -0,0 +1,50 @@ +package com.synapse.account_service.service; + +import java.util.Map; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.exceptions.TokenExpiredException; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.synapse.account_service.dto.TokenResponse; +import com.synapse.account_service.dto.TokenResult; +import com.synapse.account_service.exception.ExceptionType; +import com.synapse.account_service.exception.JWTTokenExpiredException; +import com.synapse.account_service.exception.JWTValidationException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class JwtTokenService { + private final JwtTokenTemplate jwtTokenTemplate; + + @Value("${jwt.access.expiration.minutes}") + private long accessTokenExpirationMinutes; + + @Value("${jwt.refresh.expiration.minutes}") + private long refreshTokenExpirationMinutes; + + public TokenResponse createTokenResponse(String subject, String role) { + Map accessTokenClaims = Map.of("role", role); + + TokenResult accessToken = jwtTokenTemplate.createToken(subject, accessTokenClaims, accessTokenExpirationMinutes); + TokenResult refreshToken = jwtTokenTemplate.createToken(subject, null, refreshTokenExpirationMinutes); + + return new TokenResponse(accessToken, refreshToken); + } + + public UUID getMemberIdFrom(String token) { + try { + DecodedJWT decodedJWT = jwtTokenTemplate.verifyAndDecode(token); + return UUID.fromString(decodedJWT.getSubject()); + } catch (TokenExpiredException e) { + throw new JWTTokenExpiredException(ExceptionType.EXPIRED_TOKEN); + } catch (JWTVerificationException e) { + throw new JWTValidationException(ExceptionType.INVALID_TOKEN); + } + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/service/JwtTokenTemplate.java b/account-service/src/main/java/com/synapse/account_service/service/JwtTokenTemplate.java new file mode 100644 index 0000000..6bd585b --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/service/JwtTokenTemplate.java @@ -0,0 +1,44 @@ +package com.synapse.account_service.service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.synapse.account_service.dto.TokenResult; + +@Component +public class JwtTokenTemplate { + private final Algorithm algorithm; + private final JWTVerifier verifier; + + public JwtTokenTemplate(@Value("${secret.key}") String secretKey) { + this.algorithm = Algorithm.HMAC256(secretKey); + this.verifier = JWT.require(this.algorithm).build(); + } + + public final TokenResult createToken(String subject, Map claims, long expirationMinutes) { + Instant now = Instant.now(); + Instant expiration = now.plus(expirationMinutes, ChronoUnit.MINUTES); + + String token = JWT.create() + .withSubject(subject) + .withIssuedAt(now) + .withExpiresAt(expiration) + .withPayload(claims) + .sign(algorithm); + + return new TokenResult(token, expiration); + } + + public final DecodedJWT verifyAndDecode(String token) throws JWTVerificationException { + return verifier.verify(token); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/service/handler/LoginFailureHandler.java b/account-service/src/main/java/com/synapse/account_service/service/handler/LoginFailureHandler.java new file mode 100644 index 0000000..f9fd00c --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/service/handler/LoginFailureHandler.java @@ -0,0 +1,37 @@ +package com.synapse.account_service.service.handler; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.springframework.http.MediaType; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.synapse.account_service.exception.dto.ExceptionResponse; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +import static com.synapse.account_service.exception.ExceptionType.FAIL_LOGIN; + +@RequiredArgsConstructor +@Component +public class LoginFailureHandler implements AuthenticationFailureHandler { + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + response.setStatus(FAIL_LOGIN.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getWriter().write(objectMapper.writeValueAsString( + new ExceptionResponse(FAIL_LOGIN.getCode(), FAIL_LOGIN.getMessage()))); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java b/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java new file mode 100644 index 0000000..ac4abba --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/service/handler/LoginSuccessHandler.java @@ -0,0 +1,75 @@ +package com.synapse.account_service.service.handler; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.synapse.account_service.domain.PrincipalUser; +import com.synapse.account_service.dto.TokenResponse; +import com.synapse.account_service.dto.TokenResult; +import com.synapse.account_service.service.JwtTokenService; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Component +public class LoginSuccessHandler implements AuthenticationSuccessHandler { + private final JwtTokenService jwtTokenService; + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + PrincipalUser principalUser = (PrincipalUser) authentication.getPrincipal(); + + UUID memberId = principalUser.providerUser().getId(); + + String role = authentication.getAuthorities().stream() + .findFirst() + .map(GrantedAuthority::getAuthority) + .orElseThrow(() -> new InternalAuthenticationServiceException("사용자에게 권한이 설정되어 있지 않습니다.")); + + TokenResponse tokenResponse = jwtTokenService.createTokenResponse(memberId.toString(), role); + + TokenResult refreshToken = tokenResponse.refreshToken(); + long maxAge = Duration.between(Instant.now(), refreshToken.expiresAt()).getSeconds(); + + ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken.token()) + .maxAge(maxAge) + .path("/") + .httpOnly(true) + .secure(false) // 프로덕션 환경에서는 true로 설정 + .sameSite("None") // 프론트/백엔드 도메인이 다른 경우 + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + + TokenResult accessToken = tokenResponse.accessToken(); + Map responseBody = Map.of( + "accessToken", accessToken.token(), + "expiresAt", accessToken.expiresAt().toEpochMilli() + ); + + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getWriter().write(objectMapper.writeValueAsString(responseBody)); + } + +} diff --git a/account-service/src/main/resources/application.yml b/account-service/src/main/resources/application.yml index 58b5846..09b442c 100644 --- a/account-service/src/main/resources/application.yml +++ b/account-service/src/main/resources/application.yml @@ -14,3 +14,4 @@ spring: config: import: - security/application-db.yml + - security/application-jwt.yml diff --git a/account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java b/account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java index 93d63f8..169140b 100644 --- a/account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java +++ b/account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java @@ -7,6 +7,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.UUID; + import org.springframework.http.MediaType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -24,8 +26,11 @@ import com.synapse.account_service.exception.GlobalExceptionHandler; import com.synapse.account_service.exception.DuplicatedException; import com.synapse.account_service.service.AccountService; +import com.synapse.account_service.service.CustomUserDetailsService; +import com.synapse.account_service.service.handler.LoginFailureHandler; +import com.synapse.account_service.service.handler.LoginSuccessHandler; -@WebMvcTest(AccountController.class) // AccountController만 테스트 +@WebMvcTest(AccountController.class) @Import({GlobalExceptionHandler.class, SecurityConfig.class}) public class AccountControllerTest { @Autowired @@ -37,12 +42,22 @@ public class AccountControllerTest { @MockitoBean private AccountService accountService; + @MockitoBean + private CustomUserDetailsService customUserDetailsService; + + @MockitoBean + private LoginSuccessHandler loginSuccessHandler; + + @MockitoBean + private LoginFailureHandler loginFailureHandler; + @Test @DisplayName("회원가입 API 호출 성공") void signUpApi_success() throws Exception { // given + UUID expectedId = UUID.randomUUID(); SignUpRequest request = new SignUpRequest("test@example.com", "유저", "password1234"); - SignUpResponse response = new SignUpResponse(1L, "test@example.com", "유저", "USER"); + SignUpResponse response = new SignUpResponse(expectedId, "test@example.com", "유저", "USER"); given(accountService.registerMember(any(SignUpRequest.class))).willReturn(response); @@ -50,8 +65,8 @@ void signUpApi_success() throws Exception { mockMvc.perform(post("/api/accounts/signup") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) // 201 Created 상태인지 확인 - .andExpect(jsonPath("$.id").value(1L)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(expectedId.toString())) .andExpect(jsonPath("$.email").value("test@example.com")) .andExpect(jsonPath("$.username").value("유저")) .andExpect(jsonPath("$.role").value("USER")); @@ -63,7 +78,6 @@ void signUpApi_fail_withDuplicateEmail() throws Exception { // given SignUpRequest request = new SignUpRequest("test1@example.com", "유저", "password1234"); - // accountService.registerMember가 호출되면 BusinessException을 던지도록 설정 given(accountService.registerMember(any(SignUpRequest.class))) .willThrow(new DuplicatedException(ExceptionType.DUPLICATED_EMAIL)); @@ -71,7 +85,7 @@ void signUpApi_fail_withDuplicateEmail() throws Exception { mockMvc.perform(post("/api/accounts/signup") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isConflict()) // 409 Conflict 상태인지 확인 + .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value(ExceptionType.DUPLICATED_EMAIL.getCode())); } @@ -79,13 +93,12 @@ void signUpApi_fail_withDuplicateEmail() throws Exception { @DisplayName("잘못된 요청값으로 회원가입 API 호출 시 400 Bad Request 응답") void signUpApi_fail_withInvalidInput() throws Exception { // given - // 이메일 형식이 잘못된 요청 SignUpRequest request = new SignUpRequest("test.com", "password1234", "유저"); // when & then mockMvc.perform(post("/api/accounts/signup") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); // @Valid에 의해 400 Bad Request가 발생하는지 확인 + .andExpect(status().isBadRequest()); } } diff --git a/account-service/src/test/java/com/synapse/account_service/convert/authority/CustomAuthorityMapperTest.java b/account-service/src/test/java/com/synapse/account_service/convert/authority/CustomAuthorityMapperTest.java new file mode 100644 index 0000000..160bbab --- /dev/null +++ b/account-service/src/test/java/com/synapse/account_service/convert/authority/CustomAuthorityMapperTest.java @@ -0,0 +1,57 @@ +package com.synapse.account_service.convert.authority; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collection; +import java.util.Collections; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +public class CustomAuthorityMapperTest { + private final CustomAuthorityMapper authorityMapper = new CustomAuthorityMapper(); + + @Test + @DisplayName("권한 매핑 성공: 'ROLE_' 접두사가 없는 권한에 접두사를 추가한다") + void mapAuthorities_addPrefix() { + // given + Set authorities = Set.of(new SimpleGrantedAuthority("USER")); + + // when + Collection mappedAuthorities = authorityMapper.mapAuthorities(authorities); + + // then + assertThat(mappedAuthorities).hasSize(1); + assertThat(mappedAuthorities.iterator().next().getAuthority()).isEqualTo("ROLE_USER"); + } + + @Test + @DisplayName("권한 매핑 성공: 'ROLE_' 접두사가 이미 있는 권한은 그대로 유지한다") + void mapAuthorities_keepExistingPrefix() { + // given + Set authorities = Set.of(new SimpleGrantedAuthority("ROLE_ADMIN")); + + // when + Collection mappedAuthorities = authorityMapper.mapAuthorities(authorities); + + // then + assertThat(mappedAuthorities).hasSize(1); + assertThat(mappedAuthorities.iterator().next().getAuthority()).isEqualTo("ROLE_ADMIN"); + } + + @Test + @DisplayName("권한 매핑 성공: 빈 권한 목록은 빈 목록으로 반환한다") + void mapAuthorities_emptyList() { + // given + Set authorities = Collections.emptySet(); + + // when + Collection mappedAuthorities = authorityMapper.mapAuthorities(authorities); + + // then + assertThat(mappedAuthorities).isEmpty(); + } +} diff --git a/account-service/src/test/java/com/synapse/account_service/integrationtest/LoginIntegrationTest.java b/account-service/src/test/java/com/synapse/account_service/integrationtest/LoginIntegrationTest.java new file mode 100644 index 0000000..8214e3c --- /dev/null +++ b/account-service/src/test/java/com/synapse/account_service/integrationtest/LoginIntegrationTest.java @@ -0,0 +1,118 @@ +package com.synapse.account_service.integrationtest; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.synapse.account_service.domain.Member; +import com.synapse.account_service.domain.enums.MemberRole; +import com.synapse.account_service.dto.request.LoginRequest; +import com.synapse.account_service.repository.MemberRepository; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +public class LoginIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final String TEST_USERNAME = "test_user6435"; + private final String TEST_PASSWORD = "password1234!"; + + @BeforeEach + void setUp() { + Member testMember = Member.builder() + .email("test_user1234@example.com") + .username(TEST_USERNAME) + .password(passwordEncoder.encode(TEST_PASSWORD)) + .role(MemberRole.USER) + .provider("local") + .build(); + memberRepository.save(testMember); + } + + @Test + @DisplayName("로그인 성공: 올바른 아이디와 비밀번호로 요청 시, AccessToken과 RefreshToken 쿠키를 응답한다") + void login_success() throws Exception { + // given + LoginRequest loginRequest = new LoginRequest(TEST_USERNAME, TEST_PASSWORD); + String requestBody = objectMapper.writeValueAsString(loginRequest); + + // when + ResultActions actions = mockMvc.perform(post("/api/accounts/login") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)); + + // then + actions + .andDo(print()) // 요청/응답 전체 내용 출력 + .andExpect(status().isOk()) // 200 OK 상태 코드 확인 + .andExpect(cookie().exists("refreshToken")) // refreshToken 쿠키 존재 여부 확인 + .andExpect(cookie().httpOnly("refreshToken", true)) // HttpOnly 속성 확인 + .andExpect(jsonPath("$.accessToken").isNotEmpty()) // accessToken이 비어있지 않은지 확인 + .andExpect(jsonPath("$.expiresAt").isNumber()); // expiresAt이 숫자인지 확인 + } + + @Test + @DisplayName("로그인 실패: 잘못된 비밀번호로 요청 시, 401 Unauthorized 상태 코드를 응답한다") + void login_fail_with_wrong_password() throws Exception { + // given + LoginRequest loginRequest = new LoginRequest(TEST_USERNAME, "wrong_password"); + String requestBody = objectMapper.writeValueAsString(loginRequest); + + // when + ResultActions actions = mockMvc.perform(post("/api/accounts/login") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)); + + // then + actions + .andDo(print()) + .andExpect(status().isUnauthorized()) // 401 Unauthorized 상태 코드 확인 + .andExpect(jsonPath("$.code").value("007")); // LoginFailureHandler에서 정의한 에러 코드 확인 + } + + @Test + @DisplayName("로그인 실패: 존재하지 않는 아이디로 요청 시, 401 Unauthorized 상태 코드를 응답한다") + void login_fail_with_non_existent_username() throws Exception { + // given + LoginRequest loginRequest = new LoginRequest("non_existent_username", TEST_PASSWORD); + String requestBody = objectMapper.writeValueAsString(loginRequest); + + // when + ResultActions actions = mockMvc.perform(post("/api/accounts/login") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)); + + // then + actions + .andDo(print()) + .andExpect(status().isUnauthorized()) // 401 Unauthorized 상태 코드 확인 + .andExpect(jsonPath("$.code").value("007")); + } +} diff --git a/account-service/src/test/java/com/synapse/account_service/service/CustomUserDetailsServiceTest.java b/account-service/src/test/java/com/synapse/account_service/service/CustomUserDetailsServiceTest.java new file mode 100644 index 0000000..91636f6 --- /dev/null +++ b/account-service/src/test/java/com/synapse/account_service/service/CustomUserDetailsServiceTest.java @@ -0,0 +1,69 @@ +package com.synapse.account_service.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; + +import java.util.Optional; +import java.util.UUID; + +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.security.core.userdetails.UserDetails; + +import com.synapse.account_service.domain.Member; +import com.synapse.account_service.domain.PrincipalUser; +import com.synapse.account_service.domain.enums.MemberRole; +import com.synapse.account_service.exception.NotFoundException; +import com.synapse.account_service.repository.MemberRepository; + +@ExtendWith(MockitoExtension.class) +public class CustomUserDetailsServiceTest { + @InjectMocks + private CustomUserDetailsService customUserDetailsService; + + @Mock + private MemberRepository memberRepository; + + @Test + @DisplayName("사용자 조회 성공: DB에 존재하는 사용자를 PrincipalUser 객체로 변환하여 반환한다") + void loadUserByUsername_success() { + // given + String username = "test@example.com"; + Member mockMember = Member.builder() + .id(UUID.randomUUID()) + .username(username) + .password("encodedPassword") + .role(MemberRole.USER) + .build(); + + given(memberRepository.findByUsername(username)).willReturn(Optional.of(mockMember)); + + // when + UserDetails userDetails = customUserDetailsService.loadUserByUsername(username); + + // then + assertThat(userDetails).isInstanceOf(PrincipalUser.class); + assertThat(userDetails.getUsername()).isEqualTo(username); + assertThat(userDetails.getPassword()).isEqualTo("encodedPassword"); + assertThat(userDetails.getAuthorities()).hasSize(1); + assertThat(userDetails.getAuthorities().iterator().next().getAuthority()).isEqualTo("USER"); + } + + @Test + @DisplayName("사용자 조회 실패: DB에 사용자가 없으면 UsernameNotFoundException을 던진다") + void loadUserByUsername_fail_userNotFound() { + // given + String username = "notfound@example.com"; + given(memberRepository.findByUsername(username)).willReturn(Optional.empty()); + + // when & then + assertThrows(NotFoundException.class, () -> { + customUserDetailsService.loadUserByUsername(username); + }); + } +} diff --git a/account-service/src/test/java/com/synapse/account_service/service/JwtTokenServiceTest.java b/account-service/src/test/java/com/synapse/account_service/service/JwtTokenServiceTest.java new file mode 100644 index 0000000..048b6b1 --- /dev/null +++ b/account-service/src/test/java/com/synapse/account_service/service/JwtTokenServiceTest.java @@ -0,0 +1,59 @@ +package com.synapse.account_service.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.Instant; +import java.util.UUID; + +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.test.util.ReflectionTestUtils; + +import com.synapse.account_service.dto.TokenResponse; +import com.synapse.account_service.dto.TokenResult; + +@ExtendWith(MockitoExtension.class) +public class JwtTokenServiceTest { + @InjectMocks + private JwtTokenService jwtTokenService; + + @Mock + private JwtTokenTemplate jwtTokenTemplate; + + @Test + @DisplayName("토큰 생성 서비스 성공: 올바른 인자로 토큰 생성을 요청하고 DTO를 반환한다") + void createTokenResponse_success() { + // given + ReflectionTestUtils.setField(jwtTokenService, "accessTokenExpirationMinutes", 30L); + ReflectionTestUtils.setField(jwtTokenService, "refreshTokenExpirationMinutes", 1440L); + + UUID memberId = UUID.randomUUID(); + String role = "ROLE_USER"; + + TokenResult mockAccessToken = new TokenResult("access.token.string", Instant.now().plusSeconds(1800)); + TokenResult mockRefreshToken = new TokenResult("refresh.token.string", Instant.now().plusSeconds(86400)); + + // Access Token, Refresh Token이 순서대로 반환되도록 설정 + given(jwtTokenTemplate.createToken(anyString(), any(), anyLong())).willReturn(mockAccessToken, mockRefreshToken); + + // when + TokenResponse tokenResponse = jwtTokenService.createTokenResponse(memberId.toString(), role); + + // then: 결과 검증 + assertThat(tokenResponse).isNotNull(); + assertThat(tokenResponse.accessToken().token()).isEqualTo("access.token.string"); + assertThat(tokenResponse.refreshToken().token()).isEqualTo("refresh.token.string"); + + verify(jwtTokenTemplate, times(2)).createToken(anyString(), any(), anyLong()); + } +} diff --git a/account-service/src/test/resources/application-test.yml b/account-service/src/test/resources/application-test.yml index 94752a0..d3553ab 100644 --- a/account-service/src/test/resources/application-test.yml +++ b/account-service/src/test/resources/application-test.yml @@ -23,6 +23,18 @@ spring: open-in-view: false show-sql: true +# JWT 테스트 설정 +jwt: + access: + expiration: + minutes: 1 # 테스트용 짧은 만료 시간 (1분) + refresh: + expiration: + minutes: 5 # 테스트용 짧은 만료 시간 (5분) + +secret: + key: testSecretKeyForJWTTokenGenerationInTestEnvironment2024 + logging: level: org: