Skip to content
Open
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
3 changes: 2 additions & 1 deletion festival-app/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,8 @@ export JWT_SECRET="replace-with-at-least-32-byte-secret-key"
mvn spring-boot:run
```

JWT 만료 시간은 필요하면 `JWT_EXPIRATION_MILLIS` 환경변수로 조정할 수 있습니다.
`JWT_SECRET`을 생략하면 로컬 개발용 기본값을 사용합니다. JWT 만료 시간은 필요하면
`JWT_EXPIRATION_MILLIS` 환경변수로 조정할 수 있습니다.

기본 서버 주소:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.festivalapp.api;

import com.festivalapp.dto.auth.AuthResponse;
import com.festivalapp.dto.auth.SignupRequest;
import com.festivalapp.service.auth.AuthService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
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("/api/admin/auth")
@RequiredArgsConstructor
public class AdminAuthController {

private final AuthService authService;

@PostMapping("/signup")
ResponseEntity<AuthResponse> signup(@Valid @RequestBody SignupRequest request) {
return ResponseEntity.ok(authService.signupAdmin(request));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@

import com.festivalapp.dto.BoothReservationCreateRequest;
import com.festivalapp.dto.BoothReservationResponse;
import com.festivalapp.security.AuthenticatedUser;
import com.festivalapp.service.BoothReservationService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping("/api/booth-reservations")
Expand All @@ -23,15 +26,19 @@ public class BoothReservationController {

@PostMapping
ResponseEntity<BoothReservationResponse> createReservation(
Authentication authentication,
@RequestBody BoothReservationCreateRequest request) {
validateApplicant(authentication, request.applicantId());
return ResponseEntity
.status(HttpStatus.CREATED)
.body(boothReservationService.createReservation(request));
}

@GetMapping("/applicants/{applicantId}")
ResponseEntity<List<BoothReservationResponse>> getReservationsByApplicant(
Authentication authentication,
@PathVariable String applicantId) {
validateApplicant(authentication, applicantId);
return ResponseEntity.ok(boothReservationService.getReservationsByApplicant(applicantId));
}

Expand All @@ -40,4 +47,12 @@ ResponseEntity<BoothReservationResponse> getReservation(
@PathVariable String reservationId) {
return ResponseEntity.ok(boothReservationService.getReservation(reservationId));
}

private void validateApplicant(Authentication authentication, String applicantId) {
if (authentication == null
|| !(authentication.getPrincipal() instanceof AuthenticatedUser user)
|| !user.id().equals(applicantId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인의 예약 정보만 처리할 수 있습니다.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

@Configuration
@EnableConfigurationProperties(CorsConfig.CorsProperties.class)
public class CorsConfig {

@Bean
CorsFilter corsFilter(CorsProperties properties) {
CorsConfigurationSource corsConfigurationSource(CorsProperties properties) {
var configuration = new CorsConfiguration();
configuration.setAllowedOrigins(properties.allowedOrigins());
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
Expand All @@ -23,7 +23,7 @@ CorsFilter corsFilter(CorsProperties properties) {

var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return new CorsFilter(source);
return source;
}

@ConfigurationProperties(prefix = "app.cors")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfigurationSource;

@Configuration
@EnableWebSecurity
Expand All @@ -23,10 +24,12 @@
public class SecurityConfig {

private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CorsConfigurationSource corsConfigurationSource;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http.csrf(AbstractHttpConfigurer::disable)
return http.cors(cors -> cors.configurationSource(corsConfigurationSource))
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.sessionManagement(
Expand All @@ -44,6 +47,8 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
.permitAll()
.requestMatchers("/api/auth/signup", "/api/auth/login", "/api/health")
.permitAll()
.requestMatchers("/api/admin/auth/signup")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/api/admin/auth/signuppermitAll()로 열려 있어서 URL만 알면 누구나
ADMIN 계정을 만들 수 있습니다. 일반 회원가입에서 관리자 선택을 제거했더라도,
이 공개 API로 관리자 권한을 발급받을 수 있어 /api/admin/** 보호가 무력화될
수 있습니다.

관리자 가입은 기존 관리자 권한 필요, 초대 토큰 검증, 로컬/개발 프로필 전용
등 별도 제한을 두는 게 안전해 보입니다.

.permitAll()
.requestMatchers(
HttpMethod.GET,
"/api/booths",
Expand All @@ -53,6 +58,8 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
"/api/map-locations",
"/api/map-locations/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/booth-reservations/*")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 설명에는 예약 조회를 로그인 계정 기준으로 변경했다고 되어 있는데, 단건 예
약 조회 GET /api/booth-reservations/{reservationId}는 여전히 공개 접근 가
능합니다. 응답에 신청자명, 부스, 테이블 수, 상태, QR 정보가 포함되므로
reservationId를 알면 타인의 예약 정보를 볼 수 있습니다.

QR 스캔 확인 화면 때문에 공개 조회가 의도라면 PR 설명과 API 책임을 명확히 분
리하는 게 좋고, 아니라면 단건 조회도 인증 후 본인 예약 또는 관리자만 조회 가
능하도록 제한하는 게 맞아 보입니다.

.permitAll()
.requestMatchers("/api/admin/**")
.hasRole("ADMIN")
.anyRequest()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,23 @@ public void approve(LocalDateTime now) {
updatedAt = now;
}

public boolean canApprove() {
return status == BoothReservationStatus.PENDING_APPROVAL
|| status == BoothReservationStatus.QR_FAILED;
}

public void reserve(String issuedQrCode, LocalDateTime now) {
status = BoothReservationStatus.RESERVED;
qrCode = issuedQrCode;
updatedAt = now;
}

public void compensateApprovalAfterQrFailure(LocalDateTime now) {
status = BoothReservationStatus.QR_FAILED;
qrCode = null;
updatedAt = now;
}

public String id() {
return id;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public enum BoothReservationStatus {
PENDING_APPROVAL("관리자 승인 대기"),
APPROVED("관리자 승인 완료"),
RESERVED("QR 발급 완료"),
QR_FAILED("QR 발급 실패"),
CHECKED_IN("현장 체크인 완료"),
COMPLETED("예약 이용 완료"),
CANCELLED("예약 취소");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,14 @@

public record BoothReservationApprovalRequest(
String approverId,
String approverName) {}
String approverName,
Boolean simulateQrFailure) {

public BoothReservationApprovalRequest(String approverId, String approverName) {
this(approverId, approverName, false);
}

public boolean shouldSimulateQrFailure() {
return Boolean.TRUE.equals(simulateQrFailure);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.festivalapp.dto;

import java.time.LocalDateTime;

public record BoothReservationCompensationLogResponse(
String step,
String reason,
String fromStatus,
String toStatus,
LocalDateTime createdAt) {}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public record BoothReservationResponse(
String statusDescription,
String qrCode,
List<BoothReservationSagaLogResponse> sagaLogs,
List<BoothReservationCompensationLogResponse> compensationLogs,
LocalDateTime createdAt,
LocalDateTime updatedAt) {

Expand All @@ -24,6 +25,13 @@ public static BoothReservationResponse from(BoothReservation reservation) {
public static BoothReservationResponse from(
BoothReservation reservation,
List<BoothReservationSagaLogResponse> sagaLogs) {
return from(reservation, sagaLogs, List.of());
}

public static BoothReservationResponse from(
BoothReservation reservation,
List<BoothReservationSagaLogResponse> sagaLogs,
List<BoothReservationCompensationLogResponse> compensationLogs) {
return new BoothReservationResponse(
reservation.id(),
reservation.boothId(),
Expand All @@ -34,6 +42,7 @@ public static BoothReservationResponse from(
reservation.status().getDescription(),
reservation.qrCode(),
sagaLogs,
compensationLogs,
reservation.createdAt(),
reservation.updatedAt());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.stereotype.Component;
Expand All @@ -18,7 +20,7 @@ public class JwtTokenProvider {

public JwtTokenProvider(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
this.secretKey = Keys.hmacShaKeyFor(jwtProperties.secret().getBytes(StandardCharsets.UTF_8));
this.secretKey = Keys.hmacShaKeyFor(toSigningKey(jwtProperties.secret()));
}

public String createToken(User user) {
Expand Down Expand Up @@ -56,4 +58,18 @@ private UserRole parseRole(String role) {

return UserRole.valueOf(role);
}

private byte[] toSigningKey(String secret) {
byte[] secretBytes = secret.getBytes(StandardCharsets.UTF_8);

if (secretBytes.length >= 32) {
return secretBytes;
}

try {
return MessageDigest.getInstance("SHA-256").digest(secretBytes);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 알고리즘을 사용할 수 없습니다.", exception);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.festivalapp.service;

import com.festivalapp.domain.booth.reservation.BoothReservation;
import com.festivalapp.domain.booth.reservation.BoothReservationStatus;
import com.festivalapp.dto.BoothReservationCompensationLogResponse;
import com.festivalapp.dto.BoothReservationResponse;
import com.festivalapp.dto.BoothReservationSagaLogResponse;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.stereotype.Component;

@Component
public class BoothReservationApprovalResultFactory {

public BoothReservationSagaLogResponse approvalLog(String approverName, LocalDateTime approvedAt) {
return new BoothReservationSagaLogResponse(
"APPROVED",
approverName + "가 예약 신청을 승인했습니다.",
approvedAt);
}

public BoothReservationResponse qrIssued(
BoothReservation reservation,
BoothReservationSagaLogResponse approvalLog,
LocalDateTime issuedAt) {
return BoothReservationResponse.from(
reservation,
List.of(
approvalLog,
new BoothReservationSagaLogResponse(
"QR_ISSUED",
"QR 발급에 성공해 예약 완료 상태로 전환했습니다.",
issuedAt)));
}

public BoothReservationResponse qrIssueFailed(
BoothReservation reservation,
BoothReservationSagaLogResponse approvalLog,
String failureReason,
LocalDateTime failedAt) {
return BoothReservationResponse.from(
reservation,
List.of(
approvalLog,
new BoothReservationSagaLogResponse(
"QR_ISSUE_FAILED",
failureReason,
failedAt),
new BoothReservationSagaLogResponse(
"APPROVAL_COMPENSATED",
"QR 발급 실패로 승인 상태를 보상 처리했습니다.",
failedAt)),
compensationLogs(failureReason, failedAt));
}

private List<BoothReservationCompensationLogResponse> compensationLogs(
String failureReason,
LocalDateTime failedAt) {
return List.of(
new BoothReservationCompensationLogResponse(
"APPROVAL_ROLLBACK",
failureReason,
BoothReservationStatus.APPROVED.name(),
BoothReservationStatus.QR_FAILED.name(),
failedAt),
new BoothReservationCompensationLogResponse(
"RETRY_REQUIRED",
"관리자 재승인이 필요합니다.",
BoothReservationStatus.QR_FAILED.name(),
BoothReservationStatus.QR_FAILED.name(),
failedAt));
}
}
Loading