diff --git a/.gitignore b/.gitignore index 7eb922ad..5bbb7c85 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,8 @@ out/ ### Mac OS ### .DS_Store +### Claude Code ### +CLAUDE.md + *.yml -application.properties \ No newline at end of file +application.properties diff --git a/scripts/prepare-commit-msg b/scripts/prepare-commit-msg index 436c05bf..a0c88ef5 100644 --- a/scripts/prepare-commit-msg +++ b/scripts/prepare-commit-msg @@ -1,16 +1,16 @@ -#!/bin/bash +#!/usr/bin/env bash -COMMIT_EDITMSG_FILE_PATH=$1 -DEFAULT_COMMIT_MSG=$(cat "$COMMIT_EDITMSG_FILE_PATH") +COMMIT_MSG_FILE_PATH=$1 +DEFAULT_COMMIT_MSG=$(<"$COMMIT_MSG_FILE_PATH") CURRENT_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) -ISSUE_NUMBER=$(echo "$CURRENT_BRANCH_NAME" | grep -o '/#.*' | sed 's/\///') +ISSUE_NUMBER=$(grep -oE '#[0-9]+' <<< "$CURRENT_BRANCH_NAME") -if [[ "$DEFAULT_COMMIT_MSG" =~ ^[Mm]erge || "$DEFAULT_COMMIT_MSG" =~ ^[Hh]otfix ]]; then +if [[ "$DEFAULT_COMMIT_MSG" =~ ^([Mm]erge:|[Hh]otfix:) || "$DEFAULT_COMMIT_MSG" =~ \(#[0-9]+\) ]]; then SUFFIX="" else - SUFFIX="($ISSUE_NUMBER)" + SUFFIX="${ISSUE_NUMBER:+($ISSUE_NUMBER)}" fi -echo "$DEFAULT_COMMIT_MSG $SUFFIX" > "$COMMIT_EDITMSG_FILE_PATH" +echo "$DEFAULT_COMMIT_MSG${SUFFIX:+ $SUFFIX}" > "$COMMIT_MSG_FILE_PATH" diff --git a/src/main/java/com/acon/server/admin/api/controller/AdminController.java b/src/main/java/com/acon/server/admin/api/controller/AdminController.java new file mode 100644 index 00000000..affbd2ef --- /dev/null +++ b/src/main/java/com/acon/server/admin/api/controller/AdminController.java @@ -0,0 +1,26 @@ +package com.acon.server.admin.api.controller; + +import com.acon.server.admin.api.response.CsrfTokenResponse; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin") +public class AdminController { + + @GetMapping(path = "/csrf", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getCsrfToken(CsrfToken csrfToken) { + return ResponseEntity.ok( + CsrfTokenResponse.of( + csrfToken.getHeaderName(), + csrfToken.getParameterName(), + csrfToken.getToken() + ) + ); + } + +} diff --git a/src/main/java/com/acon/server/admin/api/response/CsrfTokenResponse.java b/src/main/java/com/acon/server/admin/api/response/CsrfTokenResponse.java new file mode 100644 index 00000000..856bea28 --- /dev/null +++ b/src/main/java/com/acon/server/admin/api/response/CsrfTokenResponse.java @@ -0,0 +1,16 @@ +package com.acon.server.admin.api.response; + +public record CsrfTokenResponse( + String headerName, + String parameterName, + String token +) { + + public static CsrfTokenResponse of( + String headerName, + String parameterName, + String token + ) { + return new CsrfTokenResponse(headerName, parameterName, token); + } +} diff --git a/src/main/java/com/acon/server/admin/infra/entity/AdminEntity.java b/src/main/java/com/acon/server/admin/infra/entity/AdminEntity.java new file mode 100644 index 00000000..bdbf1e01 --- /dev/null +++ b/src/main/java/com/acon/server/admin/infra/entity/AdminEntity.java @@ -0,0 +1,35 @@ +package com.acon.server.admin.infra.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "admin") +public class AdminEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false) + private String username; + + @Column(nullable = false) + private String password; + + @Builder + public AdminEntity(String username, String password) { + this.username = username; + this.password = password; + } +} diff --git a/src/main/java/com/acon/server/admin/infra/repository/AdminRepository.java b/src/main/java/com/acon/server/admin/infra/repository/AdminRepository.java new file mode 100644 index 00000000..8b77eea6 --- /dev/null +++ b/src/main/java/com/acon/server/admin/infra/repository/AdminRepository.java @@ -0,0 +1,12 @@ +package com.acon.server.admin.infra.repository; + +import com.acon.server.admin.infra.entity.AdminEntity; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AdminRepository extends JpaRepository { + + Optional findByUsername(String username); +} diff --git a/src/main/java/com/acon/server/global/admin/AdminAccessDeniedHandler.java b/src/main/java/com/acon/server/global/admin/AdminAccessDeniedHandler.java new file mode 100644 index 00000000..3f0817aa --- /dev/null +++ b/src/main/java/com/acon/server/global/admin/AdminAccessDeniedHandler.java @@ -0,0 +1,42 @@ +package com.acon.server.global.admin; + +import com.acon.server.global.dto.ErrorResponse; +import com.acon.server.global.exception.ErrorType; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.csrf.InvalidCsrfTokenException; +import org.springframework.security.web.csrf.MissingCsrfTokenException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + ErrorType errorType = ErrorType.ACCESS_DENIED_ERROR; + + if (accessDeniedException instanceof MissingCsrfTokenException) { + errorType = ErrorType.MISSING_CSRF_TOKEN_ERROR; + } else if (accessDeniedException instanceof InvalidCsrfTokenException) { + errorType = ErrorType.INVALID_CSRF_TOKEN_ERROR; + } + + response.setStatus(errorType.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + + objectMapper.writeValue(response.getOutputStream(), ErrorResponse.fail(errorType)); + } +} diff --git a/src/main/java/com/acon/server/global/admin/AdminAuthenticationEntryPoint.java b/src/main/java/com/acon/server/global/admin/AdminAuthenticationEntryPoint.java new file mode 100644 index 00000000..282a7b37 --- /dev/null +++ b/src/main/java/com/acon/server/global/admin/AdminAuthenticationEntryPoint.java @@ -0,0 +1,41 @@ +package com.acon.server.global.admin; + +import com.acon.server.global.dto.ErrorResponse; +import com.acon.server.global.exception.ErrorType; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + if (authException instanceof InsufficientAuthenticationException) { + ErrorType errorType = ErrorType.UNAUTHORIZED_ERROR; + + response.setStatus(errorType.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + + objectMapper.writeValue(response.getOutputStream(), ErrorResponse.fail(errorType)); + + return; + } + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } +} diff --git a/src/main/java/com/acon/server/global/admin/AdminUserDetailsService.java b/src/main/java/com/acon/server/global/admin/AdminUserDetailsService.java new file mode 100644 index 00000000..bae819b7 --- /dev/null +++ b/src/main/java/com/acon/server/global/admin/AdminUserDetailsService.java @@ -0,0 +1,29 @@ +package com.acon.server.global.admin; + +import com.acon.server.admin.infra.entity.AdminEntity; +import com.acon.server.admin.infra.repository.AdminRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.User; +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; + +@Service +@RequiredArgsConstructor +public class AdminUserDetailsService implements UserDetailsService { + + private final AdminRepository adminRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + AdminEntity admin = adminRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("Admin not found: " + username)); + + return User.builder() + .username(admin.getUsername()) + .password(admin.getPassword()) + .roles("ADMIN") + .build(); + } +} diff --git a/src/main/java/com/acon/server/global/auth/CacheConfig.java b/src/main/java/com/acon/server/global/auth/CacheConfig.java index 15d39484..ebd60ae6 100644 --- a/src/main/java/com/acon/server/global/auth/CacheConfig.java +++ b/src/main/java/com/acon/server/global/auth/CacheConfig.java @@ -35,4 +35,4 @@ public CacheManager cacheManager() { cacheManager.setCaches(Collections.singletonList(refreshTokenCache)); return cacheManager; } -} \ No newline at end of file +} diff --git a/src/main/java/com/acon/server/global/auth/filter/CustomAccessDeniedHandler.java b/src/main/java/com/acon/server/global/auth/filter/JwtAccessDeniedHandler.java similarity index 71% rename from src/main/java/com/acon/server/global/auth/filter/CustomAccessDeniedHandler.java rename to src/main/java/com/acon/server/global/auth/filter/JwtAccessDeniedHandler.java index 58a2438b..e3dc0c4e 100644 --- a/src/main/java/com/acon/server/global/auth/filter/CustomAccessDeniedHandler.java +++ b/src/main/java/com/acon/server/global/auth/filter/JwtAccessDeniedHandler.java @@ -7,15 +7,13 @@ import org.springframework.stereotype.Component; @Component -public class CustomAccessDeniedHandler implements AccessDeniedHandler { +public class JwtAccessDeniedHandler implements AccessDeniedHandler { // 사용자가 인증은 되었지만 특정 리소스에 접근할 권한이 없을 때 호출 @Override - public void handle( - HttpServletRequest request, - HttpServletResponse response, - AccessDeniedException accessDeniedException - ) { + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) { setResponse(response); } diff --git a/src/main/java/com/acon/server/global/auth/filter/CustomJwtAuthenticationEntryPoint.java b/src/main/java/com/acon/server/global/auth/filter/JwtAuthenticationEntryPoint.java similarity index 84% rename from src/main/java/com/acon/server/global/auth/filter/CustomJwtAuthenticationEntryPoint.java rename to src/main/java/com/acon/server/global/auth/filter/JwtAuthenticationEntryPoint.java index cf8c8762..b096182d 100644 --- a/src/main/java/com/acon/server/global/auth/filter/CustomJwtAuthenticationEntryPoint.java +++ b/src/main/java/com/acon/server/global/auth/filter/JwtAuthenticationEntryPoint.java @@ -16,17 +16,15 @@ @Component @RequiredArgsConstructor -public class CustomJwtAuthenticationEntryPoint implements AuthenticationEntryPoint { +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { // 사용자가 인증되지 않은 상태에서 보호된 리소스에 접근하려고 할 때 호출 private final ObjectMapper objectMapper; @Override - public void commence( - HttpServletRequest request, - HttpServletResponse response, - AuthenticationException authException - ) throws IOException { + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { if (authException instanceof JwtAuthenticationException jwtEx) { ErrorType errorType = jwtEx.getErrorType(); diff --git a/src/main/java/com/acon/server/global/auth/filter/JwtAuthenticationFilter.java b/src/main/java/com/acon/server/global/auth/filter/JwtAuthenticationFilter.java index 70cb81de..d14155e0 100644 --- a/src/main/java/com/acon/server/global/auth/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/acon/server/global/auth/filter/JwtAuthenticationFilter.java @@ -28,7 +28,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; - private final CustomJwtAuthenticationEntryPoint authenticationEntryPoint; + private final JwtAuthenticationEntryPoint authenticationEntryPoint; // 각 HTTP 요청에 대해 토큰이 유효한지 확인하고, 유효하다면 해당 사용자를 인증 설정하는 필터링 로직 @Override diff --git a/src/main/java/com/acon/server/global/auth/jwt/JwtTokenProvider.java b/src/main/java/com/acon/server/global/auth/jwt/JwtTokenProvider.java index 55fb0192..b642075f 100644 --- a/src/main/java/com/acon/server/global/auth/jwt/JwtTokenProvider.java +++ b/src/main/java/com/acon/server/global/auth/jwt/JwtTokenProvider.java @@ -68,10 +68,7 @@ public String issueRefreshToken(Long memberId) { return refreshToken; } - public String generateToken( - Authentication authentication, - Long tokenExpirationTime - ) { + public String generateToken(Authentication authentication, Long tokenExpirationTime) { final Date now = new Date(); final Claims claims = Jwts.claims() @@ -164,5 +161,4 @@ public void deleteRefreshToken(String refreshToken) { validateRefreshToken(refreshToken); cache.evict(refreshToken); } - -} \ No newline at end of file +} diff --git a/src/main/java/com/acon/server/global/auth/security/SecurityConfig.java b/src/main/java/com/acon/server/global/auth/security/SecurityConfig.java index 88b039d6..c642f86b 100644 --- a/src/main/java/com/acon/server/global/auth/security/SecurityConfig.java +++ b/src/main/java/com/acon/server/global/auth/security/SecurityConfig.java @@ -1,19 +1,42 @@ package com.acon.server.global.auth.security; -import com.acon.server.global.auth.filter.CustomAccessDeniedHandler; -import com.acon.server.global.auth.filter.CustomJwtAuthenticationEntryPoint; +import com.acon.server.global.admin.AdminAccessDeniedHandler; +import com.acon.server.global.admin.AdminAuthenticationEntryPoint; +import com.acon.server.global.admin.AdminUserDetailsService; +import com.acon.server.global.auth.filter.JwtAccessDeniedHandler; +import com.acon.server.global.auth.filter.JwtAuthenticationEntryPoint; import com.acon.server.global.auth.filter.JwtAuthenticationFilter; +import com.acon.server.global.dto.ErrorResponse; +import com.acon.server.global.exception.ErrorType; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.servlet.ServletListenerRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.RequestCacheConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; +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.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.web.servlet.handler.HandlerMappingIntrospector; @Configuration @@ -21,9 +44,15 @@ @EnableWebSecurity // WebSecurity를 사용할 수 있게 public class SecurityConfig { + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAuthenticationFilter jwtAuthenticationFilter; - private final CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint; - private final CustomAccessDeniedHandler customAccessDeniedHandler; + + private final AdminAccessDeniedHandler adminAccessDeniedHandler; + private final AdminUserDetailsService adminUserDetailsService; + private final AdminAuthenticationEntryPoint adminAuthenticationEntryPoint; + + private final ObjectMapper objectMapper; private static final String[] AUTH_WHITE_LIST = { "/api/v1/auth/login", @@ -36,15 +65,55 @@ MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) { } @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc) throws Exception { - http.csrf(AbstractHttpConfigurer::disable) + @Profile({"local", "dev"}) + public CookieCsrfTokenRepository adminCsrfTokenRepositoryDev() { + var repo = CookieCsrfTokenRepository.withHttpOnlyFalse(); + + repo.setCookieCustomizer(cookie -> cookie + .sameSite("Lax") + .secure(false) + .path("/admin")); + + return repo; + } + + @Bean + @Profile("prod") + public CookieCsrfTokenRepository adminCsrfTokenRepositoryProd() { + var repo = CookieCsrfTokenRepository.withHttpOnlyFalse(); + + repo.setCookieCustomizer(cookie -> cookie + .sameSite("None") + .secure(true) + .path("/admin")); + + return repo; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(12); + } + + @Bean + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + @Bean + public ServletListenerRegistrationBean httpSessionEventPublisher() { + return new ServletListenerRegistrationBean<>(new HttpSessionEventPublisher()); + } + + @Bean + SecurityFilterChain apiFilterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc) throws Exception { + return http + .securityMatcher("/api/**") + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .exceptionHandling(exception -> { - exception.authenticationEntryPoint(customJwtAuthenticationEntryPoint); - exception.accessDeniedHandler(customAccessDeniedHandler); - }) .authorizeHttpRequests(auth -> auth .requestMatchers(AUTH_WHITE_LIST).permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/app-updates").permitAll() @@ -53,10 +122,74 @@ SecurityFilterChain securityFilterChain(HttpSecurity http, MvcRequestMatcher.Bui mvc.pattern(HttpMethod.GET, "/api/v1/spots/{spotId:\\d+}"), mvc.pattern(HttpMethod.GET, "/api/v1/spots/{spotId:\\d+}/menuboards") ).permitAll() - .anyRequest().authenticated() - ) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + .anyRequest().authenticated()) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + SecurityFilterChain adminFilterChain(HttpSecurity http, + SessionRegistry sessionRegistry, + CookieCsrfTokenRepository adminCsrfTokenRepository) throws Exception { + return http + .securityMatcher("/admin/**") + .cors(Customizer.withDefaults()) + .csrf(csrf -> csrf + .csrfTokenRepository(adminCsrfTokenRepository)) + .headers(headers -> headers + .cacheControl(Customizer.withDefaults())) + .sessionManagement(session -> session + .maximumSessions(1) + .sessionRegistry(sessionRegistry)) + .userDetailsService(adminUserDetailsService) + .requestCache(RequestCacheConfigurer::disable) + .formLogin(form -> form + .loginProcessingUrl("/admin/login") + .successHandler(adminSuccessHandler()) + .failureHandler(adminFailureHandler())) + .httpBasic(AbstractHttpConfigurer::disable) + .logout(logout -> logout + .logoutUrl("/admin/logout") + .logoutSuccessHandler(adminLogoutSuccessHandler()) + .invalidateHttpSession(true) + .deleteCookies("JSESSIONID")) // TODO: 추후 Spring Session – Redis (SESSION) 으로 전환하여 무중단 배포 지원 + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.GET, "/admin/csrf").permitAll() + .requestMatchers(HttpMethod.POST, "/admin/login").permitAll() + .requestMatchers(HttpMethod.POST, "/admin/logout").permitAll() + .anyRequest().hasRole("ADMIN")) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(adminAuthenticationEntryPoint) + .accessDeniedHandler(adminAccessDeniedHandler)) + .build(); + } + + private AuthenticationSuccessHandler adminSuccessHandler() { + return (request, response, authentication) -> response.setStatus(HttpServletResponse.SC_OK); + } + + private AuthenticationFailureHandler adminFailureHandler() { + return (request, response, exception) -> { + if (exception instanceof BadCredentialsException) { + ErrorType errorType = ErrorType.INVALID_ID_OR_PASSWORD_ERROR; + + response.setStatus(errorType.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + + objectMapper.writeValue(response.getOutputStream(), ErrorResponse.fail(errorType)); + + return; + } + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + }; + } - return http.build(); + private LogoutSuccessHandler adminLogoutSuccessHandler() { + return (request, response, authentication) -> response.setStatus(HttpServletResponse.SC_OK); } } diff --git a/src/main/java/com/acon/server/global/config/WebConfig.java b/src/main/java/com/acon/server/global/config/WebConfig.java new file mode 100644 index 00000000..e66c856b --- /dev/null +++ b/src/main/java/com/acon/server/global/config/WebConfig.java @@ -0,0 +1,23 @@ +package com.acon.server.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Value("${cors.allowed-origins}") + private String[] allowedOrigins; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/admin/**") + .allowedOrigins(allowedOrigins) + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} diff --git a/src/main/java/com/acon/server/global/exception/ErrorType.java b/src/main/java/com/acon/server/global/exception/ErrorType.java index d338aa33..16086d88 100644 --- a/src/main/java/com/acon/server/global/exception/ErrorType.java +++ b/src/main/java/com/acon/server/global/exception/ErrorType.java @@ -27,7 +27,7 @@ public enum ErrorType { /* 401 Unauthorized */ EXPIRED_ACCESS_TOKEN_ERROR(HttpStatus.UNAUTHORIZED, 40101, "만료된 accessToken입니다."), NO_PRINCIPAL_ERROR(HttpStatus.UNAUTHORIZED, 40102, "Principal 객체가 없습니다."), - UN_LOGIN_ERROR(HttpStatus.UNAUTHORIZED, 40103, "로그인 후 진행해 주세요."), + UNAUTHORIZED_ERROR(HttpStatus.UNAUTHORIZED, 40103, "접근 권한이 없습니다. 로그인 후 이용해 주세요."), BEARER_LOST_ERROR(HttpStatus.UNAUTHORIZED, 40104, "요청한 토큰이 Bearer 토큰이 아닙니다."), /* 404 Not Found */ @@ -37,6 +37,15 @@ public enum ErrorType { /* 500 Internal Server Error */ INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 50001, "예상치 못한 서버 에러가 발생했습니다."), + /* Admin Error */ + /* 401 Unauthorized */ + INVALID_ID_OR_PASSWORD_ERROR(HttpStatus.UNAUTHORIZED, 40105, "아이디 또는 비밀번호가 일치하지 않습니다."), + + /* 403 Forbidden */ + ACCESS_DENIED_ERROR(HttpStatus.FORBIDDEN, 40301, "권한이 없거나 보안 정책에 의해 요청이 차단되었습니다."), + MISSING_CSRF_TOKEN_ERROR(HttpStatus.FORBIDDEN, 40302, "CSRF 토큰이 누락되었습니다."), + INVALID_CSRF_TOKEN_ERROR(HttpStatus.FORBIDDEN, 40303, "유효하지 않은 CSRF 토큰입니다."), + /* Member Error */ /* 400 Bad Request */ INVALID_SOCIAL_TYPE_ERROR(HttpStatus.BAD_REQUEST, 40009, "유효하지 않은 socialType입니다."), diff --git a/src/main/java/com/acon/server/global/handler/GlobalExceptionHandler.java b/src/main/java/com/acon/server/global/exception/GlobalExceptionHandler.java similarity index 97% rename from src/main/java/com/acon/server/global/handler/GlobalExceptionHandler.java rename to src/main/java/com/acon/server/global/exception/GlobalExceptionHandler.java index 1869ded2..0fbce96b 100644 --- a/src/main/java/com/acon/server/global/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/acon/server/global/exception/GlobalExceptionHandler.java @@ -1,8 +1,6 @@ -package com.acon.server.global.handler; +package com.acon.server.global.exception; import com.acon.server.global.dto.ErrorResponse; -import com.acon.server.global.exception.BusinessException; -import com.acon.server.global.exception.ErrorType; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; diff --git a/src/main/java/com/acon/server/spot/application/service/SpotService.java b/src/main/java/com/acon/server/spot/application/service/SpotService.java index a2ed7f68..dbb604cf 100644 --- a/src/main/java/com/acon/server/spot/application/service/SpotService.java +++ b/src/main/java/com/acon/server/spot/application/service/SpotService.java @@ -466,7 +466,7 @@ private boolean checkIsSaved( ) { if (principalHandler.isGuestUser()) { if (!isDeepLink) { - throw new BusinessException(ErrorType.UN_LOGIN_ERROR); + throw new BusinessException(ErrorType.UNAUTHORIZED_ERROR); } return false; @@ -608,7 +608,7 @@ public SpotSearchListResponse searchSpot(final String keyword) { @Transactional public void applySpot(final ApplySpotRequest request) { if (principalHandler.isGuestUser()) { - throw new BusinessException(ErrorType.UN_LOGIN_ERROR); + throw new BusinessException(ErrorType.UNAUTHORIZED_ERROR); } long memberId = fetchMemberId();