From 86db9626a35b59cdb8e30f9914882a7b78c680ac Mon Sep 17 00:00:00 2001 From: Mattias-Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:02:27 +0200 Subject: [PATCH 1/2] refactor: Simplify login endpoint operation construction and media type resolution --- .../SpringDocSecurityConfiguration.java | 90 ++++++++++++++----- 1 file changed, 67 insertions(+), 23 deletions(-) diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java index 37cde1ebb..12b867ab1 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java @@ -132,29 +132,8 @@ OpenApiCustomizer springSecurityLoginEndpointCustomizer(ApplicationContext appli .findAny(); if (optionalFilter.isPresent()) { UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter = optionalFilter.get(); - Operation operation = new Operation(); - Schema schema = new ObjectSchema() - .addProperty(usernamePasswordAuthenticationFilter.getUsernameParameter(), new StringSchema()) - .addProperty(usernamePasswordAuthenticationFilter.getPasswordParameter(), new StringSchema()); - String mediaType = org.springframework.http.MediaType.APPLICATION_JSON_VALUE; - if (optionalDefaultLoginPageGeneratingFilter.isPresent()) { - DefaultLoginPageGeneratingFilter defaultLoginPageGeneratingFilter = optionalDefaultLoginPageGeneratingFilter.get(); - try { - boolean formLoginEnabled = (boolean) FieldUtils.readDeclaredField(defaultLoginPageGeneratingFilter, "formLoginEnabled", true); - if (formLoginEnabled) - mediaType = org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE; - } - catch (IllegalAccessException e) { - LOGGER.warn(e.getMessage()); - } - } - RequestBody requestBody = new RequestBody().content(new Content().addMediaType(mediaType, new MediaType().schema(schema))); - operation.requestBody(requestBody); - ApiResponses apiResponses = new ApiResponses(); - apiResponses.addApiResponse(String.valueOf(HttpStatus.OK.value()), new ApiResponse().description(HttpStatus.OK.getReasonPhrase())); - apiResponses.addApiResponse(String.valueOf(HttpStatus.UNAUTHORIZED.value()), new ApiResponse().description(HttpStatus.UNAUTHORIZED.getReasonPhrase())); - operation.responses(apiResponses); - operation.addTagsItem("login-endpoint"); + String mediaType = resolveMediaType(optionalDefaultLoginPageGeneratingFilter); + Operation operation = buildOperation(usernamePasswordAuthenticationFilter, mediaType); PathItem pathItem = new PathItem().post(operation); try { RequestMatcher requestMatcher = (RequestMatcher) FieldUtils.readField( @@ -176,6 +155,71 @@ OpenApiCustomizer springSecurityLoginEndpointCustomizer(ApplicationContext appli } }; } + + /** + * Resolves the request body media type based on the presence of a form login configuration. + * + * @param optionalDefaultLoginPageGeneratingFilter the optional default login page generating filter + * @return the resolved media type + */ + private String resolveMediaType(Optional optionalDefaultLoginPageGeneratingFilter) { + String mediaType = org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + if (optionalDefaultLoginPageGeneratingFilter.isPresent()) { + DefaultLoginPageGeneratingFilter defaultLoginPageGeneratingFilter = optionalDefaultLoginPageGeneratingFilter.get(); + try { + boolean formLoginEnabled = (boolean) FieldUtils.readDeclaredField(defaultLoginPageGeneratingFilter, "formLoginEnabled", true); + if (formLoginEnabled) + mediaType = org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE; + } + catch (IllegalAccessException e) { + LOGGER.warn(e.getMessage()); + } + } + return mediaType; + } + + /** + * Builds the login endpoint operation. + * + * @param usernamePasswordAuthenticationFilter the username password authentication filter + * @param mediaType the request body media type + * @return the operation + */ + private Operation buildOperation(UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter, + String mediaType) { + Operation operation = new Operation(); + operation.requestBody(buildRequestBody(usernamePasswordAuthenticationFilter, mediaType)); + operation.responses(buildApiResponses()); + operation.addTagsItem("login-endpoint"); + return operation; + } + + /** + * Builds the request body for the login endpoint operation. + * + * @param usernamePasswordAuthenticationFilter the username password authentication filter + * @param mediaType the request body media type + * @return the request body + */ + private RequestBody buildRequestBody(UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter, + String mediaType) { + Schema schema = new ObjectSchema() + .addProperty(usernamePasswordAuthenticationFilter.getUsernameParameter(), new StringSchema()) + .addProperty(usernamePasswordAuthenticationFilter.getPasswordParameter(), new StringSchema()); + return new RequestBody().content(new Content().addMediaType(mediaType, new MediaType().schema(schema))); + } + + /** + * Builds the API responses for the login endpoint operation. + * + * @return the api responses + */ + private ApiResponses buildApiResponses() { + ApiResponses apiResponses = new ApiResponses(); + apiResponses.addApiResponse(String.valueOf(HttpStatus.OK.value()), new ApiResponse().description(HttpStatus.OK.getReasonPhrase())); + apiResponses.addApiResponse(String.valueOf(HttpStatus.UNAUTHORIZED.value()), new ApiResponse().description(HttpStatus.UNAUTHORIZED.getReasonPhrase())); + return apiResponses; + } } /** From 9c0ee9f0de9cab2fdc279647cfcfd2131faacea5 Mon Sep 17 00:00:00 2001 From: Mattias-Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:19:42 +0200 Subject: [PATCH 2/2] feat: Add login endpoint configuration with example values for username and password --- .../SpringDocSecurityConfiguration.java | 32 +++- .../properties/SpringDocConfigProperties.java | 80 ++++++++++ .../api/v30/app13/SpringDocApp13Test.java | 51 +++++++ .../api/v30/app13/SpringDocConfig.java | 57 ++++++++ .../v30/app13/controllers/MyController.java | 56 +++++++ .../security/JWTAuthenticationFilter.java | 138 ++++++++++++++++++ .../security/JWTAuthorizationFilter.java | 109 ++++++++++++++ .../app13/security/MyUserDetailsService.java | 55 +++++++ .../api/v30/app13/security/WebSecurity.java | 123 ++++++++++++++++ .../api/v31/app13/SpringDocApp13Test.java | 51 +++++++ .../api/v31/app13/SpringDocConfig.java | 57 ++++++++ .../v31/app13/controllers/MyController.java | 56 +++++++ .../security/JWTAuthenticationFilter.java | 138 ++++++++++++++++++ .../security/JWTAuthorizationFilter.java | 109 ++++++++++++++ .../app13/security/MyUserDetailsService.java | 55 +++++++ .../api/v31/app13/security/WebSecurity.java | 122 ++++++++++++++++ .../test/resources/results/3.0.1/app13.json | 106 ++++++++++++++ .../test/resources/results/3.1.0/app13.json | 106 ++++++++++++++ 18 files changed, 1493 insertions(+), 8 deletions(-) create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocApp13Test.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocConfig.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/controllers/MyController.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthenticationFilter.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthorizationFilter.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/MyUserDetailsService.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/WebSecurity.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocApp13Test.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocConfig.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/controllers/MyController.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthenticationFilter.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthorizationFilter.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/MyUserDetailsService.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/WebSecurity.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.0.1/app13.json create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.1.0/app13.json diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java index 12b867ab1..d3a66a6be 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java @@ -45,6 +45,8 @@ import org.springdoc.core.configuration.hints.SpringDocSecurityHints; import org.springdoc.core.customizers.GlobalOpenApiCustomizer; import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springdoc.core.properties.SpringDocConfigProperties.LoginEndpoint; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -110,14 +112,18 @@ class SpringSecurityLoginEndpointConfiguration { /** * Spring security login endpoint customiser open api customiser. * - * @param applicationContext the application context + * @param applicationContext the application context + * @param springDocConfigProperties the springdoc configuration properties * @return the open api customiser */ @Bean @ConditionalOnProperty(SPRINGDOC_SHOW_LOGIN_ENDPOINT) @Lazy(false) - OpenApiCustomizer springSecurityLoginEndpointCustomizer(ApplicationContext applicationContext) { + OpenApiCustomizer springSecurityLoginEndpointCustomizer(ApplicationContext applicationContext, SpringDocConfigProperties springDocConfigProperties) { FilterChainProxy filterChainProxy = applicationContext.getBean(AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME, FilterChainProxy.class); + LoginEndpoint loginEndpoint = springDocConfigProperties.getLoginEndpoint(); + String usernameExample = loginEndpoint.getUsernameExample(); + String passwordExample = loginEndpoint.getPasswordExample(); return openAPI -> { for (SecurityFilterChain filterChain : filterChainProxy.getFilterChains()) { Optional optionalFilter = @@ -133,7 +139,7 @@ OpenApiCustomizer springSecurityLoginEndpointCustomizer(ApplicationContext appli if (optionalFilter.isPresent()) { UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter = optionalFilter.get(); String mediaType = resolveMediaType(optionalDefaultLoginPageGeneratingFilter); - Operation operation = buildOperation(usernamePasswordAuthenticationFilter, mediaType); + Operation operation = buildOperation(usernamePasswordAuthenticationFilter, mediaType, usernameExample, passwordExample); PathItem pathItem = new PathItem().post(operation); try { RequestMatcher requestMatcher = (RequestMatcher) FieldUtils.readField( @@ -183,12 +189,14 @@ private String resolveMediaType(Optional optio * * @param usernamePasswordAuthenticationFilter the username password authentication filter * @param mediaType the request body media type + * @param usernameExample the username example value + * @param passwordExample the password example value * @return the operation */ private Operation buildOperation(UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter, - String mediaType) { + String mediaType, String usernameExample, String passwordExample) { Operation operation = new Operation(); - operation.requestBody(buildRequestBody(usernamePasswordAuthenticationFilter, mediaType)); + operation.requestBody(buildRequestBody(usernamePasswordAuthenticationFilter, mediaType, usernameExample, passwordExample)); operation.responses(buildApiResponses()); operation.addTagsItem("login-endpoint"); return operation; @@ -199,13 +207,21 @@ private Operation buildOperation(UsernamePasswordAuthenticationFilter usernamePa * * @param usernamePasswordAuthenticationFilter the username password authentication filter * @param mediaType the request body media type + * @param usernameExample the username example value + * @param passwordExample the password example value * @return the request body */ private RequestBody buildRequestBody(UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter, - String mediaType) { + String mediaType, String usernameExample, String passwordExample) { + StringSchema usernameSchema = new StringSchema(); + if (usernameExample != null) + usernameSchema.example(usernameExample); + StringSchema passwordSchema = new StringSchema(); + if (passwordExample != null) + passwordSchema.example(passwordExample); Schema schema = new ObjectSchema() - .addProperty(usernamePasswordAuthenticationFilter.getUsernameParameter(), new StringSchema()) - .addProperty(usernamePasswordAuthenticationFilter.getPasswordParameter(), new StringSchema()); + .addProperty(usernamePasswordAuthenticationFilter.getUsernameParameter(), usernameSchema) + .addProperty(usernamePasswordAuthenticationFilter.getPasswordParameter(), passwordSchema); return new RequestBody().content(new Content().addMediaType(mediaType, new MediaType().schema(schema))); } diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java index 1bec1ad18..e91848a7c 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java @@ -166,6 +166,11 @@ public class SpringDocConfigProperties { */ private boolean showLoginEndpoint; + /** + * The login endpoint configuration. + */ + private LoginEndpoint loginEndpoint = new LoginEndpoint(); + /** * Allow for pre-loading OpenAPI */ @@ -747,6 +752,24 @@ public void setShowLoginEndpoint(boolean showLoginEndpoint) { this.showLoginEndpoint = showLoginEndpoint; } + /** + * Gets login endpoint. + * + * @return the login endpoint + */ + public LoginEndpoint getLoginEndpoint() { + return loginEndpoint; + } + + /** + * Sets login endpoint. + * + * @param loginEndpoint the login endpoint + */ + public void setLoginEndpoint(LoginEndpoint loginEndpoint) { + this.loginEndpoint = loginEndpoint; + } + /** * Gets packages to scan. * @@ -1896,4 +1919,61 @@ public int hashCode() { return Objects.hash(group); } } + + /** + * The type Login endpoint. + *

+ * These settings only take effect when the login endpoint is exposed, i.e. when + * {@code springdoc.show-login-endpoint=true}. Otherwise, they are ignored. + */ + public static class LoginEndpoint { + + /** + * The example value for the username field of the login request body. + * Only applied when {@code springdoc.show-login-endpoint=true}. + */ + private String usernameExample; + + /** + * The example value for the password field of the login request body. + * Only applied when {@code springdoc.show-login-endpoint=true}. + */ + private String passwordExample; + + /** + * Gets username example. + * + * @return the username example + */ + public String getUsernameExample() { + return usernameExample; + } + + /** + * Sets username example. + * + * @param usernameExample the username example + */ + public void setUsernameExample(String usernameExample) { + this.usernameExample = usernameExample; + } + + /** + * Gets password example. + * + * @return the password example + */ + public String getPasswordExample() { + return passwordExample; + } + + /** + * Sets password example. + * + * @param passwordExample the password example + */ + public void setPasswordExample(String passwordExample) { + this.passwordExample = passwordExample; + } + } } diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocApp13Test.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocApp13Test.java new file mode 100644 index 000000000..d3e03c80e --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocApp13Test.java @@ -0,0 +1,51 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app13; + +import test.org.springdoc.api.v30.AbstractSpringDocTest; +import test.org.springdoc.api.v30.app13.security.MyUserDetailsService; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.TestPropertySource; + +@TestPropertySource(properties = { + "springdoc.show-login-endpoint=true", + "springdoc.login-endpoint.username-example=demouser", + "springdoc.login-endpoint.password-example=secret" +}) +public class SpringDocApp13Test extends AbstractSpringDocTest { + + @SpringBootApplication(scanBasePackages = { "test.org.springdoc.api.v30.configuration", "test.org.springdoc.api.v30.app13" }) + static class SpringDocTestApp { + @Bean + MyUserDetailsService userDetailsService() { + return new MyUserDetailsService(); + } + } + +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocConfig.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocConfig.java new file mode 100644 index 000000000..d6f803e86 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocConfig.java @@ -0,0 +1,57 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app13; + +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 org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +@Configuration +public class SpringDocConfig { + + + @Bean + public OpenAPI myOpenAPI() { + final String securitySchemeName = "bearerAuth"; + return new OpenAPI().info(new Info().title("My MWE API") + .description("This document specifies the API") + .version("v23")) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components().addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } + +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/controllers/MyController.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/controllers/MyController.java new file mode 100644 index 000000000..8c83993c1 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/controllers/MyController.java @@ -0,0 +1,56 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app13.controllers; + +import java.util.List; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + + +@RestController +@RequestMapping("/fax") +@Tag(name = "Fax stuff", description = "For managing fax machines.") +public class MyController { + + @Operation(summary = "Get information about currently existing fax machines") + @ApiResponse(responseCode = "200", description = "list of existing fax machines") + @GetMapping("list") + public List getFaxList(@RequestParam(name = "vendorName", required = false) + @Parameter(description = "vendor name to restrict the list") String vendorFilter) { + + return null; + } + +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthenticationFilter.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthenticationFilter.java new file mode 100644 index 000000000..50c1482e1 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthenticationFilter.java @@ -0,0 +1,138 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app13.security; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import static org.springdoc.core.utils.SpringDocUtils.cloneViaJson; + + +public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + + private final AuthenticationManager authenticationManager; + + + private final long lifetime; + + + private final String key; + + + public JWTAuthenticationFilter(AuthenticationManager authenticationManager, long lifetime, + String key) { + this.authenticationManager = authenticationManager; + this.lifetime = lifetime; + this.key = key; + } + + + @Override + public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) + throws AuthenticationException { + try { + UserCredentials credentials = cloneViaJson(req.getInputStream(), UserCredentials.class,new ObjectMapper()); + return authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(credentials.getUsername(), + credentials.getPassword(), new ArrayList<>())); + + } + catch (IOException e) { + throw new InternalAuthenticationServiceException("Error processing credentials", e); + } + } + + + @Override + protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, + FilterChain chain, Authentication auth) + throws IOException, ServletException { + Date notBefore = new Date(); + Date expirationDate = new Date(notBefore.getTime() + lifetime); + + String token = Jwts.builder() + .setClaims(new HashMap<>()) + .setSubject(((User) auth.getPrincipal()).getUsername()) + .setNotBefore(notBefore) + .setExpiration(expirationDate) + .signWith(SignatureAlgorithm.HS512, key) + .compact(); + res.addHeader(WebSecurity.HeaderString, WebSecurity.TokenPrefix + token); + + } + + + private static class UserCredentials { + + + private String username; + + + private String password; + + + String getUsername() { + return username; + } + + + public void setUsername(String username) { + this.username = username; + } + + + String getPassword() { + return password; + } + + + public void setPassword(String password) { + this.password = password; + } + + } + +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthorizationFilter.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthorizationFilter.java new file mode 100644 index 000000000..6352a5c03 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthorizationFilter.java @@ -0,0 +1,109 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app13.security; + +import java.io.IOException; +import java.util.ArrayList; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; + + +public class JWTAuthorizationFilter extends BasicAuthenticationFilter { + + + public static final String AUTH_ERROR_ATTRIBUTE = "authError"; + + + private final String key; + + + public JWTAuthorizationFilter(AuthenticationManager authManager, String key) { + super(authManager); + this.key = key; + } + + + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws IOException, ServletException { + String header = req.getHeader(WebSecurity.HeaderString); + + if (header == null || !header.startsWith(WebSecurity.TokenPrefix)) { + chain.doFilter(req, res); + return; + } + + UsernamePasswordAuthenticationToken authentication = getAuthentication(req); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + chain.doFilter(req, res); + } + + + /** + * Check the validity of the JWT (JWS, more precisely) as submitted via the + * {@link HttpServletRequest}. + * + * @param request the {@link HttpServletRequest} containing a JWS. + * @return a {@link UsernamePasswordAuthenticationToken} if the JWS is + * valid, {@code null} otherwise. + */ + + private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) { + String token = request.getHeader(WebSecurity.HeaderString); + if (token != null) { + String user = null; + try { + user = Jwts.parser() + .setSigningKey(key) + .parseClaimsJws(token.replace(WebSecurity.TokenPrefix, "")) + .getBody() + .getSubject(); + } + catch (ExpiredJwtException e) { + request.setAttribute(AUTH_ERROR_ATTRIBUTE, e.getMessage()); + } + if (user != null) { + return new UsernamePasswordAuthenticationToken(user, null, new ArrayList<>()); + } + return null; + } + return null; + } + +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/MyUserDetailsService.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/MyUserDetailsService.java new file mode 100644 index 000000000..a5837403b --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/MyUserDetailsService.java @@ -0,0 +1,55 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app13.security; + +import java.util.Collections; + +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.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + + +@Service +public class MyUserDetailsService implements UserDetailsService { + + @Override + public UserDetails loadUserByUsername(String username) + throws UsernameNotFoundException { + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + + + if (!username.equals("demouser")) { + throw new UsernameNotFoundException(username); + } + + return new User("demouser", encoder.encode("secret"), Collections.emptyList()); + } + +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/WebSecurity.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/WebSecurity.java new file mode 100644 index 000000000..6a80dd0e5 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/WebSecurity.java @@ -0,0 +1,123 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app13.security; + +import org.springdoc.core.properties.SpringDocConfigProperties; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import static org.springdoc.core.utils.Constants.ALL_PATTERN; + +@Configuration +@EnableWebSecurity +@Order(200) +public class WebSecurity { + + + public static final String TokenPrefix = "Bearer "; + + + public static final String HeaderString = "Authorization"; + + + private final UserDetailsService userDetailsService; + + + @Autowired + SpringDocConfigProperties configProperties; + + + private long lifetime = 123456789L; + + + private String key = + "YRv13MrZah/rHJPMGIN6AjdjB09F9gpIC7i9mdFwdIDZ296doUg/nhG/mQ/CnlxPNtcWR6z6RCKtW5cCspGM9w=="; + + + public WebSecurity(UserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + + } + + + @Bean + public SecurityFilterChain securityWebFilterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception { + String apiDocsPath = configProperties.getApiDocs().getPath(); + String apiDocsYaml = apiDocsPath.substring(0, apiDocsPath.lastIndexOf('/') + 1) + "api-docs.yaml"; + + return http + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers(apiDocsPath + ALL_PATTERN).permitAll() + .requestMatchers(apiDocsYaml).permitAll() + .anyRequest().authenticated() + ) + .addFilter(new JWTAuthenticationFilter(authenticationManager, lifetime, key)) + .addFilter(new JWTAuthorizationFilter(authenticationManager, key)) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .build(); + } + + + + @Autowired + public void configure(AuthenticationManagerBuilder auth) + throws Exception { + auth.userDetailsService(userDetailsService).passwordEncoder(new BCryptPasswordEncoder()); + } + + + @Bean + CorsConfigurationSource corsConfigurationSource() { + final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + + CorsConfiguration configuration = new CorsConfiguration().applyPermitDefaultValues(); + + configuration.addExposedHeader(HeaderString); + source.registerCorsConfiguration(ALL_PATTERN, configuration); + + return source; + } + +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocApp13Test.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocApp13Test.java new file mode 100644 index 000000000..ec6fd4dab --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocApp13Test.java @@ -0,0 +1,51 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app13; + +import test.org.springdoc.api.v31.AbstractSpringDocTest; +import test.org.springdoc.api.v31.app13.security.MyUserDetailsService; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.TestPropertySource; + +@TestPropertySource(properties = { + "springdoc.show-login-endpoint=true", + "springdoc.login-endpoint.username-example=demouser", + "springdoc.login-endpoint.password-example=secret" +}) +public class SpringDocApp13Test extends AbstractSpringDocTest { + + @SpringBootApplication(scanBasePackages = { "test.org.springdoc.api.v31.configuration", "test.org.springdoc.api.v31.app13" }) + static class SpringDocTestApp { + @Bean + MyUserDetailsService userDetailsService() { + return new MyUserDetailsService(); + } + } + +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocConfig.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocConfig.java new file mode 100644 index 000000000..b4b65d277 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocConfig.java @@ -0,0 +1,57 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app13; + +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 org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +@Configuration +public class SpringDocConfig { + + + @Bean + public OpenAPI myOpenAPI() { + final String securitySchemeName = "bearerAuth"; + return new OpenAPI().info(new Info().title("My MWE API") + .description("This document specifies the API") + .version("v23")) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components().addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } + +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/controllers/MyController.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/controllers/MyController.java new file mode 100644 index 000000000..81cd69640 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/controllers/MyController.java @@ -0,0 +1,56 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app13.controllers; + +import java.util.List; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + + +@RestController +@RequestMapping("/fax") +@Tag(name = "Fax stuff", description = "For managing fax machines.") +public class MyController { + + @Operation(summary = "Get information about currently existing fax machines") + @ApiResponse(responseCode = "200", description = "list of existing fax machines") + @GetMapping("list") + public List getFaxList(@RequestParam(name = "vendorName", required = false) + @Parameter(description = "vendor name to restrict the list") String vendorFilter) { + + return null; + } + +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthenticationFilter.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthenticationFilter.java new file mode 100644 index 000000000..b94575814 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthenticationFilter.java @@ -0,0 +1,138 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app13.security; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import static org.springdoc.core.utils.SpringDocUtils.cloneViaJson; + + +public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + + private final AuthenticationManager authenticationManager; + + + private final long lifetime; + + + private final String key; + + + public JWTAuthenticationFilter(AuthenticationManager authenticationManager, long lifetime, + String key) { + this.authenticationManager = authenticationManager; + this.lifetime = lifetime; + this.key = key; + } + + + @Override + public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) + throws AuthenticationException { + try { + UserCredentials credentials = cloneViaJson(req.getInputStream(), UserCredentials.class,new ObjectMapper()); + return authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(credentials.getUsername(), + credentials.getPassword(), new ArrayList<>())); + + } + catch (IOException e) { + throw new InternalAuthenticationServiceException("Error processing credentials", e); + } + } + + + @Override + protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, + FilterChain chain, Authentication auth) + throws IOException, ServletException { + Date notBefore = new Date(); + Date expirationDate = new Date(notBefore.getTime() + lifetime); + + String token = Jwts.builder() + .setClaims(new HashMap<>()) + .setSubject(((User) auth.getPrincipal()).getUsername()) + .setNotBefore(notBefore) + .setExpiration(expirationDate) + .signWith(SignatureAlgorithm.HS512, key) + .compact(); + res.addHeader(WebSecurity.HeaderString, WebSecurity.TokenPrefix + token); + + } + + + private static class UserCredentials { + + + private String username; + + + private String password; + + + String getUsername() { + return username; + } + + + public void setUsername(String username) { + this.username = username; + } + + + String getPassword() { + return password; + } + + + public void setPassword(String password) { + this.password = password; + } + + } + +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthorizationFilter.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthorizationFilter.java new file mode 100644 index 000000000..64c9a8f53 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthorizationFilter.java @@ -0,0 +1,109 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app13.security; + +import java.io.IOException; +import java.util.ArrayList; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; + + +public class JWTAuthorizationFilter extends BasicAuthenticationFilter { + + + public static final String AUTH_ERROR_ATTRIBUTE = "authError"; + + + private final String key; + + + public JWTAuthorizationFilter(AuthenticationManager authManager, String key) { + super(authManager); + this.key = key; + } + + + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws IOException, ServletException { + String header = req.getHeader(WebSecurity.HeaderString); + + if (header == null || !header.startsWith(WebSecurity.TokenPrefix)) { + chain.doFilter(req, res); + return; + } + + UsernamePasswordAuthenticationToken authentication = getAuthentication(req); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + chain.doFilter(req, res); + } + + + /** + * Check the validity of the JWT (JWS, more precisely) as submitted via the + * {@link HttpServletRequest}. + * + * @param request the {@link HttpServletRequest} containing a JWS. + * @return a {@link UsernamePasswordAuthenticationToken} if the JWS is + * valid, {@code null} otherwise. + */ + + private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) { + String token = request.getHeader(WebSecurity.HeaderString); + if (token != null) { + String user = null; + try { + user = Jwts.parser() + .setSigningKey(key) + .parseClaimsJws(token.replace(WebSecurity.TokenPrefix, "")) + .getBody() + .getSubject(); + } + catch (ExpiredJwtException e) { + request.setAttribute(AUTH_ERROR_ATTRIBUTE, e.getMessage()); + } + if (user != null) { + return new UsernamePasswordAuthenticationToken(user, null, new ArrayList<>()); + } + return null; + } + return null; + } + +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/MyUserDetailsService.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/MyUserDetailsService.java new file mode 100644 index 000000000..d3546d4a5 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/MyUserDetailsService.java @@ -0,0 +1,55 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app13.security; + +import java.util.Collections; + +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.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + + +@Service +public class MyUserDetailsService implements UserDetailsService { + + @Override + public UserDetails loadUserByUsername(String username) + throws UsernameNotFoundException { + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + + + if (!username.equals("demouser")) { + throw new UsernameNotFoundException(username); + } + + return new User("demouser", encoder.encode("secret"), Collections.emptyList()); + } + +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/WebSecurity.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/WebSecurity.java new file mode 100644 index 000000000..bd0fa1e4b --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/WebSecurity.java @@ -0,0 +1,122 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app13.security; + +import org.springdoc.core.properties.SpringDocConfigProperties; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import static org.springdoc.core.utils.Constants.ALL_PATTERN; + +@Configuration +@EnableWebSecurity +@Order(200) +public class WebSecurity { + + + public static final String TokenPrefix = "Bearer "; + + + public static final String HeaderString = "Authorization"; + + + private final UserDetailsService userDetailsService; + + + @Autowired + SpringDocConfigProperties configProperties; + + + private long lifetime = 123456789L; + + + private String key = + "YRv13MrZah/rHJPMGIN6AjdjB09F9gpIC7i9mdFwdIDZ296doUg/nhG/mQ/CnlxPNtcWR6z6RCKtW5cCspGM9w=="; + + + public WebSecurity(UserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + + } + + + @Bean + public SecurityFilterChain securityWebFilterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception { + String apiDocsPath = configProperties.getApiDocs().getPath(); + String apiDocsYaml = apiDocsPath.substring(0, apiDocsPath.lastIndexOf('/') + 1) + "api-docs.yaml"; + + return http + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers(apiDocsPath + ALL_PATTERN).permitAll() + .requestMatchers(apiDocsYaml).permitAll() + .anyRequest().authenticated() + ) + .addFilter(new JWTAuthenticationFilter(authenticationManager, lifetime, key)) + .addFilter(new JWTAuthorizationFilter(authenticationManager, key)) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .build(); + } + + + @Autowired + public void configure(AuthenticationManagerBuilder auth) + throws Exception { + auth.userDetailsService(userDetailsService).passwordEncoder(new BCryptPasswordEncoder()); + } + + + @Bean + CorsConfigurationSource corsConfigurationSource() { + final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + + CorsConfiguration configuration = new CorsConfiguration().applyPermitDefaultValues(); + + configuration.addExposedHeader(HeaderString); + source.registerCorsConfiguration(ALL_PATTERN, configuration); + + return source; + } + +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.0.1/app13.json b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.0.1/app13.json new file mode 100644 index 000000000..22ad537bb --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.0.1/app13.json @@ -0,0 +1,106 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "My MWE API", + "description": "This document specifies the API", + "version": "v23" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "Fax stuff", + "description": "For managing fax machines." + } + ], + "paths": { + "/fax/list": { + "get": { + "tags": [ + "Fax stuff" + ], + "summary": "Get information about currently existing fax machines", + "operationId": "getFaxList", + "parameters": [ + { + "name": "vendorName", + "in": "query", + "description": "vendor name to restrict the list", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "list of existing fax machines", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "/login": { + "post": { + "tags": [ + "login-endpoint" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "example": "demouser" + }, + "password": { + "type": "string", + "example": "secret" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + } +} + diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.1.0/app13.json b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.1.0/app13.json new file mode 100644 index 000000000..4c1d12b17 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.1.0/app13.json @@ -0,0 +1,106 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "My MWE API", + "description": "This document specifies the API", + "version": "v23" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "Fax stuff", + "description": "For managing fax machines." + } + ], + "paths": { + "/fax/list": { + "get": { + "tags": [ + "Fax stuff" + ], + "summary": "Get information about currently existing fax machines", + "operationId": "getFaxList", + "parameters": [ + { + "name": "vendorName", + "in": "query", + "description": "vendor name to restrict the list", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "list of existing fax machines", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "/login": { + "post": { + "tags": [ + "login-endpoint" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "example": "demouser" + }, + "password": { + "type": "string", + "example": "secret" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + } +} +