Skip to content

Commit 8107da5

Browse files
authored
[Fix/#399] Swagger 로그인 리다이렉트 문제 수정 및 OpenAPI 인증 설정 정리 (#400)
* fix: resolve swagger login redirect and sync openapi auth - test = 보안 회귀 테스트 * fix: prod-openapi.yml * fix: test 오류 해결
1 parent 3ca3072 commit 8107da5

10 files changed

Lines changed: 203 additions & 55 deletions

File tree

.github/workflows/prod-openapi.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ concurrency:
1616
jobs:
1717
sync:
1818
runs-on: ubuntu-latest
19-
if: ${{ github.event.workflow_run.conclusion == 'success' }}
19+
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
2020

2121
env:
2222
CODIVEAPI_BRANCH: main
2323
TARGET_PATH: Sources/CodiveAPI/openapi.yaml
24+
SWAGGER_USERNAME: ${{ secrets.SWAGGER_USERNAME }}
25+
SWAGGER_PASSWORD: ${{ secrets.SWAGGER_PASSWORD }}
2426

2527
steps:
2628
- name: Checkout backend repo (for context only)
@@ -36,7 +38,9 @@ jobs:
3638
echo "Waiting for production OpenAPI to be ready..."
3739
3840
for i in {1..30}; do
39-
if curl -fsS --connect-timeout 5 "$PROD_OPENAPI_URL" > /dev/null; then
41+
if curl -fsS --connect-timeout 5 \
42+
-u "$SWAGGER_USERNAME:$SWAGGER_PASSWORD" \
43+
"$PROD_OPENAPI_URL" > /dev/null; then
4044
echo "Server is ready."
4145
exit 0
4246
fi
@@ -55,6 +59,7 @@ jobs:
5559
set -euo pipefail
5660
echo "Fetching OpenAPI from: $PROD_OPENAPI_URL"
5761
curl -fsSL --retry 5 --retry-delay 2 --connect-timeout 10 --max-time 30 \
62+
-u "$SWAGGER_USERNAME:$SWAGGER_PASSWORD" \
5863
"$PROD_OPENAPI_URL" -o openapi.json
5964
test -s openapi.json
6065
head -c 1 openapi.json | grep -q '{'

clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java

Lines changed: 15 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,18 @@
99
import org.clokey.domain.auth.service.JwtTokenService;
1010
import org.clokey.global.security.AppleAwareOAuth2AuthorizationRequestResolver;
1111
import org.clokey.global.security.JwtAuthenticationFilter;
12-
import org.clokey.helper.SpringEnvironmentHelper;
13-
import org.springframework.beans.factory.annotation.Value;
12+
import org.clokey.global.security.SwaggerBasicAuthenticationFilter;
1413
import org.springframework.context.annotation.Bean;
1514
import org.springframework.context.annotation.Configuration;
16-
import org.springframework.context.annotation.Profile;
17-
import org.springframework.core.annotation.Order;
1815
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
1916
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
2017
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
2118
import org.springframework.security.config.http.SessionCreationPolicy;
22-
import org.springframework.security.core.userdetails.User;
23-
import org.springframework.security.core.userdetails.UserDetails;
2419
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
2520
import org.springframework.security.crypto.password.PasswordEncoder;
2621
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
2722
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
2823
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
29-
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
3024
import org.springframework.security.web.SecurityFilterChain;
3125
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
3226
import org.springframework.web.cors.CorsConfiguration;
@@ -38,16 +32,13 @@
3832
@RequiredArgsConstructor
3933
public class SecurityConfig {
4034

41-
private final SpringEnvironmentHelper springEnvironmentHelper;
35+
private static final String[] SWAGGER_PATHS = {
36+
"/swagger-ui", "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs", "/v3/api-docs/**"
37+
};
38+
4239
private final CustomOAuth2UserService customOAuth2UserService;
4340
private final OidcLoginSuccessHandler oidcLoginSuccessHandler;
4441

45-
@Value("${swagger.username:default}")
46-
private String swaggerUsername;
47-
48-
@Value("${swagger.password:default}")
49-
private String swaggerPassword;
50-
5142
private void defaultFilterChain(HttpSecurity http) throws Exception {
5243
http.httpBasic(AbstractHttpConfigurer::disable)
5344
.formLogin(AbstractHttpConfigurer::disable)
@@ -58,54 +49,27 @@ private void defaultFilterChain(HttpSecurity http) throws Exception {
5849
session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED));
5950
}
6051

61-
@Bean
62-
public InMemoryUserDetailsManager inMemoryUserDetailsManager() {
63-
UserDetails user =
64-
User.withUsername(swaggerUsername)
65-
.password(passwordEncoder().encode(swaggerPassword))
66-
.roles("SWAGGER")
67-
.build();
68-
69-
return new InMemoryUserDetailsManager(user);
70-
}
71-
7252
@Bean
7353
public PasswordEncoder passwordEncoder() {
7454
return new BCryptPasswordEncoder();
7555
}
7656

7757
@Bean
78-
@Order(1)
79-
@Profile({"dev", "local", "prod"})
80-
public SecurityFilterChain swaggerFilterChain(HttpSecurity http) throws Exception {
81-
defaultFilterChain(http);
82-
83-
http.securityMatcher("/swagger-ui/**", "/v3/api-docs/**").httpBasic(withDefaults());
84-
85-
http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated());
86-
87-
return http.build();
88-
}
89-
90-
/** 인증 없이 제공하고 싶은 API는 /public 으로 시작해야 합니다. */
91-
@Bean
92-
@Order(2)
93-
@Profile({"local", "dev", "prod"})
9458
public SecurityFilterChain apiFilterChain(
9559
HttpSecurity http,
9660
JwtAuthenticationFilter jwtAuthenticationFilter,
97-
OAuth2AuthorizationRequestResolver authorizationRequestResolver)
61+
OAuth2AuthorizationRequestResolver authorizationRequestResolver,
62+
SwaggerBasicAuthenticationFilter swaggerBasicAuthenticationFilter)
9863
throws Exception {
9964
defaultFilterChain(http);
10065

10166
http.authorizeHttpRequests(
10267
auth ->
103-
auth.requestMatchers(
104-
"/public/**",
105-
"/swagger-ui/**",
106-
"/v3/api-docs/**",
107-
"/oauth2/**",
108-
"/login/oauth2/**")
68+
auth.requestMatchers("/public/**")
69+
.permitAll()
70+
.requestMatchers("/oauth2/**", "/login/oauth2/**")
71+
.permitAll()
72+
.requestMatchers(SWAGGER_PATHS)
10973
.permitAll()
11074
.anyRequest()
11175
.authenticated())
@@ -121,6 +85,9 @@ public SecurityFilterChain apiFilterChain(
12185
a.authorizationRequestResolver(
12286
authorizationRequestResolver));
12387
})
88+
.addFilterBefore(
89+
swaggerBasicAuthenticationFilter,
90+
UsernamePasswordAuthenticationFilter.class)
12491
.addFilterBefore(
12592
jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
12693

@@ -158,7 +125,6 @@ public JwtAuthenticationFilter jwtAuthenticationFilter(JwtTokenService jwtTokenS
158125
}
159126

160127
@Bean
161-
@Profile({"local", "dev", "prod"})
162128
public OAuth2AuthorizationRequestResolver oauth2AuthorizationRequestResolver(
163129
ClientRegistrationRepository clientRegistrationRepository) {
164130
AppleAwareOAuth2AuthorizationRequestResolver resolver =
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package org.clokey.global.security;
2+
3+
import jakarta.servlet.FilterChain;
4+
import jakarta.servlet.ServletException;
5+
import jakarta.servlet.http.HttpServletRequest;
6+
import jakarta.servlet.http.HttpServletResponse;
7+
import java.io.IOException;
8+
import java.nio.charset.StandardCharsets;
9+
import java.util.Base64;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.beans.factory.annotation.Value;
12+
import org.springframework.stereotype.Component;
13+
import org.springframework.web.filter.OncePerRequestFilter;
14+
15+
@Component
16+
@RequiredArgsConstructor
17+
public class SwaggerBasicAuthenticationFilter extends OncePerRequestFilter {
18+
19+
private static final String[] SWAGGER_PATHS = {
20+
"/swagger-ui", "/swagger-ui/", "/swagger-ui.html", "/v3/api-docs"
21+
};
22+
23+
@Value("${swagger.username:default}")
24+
private String swaggerUsername;
25+
26+
@Value("${swagger.password:default}")
27+
private String swaggerPassword;
28+
29+
@Override
30+
protected boolean shouldNotFilter(HttpServletRequest request) {
31+
String uri = request.getRequestURI();
32+
33+
for (String swaggerPath : SWAGGER_PATHS) {
34+
if (uri.equals(swaggerPath) || uri.startsWith(swaggerPath + "/")) {
35+
return false;
36+
}
37+
}
38+
39+
return true;
40+
}
41+
42+
@Override
43+
protected void doFilterInternal(
44+
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
45+
throws ServletException, IOException {
46+
String authorization = request.getHeader("Authorization");
47+
48+
if (authorization == null || !authorization.startsWith("Basic ")) {
49+
writeUnauthorized(response);
50+
return;
51+
}
52+
53+
String[] credentials = decodeCredentials(authorization.substring(6));
54+
if (credentials == null) {
55+
writeUnauthorized(response);
56+
return;
57+
}
58+
59+
if (!swaggerUsername.equals(credentials[0]) || !swaggerPassword.equals(credentials[1])) {
60+
writeUnauthorized(response);
61+
return;
62+
}
63+
64+
filterChain.doFilter(request, response);
65+
}
66+
67+
private String[] decodeCredentials(String encodedCredentials) {
68+
try {
69+
String decoded =
70+
new String(
71+
Base64.getDecoder().decode(encodedCredentials), StandardCharsets.UTF_8);
72+
int separatorIndex = decoded.indexOf(':');
73+
if (separatorIndex < 0) {
74+
return null;
75+
}
76+
77+
return new String[] {
78+
decoded.substring(0, separatorIndex), decoded.substring(separatorIndex + 1)
79+
};
80+
} catch (IllegalArgumentException exception) {
81+
return null;
82+
}
83+
}
84+
85+
private void writeUnauthorized(HttpServletResponse response) throws IOException {
86+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
87+
response.setHeader("WWW-Authenticate", "Basic realm=\"Swagger\"");
88+
response.getWriter().flush();
89+
}
90+
}

clokey-api/src/main/resources/application-dev.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ swagger:
9898
username: ${SWAGGER_USERNAME}
9999
password: ${SWAGGER_PASSWORD}
100100

101-
spring-doc:
101+
springdoc:
102102
default-consumes-media-type: application/json
103103
default-produces-media-type: application/json
104104
swagger-ui:

clokey-api/src/main/resources/application-local.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,14 @@ external:
9494
style-inference-path: ${STYLE_INFERENCE_PATH}
9595
cloth-detect-path: ${CLOTH_DETECT_PATH}
9696

97-
spring-doc:
97+
springdoc:
9898
default-consumes-media-type: application/json
9999
default-produces-media-type: application/json
100100
swagger-ui:
101101
tags-sorter: alpha
102-
operations-sorter : method
102+
operations-sorter: method
103103
path: /swagger-ui
104-
doc-expansion : none
104+
doc-expansion: none
105105

106106
logging:
107107
level:

clokey-api/src/main/resources/application-prod.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,20 @@ external:
9696
cloth-inference-path: ${CLOTH_INFERENCE_PATH}
9797
style-inference-path: ${STYLE_INFERENCE_PATH}
9898
cloth-detect-path: ${CLOTH_DETECT_PATH}
99+
100+
swagger:
101+
username: ${SWAGGER_USERNAME}
102+
password: ${SWAGGER_PASSWORD}
103+
104+
springdoc:
105+
default-consumes-media-type: application/json
106+
default-produces-media-type: application/json
107+
swagger-ui:
108+
tags-sorter: alpha
109+
operations-sorter : method
110+
path: /swagger-ui
111+
doc-expansion : none
112+
99113
app:
100114
oauth:
101115
redirect-scheme: codive

clokey-api/src/test/java/org/clokey/ClokeyApiApplicationTests.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.google.firebase.messaging.FirebaseMessaging;
44
import org.junit.jupiter.api.Test;
55
import org.springframework.boot.test.context.SpringBootTest;
6+
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
67
import org.springframework.test.context.ActiveProfiles;
78
import org.springframework.test.context.bean.override.mockito.MockitoBean;
89

@@ -11,6 +12,7 @@
1112
class ClokeyApiApplicationTests {
1213

1314
@MockitoBean private FirebaseMessaging mockFirebaseMessaging;
15+
@MockitoBean private ClientRegistrationRepository clientRegistrationRepository;
1416

1517
@Test
1618
void contextLoads() {}

clokey-api/src/test/java/org/clokey/IntegrationTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.junit.jupiter.api.BeforeEach;
55
import org.springframework.beans.factory.annotation.Autowired;
66
import org.springframework.boot.test.context.SpringBootTest;
7+
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
78
import org.springframework.test.context.ActiveProfiles;
89
import org.springframework.test.context.bean.override.mockito.MockitoBean;
910

@@ -13,6 +14,7 @@ public abstract class IntegrationTest {
1314

1415
@Autowired protected DatabaseCleaner databaseCleaner;
1516
@MockitoBean private FirebaseMessaging mockFirebaseMessaging;
17+
@MockitoBean private ClientRegistrationRepository clientRegistrationRepository;
1618

1719
@BeforeEach
1820
void setUp() {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package org.clokey.global.config.security;
2+
3+
import static org.hamcrest.Matchers.containsString;
4+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
5+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
6+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
7+
8+
import com.google.firebase.messaging.FirebaseMessaging;
9+
import org.junit.jupiter.api.Test;
10+
import org.springframework.beans.factory.annotation.Autowired;
11+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
12+
import org.springframework.boot.test.context.SpringBootTest;
13+
import org.springframework.test.context.ActiveProfiles;
14+
import org.springframework.test.context.TestPropertySource;
15+
import org.springframework.test.context.bean.override.mockito.MockitoBean;
16+
import org.springframework.test.web.servlet.MockMvc;
17+
18+
@SpringBootTest
19+
@AutoConfigureMockMvc
20+
@ActiveProfiles({"test", "local"})
21+
@TestPropertySource(
22+
properties = {
23+
"spring.datasource.url=jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false;MODE=MYSQL",
24+
"spring.datasource.driver-class-name=org.h2.Driver",
25+
"spring.datasource.username=sa",
26+
"spring.datasource.password=",
27+
"spring.flyway.enabled=false",
28+
"spring.jpa.hibernate.ddl-auto=none",
29+
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
30+
"jwt.access-token-secret=test-access-secret-test-access-secret",
31+
"jwt.refresh-token-secret=test-refresh-secret-test-refresh-secret",
32+
"jwt.access-token-expiration-time=3600000",
33+
"jwt.refresh-token-expiration-time=1209600000",
34+
"jwt.issuer=test-issuer",
35+
"spring.security.oauth2.client.registration.kakao.client-id=test",
36+
"spring.security.oauth2.client.registration.kakao.client-secret=test",
37+
"spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost/login/oauth2/code/kakao",
38+
"spring.security.oauth2.client.registration.apple.client-id=test",
39+
"spring.security.oauth2.client.registration.apple.client-secret=test",
40+
"spring.security.oauth2.client.registration.apple.redirect-uri=http://localhost/login/oauth2/code/apple",
41+
"external.api.ai-server-ip=http://localhost",
42+
"external.api.cloth-inference-path=/cloth",
43+
"external.api.style-inference-path=/style",
44+
"external.api.cloth-detect-path=/detect",
45+
"firebase.credentials-path=dummy"
46+
})
47+
class SwaggerSecurityIntegrationTest {
48+
49+
@Autowired private MockMvc mockMvc;
50+
@MockitoBean private FirebaseMessaging mockFirebaseMessaging;
51+
52+
@Test
53+
void swaggerUiIndexWithoutCredentialsDoesNotRedirectToLogin() throws Exception {
54+
mockMvc.perform(get("/swagger-ui/index.html"))
55+
.andExpect(status().isUnauthorized())
56+
.andExpect(header().string("WWW-Authenticate", containsString("Basic")));
57+
}
58+
59+
@Test
60+
void swaggerConfigWithoutCredentialsDoesNotRedirectToLogin() throws Exception {
61+
mockMvc.perform(get("/v3/api-docs/swagger-config"))
62+
.andExpect(status().isUnauthorized())
63+
.andExpect(header().string("WWW-Authenticate", containsString("Basic")));
64+
}
65+
}

0 commit comments

Comments
 (0)