diff --git a/Dockerfile b/Dockerfile index 17f3b9d..dc246c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,4 +10,5 @@ ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75" WORKDIR /app COPY --from=build /workspace/build/libs/*-SNAPSHOT.jar app.jar EXPOSE 8080 +LABEL com.centurylinklabs.watchtower.enable=true ENTRYPOINT ["sh","-c","java $JAVA_OPTS -jar /app/app.jar"] diff --git a/build.gradle b/build.gradle index 0454f64..2574667 100644 --- a/build.gradle +++ b/build.gradle @@ -12,14 +12,19 @@ java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } repositories { mavenCentral() } dependencies { + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.security:spring-security-oauth2-jose' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + testImplementation 'org.springframework.security:spring-security-test' runtimeOnly 'org.postgresql:postgresql' implementation 'org.flywaydb:flyway-core' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' } test { diff --git a/docker-compose.yml b/docker-compose.yml index 04e909c..6755a23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,12 +16,15 @@ services: keycloak: image: quay.io/keycloak/keycloak:24.0 - command: ["start-dev","--http-port=8081"] + command: ["start-dev","--http-port=8081","--import-realm"] environment: KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN_PASSWORD: admin + volumes: + - ./keycloak:/opt/keycloak/data/import ports: ["8081:8081"] + api: build: . image: registry.local:5000/hemhub/api:dev diff --git a/keycloak/realm-hemhub.json b/keycloak/realm-hemhub.json new file mode 100644 index 0000000..cfe0bae --- /dev/null +++ b/keycloak/realm-hemhub.json @@ -0,0 +1,56 @@ +{ + "realm": "hemhub", + "enabled": true, + "displayName": "HemHub", + "users": [ + { + "username": "maria", + "email": "maria@example.com", + "enabled": true, + "emailVerified": true, + "attributes": { "household_id": ["H-ANDERSSON"] }, + "credentials": [{ "type": "password", "value": "Passw0rd!", "temporary": false }], + "realmRoles": ["OWNER","MEMBER"] + }, + { + "username": "ulf", + "email": "ulf@example.com", + "enabled": true, + "emailVerified": true, + "attributes": { "household_id": ["H-ANDERSSON"] }, + "credentials": [{ "type": "password", "value": "Passw0rd!", "temporary": false }], + "realmRoles": ["MEMBER"] + } + ], + "roles": { + "realm": [ + {"name":"OWNER","composite":false}, + {"name":"MEMBER","composite":false}, + {"name":"ADMIN","composite":false} + ] + }, + "clients": [ + { + "clientId": "hemhub-public", + "publicClient": true, + "redirectUris": ["http://localhost:5173/*","http://localhost:8080/swagger-ui/*"], + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "attributes": { "pkce.code.challenge.method": "S256" } + }, + { + "clientId": "hemhub-service", + "serviceAccountsEnabled": true, + "secret": "dev-secret", + "publicClient": false, + "redirectUris": [], + "directAccessGrantsEnabled": false, + "standardFlowEnabled": false + } + ], + "clientScopes": [ + {"name":"roles","protocol":"openid-connect"} + ], + "defaultDefaultClientScopes": ["roles", "profile", "email"] +} diff --git a/src/main/java/se/urmo/hemhub/config/KeycloakRealmRoleConverter.java b/src/main/java/se/urmo/hemhub/config/KeycloakRealmRoleConverter.java new file mode 100644 index 0000000..51b07ef --- /dev/null +++ b/src/main/java/se/urmo/hemhub/config/KeycloakRealmRoleConverter.java @@ -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> { + + @Override + public Collection convert(Jwt jwt) { + // realm_access.roles (Keycloak) + var realmRoles = extractRoles(jwt.getClaim("realm_access"), "roles"); + + // (valfritt) resource_access..roles — plocka även klientroller om du vill + var resourceAccess = jwt.getClaim("resource_access"); + var clientRoles = new ArrayList(); + 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(); + 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 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(); + } +} diff --git a/src/main/java/se/urmo/hemhub/config/SecurityConfig.java b/src/main/java/se/urmo/hemhub/config/SecurityConfig.java new file mode 100644 index 0000000..75693d0 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/config/SecurityConfig.java @@ -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; + } +} + diff --git a/src/main/java/se/urmo/hemhub/web/MeController.java b/src/main/java/se/urmo/hemhub/web/MeController.java new file mode 100644 index 0000000..7dfc86a --- /dev/null +++ b/src/main/java/se/urmo/hemhub/web/MeController.java @@ -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 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 roles = extractRealmRoles(jwt); + + // ---- Bygg svar ---- + Map 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 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 result = new ArrayList<>(); + for (Object o : col) result.add(String.valueOf(o)); + return result; + } + return List.of(); + } +} diff --git a/src/main/java/se/urmo/hemhub/web/PublicController.java b/src/main/java/se/urmo/hemhub/web/PublicController.java index fcc5c10..4651c30 100644 --- a/src/main/java/se/urmo/hemhub/web/PublicController.java +++ b/src/main/java/se/urmo/hemhub/web/PublicController.java @@ -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 info() { return Map.of( "name", "HemHub API", - "version", version, + "version", version != null ? version : "dev", "timestamp", Instant.now().toString() ); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f5bba27..c1b310a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,3 +11,8 @@ spring: ddl-auto: none flyway: enabled: true + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:8082/realms/hemhub diff --git a/src/test/java/se/urmo/hemhub/config/KeycloakRealmRoleConverterTest.java b/src/test/java/se/urmo/hemhub/config/KeycloakRealmRoleConverterTest.java new file mode 100644 index 0000000..0523c62 --- /dev/null +++ b/src/test/java/se/urmo/hemhub/config/KeycloakRealmRoleConverterTest.java @@ -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(); + } +} diff --git a/src/test/java/se/urmo/hemhub/security/JwtTestConfig.java b/src/test/java/se/urmo/hemhub/security/JwtTestConfig.java new file mode 100644 index 0000000..bc1d20b --- /dev/null +++ b/src/test/java/se/urmo/hemhub/security/JwtTestConfig.java @@ -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 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) + )); + }; + } +} diff --git a/src/test/java/se/urmo/hemhub/web/MeControllerBranchesIT.java b/src/test/java/se/urmo/hemhub/web/MeControllerBranchesIT.java new file mode 100644 index 0000000..fcfc38a --- /dev/null +++ b/src/test/java/se/urmo/hemhub/web/MeControllerBranchesIT.java @@ -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)); + } +} diff --git a/src/test/java/se/urmo/hemhub/web/MeControllerIT.java b/src/test/java/se/urmo/hemhub/web/MeControllerIT.java new file mode 100644 index 0000000..e73a57e --- /dev/null +++ b/src/test/java/se/urmo/hemhub/web/MeControllerIT.java @@ -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")); + } +} diff --git a/src/test/java/se/urmo/hemhub/web/PublicControllerIT.java b/src/test/java/se/urmo/hemhub/web/PublicControllerIT.java new file mode 100644 index 0000000..741a835 --- /dev/null +++ b/src/test/java/se/urmo/hemhub/web/PublicControllerIT.java @@ -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()); + } +} + diff --git a/src/test/java/se/urmo/hemhub/web/PublicControllerTest.java b/src/test/java/se/urmo/hemhub/web/PublicControllerTest.java new file mode 100644 index 0000000..81f1d96 --- /dev/null +++ b/src/test/java/se/urmo/hemhub/web/PublicControllerTest.java @@ -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"); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..c03e8ed --- /dev/null +++ b/src/test/resources/application-test.yml @@ -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