diff --git a/build.gradle b/build.gradle index 899fc0e8..b8af6629 100644 --- a/build.gradle +++ b/build.gradle @@ -99,6 +99,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security:3.2.4' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.2.RELEASE' implementation "org.springframework.boot:spring-boot-starter-data-jpa:3.2.4" + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client:3.2.4' //2.2.x requires rebuild of DB file.. need migration path implementation "com.h2database:h2:2.1.214" diff --git a/exampleYmlFiles/docker-compose-latest-security-with-sso.yml b/exampleYmlFiles/docker-compose-latest-security-with-sso.yml new file mode 100644 index 00000000..41241b15 --- /dev/null +++ b/exampleYmlFiles/docker-compose-latest-security-with-sso.yml @@ -0,0 +1,39 @@ +version: '3.3' +services: + stirling-pdf: + container_name: Stirling-PDF-Security + image: frooodle/s-pdf:latest + deploy: + resources: + limits: + memory: 4G + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"] + interval: 5s + timeout: 10s + retries: 16 + ports: + - 8080:8080 + volumes: + - /stirling/latest/data:/usr/share/tessdata:rw + - /stirling/latest/config:/configs:rw + - /stirling/latest/logs:/logs:rw + environment: + DOCKER_ENABLE_SECURITY: "true" + SECURITY_ENABLELOGIN: "true" + SECURITY_OAUTH2_ENABLED: "true" + SECURITY_OAUTH2_AUTOCREATEUSER: "true" # This is set to true to allow auto-creation of non-existing users in Striling-PDF + SECURITY_OAUTH2_ISSUER: "https://accounts.google.com" # Change with any other provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) end-point + SECURITY_OAUTH2_CLIENTID: ".apps.googleusercontent.com" # Client ID from your provider + SECURITY_OAUTH2_CLIENTSECRET: "" # Client Secret from your provider + PUID: 1002 + PGID: 1002 + UMASK: "022" + SYSTEM_DEFAULTLOCALE: en-US + UI_APPNAME: Stirling-PDF + UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest with Security + UI_APPNAMENAVBAR: Stirling-PDF Latest + SYSTEM_MAXFILESIZE: "100" + METRICS_ENABLED: "true" + SYSTEM_GOOGLEVISIBILITY: "true" + restart: on-failure:5 diff --git a/src/main/java/stirling/software/SPDF/config/security/CustomLogoutSuccessHandler.java b/src/main/java/stirling/software/SPDF/config/security/CustomLogoutSuccessHandler.java new file mode 100644 index 00000000..8926814e --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/CustomLogoutSuccessHandler.java @@ -0,0 +1,43 @@ +package stirling.software.SPDF.config.security; + +import java.io.IOException; + + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import jakarta.servlet.ServletException; +import org.springframework.context.annotation.Bean; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; + +public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler +{ + @Bean + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + @Override + public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException + { + HttpSession session = request.getSession(false); + if (session != null) { + String sessionId = session.getId(); + sessionRegistry() + .removeSessionInformation( + sessionId); + } + + if(request.getParameter("oauth2AutoCreateDisabled") != null) + { + response.sendRedirect(request.getContextPath()+"/login?error=oauth2AutoCreateDisabled"); + } + else + { + response.sendRedirect(request.getContextPath() + "/login?logout=true"); + } + } +} \ No newline at end of file diff --git a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java index bbea5e96..f61bb240 100644 --- a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java +++ b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java @@ -1,7 +1,11 @@ package stirling.software.SPDF.config.security; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; @@ -10,20 +14,30 @@ import org.springframework.security.config.annotation.method.configuration.Enabl import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.Authentication; import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.security.web.savedrequest.NullRequestCache; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.oauth2.client.registration.ClientRegistrations; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import jakarta.servlet.http.HttpSession; +import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.repository.JPATokenRepositoryImpl; +import java.io.IOException; + @Configuration @EnableWebSecurity() @EnableMethodSecurity @@ -42,6 +56,8 @@ public class SecurityConfiguration { @Qualifier("loginEnabled") public boolean loginEnabledValue; + @Autowired ApplicationProperties applicationProperties; + @Autowired private UserAuthenticationFilter userAuthenticationFilter; @Autowired private LoginAttemptService loginAttemptService; @@ -87,7 +103,7 @@ public class SecurityConfiguration { logout -> logout.logoutRequestMatcher( new AntPathRequestMatcher("/logout")) - .logoutSuccessUrl("/login?logout=true") + .logoutSuccessHandler(new CustomLogoutSuccessHandler()) // Use a Custom Logout Handler to handle custom error message if OAUTH2 Auto Create is disabled .invalidateHttpSession(true) // Invalidate session .deleteCookies("JSESSIONID", "remember-me") .addLogoutHandler( @@ -124,6 +140,7 @@ public class SecurityConfiguration { : uri; return trimmedUri.startsWith("/login") + || trimmedUri.startsWith("/oauth") || trimmedUri.endsWith(".svg") || trimmedUri.startsWith( "/register") @@ -140,6 +157,33 @@ public class SecurityConfiguration { .authenticated()) .userDetailsService(userDetailsService) .authenticationProvider(authenticationProvider()); + + // Handle OAUTH2 Logins + if (applicationProperties.getSecurity().getOAUTH2().getEnabled()) { + + http.oauth2Login( oauth2 -> oauth2 + .loginPage("/oauth2") + /* + This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database. + If user exists, login proceeds as usual. If user does not exist, then it is autocreated but only if 'OAUTH2AutoCreateUser' + is set as true, else login fails with an error message advising the same. + */ + .successHandler(new AuthenticationSuccessHandler() { + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws ServletException , IOException{ + OAuth2User oauthUser = (OAuth2User) authentication.getPrincipal(); + if (userService.processOAuth2PostLogin(oauthUser.getAttribute("email"), applicationProperties.getSecurity().getOAUTH2().getAutoCreateUser())) { + response.sendRedirect("/"); + } + else{ + response.sendRedirect("/logout?oauth2AutoCreateDisabled=true"); + } + } + } + ) + ); + } } else { http.csrf(csrf -> csrf.disable()) .authorizeHttpRequests(authz -> authz.anyRequest().permitAll()); @@ -148,6 +192,24 @@ public class SecurityConfiguration { return http.build(); } + // Client Registration Repository for OAUTH2 OIDC Login + @Bean + @ConditionalOnProperty(value = "security.oauth2.enabled" , havingValue = "true", matchIfMissing = false) + public ClientRegistrationRepository clientRegistrationRepository() { + return new InMemoryClientRegistrationRepository(this.oidcClientRegistration()); + } + + private ClientRegistration oidcClientRegistration() { + return ClientRegistrations.fromOidcIssuerLocation(applicationProperties.getSecurity().getOAUTH2().getIssuer()) + .registrationId("oidc") + .clientId(applicationProperties.getSecurity().getOAUTH2().getClientId()) + .clientSecret(applicationProperties.getSecurity().getOAUTH2().getClientSecret()) + .scope("openid", "profile", "email") + .userNameAttributeName("email") + .clientName("OIDC") + .build(); + } + @Bean public IPRateLimitingFilter rateLimitingFilter() { int maxRequestsPerIp = 1000000; // Example limit TODO add config level diff --git a/src/main/java/stirling/software/SPDF/config/security/UserService.java b/src/main/java/stirling/software/SPDF/config/security/UserService.java index 83f38bd7..9fe5fdd1 100644 --- a/src/main/java/stirling/software/SPDF/config/security/UserService.java +++ b/src/main/java/stirling/software/SPDF/config/security/UserService.java @@ -30,6 +30,24 @@ public class UserService implements UserServiceInterface { @Autowired private PasswordEncoder passwordEncoder; + // Handle OAUTH2 login and user auto creation. + public boolean processOAuth2PostLogin(String username, boolean autoCreateUser) { + Optional existUser = userRepository.findByUsernameIgnoreCase(username); + if (existUser.isPresent()) { + return true; + } + if (autoCreateUser) { + User user = new User(); + user.setUsername(username); + user.setEnabled(true); + user.setFirstLogin(false); + user.addAuthority(new Authority( Role.USER.getRoleId(), user)); + userRepository.save(user); + return true; + } + return false; + } + public Authentication getAuthentication(String apiKey) { User user = getUserByApiKey(apiKey); if (user == null) { diff --git a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java index c9ffc19d..03ba2020 100644 --- a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java +++ b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java @@ -6,9 +6,11 @@ import java.util.Map; import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -19,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; +import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.Authority; import stirling.software.SPDF.model.Role; import stirling.software.SPDF.model.User; @@ -28,12 +31,16 @@ import stirling.software.SPDF.repository.UserRepository; @Tag(name = "Account Security", description = "Account Security APIs") public class AccountWebController { + @Autowired ApplicationProperties applicationProperties; + @GetMapping("/login") public String login(HttpServletRequest request, Model model, Authentication authentication) { if (authentication != null && authentication.isAuthenticated()) { return "redirect:/"; } + model.addAttribute("oAuth2Enabled", applicationProperties.getSecurity().getOAUTH2().getEnabled()); + model.addAttribute("currentPage", "login"); if (request.getParameter("error") != null) { @@ -85,14 +92,29 @@ public class AccountWebController { } if (authentication != null && authentication.isAuthenticated()) { Object principal = authentication.getPrincipal(); + String username = null; if (principal instanceof UserDetails) { // Cast the principal object to UserDetails UserDetails userDetails = (UserDetails) principal; // Retrieve username and other attributes - String username = userDetails.getUsername(); + username = userDetails.getUsername(); + // Add oAuth2 Login attributes to the model + model.addAttribute("oAuth2Login", false); + } + if (principal instanceof OAuth2User) { + // Cast the principal object to OAuth2User + OAuth2User userDetails = (OAuth2User) principal; + + // Retrieve username and other attributes + username = userDetails.getAttribute("email"); + + // Add oAuth2 Login attributes to the model + model.addAttribute("oAuth2Login", true); + } + if (username != null) { // Fetch user details from the database Optional user = userRepository.findByUsernameIgnoreCase( diff --git a/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java b/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java index c4a88fb3..72de4094 100644 --- a/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java +++ b/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java @@ -118,6 +118,7 @@ public class ApplicationProperties { private Boolean enableLogin; private Boolean csrfDisabled; private InitialLogin initialLogin; + private OAUTH2 oauth2; private int loginAttemptCount; private long loginResetTimeMinutes; @@ -145,6 +146,14 @@ public class ApplicationProperties { this.initialLogin = initialLogin; } + public OAUTH2 getOAUTH2() { + return oauth2 != null ? oauth2 : new OAUTH2(); + } + + public void setOAUTH2(OAUTH2 oauth2) { + this.oauth2 = oauth2; + } + public Boolean getEnableLogin() { return enableLogin; } @@ -165,6 +174,8 @@ public class ApplicationProperties { public String toString() { return "Security [enableLogin=" + enableLogin + + ", oauth2=" + + oauth2 + ", initialLogin=" + initialLogin + ", csrfDisabled=" @@ -202,6 +213,70 @@ public class ApplicationProperties { + "]"; } } + + public static class OAUTH2 { + + private boolean enabled; + private String issuer; + private String clientId; + private String clientSecret; + private boolean autoCreateUser; + + public boolean getEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getIssuer() { + return issuer; + } + + public void setIssuer(String issuer) { + this.issuer = issuer; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } + + public boolean getAutoCreateUser() { + return autoCreateUser; + } + + public void setAutoCreateUser(boolean autoCreateUser) { + this.autoCreateUser = autoCreateUser; + } + + @Override + public String toString() { + return "OAUTH2 [enabled=" + + enabled + + ", issuer=" + + issuer + + ", clientId=" + + clientId + + ", clientSecret=" + + (clientSecret!= null && !clientSecret.isEmpty() ? "MASKED" : "NULL") + + ", autoCreateUser=" + + autoCreateUser + + "]"; + } + } } public static class System { diff --git a/src/main/resources/messages_en_GB.properties b/src/main/resources/messages_en_GB.properties index 08631ec0..8117b6a3 100644 --- a/src/main/resources/messages_en_GB.properties +++ b/src/main/resources/messages_en_GB.properties @@ -437,6 +437,8 @@ login.rememberme=Remember me login.invalid=Invalid username or password. login.locked=Your account has been locked. login.signinTitle=Please sign in +login.ssoSignIn=Login via Single Sign-on +login.oauth2AutoCreateDisabled=OAUTH2 Auto-Create User Disabled #auto-redact diff --git a/src/main/resources/messages_en_US.properties b/src/main/resources/messages_en_US.properties index ab06056a..36c31264 100644 --- a/src/main/resources/messages_en_US.properties +++ b/src/main/resources/messages_en_US.properties @@ -437,6 +437,8 @@ login.rememberme=Remember me login.invalid=Invalid username or password. login.locked=Your account has been locked. login.signinTitle=Please sign in +login.ssoSignIn=Login via Single Sign-on +login.oauth2AutoCreateDisabled=OAUTH2 Auto-Create User Disabled #auto-redact diff --git a/src/main/resources/settings.yml.template b/src/main/resources/settings.yml.template index db9f9cd1..f6a68b5f 100644 --- a/src/main/resources/settings.yml.template +++ b/src/main/resources/settings.yml.template @@ -7,6 +7,12 @@ security: csrfDisabled: true loginAttemptCount: 5 # lock user account after 5 tries loginResetTimeMinutes : 120 # lock account for 2 hours after x attempts + #oauth2: + # enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work) + # issuer: "" # set to any provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) end-point + # clientId: "" # Client ID from your provider + # clientSecret: "" # Client Secret from your provider + # autoCreateUser: false # set to 'true' to allow auto-creation of non-existing users system: defaultLocale: 'en-US' # Set the default language (e.g. 'de-DE', 'fr-FR', etc) diff --git a/src/main/resources/templates/account.html b/src/main/resources/templates/account.html index 97e50af6..e8ed623a 100644 --- a/src/main/resources/templates/account.html +++ b/src/main/resources/templates/account.html @@ -42,7 +42,7 @@ -
+
@@ -59,8 +59,8 @@
-

Change Password?

- +

Change Password?

+
diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index 7deb22b1..8cd47550 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -139,6 +139,13 @@

Stirling-PDF

+
+ Login Via SSO +
+
OAUTH2 Auto-Create User Disabled.
+
+
+

Please sign in