diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml new file mode 100644 index 00000000..cc9f1d4b --- /dev/null +++ b/.github/workflows/cd-prod.yml @@ -0,0 +1,52 @@ +name: CD - PROD + +on: + push: + branches: [ main ] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: "temurin" + + - uses: gradle/actions/setup-gradle@v3 + + - name: Grant execute permission + run: chmod +x gradlew + + - name: Build JAR + run: ./gradlew build -x test + + - name: Docker Login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build & Push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/spot-backend:prod + ${{ secrets.DOCKERHUB_USERNAME }}/spot-backend:${{ github.sha }} + + - name: Deploy + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.PROD_SERVER_IP }} + username: ubuntu + key: ${{ secrets.PROD_SERVER_SSH_KEY }} + script: | + cd /home/ubuntu/spot + git pull origin main + docker compose pull + docker compose up -d diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index ff2232e0..ce2c3228 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -23,6 +23,12 @@ jobs: --health-timeout 5s --health-retries 5 + env: + SPRING_PROFILES_ACTIVE: test + JWT_SECRET: pPmJ9ViYt8f6HAh2q5s36QmEUeyEFRcquPaNpnIGUK8er5DjfTKa4xbDsTFXQ7HRVfTLR2DIYs7s9iGdJ+Yb7Q== + JWT_ACCESS_EXPIRE_MS: 600000 + JWT_REFRESH_EXPIRE_MS: 1209600000 + steps: - name: Checkout code uses: actions/checkout@v4 @@ -33,19 +39,13 @@ jobs: java-version: '21' distribution: 'temurin' cache: gradle - + - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Make application.properties - run: | - mkdir -p ./src/main/resources - echo "${{ secrets.APPLICATION_YML }}" > ./src/main/resources/application.yml - shell: bash - - name: Check code formatting run: ./gradlew checkstyleMain checkstyleTest continue-on-error: true @@ -66,7 +66,7 @@ jobs: # 최대 60초 대기하면서 애플리케이션 시작 확인 for i in {1..60}; do # 로그에서 시작 완료 메시지 확인 - if grep -q "Started SpotApplication" bootrun.log 2>/dev/null; then + if grep -q "Started RealMatchApplication" bootrun.log; then echo "✅ Application started successfully after ${i}s" kill $APP_PID 2>/dev/null || true exit 0 diff --git a/.gitignore b/.gitignore index 9922c12a..45116f7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,13 @@ # 특정 파일 제외 -src/main/resources/application-*.yml +#src/main/resources/application-*.yml src/main/resources/application.yml src/test/resources/application.yml src/test/resources/application-*.yml +# 환경 변수 +.env +.env.* + ### Gradle ### .gradle/ build/ diff --git a/build.gradle b/build.gradle index 6c2b21c4..8f359a16 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,12 @@ dependencies { // mySQL runtimeOnly 'com.mysql:mysql-connector-j' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' + + // postgresql (CI test 용) + runtimeOnly 'org.postgresql:postgresql' } tasks.named('test') { diff --git a/docker-compose.yaml b/docker-compose.yaml index d81aed88..a2d2f9df 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -6,10 +6,10 @@ services: container_name: mysql_db restart: always environment: - MYSQL_DATABASE: myapp_db - MYSQL_USER: admin - MYSQL_PASSWORD: secret - MYSQL_ROOT_PASSWORD: rootsecret + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} ports: - "3306:3306" volumes: @@ -34,12 +34,13 @@ services: depends_on: - db - redis + env_file: + - .env environment: - - SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/myapp_db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul - - SPRING_DATASOURCE_USERNAME=admin - - SPRING_DATASOURCE_PASSWORD=secret - - SPRING_DATASOURCE_DRIVER_CLASS_NAME=com.mysql.cj.jdbc.Driver - - SPRING_REDIS_HOST=redis - - SPRING_REDIS_PORT=6379 - - SPRING_PROFILES_ACTIVE=dev - + SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} + SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} + SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} + SPRING_DATASOURCE_DRIVER_CLASS_NAME: ${SPRING_DATASOURCE_DRIVER_CLASS_NAME} + SPRING_REDIS_HOST: ${SPRING_REDIS_HOST} + SPRING_REDIS_PORT: ${SPRING_REDIS_PORT} + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE} diff --git a/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java b/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java new file mode 100644 index 00000000..1f558a40 --- /dev/null +++ b/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java @@ -0,0 +1,76 @@ +package com.example.RealMatch.global.config; + +import java.util.List; + +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.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import com.example.RealMatch.global.config.jwt.JwtAuthenticationFilter; +import com.example.RealMatch.global.presentation.advice.CustomAccessDeniedHandler; +import com.example.RealMatch.global.presentation.advice.CustomAuthEntryPoint; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + private static final String[] PERMIT_ALL_URL_ARRAY = { + "/api/v1/test", + "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/swagger-ui.html" + }; + + private static final String[] REQUEST_AUTHENTICATED_ARRAY = { + "/api/v1/test-auth" + }; + + @Value("${cors.allowed-origin}") + private String allowedOrigin; + @Value("${swagger.server-url}") + String swaggerUrl; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomAuthEntryPoint customAuthEntryPoint, CustomAccessDeniedHandler customAccessDeniedHandler) throws Exception { + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + + .exceptionHandling(exception -> exception + .authenticationEntryPoint(customAuthEntryPoint) // 401 + .accessDeniedHandler(customAccessDeniedHandler) // 403 + ) + + .authorizeHttpRequests(auth -> auth + .requestMatchers(REQUEST_AUTHENTICATED_ARRAY).authenticated() + .requestMatchers(PERMIT_ALL_URL_ARRAY).permitAll() + .anyRequest().denyAll() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of(allowedOrigin, "http://localhost:8080", swaggerUrl)); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); // 쿠키/인증정보 포함 요청 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} + diff --git a/src/main/java/com/example/RealMatch/global/config/SwaggerConfig.java b/src/main/java/com/example/RealMatch/global/config/SwaggerConfig.java new file mode 100644 index 00000000..258b3553 --- /dev/null +++ b/src/main/java/com/example/RealMatch/global/config/SwaggerConfig.java @@ -0,0 +1,52 @@ +package com.example.RealMatch.global.config; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +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; + +@Configuration +public class SwaggerConfig { + + @Value("${swagger.server-url}") + private String swaggerUrl; + + @Bean + public OpenAPI localOpenAPI() { + Info info = new Info() + .title("🔗 RealMatch API") + .version("1.0.0") + .description("RealMatch API 명세서입니다."); + + String jwtSchemeName = "JWT Authentication"; + + io.swagger.v3.oas.models.security.SecurityScheme securityScheme = new io.swagger.v3.oas.models.security.SecurityScheme() + .name("Authorization") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"); + + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, securityScheme); + + return new OpenAPI() + .info(info) + .addSecurityItem(securityRequirement) + .components(components) + .servers(List.of( + new Server() + .url(swaggerUrl) + )); + } + +} diff --git a/src/main/java/com/example/RealMatch/global/config/jwt/CustomUserDetails.java b/src/main/java/com/example/RealMatch/global/config/jwt/CustomUserDetails.java new file mode 100644 index 00000000..4f6af748 --- /dev/null +++ b/src/main/java/com/example/RealMatch/global/config/jwt/CustomUserDetails.java @@ -0,0 +1,58 @@ +package com.example.RealMatch.global.config.jwt; + +import java.util.Collection; +import java.util.List; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import lombok.Getter; + +@Getter +public class CustomUserDetails implements UserDetails { + + private final Long userId; // DB PK + private final String providerId; // 소셜 고유 ID + private final String role; // USER / ADMIN + + public CustomUserDetails(Long userId, String providerId, String role) { + this.userId = userId; + this.providerId = providerId; + this.role = role; + } + + @Override + public Collection getAuthorities() { + return List.of(() -> "ROLE_" + role); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return providerId; + } // 소셜 UUID 기준 + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/example/RealMatch/global/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/RealMatch/global/config/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..37964ae8 --- /dev/null +++ b/src/main/java/com/example/RealMatch/global/config/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,62 @@ +package com.example.RealMatch.global.config.jwt; + +import java.io.IOException; + +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = authHeader.substring(7); + + if (!jwtProvider.validateToken(token)) { + filterChain.doFilter(request, response); + return; + } + + Long userId = jwtProvider.getUserId(token); + String providerId = jwtProvider.getProviderId(token); + String role = jwtProvider.getRole(token); + + CustomUserDetails userDetails = + new CustomUserDetails(userId, providerId, role); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + filterChain.doFilter(request, response); + } + +} diff --git a/src/main/java/com/example/RealMatch/global/config/jwt/JwtProvider.java b/src/main/java/com/example/RealMatch/global/config/jwt/JwtProvider.java new file mode 100644 index 00000000..9071df96 --- /dev/null +++ b/src/main/java/com/example/RealMatch/global/config/jwt/JwtProvider.java @@ -0,0 +1,101 @@ +package com.example.RealMatch.global.config.jwt; + +import java.util.Base64; +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; + +@Component +public class JwtProvider { + + private final SecretKey secretKey; + private final long accessTokenExpireMillis; + private final long refreshTokenExpireMillis; + + public JwtProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.access-expire-ms}") long accessTokenExpireMillis, + @Value("${jwt.refresh-expire-ms}") long refreshTokenExpireMillis + ) { + byte[] keyBytes = Base64.getDecoder().decode(secret); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + this.accessTokenExpireMillis = accessTokenExpireMillis; + this.refreshTokenExpireMillis = refreshTokenExpireMillis; + } + + // ========================= + // 토큰 생성 + // ========================= + public String createAccessToken(Long userId, String providerId, String role) { + return createToken(userId, providerId, role, accessTokenExpireMillis); + } + + public String createRefreshToken(Long userId, String providerId, String role) { + return createToken(userId, providerId, role, refreshTokenExpireMillis); + } + + private String createToken(Long userId, String providerId, String role, long expireMillis) { + long now = System.currentTimeMillis(); + + return Jwts.builder() + .subject(String.valueOf(userId)) + .claim("providerId", providerId) + .claim("role", role) + .issuedAt(new Date(now)) + .expiration(new Date(now + expireMillis)) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + // ========================= + // 토큰 검증 + // ========================= + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (ExpiredJwtException e) { + return false; // 만료 + } catch (JwtException | IllegalArgumentException e) { + return false; // 변조, 손상 등 + } + } + + // ========================= + // Claims 가져오기 + // ========================= + public Claims getClaims(String token) { + return parseClaims(token).getPayload(); + } + + public Long getUserId(String token) { + return Long.parseLong(getClaims(token).getSubject()); + } + + public String getProviderId(String token) { + return getClaims(token).get("providerId", String.class); + } + + public String getRole(String token) { + return getClaims(token).get("role", String.class); + } + + private Jws parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) // Key 타입 강제 + .build() + .parseSignedClaims(token); + } +} + diff --git a/src/main/java/com/example/RealMatch/global/controller/TestController.java b/src/main/java/com/example/RealMatch/global/controller/TestController.java new file mode 100644 index 00000000..3ee96566 --- /dev/null +++ b/src/main/java/com/example/RealMatch/global/controller/TestController.java @@ -0,0 +1,58 @@ +package com.example.RealMatch.global.controller; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.RealMatch.global.config.jwt.CustomUserDetails; +import com.example.RealMatch.global.presentation.CustomResponse; +import com.example.RealMatch.global.presentation.code.GeneralSuccessCode; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "test", description = "테스트용 API") +@RestController +@RequestMapping("/api") +public class TestController { + + @Operation(summary = "api 테스트 확인", + description = """ + 테스트용 api입니다. + 만약 이 api가 통과하지 않는다면, SecurityConfig에 url을 추가해야합니다. + + 인증이 필요없다면, PERMIT_ALL_URL_ARRAY에 추가하고, + 인증이 필요하다면, REQUEST_AUTHENTICATED_ARRAY에 추가해주세요. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "테스트 성공") + }) + @GetMapping("/test") + public CustomResponse test() { + String response = "Hello from Spring Boot 👋"; + return CustomResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, response); + } + + @Operation(summary = "api 권한 테스트 확인", + description = """ + 테스트용 api입니다. + Swagger에서 Authorize에 토큰을 입력한 후 사용해야 정상 작동합니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "테스트 성공"), + @ApiResponse(responseCode = "COMMON401_1", description = "인증이 필요합니다.") + }) + @GetMapping("/test-auth") + public CustomResponse testAuth( + @AuthenticationPrincipal CustomUserDetails user + ) { + String response = "Hello from Spring Boot 👋"; + return CustomResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, response); + } + + +} + diff --git a/src/main/java/com/example/RealMatch/global/presentation/ApiResponse.java b/src/main/java/com/example/RealMatch/global/presentation/CustomResponse.java similarity index 54% rename from src/main/java/com/example/RealMatch/global/presentation/ApiResponse.java rename to src/main/java/com/example/RealMatch/global/presentation/CustomResponse.java index b6449dc9..af6f39ae 100644 --- a/src/main/java/com/example/RealMatch/global/presentation/ApiResponse.java +++ b/src/main/java/com/example/RealMatch/global/presentation/CustomResponse.java @@ -2,6 +2,7 @@ import com.example.RealMatch.global.presentation.code.BaseErrorCode; import com.example.RealMatch.global.presentation.code.BaseSuccessCode; +import com.example.RealMatch.global.presentation.code.GeneralSuccessCode; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; @@ -11,7 +12,7 @@ @Getter @AllArgsConstructor @JsonPropertyOrder({"isSuccess", "code", "message", "result"}) -public class ApiResponse { +public class CustomResponse { @JsonProperty("isSuccess") private final Boolean isSuccess; @@ -25,11 +26,16 @@ public class ApiResponse { @JsonProperty("result") private T result; - public static ApiResponse onSuccess(BaseSuccessCode code, T result) { - return new ApiResponse<>(true, code.getCode(), code.getMessage(), result); + // 200 OK + public static CustomResponse ok(T result) { + return CustomResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, result); } - public static ApiResponse onFailure(BaseErrorCode code, T result) { - return new ApiResponse<>(false, code.getCode(), code.getMessage(), result); + public static CustomResponse onSuccess(BaseSuccessCode code, T result) { + return new CustomResponse<>(true, code.getCode(), code.getMessage(), result); + } + + public static CustomResponse onFailure(BaseErrorCode code, T result) { + return new CustomResponse<>(false, code.getCode(), code.getMessage(), result); } } diff --git a/src/main/java/com/example/RealMatch/global/presentation/advice/CustomAccessDeniedHandler.java b/src/main/java/com/example/RealMatch/global/presentation/advice/CustomAccessDeniedHandler.java new file mode 100644 index 00000000..012d123c --- /dev/null +++ b/src/main/java/com/example/RealMatch/global/presentation/advice/CustomAccessDeniedHandler.java @@ -0,0 +1,42 @@ +package com.example.RealMatch.global.presentation.advice; + +import java.io.IOException; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import com.example.RealMatch.global.presentation.CustomResponse; +import com.example.RealMatch.global.presentation.code.GeneralErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + + response.setStatus(GeneralErrorCode.FORBIDDEN.getStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + + CustomResponse body = + CustomResponse.onFailure( + GeneralErrorCode.FORBIDDEN, + null + ); + + response.getWriter() + .write(objectMapper.writeValueAsString(body)); + } +} diff --git a/src/main/java/com/example/RealMatch/global/presentation/advice/CustomAuthEntryPoint.java b/src/main/java/com/example/RealMatch/global/presentation/advice/CustomAuthEntryPoint.java new file mode 100644 index 00000000..79fdc7ed --- /dev/null +++ b/src/main/java/com/example/RealMatch/global/presentation/advice/CustomAuthEntryPoint.java @@ -0,0 +1,42 @@ +package com.example.RealMatch.global.presentation.advice; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import com.example.RealMatch.global.presentation.CustomResponse; +import com.example.RealMatch.global.presentation.code.GeneralErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CustomAuthEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + + response.setStatus(GeneralErrorCode.UNAUTHORIZED.getStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + + CustomResponse body = + CustomResponse.onFailure( + GeneralErrorCode.UNAUTHORIZED, + null + ); + + response.getWriter() + .write(objectMapper.writeValueAsString(body)); + } +} diff --git a/src/main/java/com/example/RealMatch/global/presentation/advice/GlobalExceptionHandler.java b/src/main/java/com/example/RealMatch/global/presentation/advice/GlobalExceptionHandler.java index 1d4d3264..f40ff4fa 100644 --- a/src/main/java/com/example/RealMatch/global/presentation/advice/GlobalExceptionHandler.java +++ b/src/main/java/com/example/RealMatch/global/presentation/advice/GlobalExceptionHandler.java @@ -5,7 +5,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.HandlerMethodValidationException; -import com.example.RealMatch.global.presentation.ApiResponse; +import com.example.RealMatch.global.presentation.CustomResponse; import com.example.RealMatch.global.presentation.code.GeneralErrorCode; import jakarta.validation.ConstraintViolationException; @@ -16,62 +16,62 @@ public class GlobalExceptionHandler { @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { + public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { log.warn("[IllegalArgumentException] {}", e.getMessage()); return ResponseEntity .status(GeneralErrorCode.BAD_REQUEST.getStatus()) - .body(ApiResponse.onFailure(GeneralErrorCode.BAD_REQUEST, null)); + .body(CustomResponse.onFailure(GeneralErrorCode.BAD_REQUEST, null)); } @ExceptionHandler(ConstraintViolationException.class) - public ResponseEntity> handleConstraintViolation(ConstraintViolationException e) { + public ResponseEntity> handleConstraintViolation(ConstraintViolationException e) { log.warn("[ConstraintViolationException] {}", e.getMessage()); return ResponseEntity .status(GeneralErrorCode.INVALID_PAGE.getStatus()) - .body(ApiResponse.onFailure(GeneralErrorCode.INVALID_PAGE, null)); + .body(CustomResponse.onFailure(GeneralErrorCode.INVALID_PAGE, null)); } @ExceptionHandler(HandlerMethodValidationException.class) - public ResponseEntity> handleHandlerMethodValidation(HandlerMethodValidationException e) { + public ResponseEntity> handleHandlerMethodValidation(HandlerMethodValidationException e) { log.warn("[HandlerMethodValidationException] {}", e.getMessage()); return ResponseEntity .status(GeneralErrorCode.INVALID_PAGE.getStatus()) - .body(ApiResponse.onFailure(GeneralErrorCode.INVALID_PAGE, null)); + .body(CustomResponse.onFailure(GeneralErrorCode.INVALID_PAGE, null)); } @ExceptionHandler(SecurityException.class) - public ResponseEntity> handleSecurityException(SecurityException e) { + public ResponseEntity> handleSecurityException(SecurityException e) { log.warn("[SecurityException] {}", e.getMessage()); return ResponseEntity .status(GeneralErrorCode.UNAUTHORIZED.getStatus()) - .body(ApiResponse.onFailure(GeneralErrorCode.UNAUTHORIZED, null)); + .body(CustomResponse.onFailure(GeneralErrorCode.UNAUTHORIZED, null)); } @ExceptionHandler(ResourceNotFoundException.class) - public ResponseEntity> handleResourceNotFound(ResourceNotFoundException e) { + public ResponseEntity> handleResourceNotFound(ResourceNotFoundException e) { log.warn("[ResourceNotFoundException] {}", e.getMessage()); return ResponseEntity .status(GeneralErrorCode.NOT_FOUND.getStatus()) - .body(ApiResponse.onFailure(GeneralErrorCode.NOT_FOUND, null)); + .body(CustomResponse.onFailure(GeneralErrorCode.NOT_FOUND, null)); } @ExceptionHandler(Exception.class) - public ResponseEntity> handleUnexpectedException(Exception e) { + public ResponseEntity> handleUnexpectedException(Exception e) { log.error("[UnexpectedException]", e); return ResponseEntity .status(GeneralErrorCode.INTERNAL_SERVER_ERROR.getStatus()) - .body(ApiResponse.onFailure(GeneralErrorCode.INTERNAL_SERVER_ERROR, null)); + .body(CustomResponse.onFailure(GeneralErrorCode.INTERNAL_SERVER_ERROR, null)); } } diff --git a/src/main/java/com/example/RealMatch/global/presentation/code/BaseErrorCode.java b/src/main/java/com/example/RealMatch/global/presentation/code/BaseErrorCode.java index 4c387271..43c2f3fe 100644 --- a/src/main/java/com/example/RealMatch/global/presentation/code/BaseErrorCode.java +++ b/src/main/java/com/example/RealMatch/global/presentation/code/BaseErrorCode.java @@ -3,8 +3,10 @@ import org.springframework.http.HttpStatus; public interface BaseErrorCode { - + HttpStatus getStatus(); + String getCode(); + String getMessage(); } diff --git a/src/main/java/com/example/RealMatch/global/presentation/code/BaseSuccessCode.java b/src/main/java/com/example/RealMatch/global/presentation/code/BaseSuccessCode.java index 962a30e3..6ae9b199 100644 --- a/src/main/java/com/example/RealMatch/global/presentation/code/BaseSuccessCode.java +++ b/src/main/java/com/example/RealMatch/global/presentation/code/BaseSuccessCode.java @@ -3,8 +3,10 @@ import org.springframework.http.HttpStatus; public interface BaseSuccessCode { - + HttpStatus getStatus(); + String getCode(); + String getMessage(); } diff --git a/src/main/java/com/example/RealMatch/global/presentation/code/GeneralSuccessCode.java b/src/main/java/com/example/RealMatch/global/presentation/code/GeneralSuccessCode.java index 2a347bd8..b326b6e5 100644 --- a/src/main/java/com/example/RealMatch/global/presentation/code/GeneralSuccessCode.java +++ b/src/main/java/com/example/RealMatch/global/presentation/code/GeneralSuccessCode.java @@ -16,8 +16,8 @@ public enum GeneralSuccessCode implements BaseSuccessCode { "AUTH201_1", "인증이 확인되었습니다."), CREATE(HttpStatus.CREATED, - "CREATE200_1", - "성공적으로 생성되었습니다."), + "CREATE200_1", + "성공적으로 생성되었습니다."), ALLOWED(HttpStatus.ACCEPTED, "AUTH203_1", "요청이 허용되었습니다."), diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..83a0b6ef --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,48 @@ +cors: + allowed-origin: http://${SERVER_IP} + +swagger: + server-url: ${SWAGGER_SERVER_URL} + +server: + timezone: Asia/Seoul + +spring: + application: + name: Spot + activate: + on-profile: prod + + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + driver-class-name: ${SPRING_DATASOURCE_DRIVER_CLASS_NAME} + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + idle-timeout: 30000 + connection-timeout: 10000 + max-lifetime: 600000 + + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + format_sql: true + + security: + refresh-token: + expire-days: 14 + +logging: + level: + org.hibernate.SQL: warn + +jwt: + secret: ${JWT_SECRET} + access-expire-ms: ${JWT_ACCESS_EXPIRE_MS} + refresh-expire-ms: ${JWT_REFRESH_EXPIRE_MS} + diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 00000000..41ce166c --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,42 @@ +cors: + allowed-origin: http://localhost:3000 + +swagger: + server-url: http://localhost:8080 + +server: + timezone: Asia/Seoul + +spring: + application: + name: Spot + + datasource: + url: jdbc:postgresql://localhost:5432/myapp_db + username: admin + password: secret + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + idle-timeout: 30000 + connection-timeout: 10000 + max-lifetime: 600000 + + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + show-sql: false + + security: + refresh-token: + expire-days: 14 + +jwt: + secret: ${JWT_SECRET} + access-expire-ms: ${JWT_ACCESS_EXPIRE_MS} + refresh-expire-ms: ${JWT_REFRESH_EXPIRE_MS}