Add JWT-based security and /me endpoint
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Introduced JWT-based authentication with role handling using Keycloak. Added the `/me` endpoint to return user information and roles. Configured testing, Keycloak integration, and public-facing `/public/info` endpoint enhancements.
This commit is contained in:
@ -0,0 +1,60 @@
|
||||
package se.urmo.hemhub.config;
|
||||
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class KeycloakRealmRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
|
||||
|
||||
@Override
|
||||
public Collection<GrantedAuthority> convert(Jwt jwt) {
|
||||
// realm_access.roles (Keycloak)
|
||||
var realmRoles = extractRoles(jwt.getClaim("realm_access"), "roles");
|
||||
|
||||
// (valfritt) resource_access.<client>.roles — plocka även klientroller om du vill
|
||||
var resourceAccess = jwt.getClaim("resource_access");
|
||||
var clientRoles = new ArrayList<String>();
|
||||
if (resourceAccess instanceof Map<?,?> ra) {
|
||||
ra.values().forEach(v -> clientRoles.addAll(extractRoles(v, "roles")));
|
||||
}
|
||||
|
||||
// scope/scp (standard i Spring): "read write" eller ["read","write"]
|
||||
var scopeRoles = new ArrayList<String>();
|
||||
Object scope = Optional.ofNullable(jwt.getClaims().get("scope"))
|
||||
.orElse(jwt.getClaims().get("scp"));
|
||||
if (scope instanceof String s) {
|
||||
scopeRoles.addAll(Arrays.asList(s.split("\\s+")));
|
||||
} else if (scope instanceof Collection<?> c) {
|
||||
c.forEach(x -> scopeRoles.add(String.valueOf(x)));
|
||||
}
|
||||
|
||||
// Samla ihop och prefixa ROLE_
|
||||
return Stream.of(realmRoles, clientRoles, scopeRoles)
|
||||
.flatMap(Collection::stream)
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::valueOf)
|
||||
.filter(s -> !s.isBlank())
|
||||
.distinct()
|
||||
.map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r)
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hjälpmetod som läser en lista av roller från en otypad struktur:
|
||||
* förväntar sig t.ex. { "roles": ["OWNER","MEMBER"] }
|
||||
*/
|
||||
private static List<String> extractRoles(Object container, String key) {
|
||||
if (!(container instanceof Map<?,?> m)) return List.of();
|
||||
Object raw = m.get(key);
|
||||
if (raw instanceof Collection<?> c) {
|
||||
return c.stream().map(String::valueOf).toList();
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
45
src/main/java/se/urmo/hemhub/config/SecurityConfig.java
Normal file
45
src/main/java/se/urmo/hemhub/config/SecurityConfig.java
Normal file
@ -0,0 +1,45 @@
|
||||
package se.urmo.hemhub.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Configuration
|
||||
@EnableMethodSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
"/public/**",
|
||||
"/actuator/health", "/actuator/info",
|
||||
"/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html"
|
||||
).permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter())));
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
JwtAuthenticationConverter jwtConverter() {
|
||||
var converter = new JwtAuthenticationConverter();
|
||||
converter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());
|
||||
return converter;
|
||||
}
|
||||
}
|
||||
|
||||
45
src/main/java/se/urmo/hemhub/web/MeController.java
Normal file
45
src/main/java/se/urmo/hemhub/web/MeController.java
Normal file
@ -0,0 +1,45 @@
|
||||
package se.urmo.hemhub.web;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
public class MeController {
|
||||
|
||||
@GetMapping("/me")
|
||||
public Map<String, Object> me(Authentication auth) {
|
||||
if (auth == null || !(auth.getPrincipal() instanceof Jwt jwt)) {
|
||||
throw new IllegalStateException("No JWT principal");
|
||||
}
|
||||
|
||||
// ---- Läs roller på ett säkert sätt ----
|
||||
List<String> roles = extractRealmRoles(jwt);
|
||||
|
||||
// ---- Bygg svar ----
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("sub", jwt.getSubject());
|
||||
response.put("email", jwt.getClaimAsString("email"));
|
||||
response.put("preferred_username", jwt.getClaimAsString("preferred_username"));
|
||||
response.put("householdId", jwt.getClaimAsString("household_id"));
|
||||
response.put("roles", roles);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static List<String> extractRealmRoles(Jwt jwt) {
|
||||
Object realmAccess = jwt.getClaims().get("realm_access");
|
||||
if (!(realmAccess instanceof Map<?,?> map)) return List.of();
|
||||
|
||||
Object rawRoles = map.get("roles");
|
||||
if (rawRoles instanceof Collection<?> col) {
|
||||
List<String> result = new ArrayList<>();
|
||||
for (Object o : col) result.add(String.valueOf(o));
|
||||
return result;
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@ -9,14 +9,17 @@ import java.util.Map;
|
||||
@RestController
|
||||
public class PublicController {
|
||||
|
||||
@Value("${app.version:dev}")
|
||||
String version;
|
||||
private final String version;
|
||||
|
||||
public PublicController(@Value("${app.version:dev}") String version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
@GetMapping("/public/info")
|
||||
public Map<String, Object> info() {
|
||||
return Map.of(
|
||||
"name", "HemHub API",
|
||||
"version", version,
|
||||
"version", version != null ? version : "dev",
|
||||
"timestamp", Instant.now().toString()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user