Add JWT-based security and /me endpoint
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:
Urban Modig
2025-10-05 19:17:56 +02:00
parent 7cb6265125
commit 699fb3836b
16 changed files with 445 additions and 4 deletions

View File

@ -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();
}
}

View 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;
}
}

View 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();
}
}

View File

@ -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()
);
}

View File

@ -11,3 +11,8 @@ spring:
ddl-auto: none
flyway:
enabled: true
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8082/realms/hemhub

View File

@ -0,0 +1,42 @@
package se.urmo.hemhub.config;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.Jwt;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class KeycloakRealmRoleConverterTest {
@Test
void picksRealmRoles_scopes_andClientRoles() {
var jwt = new Jwt(
"t", Instant.now(), Instant.now().plusSeconds(3600),
Map.of("alg","none"),
Map.of(
"realm_access", Map.of("roles", List.of("OWNER","MEMBER")),
"resource_access", Map.of(
"hemhub-public", Map.of("roles", List.of("reader"))
),
"scope", "read write"
)
);
var conv = new KeycloakRealmRoleConverter();
var auths = conv.convert(jwt);
assertThat(auths).extracting("authority")
.contains("ROLE_OWNER","ROLE_MEMBER","ROLE_reader","ROLE_read","ROLE_write");
}
@Test
void tolerantToMissingFields_returnsEmpty() {
var jwt = new Jwt("t", Instant.now(), Instant.now().plusSeconds(3600),
Map.of("alg","none"), Map.of("dummy", "value"));
var conv = new KeycloakRealmRoleConverter();
assertThat(conv.convert(jwt)).isEmpty();
}
}

View File

@ -0,0 +1,32 @@
package se.urmo.hemhub.security;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.jwt.*;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@TestConfiguration
public class JwtTestConfig {
@Bean
JwtDecoder jwtDecoder() {
return token -> {
// tokenformat i test: "HID:ROLE1,ROLE2"
String[] parts = token.split(":");
String hid = parts.length > 0 ? parts[0] : "H-TEST";
List<String> roles = List.of("MEMBER");
Instant now = Instant.now();
return new Jwt(token, now, now.plusSeconds(3600),
Map.of("alg","none"),
Map.of(
"sub","test-user",
"email","test@example.com",
"preferred_username","test",
"household_id", hid,
"realm_access", Map.of("roles", roles)
));
};
}
}

View File

@ -0,0 +1,47 @@
package se.urmo.hemhub.web;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class MeControllerBranchesIT {
@Autowired MockMvc mvc;
@Test
void me_withoutRealmAccess_rolesEmpty() throws Exception {
mvc.perform(get("/me").with(jwt().jwt(j -> {
j.subject("u1");
j.claim("preferred_username", "test1");
j.claim("household_id", "H1");
// ingen realm_access-claim
})))
.andExpect(status().isOk())
.andExpect(jsonPath("$.roles").isArray())
.andExpect(jsonPath("$.roles.length()").value(0));
}
@Test
void me_withMalformedRealmAccess_rolesEmpty() throws Exception {
mvc.perform(get("/me").with(jwt().jwt(j -> {
j.subject("u2");
j.claim("preferred_username", "test2");
j.claim("household_id", "H2");
j.claim("realm_access", Map.of("roles", "NOT_A_LIST"));
})))
.andExpect(status().isOk())
.andExpect(jsonPath("$.roles.length()").value(0));
}
}

View File

@ -0,0 +1,39 @@
package se.urmo.hemhub.integration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ActiveProfiles;
import java.util.List;
import java.util.Map;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class MeControllerIT {
@Autowired
MockMvc mvc;
@Test
void me_withToken_200_andContainsClaims() throws Exception {
mvc.perform(get("/me").with(jwt().jwt(j -> {
j.subject("test-user");
j.claim("email", "test@example.com");
j.claim("preferred_username", "test");
j.claim("household_id", "H-ANDERSSON");
j.claim("realm_access", Map.of("roles", List.of("MEMBER")));
})))
.andExpect(status().isOk())
.andExpect(jsonPath("$.preferred_username").value("test"))
.andExpect(jsonPath("$.householdId").value("H-ANDERSSON"))
.andExpect(jsonPath("$.roles[0]").value("MEMBER"));
}
}

View File

@ -0,0 +1,30 @@
package se.urmo.hemhub.web;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.beans.factory.annotation.Autowired;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class PublicControllerIT {
@Autowired
MockMvc mvc;
@Test
void publicInfo_ok() throws Exception {
mvc.perform(get("/public/info"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("HemHub API"))
.andExpect(jsonPath("$.version").exists())
.andExpect(jsonPath("$.timestamp").exists());
}
}

View File

@ -0,0 +1,17 @@
package se.urmo.hemhub.web;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicControllerTest {
@Test
void info_returnsExpectedKeys() {
var controller = new PublicController("test");
var result = controller.info();
assertThat(result).containsKeys("name", "version", "timestamp");
assertThat(result.get("name")).isEqualTo("HemHub API");
}
}

View File

@ -0,0 +1,11 @@
spring:
datasource:
url: jdbc:h2:mem:hemhub;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false
driverClassName: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: none
flyway:
enabled: false