Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
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;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SpringdocConfig {
private static final String ADMIN_BASIC_AUTH = "adminBasicAuth";

@Bean
OpenAPI checkInOpenApi() {
Expand All @@ -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("/"));
}

Expand All @@ -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)
));
}
});
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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<Event, Long> {
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
7 changes: 6 additions & 1 deletion src/main/resources/application.properties
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Loading