Added authentication and authorization via Keycloak

This commit is contained in:
2023-07-16 15:31:20 +02:00
parent a42e1d0f3e
commit 7139d8c331
10 changed files with 137 additions and 33 deletions
+12
View File
@@ -13,6 +13,7 @@ mapstruct = "1.5.3.Final"
junit = "5.9.2" junit = "5.9.2"
assertj = "3.24.2" assertj = "3.24.2"
mockito = "5.3.0" mockito = "5.3.0"
keycloak = "22.0.0"
plugin-nebula-release = "17.1.0" plugin-nebula-release = "17.1.0"
plugin-lombok = "8.0.1" plugin-lombok = "8.0.1"
plugin-gradle-versions = "0.46.0" 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-validation = { module = "org.springframework.boot:spring-boot-starter-validation" }
spring-boot-test = { module = "org.springframework.boot:spring-boot-starter-test" } spring-boot-test = { module = "org.springframework.boot:spring-boot-starter-test" }
spring-boot-security = { module = "org.springframework.boot:spring-boot-starter-security" } 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-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 = { module = "org.springframework.cloud:spring-cloud-dependencies", version.ref = "spring-cloud" }
spring-cloud-starter = { module = "org.springframework.cloud:spring-cloud-starter" } 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-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-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" } 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-api = { module = "io.grpc:grpc-api", version.ref = "grpc" }
grpc-context = { module = "io.grpc:grpc-context", version.ref = "grpc" } grpc-context = { module = "io.grpc:grpc-context", version.ref = "grpc" }
grpc-core = { module = "io.grpc:grpc-core", version.ref = "grpc" } grpc-core = { module = "io.grpc:grpc-core", version.ref = "grpc" }
@@ -104,6 +108,14 @@ spring-boot-server = [
"spring-sec", "spring-sec",
"spring-ui", "spring-ui",
] ]
spring-boot-security = [
"spring-boot-security",
"spring-boot-oauth2-resource-server",
"keycloak-core",
"keycloak-policy-enforcer"
]
test = [ test = [
"assertj", "assertj",
"junit-jupiter", "junit-jupiter",
+1 -1
View File
@@ -4,7 +4,7 @@ plugins {
dependencies { dependencies {
implementation libs.mapstruct.base implementation libs.mapstruct.base
implementation libs.spring.boot.security implementation libs.bundles.spring.boot.security
annotationProcessor libs.mapstruct.processor annotationProcessor libs.mapstruct.processor
} }
@@ -1,6 +1,8 @@
package de.twomartens.wahlrecht.configuration; package de.twomartens.wahlrecht.configuration;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; 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.annotations.security.SecurityScheme;
import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.OpenAPI;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -8,9 +10,21 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@SecurityScheme( @SecurityScheme(
name = "basicAuth", name = "bearerAuth",
type = SecuritySchemeType.HTTP, 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 @Configuration
public class OpenApiConfiguration { public class OpenApiConfiguration {
@@ -1,57 +1,101 @@
package de.twomartens.wahlrecht.configuration; 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User; import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
public class WebSecurityConfiguration { 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 @Bean
public SecurityFilterChain securityFilterChain(@NonNull HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(@NonNull HttpSecurity http) throws Exception {
http http
.csrf().disable() .csrf().disable()
.authorizeHttpRequests() .authorizeHttpRequests()
.requestMatchers(HttpMethod.PUT, "/wahlrecht/v1/**") .requestMatchers(PERMITTED_PATHS.toArray(new String[0]))
.authenticated()
.and()
.authorizeHttpRequests()
.requestMatchers("/wahlrecht/v1/**", "/wahlrecht/version",
"/error")
.permitAll()
.and()
.authorizeHttpRequests()
.requestMatchers("/actuator/**")
.permitAll() .permitAll()
.and() .and()
.authorizeHttpRequests() .authorizeHttpRequests()
.requestMatchers("/resources/**") .anyRequest()
.permitAll() .authenticated()
.and() .and()
.httpBasic(); .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
.addFilterAfter(createPolicyEnforcerFilter(), BearerTokenAuthenticationFilter.class);
return http.build(); return http.build();
} }
@Bean @Bean
public UserDetailsService userDetailsService() { JwtDecoder jwtDecoder() {
UserDetails user = User.withDefaultPasswordEncoder() return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri).build();
.username("user")
.password("password")
.roles(ROLE_USER)
.build();
return new InMemoryUserDetailsManager(user);
} }
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;
}
} }
@@ -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.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; 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 io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -46,6 +47,8 @@ public class CalculationController {
} }
) )
@PostMapping(value = "/calculate", produces = MediaType.APPLICATION_JSON_VALUE) @PostMapping(value = "/calculate", produces = MediaType.APPLICATION_JSON_VALUE)
@SecurityRequirement(name = "bearerAuth")
@SecurityRequirement(name = "oauth2")
public ResponseEntity<ElectedCandidates> calculateResult( public ResponseEntity<ElectedCandidates> calculateResult(
@RequestBody ElectionResult electionResult @RequestBody ElectionResult electionResult
) { ) {
@@ -46,6 +46,8 @@ public class ElectionController {
} }
) )
@GetMapping(value = "/elections", produces = MediaType.APPLICATION_JSON_VALUE) @GetMapping(value = "/elections", produces = MediaType.APPLICATION_JSON_VALUE)
@SecurityRequirement(name = "bearerAuth")
@SecurityRequirement(name = "oauth2")
public ResponseEntity<Collection<Election>> getElections() { public ResponseEntity<Collection<Election>> getElections() {
List<Election> elections = service.getElections().stream().map(mapper::mapToExternal).toList(); List<Election> elections = service.getElections().stream().map(mapper::mapToExternal).toList();
return ResponseEntity.ok() return ResponseEntity.ok()
@@ -68,7 +70,8 @@ public class ElectionController {
} }
) )
@PutMapping("/election") @PutMapping("/election")
@SecurityRequirement(name = "basicAuth") @SecurityRequirement(name = "bearerAuth")
@SecurityRequirement(name = "oauth2")
public ResponseEntity<?> putElection(@RequestBody Election election) { public ResponseEntity<?> putElection(@RequestBody Election election) {
boolean createdNew = service.storeElection(mapper.mapToDB(election)); boolean createdNew = service.storeElection(mapper.mapToDB(election));
return createdNew return createdNew
@@ -55,6 +55,8 @@ public class ElectionResultController {
} }
) )
@GetMapping(value = "/electionResult/by-election-name/{electionName}", produces = MediaType.APPLICATION_JSON_VALUE) @GetMapping(value = "/electionResult/by-election-name/{electionName}", produces = MediaType.APPLICATION_JSON_VALUE)
@SecurityRequirement(name = "bearerAuth")
@SecurityRequirement(name = "oauth2")
public ResponseEntity<ElectionResult> getElectionByName( public ResponseEntity<ElectionResult> getElectionByName(
@PathVariable @Parameter(description = "the election name", example = "Bezirkswahl 2019") String electionName) { @PathVariable @Parameter(description = "the election name", example = "Bezirkswahl 2019") String electionName) {
ElectionResult result = mapper.mapToExternal(service.getElectionResult(electionName)); ElectionResult result = mapper.mapToExternal(service.getElectionResult(electionName));
@@ -78,7 +80,8 @@ public class ElectionResultController {
} }
) )
@PutMapping("/electionResult") @PutMapping("/electionResult")
@SecurityRequirement(name = "basicAuth") @SecurityRequirement(name = "bearerAuth")
@SecurityRequirement(name = "oauth2")
public ResponseEntity<?> putElection(@RequestBody ElectionResult electionResult) { public ResponseEntity<?> putElection(@RequestBody ElectionResult electionResult) {
boolean createdNew = service.storeResult(mapper.mapToDB(electionResult)); boolean createdNew = service.storeResult(mapper.mapToDB(electionResult));
return createdNew return createdNew
@@ -58,6 +58,8 @@ public class PartyController {
} }
) )
@GetMapping(value = "/parties/by-election-name/{electionName}", produces = MediaType.APPLICATION_JSON_VALUE) @GetMapping(value = "/parties/by-election-name/{electionName}", produces = MediaType.APPLICATION_JSON_VALUE)
@SecurityRequirement(name = "bearerAuth")
@SecurityRequirement(name = "oauth2")
public ResponseEntity<Collection<PartyInElection>> getPartiesByElectionName( public ResponseEntity<Collection<PartyInElection>> getPartiesByElectionName(
@PathVariable @Parameter(description = "the election name", example = "Bezirkswahl 2019") String electionName) { @PathVariable @Parameter(description = "the election name", example = "Bezirkswahl 2019") String electionName) {
List<PartyInElection> parties = service.getPartiesByElectionName(electionName).stream() List<PartyInElection> parties = service.getPartiesByElectionName(electionName).stream()
@@ -82,7 +84,8 @@ public class PartyController {
} }
) )
@PutMapping("/party") @PutMapping("/party")
@SecurityRequirement(name = "basicAuth") @SecurityRequirement(name = "bearerAuth")
@SecurityRequirement(name = "oauth2")
public ResponseEntity<?> putParty(@RequestBody PartyInElection party) { public ResponseEntity<?> putParty(@RequestBody PartyInElection party) {
boolean createdNew = service.storeParty(mapper.mapToDB(party)); boolean createdNew = service.storeParty(mapper.mapToDB(party));
return createdNew return createdNew
@@ -30,6 +30,12 @@ spring:
data.mongodb: data.mongodb:
uri: ${MONGODB_CONNECTION_STRING} uri: ${MONGODB_CONNECTION_STRING}
auto-index-creation: true auto-index-creation: true
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://id.2martens.de/realms/wahlrecht/protocol/openid-connect/certs
# properties for application # properties for application
de.twomartens.wahlrecht: de.twomartens.wahlrecht:
@@ -49,6 +55,16 @@ springdoc:
openapi: openapi:
description: | description: |
Open API Documentation for the Wahlrecht API 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 ## Calculate election results
@@ -0,0 +1,6 @@
{
"realm": "wahlrecht",
"auth-server-url": "https://id.2martens.de",
"resource": "wahlrecht",
"http-method-as-scope": true
}