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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ REDIS_PASSWORD=
# JWT
JWT_SECRET_KEY=

# App key
# Sent by the official client as the x-app-key header on every /api request.
# Comma separated (KEY_NEW,KEY_OLD) while rotating; leave it empty to turn the check off.
APP_KEY=

# Gmail SMTP (auth verification emails)
GMAIL_ADDRESS=
GMAIL_PASSWORD=
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/cd-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ jobs:
GMAIL_ADDRESS: ${{ secrets.GMAIL_ADDRESS }}
GMAIL_PASSWORD: ${{ secrets.GMAIL_PASSWORD }}
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
APP_KEY: ${{ secrets.APP_KEY }}
run: ./gradlew build -x test --stacktrace

- name: Verify Docker environment
Expand Down Expand Up @@ -139,6 +140,7 @@ jobs:
echo "GMAIL_PASSWORD=${{ secrets.GMAIL_PASSWORD }}" >> .env
echo "GMAIL_ADDRESS=\"${{ secrets.GMAIL_ADDRESS }}\"" >> .env
echo "JWT_SECRET_KEY=\"${{ secrets.JWT_SECRET_KEY }}\"" >> .env
echo "APP_KEY=\"${{ secrets.APP_KEY }}\"" >> .env
echo "LOKI_TOKEN=${{ secrets.LOKI_TOKEN }}" >> .env
echo "GOOGLE_PLAY_PACKAGE_NAME=com.renzzle_fe" >> .env
echo "GOOGLE_APPLICATION_CREDENTIALS=/app/secrets/google-play-service-account.json" >> .env
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/cd-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jobs:
GMAIL_ADDRESS: ${{ secrets.GMAIL_ADDRESS }}
GMAIL_PASSWORD: ${{ secrets.GMAIL_PASSWORD }}
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
APP_KEY: ${{ secrets.APP_KEY }}
run: ./gradlew build -x test --stacktrace

- name: Login to Docker Hub
Expand Down Expand Up @@ -89,6 +90,7 @@ jobs:
echo "GMAIL_PASSWORD=${{ secrets.GMAIL_PASSWORD }}" >> .env
echo "GMAIL_ADDRESS=\"${{ secrets.GMAIL_ADDRESS }}\"" >> .env
echo "JWT_SECRET_KEY=\"${{ secrets.JWT_SECRET_KEY }}\"" >> .env
echo "APP_KEY=\"${{ secrets.APP_KEY }}\"" >> .env
echo "LOKI_TOKEN=${{ secrets.LOKI_TOKEN }}" >> .env
echo "GOOGLE_PLAY_PACKAGE_NAME=com.renzzle_fe" >> .env
echo "GOOGLE_APPLICATION_CREDENTIALS=/app/secrets/google-play-service-account.json" >> .env
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ jobs:
GMAIL_ADDRESS: ${{ secrets.GMAIL_ADDRESS }}
GMAIL_PASSWORD: ${{ secrets.GMAIL_PASSWORD }}
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
APP_KEY: ${{ secrets.APP_KEY }}
GOOGLE_PLAY_PACKAGE_NAME: com.renzzle_fe
GOOGLE_APPLICATION_CREDENTIALS: secrets/google-play-service-account.json
DOCKER_HOST: ${{ steps.docker_host.outputs.docker_host }}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.renzzle.backend.global.config;

import com.renzzle.backend.domain.auth.domain.GrantType;
import com.renzzle.backend.global.security.AppKeyAuthenticationFilter;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
Expand All @@ -10,22 +11,31 @@
import org.springframework.context.annotation.Configuration;

@Configuration
public class SwaggerConfig {
public class OpenApiConfig {

@Bean
public OpenAPI openAPI() {
String jwtSchemeName = "Authorization";
String appKeySchemeName = AppKeyAuthenticationFilter.APP_KEY_HEADER;

SecurityRequirement securityRequirement = new SecurityRequirement();
securityRequirement.addList(jwtSchemeName);
securityRequirement.addList(appKeySchemeName);

SecurityScheme securityScheme = new SecurityScheme();
securityScheme.name(jwtSchemeName)
.type(SecurityScheme.Type.HTTP)
.scheme(GrantType.BEARER.getType());

// Every /api request is rejected without this header
SecurityScheme appKeyScheme = new SecurityScheme();
appKeyScheme.name(appKeySchemeName)
.type(SecurityScheme.Type.APIKEY)
.in(SecurityScheme.In.HEADER);

Components components = new Components();
components.addSecuritySchemes(jwtSchemeName, securityScheme);
components.addSecuritySchemes(appKeySchemeName, appKeyScheme);

return new OpenAPI()
.addSecurityItem(securityRequirement)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
import com.renzzle.backend.domain.auth.dao.AdminRepository;
import com.renzzle.backend.domain.auth.service.JwtProvider;
import com.renzzle.backend.domain.user.dao.UserRepository;
import com.renzzle.backend.global.security.AppKeyAuthenticationFilter;
import com.renzzle.backend.global.security.CustomAccessDeniedHandler;
import com.renzzle.backend.global.security.CustomAuthenticationEntryPoint;
import com.renzzle.backend.global.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
Expand All @@ -19,11 +22,15 @@
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StringUtils;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import static com.renzzle.backend.domain.auth.domain.Admin.ADMIN_PREFIX;

@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
Expand All @@ -36,7 +43,11 @@ public class SecurityConfig {
private final CustomAuthenticationEntryPoint authenticationEntryPoint;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, @Value("${app.key}") String appKey) throws Exception {
// Every API request must carry an app key, whether or not it needs a token
RequestMatcher appKeyRequestMatcher = AntPathRequestMatcher.antMatcher("/api/**");
Set<String> appKeys = parseAppKeys(appKey);

List<RequestMatcher> permitAllRequestMatchers = Arrays.asList(
AntPathRequestMatcher.antMatcher("/admin"), // Admin login page (excluded from JWT filter)
AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/admin/login"), // Admin login API (called without a token)
Expand All @@ -56,7 +67,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws
AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/actuator/**")
);

return httpSecurity.csrf(AbstractHttpConfigurer::disable)
httpSecurity.csrf(AbstractHttpConfigurer::disable)
.formLogin(FormLoginConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement ->
Expand Down Expand Up @@ -106,7 +117,23 @@ public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws
.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint)
)
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider, userRepository, adminRepository, permitAllRequestMatchers), UsernamePasswordAuthenticationFilter.class)
.build();
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider, userRepository, adminRepository, permitAllRequestMatchers), UsernamePasswordAuthenticationFilter.class);

if (!appKeys.isEmpty()) {
// Runs after authentication so admin dashboard requests, which cannot hold the key in a browser, are exempt
httpSecurity.addFilterAfter(new AppKeyAuthenticationFilter(appKeys, appKeyRequestMatcher), JwtAuthenticationFilter.class);
} else {
log.warn("APP_KEY is empty, so app key verification is disabled");
}

return httpSecurity.build();
}

// APP_KEY holds a comma separated list, so a new key can be accepted before the old one is dropped
private Set<String> parseAppKeys(String appKey) {
return Arrays.stream(appKey.split(","))
.map(String::trim)
.filter(StringUtils::hasText)
.collect(Collectors.toUnmodifiableSet());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public enum ErrorCode {
// Global
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,"G500","Internal server error"),
VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "G400", "Invalid request"),
INVALID_APP_KEY(HttpStatus.FORBIDDEN, "G403", "Forbidden request"),
GLOBAL_NOT_FOUND(HttpStatus.NOT_FOUND, "G404", "Resource not found"),
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "G405", "Unsupported HTTP method"),
UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "G415", "Unsupported media type"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.renzzle.backend.global.security;

import com.renzzle.backend.global.exception.ErrorCode;
import jakarta.annotation.Nonnull;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Set;
import static com.renzzle.backend.domain.auth.domain.Admin.ADMIN_PREFIX;

@Slf4j
@RequiredArgsConstructor
public class AppKeyAuthenticationFilter extends OncePerRequestFilter {

public static final String APP_KEY_HEADER = "x-app-key";

private final Set<String> appKeys;
private final RequestMatcher protectedRequestMatcher;

@Override
protected boolean shouldNotFilter(@Nonnull HttpServletRequest request) {
return !protectedRequestMatcher.matches(request);
}

@Override
protected void doFilterInternal(@Nonnull HttpServletRequest request, @Nonnull HttpServletResponse response, @Nonnull FilterChain filterChain)
throws ServletException, IOException {
if (isAdminRequest() || isValidAppKey(request.getHeader(APP_KEY_HEADER))) {
filterChain.doFilter(request, response);
return;
}

log.warn("Blocked request with missing or invalid app key: {} {}", request.getMethod(), request.getRequestURI());
SecurityErrorResponder.writeJsonError(response, ErrorCode.INVALID_APP_KEY);
}

private boolean isAdminRequest() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
return authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(ADMIN_PREFIX::equals);
}

private boolean isValidAppKey(String requestAppKey) {
if (requestAppKey == null) {
return false;
}

byte[] requestKey = requestAppKey.getBytes(StandardCharsets.UTF_8);
boolean matched = false;
for (String appKey : appKeys) {
// Not short circuited, so the time spent here does not depend on which key matches
matched |= MessageDigest.isEqual(requestKey, appKey.getBytes(StandardCharsets.UTF_8));
}
return matched;
}

}
4 changes: 4 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ spring:
init:
mode: always

app:
# Required in the x-app-key header of every /api request
key: ${APP_KEY:}

rank:
session:
ttl: 3600 # seconds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public void initialize(@NotNull ConfigurableApplicationContext applicationContex
"spring.data.redis.host=" + redisContainer.getHost(),
"spring.data.redis.port=" + redisContainer.getFirstMappedPort(),
"spring.jwt.secret=" + "testsecrettestsecrettestsecrettestsecret",
"app.key=" + "test-app-key",
"spring.mail.username=" + "test@gmail.com",
"spring.mail.password=" + "password",
"rank.session.ttl=" + "10",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.renzzle.backend.domain.puzzle.shared.util.ZobristHashUtils;
import com.renzzle.backend.domain.user.dao.UserRepository;
import com.renzzle.backend.domain.user.domain.UserEntity;
import com.renzzle.backend.global.security.AppKeyAuthenticationFilter;
import com.renzzle.backend.support.TestUserEntityBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -76,6 +77,7 @@ void setUp() {

authHeaders = new HttpHeaders();
authHeaders.setBearerAuth(token);
authHeaders.set(AppKeyAuthenticationFilter.APP_KEY_HEADER, "test-app-key");
}

@Test
Expand All @@ -94,7 +96,7 @@ void lookupPerformance_WhenCachePuzzleRowsGrowTo10000_ThenComparesLatency() {
.solutionDag(serializer.serialize(targetDag))
.build());

double beforeMs = measureApiCall(1L);
double beforeMs = measureApiCall();

List<PuzzleCache> dummies = new ArrayList<>();
for (long id = 2; id <= 10_000; id++) {
Expand All @@ -111,7 +113,7 @@ void lookupPerformance_WhenCachePuzzleRowsGrowTo10000_ThenComparesLatency() {
}
puzzleCacheRepository.saveAll(dummies);

double afterMs = measureApiCall(1L);
double afterMs = measureApiCall();

System.out.println("========================================");
System.out.println("[Scenario 1] cache_puzzle row count growth");
Expand Down Expand Up @@ -140,7 +142,7 @@ void lookupPerformance_WhenSolutionDagGrowsTo10000Entries_ThenComparesLatency()
.solutionDag(serializer.serialize(smallDag))
.build());

double beforeMs = measureApiCall(1L);
double beforeMs = measureApiCall();

Map<Long, Integer> largeDag = new HashMap<>();
largeDag.put(knownHash, 112);
Expand All @@ -151,7 +153,7 @@ void lookupPerformance_WhenSolutionDagGrowsTo10000Entries_ThenComparesLatency()
.solutionDag(serializer.serialize(largeDag))
.build());

double afterMs = measureApiCall(1L);
double afterMs = measureApiCall();

System.out.println("========================================");
System.out.println("[Scenario 2] solution_dag size growth");
Expand All @@ -164,19 +166,19 @@ void lookupPerformance_WhenSolutionDagGrowsTo10000Entries_ThenComparesLatency()
assertThat(afterMs).isPositive();
}

private double measureApiCall(Long puzzleId) {
private double measureApiCall() {
String url = "/api/puzzle/cache/ai-response?puzzleType={type}&puzzleId={id}&currentBoardState={state}";
HttpEntity<Void> entity = new HttpEntity<>(authHeaders);

// warmup
restTemplate.exchange(url, HttpMethod.GET, entity, String.class, TYPE.name(), puzzleId, TEST_BOARD_STATE);
restTemplate.exchange(url, HttpMethod.GET, entity, String.class, TYPE.name(), 1L, TEST_BOARD_STATE);

StopWatch stopWatch = new StopWatch();
for (int i = 0; i < MEASURE_ITERATIONS; i++) {
stopWatch.start();
ResponseEntity<String> response = restTemplate.exchange(
url, HttpMethod.GET, entity, String.class,
TYPE.name(), puzzleId, TEST_BOARD_STATE
TYPE.name(), 1L, TEST_BOARD_STATE
);
stopWatch.stop();

Expand Down
Loading
Loading