diff --git a/.env.example b/.env.example index ab5ee6b..1ff841a 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,5 @@ MYSQL_DATABASE=ject_checkin MYSQL_USER=ject_app MYSQL_PASSWORD=replace-me MYSQL_ROOT_PASSWORD=replace-me +ADMIN_USERNAME=admin +ADMIN_PASSWORD=replace-me diff --git a/build.gradle b/build.gradle index 7016f02..b5b069c 100644 --- a/build.gradle +++ b/build.gradle @@ -20,6 +20,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' @@ -29,6 +30,7 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation 'org.springframework.security:spring-security-test' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/ject/official_qr_checkin_server/common/security/AuthErrorCode.java b/src/main/java/ject/official_qr_checkin_server/common/security/AuthErrorCode.java new file mode 100644 index 0000000..8a9df84 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/security/AuthErrorCode.java @@ -0,0 +1,43 @@ +package ject.official_qr_checkin_server.common.security; + +import ject.official_qr_checkin_server.common.exception.ErrorCode; +import org.springframework.http.HttpStatus; + +public enum AuthErrorCode implements ErrorCode { + + AUTHENTICATION_REQUIRED( + HttpStatus.UNAUTHORIZED, + "AUTH-001", + "관리자 인증이 필요합니다." + ), + ACCESS_DENIED( + HttpStatus.FORBIDDEN, + "AUTH-002", + "접근 권한이 없습니다." + ); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + AuthErrorCode(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/ject/official_qr_checkin_server/common/security/SecurityConfig.java b/src/main/java/ject/official_qr_checkin_server/common/security/SecurityConfig.java new file mode 100644 index 0000000..883ca54 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/security/SecurityConfig.java @@ -0,0 +1,74 @@ +package ject.official_qr_checkin_server.common.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +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.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.AndRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; + +@Configuration +public class SecurityConfig { + + private static final RequestMatcher ADMIN_CSRF_MATCHER = new AndRequestMatcher( + CsrfFilter.DEFAULT_CSRF_MATCHER, + PathPatternRequestMatcher.pathPattern("/admin/**") + ); + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + UserDetailsService adminUserDetailsService( + @Value("${app.security.admin.username}") String username, + @Value("${app.security.admin.password}") String password, + PasswordEncoder passwordEncoder + ) { + UserDetails admin = User.builder() + .username(username) + .password(passwordEncoder.encode(password)) + .roles("ADMIN") + .build(); + + return new InMemoryUserDetailsManager(admin); + } + + @Bean + SecurityFilterChain securityFilterChain( + HttpSecurity http, + SecurityErrorResponseHandler securityErrorResponseHandler + ) throws Exception { + http + .csrf(csrf -> csrf + .spa() + .requireCsrfProtectionMatcher(ADMIN_CSRF_MATCHER) + ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().permitAll() + ) + .httpBasic(basic -> basic.authenticationEntryPoint(securityErrorResponseHandler)) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(securityErrorResponseHandler) + .accessDeniedHandler(securityErrorResponseHandler) + ) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable); + + return http.build(); + } +} diff --git a/src/main/java/ject/official_qr_checkin_server/common/security/SecurityErrorResponseHandler.java b/src/main/java/ject/official_qr_checkin_server/common/security/SecurityErrorResponseHandler.java new file mode 100644 index 0000000..864389b --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/security/SecurityErrorResponseHandler.java @@ -0,0 +1,55 @@ +package ject.official_qr_checkin_server.common.security; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import ject.official_qr_checkin_server.common.exception.ErrorCode; +import ject.official_qr_checkin_server.common.response.ErrorResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +@Component +public class SecurityErrorResponseHandler implements AuthenticationEntryPoint, AccessDeniedHandler { + + private static final String ADMIN_REALM = "Basic realm=\"admin\""; + + private final ObjectMapper objectMapper; + + public SecurityErrorResponseHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authenticationException + ) throws IOException, ServletException { + response.setHeader(HttpHeaders.WWW_AUTHENTICATE, ADMIN_REALM); + writeError(response, AuthErrorCode.AUTHENTICATION_REQUIRED); + } + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + writeError(response, AuthErrorCode.ACCESS_DENIED); + } + + private void writeError(HttpServletResponse response, ErrorCode errorCode) throws IOException { + response.setStatus(errorCode.getHttpStatus().value()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getOutputStream(), ErrorResponse.of(errorCode).toApiResponse()); + } +} diff --git a/src/main/java/ject/official_qr_checkin_server/common/springdoc/SpringdocConfig.java b/src/main/java/ject/official_qr_checkin_server/common/springdoc/SpringdocConfig.java index b051034..a789644 100644 --- a/src/main/java/ject/official_qr_checkin_server/common/springdoc/SpringdocConfig.java +++ b/src/main/java/ject/official_qr_checkin_server/common/springdoc/SpringdocConfig.java @@ -1,7 +1,10 @@ package ject.official_qr_checkin_server.common.springdoc; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; import org.springdoc.core.models.GroupedOpenApi; import org.springframework.context.annotation.Bean; @@ -9,6 +12,7 @@ @Configuration public class SpringdocConfig { + private static final String ADMIN_BASIC_AUTH = "adminBasicAuth"; @Bean OpenAPI checkInOpenApi() { @@ -17,6 +21,13 @@ OpenAPI checkInOpenApi() { .title("젝트 행사 출석체크 API") .description("젝트 공식 행사 QR 출석체크 서버 API 명세서입니다.") .version("v1")) + .components(new Components().addSecuritySchemes( + ADMIN_BASIC_AUTH, + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("basic") + .description("관리자 API용 HTTP Basic 인증") + )) .addServersItem(new Server().url("/")); } @@ -32,8 +43,23 @@ GroupedOpenApi checkInApi( "/actuator/**", "/error" ) + .addOpenApiCustomizer(this::applyAdminSecurity) .addOperationCustomizer(successResponseCustomizer) .addOperationCustomizer(errorResponseCustomizer) .build(); } + + private void applyAdminSecurity(OpenAPI openApi) { + if (openApi.getPaths() == null) { + return; + } + + openApi.getPaths().forEach((path, pathItem) -> { + if (path.startsWith("/admin/")) { + pathItem.readOperations().forEach(operation -> operation.addSecurityItem( + new SecurityRequirement().addList(ADMIN_BASIC_AUTH) + )); + } + }); + } } diff --git a/src/main/java/ject/official_qr_checkin_server/domain/event/controller/EventController.java b/src/main/java/ject/official_qr_checkin_server/domain/event/controller/EventController.java new file mode 100644 index 0000000..efeae4a --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/domain/event/controller/EventController.java @@ -0,0 +1,21 @@ +package ject.official_qr_checkin_server.domain.event.controller; + +import ject.official_qr_checkin_server.domain.event.dto.EventDto; +import ject.official_qr_checkin_server.domain.event.service.EventService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/events") +@RequiredArgsConstructor +public class EventController { + private final EventService eventService; + + @PostMapping + public void createEvent(@RequestBody EventDto eventDto) { + eventService.createEvent(eventDto); + } +} diff --git a/src/main/java/ject/official_qr_checkin_server/domain/event/dto/EventDto.java b/src/main/java/ject/official_qr_checkin_server/domain/event/dto/EventDto.java new file mode 100644 index 0000000..10bafe8 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/domain/event/dto/EventDto.java @@ -0,0 +1,30 @@ +package ject.official_qr_checkin_server.domain.event.dto; + +import java.time.LocalDateTime; +import ject.official_qr_checkin_server.domain.event.model.Event; +import ject.official_qr_checkin_server.domain.event.model.EventStatus; +import lombok.Builder; + +@Builder +public record EventDto( + Long id, + String name, + LocalDateTime eventDateTime +) { + + public Event toEntity() { + return Event.builder() + .name(name) + .eventDateTime(eventDateTime) + .status(EventStatus.INACTIVE) + .build(); + } + + public static EventDto fromEntity(Event event) { + return new EventDto( + event.getId(), + event.getName(), + event.getEventDateTime() + ); + } +} diff --git a/src/main/java/ject/official_qr_checkin_server/domain/event/repository/EventRepository.java b/src/main/java/ject/official_qr_checkin_server/domain/event/repository/EventRepository.java new file mode 100644 index 0000000..7e0f026 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/domain/event/repository/EventRepository.java @@ -0,0 +1,7 @@ +package ject.official_qr_checkin_server.domain.event.repository; + +import ject.official_qr_checkin_server.domain.event.model.Event; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventRepository extends JpaRepository { +} diff --git a/src/main/java/ject/official_qr_checkin_server/domain/event/service/EventService.java b/src/main/java/ject/official_qr_checkin_server/domain/event/service/EventService.java new file mode 100644 index 0000000..4205702 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/domain/event/service/EventService.java @@ -0,0 +1,17 @@ +package ject.official_qr_checkin_server.domain.event.service; + +import ject.official_qr_checkin_server.domain.event.dto.EventDto; +import ject.official_qr_checkin_server.domain.event.repository.EventRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EventService { + + private final EventRepository eventRepository; + + public void createEvent(final EventDto eventDto) { + eventRepository.save(eventDto.toEntity()); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ab0f204..e4e906b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,14 +1,18 @@ spring.application.name=official-qr-checkin-server +spring.config.import=optional:file:.env[.properties] spring.datasource.url=${DB_URL:jdbc:mysql://localhost:3306/ject_checkin?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8} spring.datasource.username=${DB_USERNAME:ject_app} spring.datasource.password=${DB_PASSWORD:local-app-password} spring.datasource.hikari.maximum-pool-size=${DB_POOL_MAX_SIZE:5} spring.datasource.hikari.minimum-idle=${DB_POOL_MIN_IDLE:1} spring.datasource.hikari.connection-timeout=10000 -spring.jpa.hibernate.ddl-auto=${JPA_DDL_AUTO:validate} +spring.jpa.hibernate.ddl-auto=${JPA_DDL_AUTO:update} spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Seoul spring.jpa.open-in-view=false +app.security.admin.username=${ADMIN_USERNAME:admin} +app.security.admin.password=${ADMIN_PASSWORD} + management.endpoints.web.exposure.include=health management.endpoint.health.show-details=never management.endpoint.health.probes.enabled=true @@ -18,6 +22,7 @@ springdoc.swagger-ui.path=/swagger-ui.html springdoc.swagger-ui.operations-sorter=method springdoc.swagger-ui.tags-sorter=alpha springdoc.swagger-ui.display-request-duration=true +springdoc.swagger-ui.csrf.enabled=true server.forward-headers-strategy=framework server.shutdown=graceful diff --git a/src/test/java/ject/official_qr_checkin_server/common/security/SecurityIntegrationTests.java b/src/test/java/ject/official_qr_checkin_server/common/security/SecurityIntegrationTests.java new file mode 100644 index 0000000..1797bf7 --- /dev/null +++ b/src/test/java/ject/official_qr_checkin_server/common/security/SecurityIntegrationTests.java @@ -0,0 +1,105 @@ +package ject.official_qr_checkin_server.common.security; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +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.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(SecurityIntegrationTests.PublicController.class) +class SecurityIntegrationTests { + + private static final String EVENT_REQUEST = """ + { + "name": "테스트 행사", + "eventDateTime": "2026-09-05T12:00:00" + } + """; + + @Autowired + private MockMvc mockMvc; + + @Test + void rejectsAdminRequestWithoutAuthentication() throws Exception { + mockMvc.perform(post("/admin/events") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(EVENT_REQUEST)) + .andExpect(status().isUnauthorized()) + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"admin\"")) + .andExpect(jsonPath("$.status").value("AUTH-001")) + .andExpect(jsonPath("$.data[0]").value("관리자 인증이 필요합니다.")); + } + + @Test + void rejectsAdminRequestWithInvalidCredentials() throws Exception { + mockMvc.perform(post("/admin/events") + .with(csrf()) + .with(httpBasic("test-admin", "wrong-password")) + .contentType(MediaType.APPLICATION_JSON) + .content(EVENT_REQUEST)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.status").value("AUTH-001")); + } + + @Test + void rejectsAdminRequestWithoutAdminRole() throws Exception { + mockMvc.perform(post("/admin/events") + .with(csrf()) + .with(user("member").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(EVENT_REQUEST)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.status").value("AUTH-002")) + .andExpect(jsonPath("$.data[0]").value("접근 권한이 없습니다.")); + } + + @Test + void rejectsAdminRequestWithoutCsrfToken() throws Exception { + mockMvc.perform(post("/admin/events") + .with(httpBasic("test-admin", "test-admin-password")) + .contentType(MediaType.APPLICATION_JSON) + .content(EVENT_REQUEST)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.status").value("AUTH-002")); + } + + @Test + void allowsAdminRequestWithValidCredentials() throws Exception { + mockMvc.perform(post("/admin/events") + .with(csrf()) + .with(httpBasic("test-admin", "test-admin-password")) + .contentType(MediaType.APPLICATION_JSON) + .content(EVENT_REQUEST)) + .andExpect(status().isOk()); + } + + @Test + void allowsPublicPostRequestWithoutAuthenticationOrCsrfToken() throws Exception { + mockMvc.perform(post("/security-test/public")) + .andExpect(status().isOk()); + } + + @RestController + static class PublicController { + + @PostMapping("/security-test/public") + void publicPost() { + } + } +} diff --git a/src/test/java/ject/official_qr_checkin_server/common/springdoc/SpringdocIntegrationTests.java b/src/test/java/ject/official_qr_checkin_server/common/springdoc/SpringdocIntegrationTests.java index 32261e7..11cd978 100644 --- a/src/test/java/ject/official_qr_checkin_server/common/springdoc/SpringdocIntegrationTests.java +++ b/src/test/java/ject/official_qr_checkin_server/common/springdoc/SpringdocIntegrationTests.java @@ -2,6 +2,7 @@ import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -30,6 +31,9 @@ void exposesGroupedOpenApiDocumentWithCommonResponses() throws Exception { mockMvc.perform(get("/v3/api-docs/check-in-api")) .andExpect(status().isOk()) .andExpect(jsonPath("$.info.title").value("젝트 행사 출석체크 API")) + .andExpect(jsonPath("$.components.securitySchemes.adminBasicAuth.type").value("http")) + .andExpect(jsonPath("$.components.securitySchemes.adminBasicAuth.scheme").value("basic")) + .andExpect(jsonPath("$.paths['/admin/events'].post.security[0].adminBasicAuth").isArray()) .andExpect(jsonPath("$.paths['/swagger-test']").exists()) .andExpect(jsonPath( "$.paths['/swagger-test'].get.responses['200'].content['*/*'].schema.properties.status.example" @@ -43,6 +47,7 @@ void exposesGroupedOpenApiDocumentWithCommonResponses() throws Exception { void exposesSwaggerUi() throws Exception { mockMvc.perform(get("/swagger-ui.html")) .andExpect(status().is3xxRedirection()) + .andExpect(cookie().exists("XSRF-TOKEN")) .andExpect(header().string("Location", containsString("/swagger-ui/index.html"))); } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 675648f..23e5d84 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -3,3 +3,5 @@ spring.datasource.username=sa spring.datasource.password= spring.jpa.hibernate.ddl-auto=create-drop spring.docker.compose.enabled=false +app.security.admin.username=test-admin +app.security.admin.password=test-admin-password