diff --git a/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java b/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java index 98492568b..6afc199de 100644 --- a/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java +++ b/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java @@ -13,11 +13,14 @@ 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.access.ExceptionTranslationFilter; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import static com.example.solidconnection.type.Role.ADMIN; + @Configuration @EnableWebSecurity @RequiredArgsConstructor @@ -55,10 +58,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .formLogin(AbstractHttpConfigurer::disable) .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/admin/**").hasRole(ADMIN.name()) + .anyRequest().permitAll() + ) .addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter.class) .addFilterBefore(signOutCheckFilter, JwtAuthenticationFilter.class) - .addFilterBefore(exceptionHandlerFilter, SignOutCheckFilter.class) + .addFilterAfter(exceptionHandlerFilter, ExceptionTranslationFilter.class) .build(); } } diff --git a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java index 529430749..8c74ea55b 100644 --- a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java @@ -48,6 +48,7 @@ public enum ErrorCode { AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED.value(), "인증이 필요한 접근입니다."), ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "액세스 토큰이 만료되었습니다. 재발급 api를 호출해주세요."), REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "리프레시 토큰이 만료되었습니다. 다시 로그인을 진행해주세요."), + ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."), // s3 S3_SERVICE_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 서비스 에러 발생"), diff --git a/src/main/java/com/example/solidconnection/custom/security/authentication/JwtAuthentication.java b/src/main/java/com/example/solidconnection/custom/security/authentication/JwtAuthentication.java index ba195caff..6c9f2fa21 100644 --- a/src/main/java/com/example/solidconnection/custom/security/authentication/JwtAuthentication.java +++ b/src/main/java/com/example/solidconnection/custom/security/authentication/JwtAuthentication.java @@ -1,6 +1,9 @@ package com.example.solidconnection.custom.security.authentication; import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collections; public abstract class JwtAuthentication extends AbstractAuthenticationToken { @@ -9,7 +12,9 @@ public abstract class JwtAuthentication extends AbstractAuthenticationToken { private final Object principal; public JwtAuthentication(String token, Object principal) { - super(null); + super(principal instanceof UserDetails ? + ((UserDetails) principal).getAuthorities() : + Collections.emptyList()); this.credentials = token; this.principal = principal; } diff --git a/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java b/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java index 8d09bfada..1b8fac2bb 100644 --- a/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java +++ b/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java @@ -1,6 +1,7 @@ package com.example.solidconnection.custom.security.filter; import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; import com.example.solidconnection.custom.response.ErrorResponse; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.FilterChain; @@ -9,12 +10,16 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.NonNull; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import static com.example.solidconnection.custom.exception.ErrorCode.ACCESS_DENIED; import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; @Component @@ -31,25 +36,28 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, filterChain.doFilter(request, response); } catch (CustomException e) { customCommence(response, e); + } catch (AccessDeniedException e) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + ErrorCode errorCode = auth instanceof AnonymousAuthenticationToken ? AUTHENTICATION_FAILED : ACCESS_DENIED; + generalCommence(response, e, errorCode); } catch (Exception e) { - generalCommence(response, e); + generalCommence(response, e, AUTHENTICATION_FAILED); } } public void customCommence(HttpServletResponse response, CustomException customException) throws IOException { - SecurityContextHolder.clearContext(); ErrorResponse errorResponse = new ErrorResponse(customException); - writeResponse(response, errorResponse); + writeResponse(response, errorResponse, customException.getCode()); } - public void generalCommence(HttpServletResponse response, Exception exception) throws IOException { - SecurityContextHolder.clearContext(); - ErrorResponse errorResponse = new ErrorResponse(AUTHENTICATION_FAILED, exception.getMessage()); - writeResponse(response, errorResponse); + public void generalCommence(HttpServletResponse response, Exception exception, ErrorCode errorCode) throws IOException { + ErrorResponse errorResponse = new ErrorResponse(errorCode, exception.getMessage()); + writeResponse(response, errorResponse, errorCode.getCode()); } - private void writeResponse(HttpServletResponse response, ErrorResponse errorResponse) throws IOException { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + private void writeResponse(HttpServletResponse response, ErrorResponse errorResponse, int statusCode) throws IOException { + SecurityContextHolder.clearContext(); + response.setStatus(statusCode); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); diff --git a/src/main/java/com/example/solidconnection/custom/security/userdetails/SecurityRoleMapper.java b/src/main/java/com/example/solidconnection/custom/security/userdetails/SecurityRoleMapper.java new file mode 100644 index 000000000..3af238f13 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/userdetails/SecurityRoleMapper.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.custom.security.userdetails; + +import com.example.solidconnection.type.Role; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; + +public class SecurityRoleMapper { + + private static final String ROLE_PREFIX = "ROLE_"; + + private SecurityRoleMapper() { + } + + public static List mapRoleToAuthorities(Role role) { + return List.of(new SimpleGrantedAuthority(ROLE_PREFIX + role.name())); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetails.java b/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetails.java index 36a0b815a..008f77ef5 100644 --- a/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetails.java +++ b/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetails.java @@ -27,7 +27,7 @@ public String getUsername() { @Override public Collection getAuthorities() { - return null; + return SecurityRoleMapper.mapRoleToAuthorities(siteUser.getRole()); } @Override diff --git a/src/main/java/com/example/solidconnection/type/Role.java b/src/main/java/com/example/solidconnection/type/Role.java index aaf464bf8..8223e8de0 100644 --- a/src/main/java/com/example/solidconnection/type/Role.java +++ b/src/main/java/com/example/solidconnection/type/Role.java @@ -1,6 +1,8 @@ package com.example.solidconnection.type; public enum Role { + + ADMIN, MENTOR, - MENTEE + MENTEE; } diff --git a/src/main/resources/db/migration/V6__add_admin_to_role_enum.sql b/src/main/resources/db/migration/V6__add_admin_to_role_enum.sql new file mode 100644 index 000000000..a661a2a61 --- /dev/null +++ b/src/main/resources/db/migration/V6__add_admin_to_role_enum.sql @@ -0,0 +1,2 @@ +ALTER TABLE site_user + modify ROLE enum ('MENTEE', 'MENTOR', 'ADMIN') NOT NULL; diff --git a/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java b/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java index 091a75eb8..fd4bd62a8 100644 --- a/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java +++ b/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java @@ -13,8 +13,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContextHolder; import java.util.stream.Stream; @@ -82,10 +85,46 @@ void setUp() { assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); } + @Test + void 익명_사용자의_접근_거부시_401_예외_응답을_반환한다() throws Exception { + // given + Authentication anonymousAuth = getAnonymousAuth(); + SecurityContextHolder.getContext().setAuthentication(anonymousAuth); + willThrow(new AccessDeniedException("Access Denied")).given(filterChain).doFilter(request, response); + + // when + exceptionHandlerFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); + } + + @Test + void 인증된_사용자의_접근_거부하면_403_예외_응답을_반환한다() throws Exception { + // given + Authentication auth = new TestingAuthenticationToken("user", "password", "ROLE_USER"); + SecurityContextHolder.getContext().setAuthentication(auth); + willThrow(new AccessDeniedException("Access Denied")).given(filterChain).doFilter(request, response); + + // when + exceptionHandlerFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN); + } + private static Stream provideException() { return Stream.of( new RuntimeException(), new CustomException(ErrorCode.INVALID_TOKEN) ); } + + private Authentication getAnonymousAuth() { + return new AnonymousAuthenticationToken( + "key", + "anonymousUser", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS") + ); + } } diff --git a/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsServiceTest.java b/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsServiceTest.java index 731f840f3..99e463955 100644 --- a/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsServiceTest.java +++ b/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsServiceTest.java @@ -18,7 +18,7 @@ import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertAll; @DisplayName("사용자 인증 정보 서비스 테스트") @TestContainerSpringBootTest diff --git a/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsTest.java b/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsTest.java new file mode 100644 index 000000000..912072d2b --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsTest.java @@ -0,0 +1,51 @@ +package com.example.solidconnection.custom.security.userdetails; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("사용자 인증 정보 테스트") +@TestContainerSpringBootTest +class SiteUserDetailsTest { + + @Autowired + private SiteUserRepository siteUserRepository; + + @Test + void 사용자_권한을_정상적으로_반환한다() { + // given + SiteUser siteUser = siteUserRepository.save(createSiteUser()); + SiteUserDetails siteUserDetails = new SiteUserDetails(siteUser); + + // when + Collection authorities = siteUserDetails.getAuthorities(); + + // then + assertThat(authorities) + .extracting("authority") + .containsExactly("ROLE_" + siteUser.getRole().name()); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } +}