├── .gitattributes ├── run-full-stack.cmd ├── src └── main │ ├── java │ └── com │ │ └── vulinh │ │ ├── enums │ │ └── UserRole.java │ │ ├── exception │ │ └── AuthorizationException.java │ │ ├── configuration │ │ ├── ApplicationProperties.java │ │ ├── OpenAPIConfiguration.java │ │ ├── AuthorizedUserDetails.java │ │ ├── JwtConverter.java │ │ └── SecurityConfig.java │ │ ├── controller │ │ ├── api │ │ │ └── TestAPI.java │ │ └── rest │ │ │ └── TestController.java │ │ ├── Application.java │ │ └── utils │ │ └── SecurityUtils.java │ └── resources │ └── application.yaml ├── .mvn └── wrapper │ └── maven-wrapper.properties ├── .gitignore ├── README.md ├── docker-compose.yaml ├── Dockerfile ├── docker-compose-full.yaml ├── pom.xml ├── run-keycloak-postgresql.cmd ├── mvnw.cmd └── mvnw /.gitattributes: -------------------------------------------------------------------------------- 1 | /mvnw text eol=lf 2 | *.cmd text eol=crlf -------------------------------------------------------------------------------- /run-full-stack.cmd: -------------------------------------------------------------------------------- 1 | docker compose down 2 | docker image rm -f spring-boot-3-keycloak:1.0 3 | docker compose up -d -------------------------------------------------------------------------------- /src/main/java/com/vulinh/enums/UserRole.java: -------------------------------------------------------------------------------- 1 | package com.vulinh.enums; 2 | 3 | public enum UserRole { 4 | ROLE_ADMIN, 5 | ROLE_USER 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/com/vulinh/exception/AuthorizationException.java: -------------------------------------------------------------------------------- 1 | package com.vulinh.exception; 2 | 3 | import java.io.Serial; 4 | 5 | public class AuthorizationException extends RuntimeException { 6 | 7 | @Serial private static final long serialVersionUID = -4977646741872972264L; 8 | 9 | public AuthorizationException(String message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/vulinh/configuration/ApplicationProperties.java: -------------------------------------------------------------------------------- 1 | package com.vulinh.configuration; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | 5 | import java.util.List; 6 | 7 | @ConfigurationProperties(prefix = "application-properties") 8 | public record ApplicationProperties( 9 | String clientName, List adminPrivilegeUrls, List noAuthUrls) {} 10 | -------------------------------------------------------------------------------- /src/main/java/com/vulinh/controller/api/TestAPI.java: -------------------------------------------------------------------------------- 1 | package com.vulinh.controller.api; 2 | 3 | import org.springframework.web.bind.annotation.GetMapping; 4 | import org.springframework.web.bind.annotation.RequestMapping; 5 | 6 | @RequestMapping("/test") 7 | public interface TestAPI { 8 | 9 | @GetMapping("/free") 10 | String free(); 11 | 12 | @GetMapping 13 | String normalAccess(); 14 | 15 | @GetMapping("/admin") 16 | String adminAccess(); 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/vulinh/Application.java: -------------------------------------------------------------------------------- 1 | package com.vulinh; 2 | 3 | import com.vulinh.configuration.ApplicationProperties; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 7 | 8 | @SpringBootApplication 9 | @EnableConfigurationProperties(ApplicationProperties.class) 10 | public class Application { 11 | 12 | public static void main(String[] args) { 13 | SpringApplication.run(Application.class, args); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/vulinh/controller/rest/TestController.java: -------------------------------------------------------------------------------- 1 | package com.vulinh.controller.rest; 2 | 3 | import com.vulinh.controller.api.TestAPI; 4 | import com.vulinh.utils.SecurityUtils; 5 | import org.springframework.web.bind.annotation.RestController; 6 | 7 | @RestController 8 | public class TestController implements TestAPI { 9 | 10 | @Override 11 | public String free() { 12 | return "Hello!"; 13 | } 14 | 15 | @Override 16 | public String normalAccess() { 17 | return "Hello, World!"; 18 | } 19 | 20 | @Override 21 | public String adminAccess() { 22 | return "Hello %s".formatted(SecurityUtils.getUserDetails().getUsername()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | application-properties: 2 | realm-name: spring-boot-realm 3 | client-name: spring-boot-client 4 | admin-privilege-urls: 5 | - /test/admin/** 6 | no-auth-urls: 7 | # OpenAPI Swagger URLs 8 | - /swagger-ui.html 9 | - /swagger-ui/** 10 | - /v3/api-docs/** 11 | - /v3/api-docs.yaml 12 | # Actuator endpoints: 13 | - /actuator/** 14 | # Custom no-auth URLs: 15 | - /test/free 16 | server.port: 8088 17 | spring: 18 | threads.virtual.enabled: true 19 | security.oauth2.resourceserver: 20 | jwt.issuer-uri: http://${KEYCLOAK_HOST:localhost:8080}/realms/${application-properties.realm-name} 21 | logging.level: 22 | # If you are curious about how Spring Security OAuth2 works behind the scene 23 | org.springframework.security.oauth2: TRACE -------------------------------------------------------------------------------- /src/main/java/com/vulinh/configuration/OpenAPIConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.vulinh.configuration; 2 | 3 | import io.swagger.v3.oas.annotations.OpenAPIDefinition; 4 | import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; 5 | import io.swagger.v3.oas.annotations.info.Info; 6 | import io.swagger.v3.oas.annotations.security.SecurityScheme; 7 | import org.springframework.context.annotation.Configuration; 8 | 9 | @Configuration 10 | @OpenAPIDefinition( 11 | info = 12 | @Info( 13 | title = "Spring Boot 3 + KeyCloak Integration API", 14 | version = "v1", 15 | description = "Sample APIs for testing KeyCloak integration")) 16 | @SecurityScheme( 17 | name = "Bearer Token", 18 | type = SecuritySchemeType.HTTP, 19 | bearerFormat = "JWT", 20 | scheme = "bearer") 21 | public class OpenAPIConfiguration {} 22 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.10/apache-maven-3.9.10-bin.zip 20 | -------------------------------------------------------------------------------- /src/main/java/com/vulinh/configuration/AuthorizedUserDetails.java: -------------------------------------------------------------------------------- 1 | package com.vulinh.configuration; 2 | 3 | import java.util.Collection; 4 | import java.util.Collections; 5 | import java.util.UUID; 6 | import lombok.Builder; 7 | import org.springframework.security.core.GrantedAuthority; 8 | import org.springframework.security.core.userdetails.UserDetails; 9 | 10 | // Customized UserDetails object 11 | @Builder 12 | public record AuthorizedUserDetails( 13 | UUID userId, String username, String email, Collection authorities) 14 | implements UserDetails { 15 | 16 | public AuthorizedUserDetails { 17 | authorities = authorities == null ? Collections.emptyList() : authorities; 18 | } 19 | 20 | @Override 21 | public Collection getAuthorities() { 22 | return authorities; 23 | } 24 | 25 | // No credentials expose 26 | @Override 27 | public String getPassword() { 28 | return null; 29 | } 30 | 31 | @Override 32 | public String getUsername() { 33 | return username; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ############################## 2 | ## Java 3 | ############################## 4 | .mtj.tmp/ 5 | *.class 6 | *.jar 7 | *.war 8 | *.ear 9 | *.nar 10 | hs_err_pid* 11 | 12 | ############################## 13 | ## Maven 14 | ############################## 15 | target/ 16 | pom.xml.tag 17 | pom.xml.releaseBackup 18 | pom.xml.versionsBackup 19 | pom.xml.next 20 | pom.xml.bak 21 | release.properties 22 | dependency-reduced-pom.xml 23 | buildNumber.properties 24 | .mvn/timing.properties 25 | .mvn/wrapper/maven-wrapper.jar 26 | 27 | ############################## 28 | ## Gradle 29 | ############################## 30 | bin/ 31 | build/ 32 | .gradle 33 | .gradletasknamecache 34 | gradle-app.setting 35 | !gradle-wrapper.jar 36 | 37 | ############################## 38 | ## IntelliJ 39 | ############################## 40 | out/ 41 | .idea/ 42 | .idea_modules/ 43 | *.iml 44 | *.ipr 45 | *.iws 46 | 47 | ############################## 48 | ## Eclipse 49 | ############################## 50 | .settings/ 51 | tmp/ 52 | .metadata 53 | .classpath 54 | .project 55 | *.tmp 56 | *.bak 57 | *.swp 58 | *~.nib 59 | local.properties 60 | .loadpath 61 | .factorypath 62 | .env 63 | 64 | ############################## 65 | ## NetBeans 66 | ############################## 67 | nbproject/private/ 68 | nbbuild/ 69 | dist/ 70 | nbdist/ 71 | nbactions.xml 72 | nb-configuration.xml 73 | 74 | ############################## 75 | ## Visual Studio Code 76 | ############################## 77 | .vscode/ 78 | .code-workspace 79 | 80 | ############################## 81 | ## OS X 82 | ############################## 83 | .DS_Store -------------------------------------------------------------------------------- /src/main/java/com/vulinh/utils/SecurityUtils.java: -------------------------------------------------------------------------------- 1 | package com.vulinh.utils; 2 | 3 | import com.vulinh.configuration.AuthorizedUserDetails; 4 | import com.vulinh.exception.AuthorizationException; 5 | import lombok.AccessLevel; 6 | import lombok.NoArgsConstructor; 7 | import org.springframework.security.core.context.SecurityContextHolder; 8 | import org.springframework.security.core.userdetails.UserDetails; 9 | 10 | /** Utility class meant for quickly retrieve authenticated user info */ 11 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 12 | public class SecurityUtils { 13 | 14 | // Stupid engineers who DARE reparse the JWT (authentication.getCredentials()) AGAIN 15 | // should be axed and never be allowed to work in this industry 16 | // Yes, some people in a banking system did not even now how to use SecurityContextHolder 17 | 18 | /** 19 | * Retrieve the details of the current authenticated user. 20 | * 21 | * @return an UserDetails object 22 | */ 23 | public static UserDetails getUserDetails() { 24 | var authentication = SecurityContextHolder.getContext().getAuthentication(); 25 | 26 | if (authentication == null) { 27 | throw new AuthorizationException("Empty authentication"); 28 | } 29 | 30 | var userPrincipal = authentication.getPrincipal(); 31 | 32 | if (!(userPrincipal instanceof AuthorizedUserDetails details)) { 33 | throw new AuthorizationException( 34 | "Invalid user details object, expected class [%s]" 35 | .formatted(AuthorizedUserDetails.class.getName())); 36 | } 37 | 38 | return details; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Keycloak Local Development Setup 2 | 3 | This project provides a Docker Compose configuration for running Keycloak locally with PostgreSQL persistence. 4 | 5 | ## Quick Start 6 | 7 | ```bash 8 | docker compose up -d 9 | ``` 10 | 11 | ## What's Included 12 | 13 | - **Keycloak 26.3**: Identity and Access Management server 14 | - **PostgreSQL 17.5**: Database for persistent data storage 15 | - **Persistent Volume**: Preserves realms, clients, users, and configurations between restarts 16 | 17 | ## Access Information 18 | 19 | - **Keycloak Admin Console**: http://localhost:8080 20 | - **Default Admin Credentials**: 21 | - Username: `admin` 22 | - Password: `admin` 23 | - **PostgreSQL Database**: `localhost:5432` 24 | - Database: `keycloak` 25 | - Username: `postgres` 26 | - Password: `123456` 27 | 28 | ## Features 29 | 30 | - Development mode configuration (`start-dev`) 31 | - Health checks enabled on port 9000 32 | - Metrics endpoint available 33 | - Automatic database initialization 34 | - Data persistence across container restarts 35 | 36 | ## Configuration 37 | 38 | The setup uses environment variables for configuration. Key settings include: 39 | 40 | - Database connection to PostgreSQL 41 | - Admin user bootstrap 42 | - Health and metrics endpoints 43 | - Development mode for easier local testing 44 | 45 | ## Data Persistence 46 | 47 | Your Keycloak data (realms, clients, users, roles, etc.) is automatically persisted in the `keycloak-postgres-volume` 48 | Docker volume. This means your configuration will survive container restarts and recreations. 49 | 50 | ## Documentation 51 | 52 | For detailed integration instructions and usage examples, see the comprehensive guide: 53 | 54 | > https://vulinhjava.io.vn/blog/spring-boot-3-keycloak-integration 55 | 56 | ## Stopping the Services 57 | 58 | ```bash 59 | docker compose down 60 | ``` 61 | 62 | To remove everything including the persistent volume: 63 | 64 | ```bash 65 | docker compose down -v 66 | ``` -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | keycloak: 3 | networks: 4 | - keycloak-network 5 | image: quay.io/keycloak/keycloak:26.3 6 | container_name: standalone-keycloak 7 | command: start-dev 8 | environment: 9 | # Database Settings 10 | - KC_DB=postgres 11 | - KC_DB_URL_HOST=postgresql 12 | - KC_DB_URL_DATABASE=keycloak 13 | - KC_DB_USERNAME=postgres 14 | - KC_DB_PASSWORD=123456 15 | 16 | # Admin Credentials 17 | - KC_BOOTSTRAP_ADMIN_USERNAME=admin 18 | - KC_BOOTSTRAP_ADMIN_PASSWORD=admin 19 | 20 | # Enable Health and Metrics 21 | - KC_HEALTH_ENABLED=true 22 | - KC_METRICS_ENABLED=true 23 | 24 | # KC explicit settings 25 | - KC_HOSTNAME=keycloak 26 | ports: 27 | - "8080:8080" 28 | - "9000:9000" 29 | depends_on: 30 | postgresql: 31 | condition: service_healthy 32 | healthcheck: 33 | test: [ 'CMD-SHELL', '[ -f /tmp/HealthCheck.java ] || echo "public class HealthCheck { public static void main(String[] args) throws java.lang.Throwable { java.net.URI uri = java.net.URI.create(args[0]); System.exit(java.net.HttpURLConnection.HTTP_OK == ((java.net.HttpURLConnection)uri.toURL().openConnection()).getResponseCode() ? 0 : 1); } }" > /tmp/HealthCheck.java && java /tmp/HealthCheck.java http://localhost:9000/health/live' ] 34 | interval: 5s 35 | timeout: 5s 36 | retries: 15 37 | start_period: 10s 38 | 39 | postgresql: 40 | networks: 41 | - keycloak-network 42 | image: 'postgres:17.5-alpine' 43 | container_name: keycloak-postgresql 44 | volumes: 45 | - keycloak-postgres-volume:/var/lib/postgresql/data 46 | environment: 47 | - POSTGRES_DB=keycloak 48 | - POSTGRES_USER=postgres 49 | - POSTGRES_PASSWORD=123456 50 | ports: 51 | - "5432:5432" 52 | healthcheck: 53 | test: ["CMD-SHELL", "pg_isready -U postgres -d keycloak"] 54 | interval: 10s 55 | timeout: 5s 56 | retries: 5 57 | 58 | volumes: 59 | keycloak-postgres-volume: 60 | name: keycloak-postgres-volume 61 | 62 | networks: 63 | keycloak-network: 64 | driver: bridge -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # Multi-stage Dockerfile for Java Spring Boot application 3 | # 4 | 5 | # Stage 1: Build stage - Compile the application and create a minimal JRE 6 | FROM eclipse-temurin:21-jdk-alpine AS build 7 | WORKDIR /usr/src/project 8 | 9 | # Copy Maven configuration files first to leverage Docker cache 10 | COPY pom.xml mvnw ./ 11 | COPY .mvn/ .mvn/ 12 | RUN chmod +x mvnw 13 | 14 | # Download dependencies (will be cached if pom.xml doesn't change) 15 | RUN ./mvnw dependency:go-offline 16 | 17 | # Copy source code 18 | COPY src/ src/ 19 | 20 | # Build the application using Maven wrapper 21 | RUN ./mvnw clean package -DskipTests 22 | 23 | # Extract the JAR to analyze its dependencies 24 | RUN jar xf target/app.jar 25 | 26 | # Use jdeps to identify necessary Java modules for a minimal JRE 27 | RUN jdeps \ 28 | --ignore-missing-deps \ 29 | -q --recursive \ 30 | --multi-release 21 \ 31 | --print-module-deps \ 32 | --class-path 'BOOT-INF/lib/*' \ 33 | target/app.jar > deps.info 34 | 35 | RUN cat deps.info 36 | 37 | # Create a custom JRE with only the required modules 38 | # jdk.crypto.ec is needed for HTTPS 39 | # JDEPS currently does not detect this module 40 | RUN jlink \ 41 | --add-modules $(cat deps.info),jdk.crypto.ec \ 42 | --strip-debug \ 43 | --compress 2 \ 44 | --no-header-files \ 45 | --no-man-pages \ 46 | --output /jre-21-minimalist 47 | 48 | # Stage 2: Production stage - Minimal Alpine image with custom JRE 49 | FROM alpine:3.21.3 AS final 50 | 51 | # Set up Java environment 52 | ENV JAVA_HOME=/opt/java/jre-21-minimalist 53 | ENV PATH=$JAVA_HOME/bin:$PATH 54 | 55 | # Copy the custom JRE from the build stage 56 | COPY --from=build /jre-21-minimalist $JAVA_HOME 57 | 58 | # Create a non-root user for security 59 | RUN addgroup -S springgroup \ 60 | && adduser -S springuser -G springgroup \ 61 | && mkdir -p /app \ 62 | && chown -R springuser:springgroup /app 63 | 64 | # Copy application artifacts from build stage 65 | COPY --from=build /usr/src/project/target/app.jar /app/ 66 | 67 | WORKDIR /app 68 | 69 | USER springuser 70 | 71 | # 72 | # Run the application with optimized JVM settings 73 | # 74 | 75 | # - MaxRAMPercentage: Limit max heap to 75% of container memory 76 | # - InitialRAMPercentage: Start with 50% of container memory 77 | # - MaxMetaspaceSize: Limit metaspace to 512MB 78 | # - UseG1GC: Use the G1 garbage collector for better performance 79 | ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:InitialRAMPercentage=50.0", "-XX:MaxMetaspaceSize=512m", "-XX:+UseG1GC", "-jar", "app.jar"] -------------------------------------------------------------------------------- /docker-compose-full.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | spring-boot-3-keycloak: 3 | image: spring-boot-3-keycloak:1.0 4 | networks: 5 | - keycloak-network 6 | container_name: spring-boot-3-keycloak 7 | environment: 8 | - KEYCLOAK_HOST=keycloak:8080 9 | build: 10 | context: . 11 | dockerfile: Dockerfile 12 | ports: 13 | - "8088:8088" 14 | depends_on: 15 | keycloak: 16 | condition: service_healthy 17 | 18 | keycloak: 19 | networks: 20 | - keycloak-network 21 | image: quay.io/keycloak/keycloak:26.3 22 | container_name: standalone-keycloak 23 | command: start-dev 24 | environment: 25 | # Database Settings 26 | - KC_DB=postgres 27 | - KC_DB_URL_HOST=postgresql 28 | - KC_DB_URL_DATABASE=keycloak 29 | - KC_DB_USERNAME=postgres 30 | - KC_DB_PASSWORD=123456 31 | 32 | # Admin Credentials 33 | - KC_BOOTSTRAP_ADMIN_USERNAME=admin 34 | - KC_BOOTSTRAP_ADMIN_PASSWORD=admin 35 | 36 | # Enable Health and Metrics 37 | - KC_HEALTH_ENABLED=true 38 | - KC_METRICS_ENABLED=true 39 | 40 | # KC explicit settings 41 | - KC_HOSTNAME=keycloak 42 | ports: 43 | - "8080:8080" 44 | - "9000:9000" # Note: Port 9000 is often for Keycloak's internal/management interface, keeping it can be useful. 45 | depends_on: 46 | postgresql: 47 | condition: service_healthy 48 | healthcheck: 49 | test: ['CMD-SHELL', '[ -f /tmp/HealthCheck.java ] || echo "public class HealthCheck { public static void main(String[] args) throws java.lang.Throwable { java.net.URI uri = java.net.URI.create(args[0]); System.exit(java.net.HttpURLConnection.HTTP_OK == ((java.net.HttpURLConnection)uri.toURL().openConnection()).getResponseCode() ? 0 : 1); } }" > /tmp/HealthCheck.java && java /tmp/HealthCheck.java http://localhost:9000/health/live'] 50 | interval: 5s 51 | timeout: 5s 52 | retries: 15 53 | start_period: 10s 54 | 55 | postgresql: 56 | networks: 57 | - keycloak-network 58 | image: 'postgres:17.5-alpine' 59 | container_name: keycloak-postgresql 60 | volumes: 61 | - keycloak-postgres-volume:/var/lib/postgresql/data 62 | environment: 63 | - POSTGRES_DB=keycloak 64 | - POSTGRES_USER=postgres 65 | - POSTGRES_PASSWORD=123456 66 | ports: 67 | - "5432:5432" 68 | healthcheck: 69 | test: ["CMD-SHELL", "pg_isready -U postgres -d keycloak"] 70 | interval: 10s 71 | timeout: 5s 72 | retries: 5 73 | 74 | volumes: 75 | keycloak-postgres-volume: 76 | name: keycloak-postgres-volume 77 | 78 | networks: 79 | keycloak-network: 80 | driver: bridge -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | 7 | org.springframework.boot 8 | spring-boot-starter-parent 9 | 3.5.3 10 | 11 | 12 | 13 | com.vulinh 14 | spring-boot-keycloak-integration 15 | 0.0.1-SNAPSHOT 16 | Spring Boot + KeyCloak Integration 17 | Spring Boot + KeyCloak Integration 18 | 19 | 20 | 21 21 | 2.8.9 22 | 23 | 24 | 25 | 26 | org.springframework.boot 27 | spring-boot-starter-oauth2-resource-server 28 | 29 | 30 | org.springframework.boot 31 | spring-boot-starter-security 32 | 33 | 34 | org.springframework.boot 35 | spring-boot-starter-web 36 | 37 | 38 | org.springframework.boot 39 | spring-boot-starter-actuator 40 | 41 | 42 | org.projectlombok 43 | lombok 44 | true 45 | 46 | 47 | org.apache.commons 48 | commons-lang3 49 | 50 | 51 | org.springdoc 52 | springdoc-openapi-starter-webmvc-ui 53 | ${springdoc.openapi.version} 54 | 55 | 56 | 57 | 58 | app 59 | 60 | 61 | org.apache.maven.plugins 62 | maven-compiler-plugin 63 | 64 | 65 | 66 | org.projectlombok 67 | lombok 68 | 69 | 70 | 71 | 72 | 73 | org.springframework.boot 74 | spring-boot-maven-plugin 75 | 76 | 77 | 78 | org.projectlombok 79 | lombok 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /src/main/java/com/vulinh/configuration/JwtConverter.java: -------------------------------------------------------------------------------- 1 | package com.vulinh.configuration; 2 | 3 | import com.vulinh.exception.AuthorizationException; 4 | import java.util.Collection; 5 | import java.util.Map; 6 | import java.util.UUID; 7 | import java.util.stream.Collectors; 8 | import lombok.RequiredArgsConstructor; 9 | import org.apache.commons.lang3.ArrayUtils; 10 | import org.apache.commons.lang3.StringUtils; 11 | import org.springframework.core.convert.converter.Converter; 12 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 13 | import org.springframework.security.core.GrantedAuthority; 14 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 15 | import org.springframework.security.oauth2.jwt.Jwt; 16 | import org.springframework.stereotype.Component; 17 | 18 | @Component 19 | @RequiredArgsConstructor 20 | public class JwtConverter implements Converter { 21 | 22 | static final String RESOURCE_ACCESS_CLAIM = "resource_access"; 23 | static final String EMAIL_CLAIM = "email"; 24 | 25 | private final ApplicationProperties applicationProperties; 26 | 27 | @Override 28 | @SuppressWarnings("unchecked") 29 | public UsernamePasswordAuthenticationToken convert(Jwt jwt) { 30 | var clientName = applicationProperties.clientName(); 31 | 32 | // cannot have different authorized party 33 | if (!clientName.equalsIgnoreCase(jwt.getClaimAsString("azp"))) { 34 | throw new AuthorizationException( 35 | "Invalid authorized party (azp), expected [%s]".formatted(clientName)); 36 | } 37 | 38 | // get the top-level "resource_access" claim. 39 | var resourceAccess = 40 | nonMissing(jwt.getClaimAsMap(RESOURCE_ACCESS_CLAIM), RESOURCE_ACCESS_CLAIM); 41 | 42 | // get the map specific to our client ID. 43 | var clientRolesMap = 44 | (Map>) 45 | getMapValue(resourceAccess, clientName, RESOURCE_ACCESS_CLAIM); 46 | 47 | // get the collection of role strings from that map. 48 | var roleNames = getMapValue(clientRolesMap, "roles", RESOURCE_ACCESS_CLAIM, clientName); 49 | 50 | var authorities = 51 | roleNames.stream() 52 | // roughly equivalent to: 53 | // .filter(StringUtils::nonNull).map(e -> new SimpleGrantedAuthority(e.toUpperCase)) 54 | .mapMulti( 55 | (element, downstream) -> { 56 | if (StringUtils.isNotBlank(element)) { 57 | downstream.accept(new SimpleGrantedAuthority(element.toUpperCase())); 58 | } 59 | }) 60 | .collect(Collectors.toSet()); 61 | 62 | var userDetails = 63 | AuthorizedUserDetails.builder() 64 | .userId(UUID.fromString(nonMissing(jwt.getSubject(), "subject"))) 65 | .username(nonMissing(jwt.getClaimAsString("preferred_username"), "username")) 66 | .email(nonMissing(jwt.getClaimAsString(EMAIL_CLAIM), EMAIL_CLAIM)) 67 | .authorities(authorities) 68 | .build(); 69 | 70 | return UsernamePasswordAuthenticationToken.authenticated( 71 | userDetails, jwt.getTokenValue(), authorities); 72 | } 73 | 74 | private static T getMapValue(Map map, String key, String... origins) { 75 | return nonMissing( 76 | map.get(key), 77 | ArrayUtils.isEmpty(origins) ? key : "%s.%s".formatted(String.join(".", origins), key)); 78 | } 79 | 80 | private static T nonMissing(T object, String name) { 81 | if (object == null) { 82 | throw new AuthorizationException("Claim [%s] is missing".formatted(name)); 83 | } 84 | 85 | return object; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/java/com/vulinh/configuration/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.vulinh.configuration; 2 | 3 | import com.vulinh.enums.UserRole; 4 | import java.util.List; 5 | import lombok.RequiredArgsConstructor; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.security.access.hierarchicalroles.RoleHierarchy; 10 | import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; 11 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 12 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 13 | import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 14 | import org.springframework.security.config.http.SessionCreationPolicy; 15 | import org.springframework.security.web.SecurityFilterChain; 16 | import org.springframework.security.web.header.writers.XXssProtectionHeaderWriter; 17 | import org.springframework.web.cors.CorsConfiguration; 18 | import org.springframework.web.cors.CorsConfigurationSource; 19 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource; 20 | 21 | @Slf4j 22 | @EnableWebSecurity 23 | @Configuration 24 | @RequiredArgsConstructor 25 | public class SecurityConfig { 26 | 27 | private final ApplicationProperties applicationProperties; 28 | 29 | @Bean 30 | SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, JwtConverter jwtConverter) 31 | throws Exception { 32 | return httpSecurity 33 | .headers( 34 | headers -> 35 | headers 36 | .xssProtection( 37 | xssConfig -> 38 | xssConfig.headerValue( 39 | XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK)) 40 | .contentSecurityPolicy(cps -> cps.policyDirectives("script-src 'self'"))) 41 | .csrf(AbstractHttpConfigurer::disable) 42 | .cors(customizer -> customizer.configurationSource(corsConfigurationSource())) 43 | .sessionManagement( 44 | sessionManagementConfigurer -> 45 | sessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) 46 | .authorizeHttpRequests( 47 | customizer -> 48 | customizer 49 | .requestMatchers(asArray(applicationProperties.noAuthUrls())) 50 | .permitAll() 51 | .requestMatchers(asArray(applicationProperties.adminPrivilegeUrls())) 52 | .hasAuthority(UserRole.ROLE_ADMIN.name()) 53 | .anyRequest() 54 | .authenticated()) 55 | .oauth2ResourceServer( 56 | customizer -> 57 | customizer.jwt( 58 | jwtConfigurer -> jwtConfigurer.jwtAuthenticationConverter(jwtConverter))) 59 | .build(); 60 | } 61 | 62 | @Bean 63 | public RoleHierarchy roleHierarchy() { 64 | var roleHierarchy = "%s > %s".formatted(UserRole.ROLE_ADMIN, UserRole.ROLE_USER); 65 | 66 | log.info("Role hierarchy configured -- {}", roleHierarchy); 67 | 68 | return RoleHierarchyImpl.fromHierarchy(roleHierarchy); 69 | } 70 | 71 | private static CorsConfigurationSource corsConfigurationSource() { 72 | var corsConfigurationSource = new UrlBasedCorsConfigurationSource(); 73 | 74 | var corsConfiguration = new CorsConfiguration(); 75 | 76 | corsConfiguration.setAllowCredentials(true); 77 | 78 | var everything = List.of("*"); 79 | 80 | corsConfiguration.setAllowedOriginPatterns(everything); 81 | corsConfiguration.setAllowedHeaders(everything); 82 | corsConfiguration.setAllowedMethods(everything); 83 | 84 | corsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration); 85 | 86 | return corsConfigurationSource; 87 | } 88 | 89 | private static String[] asArray(List list) { 90 | return list.toArray(String[]::new); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /run-keycloak-postgresql.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | 4 | :: Configuration Variables 5 | SET NETWORK_NAME=keycloak-network 6 | SET VOLUME_NAME=keycloak-postgres-volume 7 | SET PG_CONTAINER_NAME=standalone-postgresql 8 | SET KC_CONTAINER_NAME=standalone-keycloak 9 | 10 | SET DB_NAME=keycloak 11 | SET DB_USER=postgres 12 | SET DB_PASSWORD=123456 13 | SET PG_IMAGE=postgres:17.5-alpine 14 | 15 | SET KC_IMAGE=quay.io/keycloak/keycloak:26.3 16 | SET KC_ADMIN_USER=admin 17 | SET KC_ADMIN_PASSWORD=admin 18 | 19 | SET PG_PORT=5432 20 | SET KC_HTTP_PORT=8080 21 | SET KC_MGMT_PORT=9000 22 | 23 | SET HEALTH_INTERVAL=10s 24 | SET HEALTH_TIMEOUT=5s 25 | SET HEALTH_RETRIES=5 26 | SET HEALTH_CHECK_TIMEOUT=60 27 | 28 | :: Check if Docker is installed 29 | where docker >nul 2>&1 30 | if !errorlevel! neq 0 ( 31 | echo ERROR: Docker is not installed. Please install Docker and try again. 32 | exit /b 1 33 | ) 34 | 35 | :: Check if Docker daemon is running 36 | docker info >nul 2>&1 37 | if !errorlevel! neq 0 ( 38 | echo ERROR: Docker daemon is not running. Please start Docker and try again. 39 | exit /b 1 40 | ) 41 | 42 | :: Check if network exists, create it only if it doesn't 43 | docker network ls | findstr !NETWORK_NAME! >nul 44 | if !errorlevel! equ 0 ( 45 | echo Network !NETWORK_NAME! already exists, skipping creation... 46 | ) else ( 47 | echo Creating network !NETWORK_NAME!... 48 | docker network create !NETWORK_NAME! 49 | if !errorlevel! neq 0 ( 50 | echo ERROR: Failed to create network !NETWORK_NAME!. 51 | exit /b 1 52 | ) 53 | ) 54 | 55 | :: Check if PostgreSQL container exists and remove it if it does 56 | docker ps -a | findstr !PG_CONTAINER_NAME! >nul 57 | if !errorlevel! equ 0 ( 58 | echo Removing existing PostgreSQL container !PG_CONTAINER_NAME!... 59 | docker rm -f !PG_CONTAINER_NAME! 60 | if !errorlevel! neq 0 ( 61 | echo ERROR: Failed to remove PostgreSQL container !PG_CONTAINER_NAME!. 62 | exit /b 1 63 | ) 64 | ) 65 | 66 | :: Create PostgreSQL container 67 | echo Creating PostgreSQL container !PG_CONTAINER_NAME!... 68 | docker run -d --name !PG_CONTAINER_NAME! --network !NETWORK_NAME! -v !VOLUME_NAME!:/var/lib/postgresql/data -e POSTGRES_DB=!DB_NAME! -e POSTGRES_USER=!DB_USER! -e POSTGRES_PASSWORD=!DB_PASSWORD! -p !PG_PORT!:!PG_PORT! --health-cmd="pg_isready -U !DB_USER! -d !DB_NAME!" --health-interval=!HEALTH_INTERVAL! --health-timeout=!HEALTH_TIMEOUT! --health-retries=!HEALTH_RETRIES! !PG_IMAGE! 69 | if !errorlevel! neq 0 ( 70 | echo ERROR: Failed to create PostgreSQL container !PG_CONTAINER_NAME!. 71 | exit /b 1 72 | ) 73 | 74 | :: Wait for PostgreSQL to be healthy with timeout 75 | echo Waiting for PostgreSQL to be healthy... 76 | set /a MAX_WAIT=%HEALTH_CHECK_TIMEOUT% 77 | set /a WAIT_COUNT=0 78 | :CHECK_PG_HEALTH 79 | timeout /t 2 /nobreak >nul 80 | docker inspect --format="{{.State.Health.Status}}" !PG_CONTAINER_NAME! | findstr "healthy" >nul 81 | if !errorlevel! equ 0 ( 82 | echo PostgreSQL is healthy! 83 | goto :PG_HEALTHY 84 | ) 85 | set /a WAIT_COUNT+=2 86 | if !WAIT_COUNT! geq %HEALTH_CHECK_TIMEOUT% ( 87 | echo ERROR: PostgreSQL failed to become healthy within %HEALTH_CHECK_TIMEOUT% seconds. 88 | exit /b 1 89 | ) 90 | echo PostgreSQL is not yet healthy, checking again... 91 | goto :CHECK_PG_HEALTH 92 | :PG_HEALTHY 93 | 94 | :: Check if Keycloak container exists and remove it if it does 95 | docker ps -a | findstr !KC_CONTAINER_NAME! >nul 96 | if !errorlevel! equ 0 ( 97 | echo Removing existing Keycloak container !KC_CONTAINER_NAME!... 98 | docker rm -f !KC_CONTAINER_NAME! 99 | if !errorlevel! neq 0 ( 100 | echo ERROR: Failed to remove Keycloak container !KC_CONTAINER_NAME!. 101 | exit /b 1 102 | ) 103 | ) 104 | 105 | :: Create Keycloak container 106 | echo Creating Keycloak container !KC_CONTAINER_NAME!... 107 | docker run -d --name !KC_CONTAINER_NAME! --network !NETWORK_NAME! -e KC_DB=postgres -e KC_DB_URL_HOST=!PG_CONTAINER_NAME! -e KC_DB_URL_DATABASE=!DB_NAME! -e KC_DB_USERNAME=!DB_USER! -e KC_DB_PASSWORD=!DB_PASSWORD! -e KC_BOOTSTRAP_ADMIN_USERNAME=!KC_ADMIN_USER! -e KC_BOOTSTRAP_ADMIN_PASSWORD=!KC_ADMIN_PASSWORD! -e KC_HEALTH_ENABLED=true -e KC_METRICS_ENABLED=true -p !KC_HTTP_PORT!:!KC_HTTP_PORT! -p !KC_MGMT_PORT!:!KC_MGMT_PORT! !KC_IMAGE! start-dev 108 | if !errorlevel! neq 0 ( 109 | echo ERROR: Failed to create Keycloak container !KC_CONTAINER_NAME!. 110 | exit /b 1 111 | ) 112 | 113 | echo Setup completed successfully! -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | <# : batch portion 2 | @REM ---------------------------------------------------------------------------- 3 | @REM Licensed to the Apache Software Foundation (ASF) under one 4 | @REM or more contributor license agreements. See the NOTICE file 5 | @REM distributed with this work for additional information 6 | @REM regarding copyright ownership. The ASF licenses this file 7 | @REM to you under the Apache License, Version 2.0 (the 8 | @REM "License"); you may not use this file except in compliance 9 | @REM with the License. You may obtain a copy of the License at 10 | @REM 11 | @REM http://www.apache.org/licenses/LICENSE-2.0 12 | @REM 13 | @REM Unless required by applicable law or agreed to in writing, 14 | @REM software distributed under the License is distributed on an 15 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | @REM KIND, either express or implied. See the License for the 17 | @REM specific language governing permissions and limitations 18 | @REM under the License. 19 | @REM ---------------------------------------------------------------------------- 20 | 21 | @REM ---------------------------------------------------------------------------- 22 | @REM Apache Maven Wrapper startup batch script, version 3.3.2 23 | @REM 24 | @REM Optional ENV vars 25 | @REM MVNW_REPOURL - repo url base for downloading maven distribution 26 | @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 27 | @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output 28 | @REM ---------------------------------------------------------------------------- 29 | 30 | @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) 31 | @SET __MVNW_CMD__= 32 | @SET __MVNW_ERROR__= 33 | @SET __MVNW_PSMODULEP_SAVE=%PSModulePath% 34 | @SET PSModulePath= 35 | @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( 36 | IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) 37 | ) 38 | @SET PSModulePath=%__MVNW_PSMODULEP_SAVE% 39 | @SET __MVNW_PSMODULEP_SAVE= 40 | @SET __MVNW_ARG0_NAME__= 41 | @SET MVNW_USERNAME= 42 | @SET MVNW_PASSWORD= 43 | @IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) 44 | @echo Cannot start maven from wrapper >&2 && exit /b 1 45 | @GOTO :EOF 46 | : end batch / begin powershell #> 47 | 48 | $ErrorActionPreference = "Stop" 49 | if ($env:MVNW_VERBOSE -eq "true") { 50 | $VerbosePreference = "Continue" 51 | } 52 | 53 | # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties 54 | $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl 55 | if (!$distributionUrl) { 56 | Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" 57 | } 58 | 59 | switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { 60 | "maven-mvnd-*" { 61 | $USE_MVND = $true 62 | $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" 63 | $MVN_CMD = "mvnd.cmd" 64 | break 65 | } 66 | default { 67 | $USE_MVND = $false 68 | $MVN_CMD = $script -replace '^mvnw','mvn' 69 | break 70 | } 71 | } 72 | 73 | # apply MVNW_REPOURL and calculate MAVEN_HOME 74 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 75 | if ($env:MVNW_REPOURL) { 76 | $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } 77 | $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" 78 | } 79 | $distributionUrlName = $distributionUrl -replace '^.*/','' 80 | $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' 81 | $MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" 82 | if ($env:MAVEN_USER_HOME) { 83 | $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" 84 | } 85 | $MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' 86 | $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" 87 | 88 | if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { 89 | Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" 90 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 91 | exit $? 92 | } 93 | 94 | if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { 95 | Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" 96 | } 97 | 98 | # prepare tmp dir 99 | $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile 100 | $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" 101 | $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null 102 | trap { 103 | if ($TMP_DOWNLOAD_DIR.Exists) { 104 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 105 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 106 | } 107 | } 108 | 109 | New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null 110 | 111 | # Download and Install Apache Maven 112 | Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 113 | Write-Verbose "Downloading from: $distributionUrl" 114 | Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 115 | 116 | $webclient = New-Object System.Net.WebClient 117 | if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { 118 | $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) 119 | } 120 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 121 | $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null 122 | 123 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 124 | $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum 125 | if ($distributionSha256Sum) { 126 | if ($USE_MVND) { 127 | Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." 128 | } 129 | Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash 130 | if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { 131 | Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." 132 | } 133 | } 134 | 135 | # unzip and move 136 | Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null 137 | Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null 138 | try { 139 | Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null 140 | } catch { 141 | if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { 142 | Write-Error "fail to move MAVEN_HOME" 143 | } 144 | } finally { 145 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 146 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 147 | } 148 | 149 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 150 | -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.3.2 23 | # 24 | # Optional ENV vars 25 | # ----------------- 26 | # JAVA_HOME - location of a JDK home dir, required when download maven via java source 27 | # MVNW_REPOURL - repo url base for downloading maven distribution 28 | # MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 29 | # MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output 30 | # ---------------------------------------------------------------------------- 31 | 32 | set -euf 33 | [ "${MVNW_VERBOSE-}" != debug ] || set -x 34 | 35 | # OS specific support. 36 | native_path() { printf %s\\n "$1"; } 37 | case "$(uname)" in 38 | CYGWIN* | MINGW*) 39 | [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" 40 | native_path() { cygpath --path --windows "$1"; } 41 | ;; 42 | esac 43 | 44 | # set JAVACMD and JAVACCMD 45 | set_java_home() { 46 | # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched 47 | if [ -n "${JAVA_HOME-}" ]; then 48 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then 49 | # IBM's JDK on AIX uses strange locations for the executables 50 | JAVACMD="$JAVA_HOME/jre/sh/java" 51 | JAVACCMD="$JAVA_HOME/jre/sh/javac" 52 | else 53 | JAVACMD="$JAVA_HOME/bin/java" 54 | JAVACCMD="$JAVA_HOME/bin/javac" 55 | 56 | if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then 57 | echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 58 | echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 59 | return 1 60 | fi 61 | fi 62 | else 63 | JAVACMD="$( 64 | 'set' +e 65 | 'unset' -f command 2>/dev/null 66 | 'command' -v java 67 | )" || : 68 | JAVACCMD="$( 69 | 'set' +e 70 | 'unset' -f command 2>/dev/null 71 | 'command' -v javac 72 | )" || : 73 | 74 | if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then 75 | echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 76 | return 1 77 | fi 78 | fi 79 | } 80 | 81 | # hash string like Java String::hashCode 82 | hash_string() { 83 | str="${1:-}" h=0 84 | while [ -n "$str" ]; do 85 | char="${str%"${str#?}"}" 86 | h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) 87 | str="${str#?}" 88 | done 89 | printf %x\\n $h 90 | } 91 | 92 | verbose() { :; } 93 | [ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } 94 | 95 | die() { 96 | printf %s\\n "$1" >&2 97 | exit 1 98 | } 99 | 100 | trim() { 101 | # MWRAPPER-139: 102 | # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. 103 | # Needed for removing poorly interpreted newline sequences when running in more 104 | # exotic environments such as mingw bash on Windows. 105 | printf "%s" "${1}" | tr -d '[:space:]' 106 | } 107 | 108 | # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties 109 | while IFS="=" read -r key value; do 110 | case "${key-}" in 111 | distributionUrl) distributionUrl=$(trim "${value-}") ;; 112 | distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; 113 | esac 114 | done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" 115 | [ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" 116 | 117 | case "${distributionUrl##*/}" in 118 | maven-mvnd-*bin.*) 119 | MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ 120 | case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in 121 | *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; 122 | :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; 123 | :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; 124 | :Linux*x86_64*) distributionPlatform=linux-amd64 ;; 125 | *) 126 | echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 127 | distributionPlatform=linux-amd64 128 | ;; 129 | esac 130 | distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" 131 | ;; 132 | maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; 133 | *) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; 134 | esac 135 | 136 | # apply MVNW_REPOURL and calculate MAVEN_HOME 137 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 138 | [ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" 139 | distributionUrlName="${distributionUrl##*/}" 140 | distributionUrlNameMain="${distributionUrlName%.*}" 141 | distributionUrlNameMain="${distributionUrlNameMain%-bin}" 142 | MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" 143 | MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" 144 | 145 | exec_maven() { 146 | unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : 147 | exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" 148 | } 149 | 150 | if [ -d "$MAVEN_HOME" ]; then 151 | verbose "found existing MAVEN_HOME at $MAVEN_HOME" 152 | exec_maven "$@" 153 | fi 154 | 155 | case "${distributionUrl-}" in 156 | *?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; 157 | *) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; 158 | esac 159 | 160 | # prepare tmp dir 161 | if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then 162 | clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } 163 | trap clean HUP INT TERM EXIT 164 | else 165 | die "cannot create temp dir" 166 | fi 167 | 168 | mkdir -p -- "${MAVEN_HOME%/*}" 169 | 170 | # Download and Install Apache Maven 171 | verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 172 | verbose "Downloading from: $distributionUrl" 173 | verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 174 | 175 | # select .zip or .tar.gz 176 | if ! command -v unzip >/dev/null; then 177 | distributionUrl="${distributionUrl%.zip}.tar.gz" 178 | distributionUrlName="${distributionUrl##*/}" 179 | fi 180 | 181 | # verbose opt 182 | __MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' 183 | [ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v 184 | 185 | # normalize http auth 186 | case "${MVNW_PASSWORD:+has-password}" in 187 | '') MVNW_USERNAME='' MVNW_PASSWORD='' ;; 188 | has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; 189 | esac 190 | 191 | if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then 192 | verbose "Found wget ... using wget" 193 | wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" 194 | elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then 195 | verbose "Found curl ... using curl" 196 | curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" 197 | elif set_java_home; then 198 | verbose "Falling back to use Java to download" 199 | javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" 200 | targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" 201 | cat >"$javaSource" <<-END 202 | public class Downloader extends java.net.Authenticator 203 | { 204 | protected java.net.PasswordAuthentication getPasswordAuthentication() 205 | { 206 | return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); 207 | } 208 | public static void main( String[] args ) throws Exception 209 | { 210 | setDefault( new Downloader() ); 211 | java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); 212 | } 213 | } 214 | END 215 | # For Cygwin/MinGW, switch paths to Windows format before running javac and java 216 | verbose " - Compiling Downloader.java ..." 217 | "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" 218 | verbose " - Running Downloader.java ..." 219 | "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" 220 | fi 221 | 222 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 223 | if [ -n "${distributionSha256Sum-}" ]; then 224 | distributionSha256Result=false 225 | if [ "$MVN_CMD" = mvnd.sh ]; then 226 | echo "Checksum validation is not supported for maven-mvnd." >&2 227 | echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 228 | exit 1 229 | elif command -v sha256sum >/dev/null; then 230 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then 231 | distributionSha256Result=true 232 | fi 233 | elif command -v shasum >/dev/null; then 234 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then 235 | distributionSha256Result=true 236 | fi 237 | else 238 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 239 | echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 240 | exit 1 241 | fi 242 | if [ $distributionSha256Result = false ]; then 243 | echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 244 | echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 245 | exit 1 246 | fi 247 | fi 248 | 249 | # unzip and move 250 | if command -v unzip >/dev/null; then 251 | unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" 252 | else 253 | tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" 254 | fi 255 | printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" 256 | mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" 257 | 258 | clean || : 259 | exec_maven "$@" 260 | --------------------------------------------------------------------------------