Added authentication and authorization via Keycloak

This commit is contained in:
Jim Martens 2023-07-16 15:31:20 +02:00
parent a42e1d0f3e
commit 7139d8c331
10 changed files with 137 additions and 33 deletions

View File

@ -13,6 +13,7 @@ mapstruct = "1.5.3.Final"
junit = "5.9.2"
assertj = "3.24.2"
mockito = "5.3.0"
keycloak = "22.0.0"
plugin-nebula-release = "17.1.0"
plugin-lombok = "8.0.1"
plugin-gradle-versions = "0.46.0"
@ -28,6 +29,7 @@ spring-boot-mongo = { module = "org.springframework.boot:spring-boot-starter-dat
spring-boot-validation = { module = "org.springframework.boot:spring-boot-starter-validation" }
spring-boot-test = { module = "org.springframework.boot:spring-boot-starter-test" }
spring-boot-security = { module = "org.springframework.boot:spring-boot-starter-security" }
spring-boot-oauth2-resource-server = { module = "org.springframework.boot:spring-boot-starter-oauth2-resource-server" }
spring-boot-config = { module = "org.springframework.boot:spring-boot-configuration-processor", version.ref = "spring-boot" }
spring-cloud = { module = "org.springframework.cloud:spring-cloud-dependencies", version.ref = "spring-cloud" }
spring-cloud-starter = { module = "org.springframework.cloud:spring-cloud-starter" }
@ -36,6 +38,8 @@ spring-grpc = { module = "net.devh:grpc-spring-boot-starter", version.ref = "spr
spring-ui = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "spring-doc" }
spring-sec = { module = "org.springdoc:springdoc-openapi-starter-common", version.ref = "spring-doc" }
spring-openapi = { module = "org.springdoc:springdoc-openapi-starter-webmvc-api", version.ref = "spring-doc" }
keycloak-core = { module = "org.keycloak:keycloak-core", version.ref = "keycloak" }
keycloak-policy-enforcer = { module = "org.keycloak:keycloak-policy-enforcer", version.ref = "keycloak" }
grpc-api = { module = "io.grpc:grpc-api", version.ref = "grpc" }
grpc-context = { module = "io.grpc:grpc-context", version.ref = "grpc" }
grpc-core = { module = "io.grpc:grpc-core", version.ref = "grpc" }
@ -104,6 +108,14 @@ spring-boot-server = [
"spring-sec",
"spring-ui",
]
spring-boot-security = [
"spring-boot-security",
"spring-boot-oauth2-resource-server",
"keycloak-core",
"keycloak-policy-enforcer"
]
test = [
"assertj",
"junit-jupiter",

View File

@ -4,7 +4,7 @@ plugins {
dependencies {
implementation libs.mapstruct.base
implementation libs.spring.boot.security
implementation libs.bundles.spring.boot.security
annotationProcessor libs.mapstruct.processor
}

View File

@ -1,6 +1,8 @@
package de.twomartens.wahlrecht.configuration;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.OAuthFlow;
import io.swagger.v3.oas.annotations.security.OAuthFlows;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.models.OpenAPI;
import org.springframework.beans.factory.annotation.Value;
@ -8,9 +10,21 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@SecurityScheme(
name = "basicAuth",
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
scheme = "basic"
scheme = "bearer",
bearerFormat = "JWT"
)
@SecurityScheme(
name = "oauth2",
type = SecuritySchemeType.OAUTH2,
flows = @OAuthFlows(
implicit = @OAuthFlow(
authorizationUrl = "https://id.2martens.de/realms/wahlrecht/protocol/openid-connect/auth",
tokenUrl = "https://id.2martens.de/realms/wahlrecht/protocol/openid-connect/token",
scopes = {}
)
)
)
@Configuration
public class OpenApiConfiguration {

View File

@ -1,57 +1,101 @@
package de.twomartens.wahlrecht.configuration;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.keycloak.adapters.authorization.integration.jakarta.ServletPolicyEnforcerFilter;
import org.keycloak.adapters.authorization.spi.ConfigurationResolver;
import org.keycloak.adapters.authorization.spi.HttpRequest;
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig;
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.EnforcementMode;
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.PathConfig;
import org.keycloak.util.JsonSerialization;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.lang.NonNull;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration {
public static final String ROLE_USER = "USER";
private static final Collection<String> PERMITTED_PATHS = List.of(
"/wahlrecht/v1/getToken",
"/wahlrecht/v1/doc/**",
"/wahlrecht/v1/api-docs/**",
"/error");
private static final List<PathConfig> PATHS = buildPathConfigs();
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private String jwkSetUri;
@Value("#{environment.CLIENT_SECRET}")
private String clientSecret;
@Bean
public SecurityFilterChain securityFilterChain(@NonNull HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeHttpRequests()
.requestMatchers(HttpMethod.PUT, "/wahlrecht/v1/**")
.authenticated()
.and()
.authorizeHttpRequests()
.requestMatchers("/wahlrecht/v1/**", "/wahlrecht/version",
"/error")
.permitAll()
.and()
.authorizeHttpRequests()
.requestMatchers("/actuator/**")
.requestMatchers(PERMITTED_PATHS.toArray(new String[0]))
.permitAll()
.and()
.authorizeHttpRequests()
.requestMatchers("/resources/**")
.permitAll()
.and()
.httpBasic();
.anyRequest()
.authenticated()
.and()
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
.addFilterAfter(createPolicyEnforcerFilter(), BearerTokenAuthenticationFilter.class);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles(ROLE_USER)
.build();
return new InMemoryUserDetailsManager(user);
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri).build();
}
private ServletPolicyEnforcerFilter createPolicyEnforcerFilter() {
return new ServletPolicyEnforcerFilter(new ConfigurationResolver() {
@Override
public PolicyEnforcerConfig resolve(HttpRequest request) {
try {
PolicyEnforcerConfig policyEnforcerConfig = JsonSerialization.readValue(
getClass().getResourceAsStream("/policy-enforcer.json"), PolicyEnforcerConfig.class);
policyEnforcerConfig.setCredentials(Map.of("secret", clientSecret));
policyEnforcerConfig.setPaths(PATHS);
return policyEnforcerConfig;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
});
}
@NonNull
private static List<PathConfig> buildPathConfigs() {
List<PathConfig> paths = new ArrayList<>();
for (String path : PERMITTED_PATHS) {
PathConfig pathConfig = new PathConfig();
pathConfig.setPath(path.replace("**", "*"));
pathConfig.setEnforcementMode(EnforcementMode.DISABLED);
paths.add(pathConfig);
}
return paths;
}
}

View File

@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -46,6 +47,8 @@ public class CalculationController {
}
)
@PostMapping(value = "/calculate", produces = MediaType.APPLICATION_JSON_VALUE)
@SecurityRequirement(name = "bearerAuth")
@SecurityRequirement(name = "oauth2")
public ResponseEntity<ElectedCandidates> calculateResult(
@RequestBody ElectionResult electionResult
) {

View File

@ -46,6 +46,8 @@ public class ElectionController {
}
)
@GetMapping(value = "/elections", produces = MediaType.APPLICATION_JSON_VALUE)
@SecurityRequirement(name = "bearerAuth")
@SecurityRequirement(name = "oauth2")
public ResponseEntity<Collection<Election>> getElections() {
List<Election> elections = service.getElections().stream().map(mapper::mapToExternal).toList();
return ResponseEntity.ok()
@ -68,7 +70,8 @@ public class ElectionController {
}
)
@PutMapping("/election")
@SecurityRequirement(name = "basicAuth")
@SecurityRequirement(name = "bearerAuth")
@SecurityRequirement(name = "oauth2")
public ResponseEntity<?> putElection(@RequestBody Election election) {
boolean createdNew = service.storeElection(mapper.mapToDB(election));
return createdNew

View File

@ -55,6 +55,8 @@ public class ElectionResultController {
}
)
@GetMapping(value = "/electionResult/by-election-name/{electionName}", produces = MediaType.APPLICATION_JSON_VALUE)
@SecurityRequirement(name = "bearerAuth")
@SecurityRequirement(name = "oauth2")
public ResponseEntity<ElectionResult> getElectionByName(
@PathVariable @Parameter(description = "the election name", example = "Bezirkswahl 2019") String electionName) {
ElectionResult result = mapper.mapToExternal(service.getElectionResult(electionName));
@ -78,7 +80,8 @@ public class ElectionResultController {
}
)
@PutMapping("/electionResult")
@SecurityRequirement(name = "basicAuth")
@SecurityRequirement(name = "bearerAuth")
@SecurityRequirement(name = "oauth2")
public ResponseEntity<?> putElection(@RequestBody ElectionResult electionResult) {
boolean createdNew = service.storeResult(mapper.mapToDB(electionResult));
return createdNew

View File

@ -58,6 +58,8 @@ public class PartyController {
}
)
@GetMapping(value = "/parties/by-election-name/{electionName}", produces = MediaType.APPLICATION_JSON_VALUE)
@SecurityRequirement(name = "bearerAuth")
@SecurityRequirement(name = "oauth2")
public ResponseEntity<Collection<PartyInElection>> getPartiesByElectionName(
@PathVariable @Parameter(description = "the election name", example = "Bezirkswahl 2019") String electionName) {
List<PartyInElection> parties = service.getPartiesByElectionName(electionName).stream()
@ -82,7 +84,8 @@ public class PartyController {
}
)
@PutMapping("/party")
@SecurityRequirement(name = "basicAuth")
@SecurityRequirement(name = "bearerAuth")
@SecurityRequirement(name = "oauth2")
public ResponseEntity<?> putParty(@RequestBody PartyInElection party) {
boolean createdNew = service.storeParty(mapper.mapToDB(party));
return createdNew

View File

@ -30,6 +30,12 @@ spring:
data.mongodb:
uri: ${MONGODB_CONNECTION_STRING}
auto-index-creation: true
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://id.2martens.de/realms/wahlrecht/protocol/openid-connect/certs
# properties for application
de.twomartens.wahlrecht:
@ -49,6 +55,16 @@ springdoc:
openapi:
description: |
Open API Documentation for the Wahlrecht API
## Authenticate
The API is secured by the need to provide bearer tokens. Anonymous access is supported
with the user "anonymous" with password "anonymous". It grants access to all GET
operations as well as the calculation of results.
For changes to the database-stored data, you need a proper authorized user.
The client_id is "wahlrecht", if you are on the Swagger UI and want to authorize there.
## Calculate election results

View File

@ -0,0 +1,6 @@
{
"realm": "wahlrecht",
"auth-server-url": "https://id.2martens.de",
"resource": "wahlrecht",
"http-method-as-scope": true
}