diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..788be24434 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +docker/postgresql-data +docker/postgresql-16-data +docker/roller-data +it-selenium diff --git a/Dockerfile b/Dockerfile index 3376091fc3..09e0a5fe2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,17 +23,9 @@ FROM maven:3-eclipse-temurin-17 AS builder -COPY ./docker /project/docker - -# Build Apache Roller - -WORKDIR /tmp -RUN apt-get update && apt-get install -y git -RUN git clone https://github.com/apache/roller.git -WORKDIR /tmp/roller -# change to branch/tag you prefer -RUN git checkout tags/roller-6.1.0; \ -mvn -Duser.home=/builder/home -DskipTests=true -B clean install +COPY . /project +WORKDIR /project +RUN mvn -Duser.home=/builder/home -DskipTests=true -B clean install # STAGE 2 - PACKAGE ------------------------------------------------ @@ -51,7 +43,7 @@ ARG DATABASE_JDBC_DRIVERCLASS=org.postgresql.Driver ARG DATABASE_JDBC_CONNECTIONURL=jdbc:postgresql://postgresql/rollerdb ARG DATABASE_JDBC_USERNAME=scott ARG DATABASE_JDBC_PASSWORD=tiger -ARG DATABASE_HOST=postgresql:5434 +ARG DATABASE_HOST=postgresql:5432 ENV STORAGE_ROOT ${STORAGE_ROOT} ENV DATABASE_JDBC_DRIVERCLASS ${DATABASE_JDBC_DRIVERCLASS} @@ -63,7 +55,7 @@ ENV DATABASE_HOST ${DATABASE_HOST} # install Roller WAR as ROOT.war, create data dirs WORKDIR /usr/local/roller -COPY --from=builder /tmp/roller/app/target/roller.war /usr/local/tomcat/webapps/ROOT.war +COPY --from=builder /project/app/target/roller.war /usr/local/tomcat/webapps/ROOT.war RUN mkdir -p data/mediafiles data/searchindex # download PostgreSQL and MySQL drivers plus Mail and Activation JARs @@ -78,8 +70,8 @@ RUN wget https://repo1.maven.org/maven2/org/eclipse/angus/angus-activation/2.0.3 # Add Roller entry-point and go! -COPY --from=builder /project/docker/entry-point.sh /usr/local/tomcat/bin -COPY --from=builder /project/docker/wait-for-it.sh /usr/local/tomcat/bin +COPY docker/entry-point.sh /usr/local/tomcat/bin +COPY docker/wait-for-it.sh /usr/local/tomcat/bin RUN chgrp -R 0 /usr/local/tomcat RUN chmod -R g+rw /usr/local/tomcat diff --git a/app/pom.xml b/app/pom.xml index fcd2fef571..bb12b1814d 100644 --- a/app/pom.xml +++ b/app/pom.xml @@ -484,6 +484,24 @@ limitations under the License. + + org.springframework.security + spring-security-oauth2-client + ${spring.security.version} + + + + org.springframework.security + spring-security-oauth2-jose + ${spring.security.version} + + + + com.fasterxml.jackson.core + jackson-databind + 2.22.3 + + diff --git a/app/src/main/java/org/apache/roller/weblogger/config/AuthMethod.java b/app/src/main/java/org/apache/roller/weblogger/config/AuthMethod.java index 3c4fa58af1..1d2248b9fc 100644 --- a/app/src/main/java/org/apache/roller/weblogger/config/AuthMethod.java +++ b/app/src/main/java/org/apache/roller/weblogger/config/AuthMethod.java @@ -17,11 +17,14 @@ */ package org.apache.roller.weblogger.config; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + public enum AuthMethod { ROLLERDB("db"), LDAP("ldap"), - OPENID("openid"), - DB_OPENID("db-openid"), + OIDC("oidc"), + DB_OIDC("db-oidc"), CMA("cma"); private final String propertyName; @@ -34,7 +37,22 @@ public String getPropertyName() { return propertyName; } + private static final Log log = LogFactory.getLog(AuthMethod.class); + private static boolean warnedAboutOpenId; + public static AuthMethod getAuthMethod(String propertyName) { + // OpenID 2.0 was replaced by OIDC; accept the old property values so + // an upgraded install boots instead of failing on every request + if ("openid".equals(propertyName) || "db-openid".equals(propertyName)) { + AuthMethod replacement = "openid".equals(propertyName) ? OIDC : DB_OIDC; + if (!warnedAboutOpenId) { + warnedAboutOpenId = true; + log.warn("authentication.method=" + propertyName + " is no longer supported and is " + + "treated as " + replacement.getPropertyName() + "; update the property and " + + "configure an oidc.{id}.* provider registration"); + } + return replacement; + } for (AuthMethod test : AuthMethod.values()) { if (test.getPropertyName().equals(propertyName)) { return test; diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerContext.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerContext.java index b78991085d..f460fcaf5d 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerContext.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerContext.java @@ -46,6 +46,7 @@ import org.apache.roller.weblogger.ui.core.plugins.UIPluginManagerImpl; import org.apache.roller.weblogger.ui.core.security.AutoProvision; import org.apache.roller.weblogger.ui.core.security.BootstrapSecurity; +import org.apache.roller.weblogger.ui.core.security.RollerClientRegistrationRepository; import org.apache.roller.weblogger.util.Reflection; import org.apache.roller.weblogger.util.cache.CacheManager; import org.apache.velocity.runtime.RuntimeSingleton; @@ -317,7 +318,10 @@ private DelegatingPasswordEncoder createPasswordEncoder() { // supported encoders encoders.put("bcrypt", new BCryptPasswordEncoder()); - encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8()); + // pbkdf2 stores only salt+hash, so its parameters must stay as they were + // when existing passwords were encoded or those passwords stop verifying. + // scrypt and argon2 encode their parameters, so they can take v5_8. + encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5()); // provided by bouncy castle dependency encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8()); encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()); @@ -348,6 +352,21 @@ private DelegatingPasswordEncoder createPasswordEncoder() { } + /** + * The OIDC client registrations declared in security.xml, or null when + * OIDC is not configured. + */ + public static RollerClientRegistrationRepository getClientRegistrationRepository() { + ApplicationContext ctx = + WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext); + try { + return ctx.getBean("clientRegistrationRepository", RollerClientRegistrationRepository.class); + } catch (NoSuchBeanDefinitionException exc) { + log.debug("No clientRegistrationRepository bean in context", exc); + return null; + } + } + /** * Flush user from any caches maintained by security system. */ diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerSession.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerSession.java index 959cc3e908..f02fba54b6 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerSession.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerSession.java @@ -34,6 +34,9 @@ import org.apache.roller.weblogger.business.UserManager; import org.apache.roller.weblogger.pojos.User; import org.apache.roller.weblogger.ui.core.security.AutoProvision; +import org.apache.roller.weblogger.ui.core.security.RollerOidcUserService; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; /** @@ -90,6 +93,15 @@ public static RollerSession getRollerSession(HttpServletRequest request) { UserManager umgr = WebloggerFactory.getWeblogger().getUserManager(); User user = umgr.getUserByUserName(principal.getName()); + // For OIDC-authenticated users, look up by OIDC subject + if (user == null && principal instanceof OAuth2AuthenticationToken oauthToken) { + Object oauthPrincipal = oauthToken.getPrincipal(); + if (oauthPrincipal instanceof OidcUser oidcUser) { + String oidcSubject = RollerOidcUserService.toOidcSubject(oidcUser); + user = umgr.getUserByOpenIdUrl(oidcSubject); + } + } + // try one time to auto-provision, only happens if user==null // which means installation has LDAP enabled in security.xml if (user == null && WebloggerConfig.getBooleanProperty("users.ldap.autoProvision.enabled")) { diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/BootstrapSecurityFilter.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/BootstrapSecurityFilter.java index 727b061d20..106d11926e 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/BootstrapSecurityFilter.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/BootstrapSecurityFilter.java @@ -17,7 +17,10 @@ public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) boolean installer = uri != null && (tokenPage || uri.contains("/roller-ui/install/") || uri.endsWith("/roller-ui/register.rol") || uri.endsWith("/roller-ui/register!save.rol") - || uri.endsWith("/roller-ui/setup.rol")); + || uri.endsWith("/roller-ui/setup.rol") + // OIDC sign-in can provision the first account + || uri.contains("/oauth2/authorization/") + || uri.contains("/login/oauth2/code/")); if (installer && !BootstrapSecurity.isCompleted()) { if (tokenPage) { chain.doFilter(req, res); return; } if (!BootstrapSecurity.isValid(r)) { p.sendRedirect(r.getContextPath() + "/roller-ui/bootstrap-token.rol"); return; } diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/security/RollerClientRegistrationRepository.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/security/RollerClientRegistrationRepository.java new file mode 100644 index 0000000000..d84db0cba1 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/security/RollerClientRegistrationRepository.java @@ -0,0 +1,235 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. The ASF licenses this file to You + * under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ +package org.apache.roller.weblogger.ui.core.security; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.roller.weblogger.config.AuthMethod; +import org.apache.roller.weblogger.config.WebloggerConfig; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.ClientRegistrations; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; + +/** + * Builds OAuth2/OIDC client registrations from Roller properties. + * + *

No registrations are served unless {@code authentication.method} is + * {@code oidc} or {@code db-oidc}, so configuring providers under another + * method does not open the {@code /oauth2/authorization/*} endpoints. + * + *

OIDC discovery is deferred until first access so the identity provider + * does not need to be reachable during application startup. Each provider is + * resolved and cached independently under a discovery timeout: one unreachable + * provider does not block the others, a failed provider is retried with a + * bounded backoff instead of on every request, and concurrent requests do not + * pile onto the same discovery (an in-flight provider is simply skipped until + * its attempt finishes). + * + *

Properties follow the pattern: + *

+ * oidc.{registrationId}.client-id=...
+ * oidc.{registrationId}.client-secret=...
+ * oidc.{registrationId}.issuer-uri=...
+ * oidc.{registrationId}.client-name=...  (optional, defaults to registrationId)
+ * oidc.{registrationId}.scope=openid,profile,email  (optional)
+ * oidc.{registrationId}.client-authentication-method=none  (optional, for a
+ *     public client using PKCE; without it a client-secret is required)
+ * 
+ */ +public class RollerClientRegistrationRepository implements ClientRegistrationRepository, Iterable { + + private static final Log log = LogFactory.getLog(RollerClientRegistrationRepository.class); + private static final String PREFIX = "oidc."; + private static final long RETRY_BACKOFF_MS = 60_000; + private static final long DISCOVERY_TIMEOUT_MS = 10_000; + + private final Map resolved = new ConcurrentHashMap<>(); + private final Map failedAt = new ConcurrentHashMap<>(); + private final Map inFlight = new ConcurrentHashMap<>(); + + @Override + public ClientRegistration findByRegistrationId(String registrationId) { + if (!oidcEnabled()) { + return null; + } + ClientRegistration registration = resolved.get(registrationId); + if (registration != null) { + return registration; + } + // resolve only the requested provider, not the whole configuration + String clientId = configuredProviderIds().get(registrationId); + if (clientId != null) { + resolveProvider(registrationId, clientId); + } + return resolved.get(registrationId); + } + + @Override + public Iterator iterator() { + return getRegistrations().values().iterator(); + } + + /** Whether the configured authentication method allows OIDC login at all. */ + static boolean oidcEnabled() { + AuthMethod method = WebloggerConfig.getAuthMethod(); + return method == AuthMethod.OIDC || method == AuthMethod.DB_OIDC; + } + + /** + * The successfully resolved registrations, in configuration order. Providers + * that have not resolved yet are attempted, unless they failed within the + * retry backoff window. + */ + public Map getRegistrations() { + if (!oidcEnabled()) { + return Collections.emptyMap(); + } + + Map configured = configuredProviderIds(); + for (Map.Entry entry : configured.entrySet()) { + resolveProvider(entry.getKey(), entry.getValue()); + } + + // return in configuration order, only what resolved + Map result = new LinkedHashMap<>(); + for (String id : configured.keySet()) { + ClientRegistration registration = resolved.get(id); + if (registration != null) { + result.put(id, registration); + } + } + return Collections.unmodifiableMap(result); + } + + /** + * Attempts discovery for one provider unless it is already resolved, failed + * within the backoff window, or another thread is on it right now. + */ + private void resolveProvider(String id, String clientId) { + if (resolved.containsKey(id)) { + return; + } + Long lastFailure = failedAt.get(id); + if (lastFailure != null && System.currentTimeMillis() - lastFailure < RETRY_BACKOFF_MS) { + return; + } + if (inFlight.putIfAbsent(id, Boolean.TRUE) != null) { + return; + } + try { + // discovery has no timeout hook of its own, so bound the wait here; + // an abandoned attempt still occupies its pool thread until the + // connection gives up, but request threads stop paying for it + ClientRegistration registration = null; + try { + registration = CompletableFuture.supplyAsync(() -> buildRegistration(id, clientId)) + .get(DISCOVERY_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + log.error("OIDC discovery for provider '" + id + "' timed out after " + + DISCOVERY_TIMEOUT_MS + "ms, will retry in " + (RETRY_BACKOFF_MS / 1000) + "s"); + } catch (Exception e) { + log.error("OIDC discovery for provider '" + id + "' failed", e); + } + if (registration != null) { + resolved.put(id, registration); + failedAt.remove(id); + } else { + failedAt.put(id, System.currentTimeMillis()); + } + } finally { + inFlight.remove(id); + } + } + + /** Registration ids that have an {@code oidc..client-id} property set. */ + static Map configuredProviderIds() { + Map registrationIds = new LinkedHashMap<>(); + Enumeration keys = WebloggerConfig.keys(); + while (keys.hasMoreElements()) { + String key = (String) keys.nextElement(); + if (key.startsWith(PREFIX) && key.endsWith(".client-id")) { + String id = key.substring(PREFIX.length(), key.length() - ".client-id".length()); + String clientId = WebloggerConfig.getProperty(key); + if (clientId != null && !clientId.isBlank()) { + registrationIds.put(id, clientId); + } + } + } + return registrationIds; + } + + private ClientRegistration buildRegistration(String id, String clientId) { + String clientSecret = WebloggerConfig.getProperty(PREFIX + id + ".client-secret"); + String issuerUri = WebloggerConfig.getProperty(PREFIX + id + ".issuer-uri"); + String clientName = WebloggerConfig.getProperty(PREFIX + id + ".client-name", id); + String scopeStr = WebloggerConfig.getProperty(PREFIX + id + ".scope", "openid,profile,email"); + String clientAuthMethod = WebloggerConfig.getProperty(PREFIX + id + ".client-authentication-method"); + + if (clientId == null || clientId.isBlank() || issuerUri == null || issuerUri.isBlank()) { + log.warn("Skipping OIDC registration '" + id + "': client-id and issuer-uri are required"); + return null; + } + + boolean publicClient = "none".equalsIgnoreCase(clientAuthMethod); + if (!publicClient && (clientSecret == null || clientSecret.isBlank())) { + log.error("Skipping OIDC registration '" + id + "': no client-secret configured. Set oidc." + + id + ".client-secret, or oidc." + id + + ".client-authentication-method=none for a public client using PKCE."); + return null; + } + + try { + ClientRegistration.Builder builder = ClientRegistrations.fromIssuerLocation(issuerUri) + .registrationId(id) + .clientId(clientId) + .clientName(clientName) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .scope(Arrays.stream(scopeStr.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toArray(String[]::new)); + + if (publicClient) { + builder.clientAuthenticationMethod(ClientAuthenticationMethod.NONE); + } else { + builder.clientSecret(clientSecret); + } + + ClientRegistration registration = builder.build(); + log.info("Registered OIDC provider: " + id + " (issuer: " + issuerUri + ")"); + return registration; + } catch (Exception e) { + log.error("Failed to configure OIDC provider '" + id + "' (issuer: " + issuerUri + + "), will retry in " + (RETRY_BACKOFF_MS / 1000) + "s", e); + return null; + } + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/security/RollerOidcUserService.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/security/RollerOidcUserService.java new file mode 100644 index 0000000000..93d359df58 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/security/RollerOidcUserService.java @@ -0,0 +1,297 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. The ASF licenses this file to You + * under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ +package org.apache.roller.weblogger.ui.core.security; + +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.roller.util.UUIDGenerator; +import org.apache.roller.weblogger.business.UserManager; +import org.apache.roller.weblogger.business.WebloggerFactory; +import org.apache.roller.weblogger.config.WebloggerConfig; +import org.apache.roller.weblogger.pojos.RuntimeConfigProperty; +import org.apache.roller.weblogger.pojos.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +/** + * Bridges OIDC-authenticated users to Roller's user store. + * + *

The OIDC subject (formatted as {@code issuer#sub}) is matched against the + * User.openIdUrl column. Users who authenticate for the first time are + * provisioned just-in-time from their OIDC claims. Either way the returned + * OidcUser carries the Roller roles as authorities, so authorization works on + * the very first request after login. + */ +public class RollerOidcUserService implements OAuth2UserService { + + private static final Log log = LogFactory.getLog(RollerOidcUserService.class); + private final OidcUserService delegate = new OidcUserService(); + + @Override + public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { + // hiding the login buttons is not an authentication control: reject the + // whole flow server side unless the configured method allows OIDC + if (!RollerClientRegistrationRepository.oidcEnabled()) { + throw new OAuth2AuthenticationException(new OAuth2Error("oidc_not_enabled"), + "authentication.method does not allow OIDC login"); + } + return resolveUser(delegate.loadUser(userRequest)); + } + + /** + * Resolves the Roller account behind an authenticated OIDC user and returns + * a principal carrying that account's Roller roles as authorities. + */ + OidcUser resolveUser(OidcUser oidcUser) throws OAuth2AuthenticationException { + if (!WebloggerFactory.isBootstrapped()) { + throw new OAuth2AuthenticationException(new OAuth2Error("roller_not_bootstrapped"), + "Roller is not bootstrapped; cannot resolve OIDC user"); + } + + String oidcSubject = toOidcSubject(oidcUser); + + try { + UserManager umgr = WebloggerFactory.getWeblogger().getUserManager(); + User rollerUser = umgr.getUserByOpenIdUrl(oidcSubject); + + if (rollerUser == null) { + rollerUser = linkExistingUser(umgr, oidcUser, oidcSubject); + } + if (rollerUser == null) { + rollerUser = provisionUser(umgr, oidcUser, oidcSubject); + } + if (!Boolean.TRUE.equals(rollerUser.getEnabled())) { + throw new OAuth2AuthenticationException(new OAuth2Error("user_disabled"), + "Roller user is disabled: " + rollerUser.getUserName()); + } + + List authorities = new ArrayList<>(); + for (String role : umgr.getRoles(rollerUser)) { + authorities.add(new SimpleGrantedAuthority(role)); + } + return new RollerOidcUser(authorities, oidcUser.getIdToken(), oidcUser.getUserInfo(), + rollerUser.getUserName()); + + } catch (OAuth2AuthenticationException e) { + throw e; + } catch (Exception e) { + log.error("Error resolving Roller user for OIDC subject: " + oidcSubject, e); + throw new OAuth2AuthenticationException(new OAuth2Error("user_resolution_failed"), + "Could not resolve Roller user for OIDC subject: " + oidcSubject, e); + } + } + + /** + * Adopts a pre-existing Roller account whose username matches the one + * asserted by the provider, which is how database users carry over when a + * site turns on OIDC. Linking requires the provider to have verified an + * email address matching the account, so that control of an unverified + * address at the provider cannot be used to take over a Roller account. + * + * @return the linked user, or null if there is no account to adopt + */ + private User linkExistingUser(UserManager umgr, OidcUser oidcUser, String oidcSubject) throws Exception { + String username = usernameOf(oidcUser); + // include disabled accounts, otherwise a pending or deactivated user + // falls through to provisioning and dies on the username constraint + User existing = umgr.getUserByUserName(username, null); + if (existing == null) { + return null; + } + if (!Boolean.TRUE.equals(existing.getEnabled())) { + throw new OAuth2AuthenticationException(new OAuth2Error("user_disabled"), + "Roller user is disabled: " + username); + } + + String email = oidcUser.getEmail(); + boolean emailVerified = Boolean.TRUE.equals(oidcUser.getEmailVerified()) + && email != null && email.equalsIgnoreCase(existing.getEmailAddress()); + + if (!emailVerified) { + throw new OAuth2AuthenticationException(new OAuth2Error("account_link_required"), + "A Roller account named '" + username + "' already exists but is not linked to " + + oidcSubject + ". An administrator must set its federated identity, or the" + + " provider must assert a verified email address matching the account."); + } + + existing.setOpenIdUrl(oidcSubject); + umgr.saveUser(existing); + WebloggerFactory.getWeblogger().flush(); + log.info("Linked existing Roller user '" + username + "' to OIDC subject " + oidcSubject); + return existing; + } + + /** + * Creates a Roller account from the OIDC claims. The account is linked to + * the identity provider by subject, and gets a random password since it is + * never used for authentication. + */ + private User provisionUser(UserManager umgr, OidcUser oidcUser, String oidcSubject) throws Exception { + String username = usernameOf(oidcUser); + + // the identity provider decides who may sign in, so provisioning is a + // static config choice like users.ldap.autoProvision.enabled, not tied + // to the runtime form-registration toggle + if (!WebloggerConfig.getBooleanProperty("users.oidc.autoProvision.enabled")) { + throw new OAuth2AuthenticationException(new OAuth2Error("auto_provision_disabled"), + "OIDC auto-provisioning is disabled; no Roller account exists for " + oidcSubject); + } + + User user = new User(); + user.setId(UUIDGenerator.generateUUID()); + user.setUserName(username); + + String fullName = oidcUser.getFullName(); + if (fullName == null || fullName.isBlank()) { + fullName = username; + } + user.setFullName(fullName); + user.setScreenName(username); + + String email = oidcUser.getEmail(); + if (email == null || email.isBlank()) { + throw new OAuth2AuthenticationException(new OAuth2Error("missing_email"), + "OIDC provider did not supply an email address for " + username); + } + user.setEmailAddress(email); + + user.setOpenIdUrl(oidcSubject); + user.setPassword(UUIDGenerator.generateUUID()); + user.setDateCreated(new Timestamp(System.currentTimeMillis())); + user.setLocale(Locale.getDefault().toString()); + user.setTimeZone(TimeZone.getDefault().getID()); + user.setEnabled(Boolean.TRUE); + + boolean bootstrapAdmin = umgr.getUserCount() == 0 + && WebloggerConfig.getBooleanProperty("users.firstUserAdmin"); + + // Until initial setup completes, BootstrapSecurityFilter only lets the + // session that redeemed the one-time setup token start an OIDC sign-in, + // so this account belongs to the operator: like a form registration it + // keeps the first-user admin grant and completes setup. + boolean initialSetup = !BootstrapSecurity.isCompleted(); + + // grants the "editor" role, and "admin" if this is the first user + umgr.addUser(user); + + // flush before granting so the roles addUser() just created are visible + // to grantRole()'s duplicate check, which queries the database + WebloggerFactory.getWeblogger().flush(); + + Collection claimRoles = extractRoles(oidcUser); + if (claimRoles.contains("admin")) { + umgr.grantRole("admin", user); + WebloggerFactory.getWeblogger().flush(); + } else if (bootstrapAdmin && !initialSetup + && !WebloggerConfig.getBooleanProperty("users.oidc.firstUserAdmin")) { + // After setup, an install with no users left would otherwise hand + // the admin role to whichever provider user signs in first. That + // grant needs an explicit opt-in for OIDC. + umgr.revokeRole("admin", user); + WebloggerFactory.getWeblogger().flush(); + log.warn("First user '" + username + "' was auto-provisioned from OIDC and did NOT " + + "receive the admin role. To bootstrap an administrator, assert an 'admin' " + + "role claim at the provider, set users.oidc.firstUserAdmin=true, or create " + + "the account before enabling OIDC."); + } + if (initialSetup) { + WebloggerFactory.getWeblogger().getPropertiesManager().saveProperty( + new RuntimeConfigProperty(BootstrapSecurity.COMPLETION_PROPERTY, "true")); + WebloggerFactory.getWeblogger().flush(); + BootstrapSecurity.complete(); + log.info("Initial setup completed by the OIDC sign-in of '" + username + "'"); + } + log.info("Auto-provisioned OIDC user '" + username + "' from claims (roles: " + + claimRoles + ", subject: " + oidcSubject + ")"); + return user; + } + + private String usernameOf(OidcUser oidcUser) { + String username = oidcUser.getPreferredUsername(); + return (username == null || username.isBlank()) ? oidcUser.getSubject() : username; + } + + /** + * Reads role names from the token. Providers differ in where they put them, + * so both a flat "roles" claim and Keycloak's nested "realm_access.roles" + * are supported. + */ + @SuppressWarnings("unchecked") + private Collection extractRoles(OidcUser oidcUser) { + Object roles = oidcUser.getClaim("roles"); + if (roles instanceof Collection) { + return (Collection) roles; + } + Object realmAccess = oidcUser.getClaim("realm_access"); + if (realmAccess instanceof Map) { + Object realmRoles = ((Map) realmAccess).get("roles"); + if (realmRoles instanceof Collection) { + return (Collection) realmRoles; + } + } + return Collections.emptyList(); + } + + /** + * Formats the OIDC issuer and subject as {@code issuer#sub} for storage + * in the User.openIdUrl column. + */ + public static String toOidcSubject(OidcUser oidcUser) { + return oidcUser.getIssuer().toString() + "#" + oidcUser.getSubject(); + } + + /** + * An OidcUser named after its Roller account. Roller looks users up by + * principal name throughout (ParsedRequest, RoleAssignmentFilter, Struts + * actions), and an OidcUser's default name is the "sub" claim: an opaque + * provider ID that matches no Roller account. + */ + private static class RollerOidcUser extends DefaultOidcUser { + + private final String rollerUserName; + + RollerOidcUser(Collection authorities, + OidcIdToken idToken, OidcUserInfo userInfo, String rollerUserName) { + super(authorities, idToken, userInfo); + this.rollerUserName = rollerUserName; + } + + @Override + public String getName() { + return rollerUserName; + } + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/security/RollerUserDetailsService.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/security/RollerUserDetailsService.java index 853cfb7429..249b8eb3ec 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/core/security/RollerUserDetailsService.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/security/RollerUserDetailsService.java @@ -9,6 +9,8 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.apache.roller.weblogger.WebloggerException; +import org.apache.roller.weblogger.config.AuthMethod; +import org.apache.roller.weblogger.config.WebloggerConfig; import org.apache.roller.weblogger.business.Weblogger; import org.apache.roller.weblogger.business.WebloggerFactory; import org.apache.roller.weblogger.business.UserManager; @@ -27,6 +29,13 @@ public class RollerUserDetailsService implements UserDetailsService { */ @Override public UserDetails loadUserByUsername(String userName) { + // hiding the login form is not an authentication control: refuse + // password lookups server side when only OIDC login is configured + if (WebloggerConfig.getAuthMethod() == AuthMethod.OIDC) { + throw new UsernameNotFoundException( + "form login is disabled: authentication.method is oidc"); + } + Weblogger roller; try { roller = WebloggerFactory.getWeblogger(); diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/admin/UserEdit.java b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/admin/UserEdit.java index d27a0e0ca0..fb128e8d00 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/admin/UserEdit.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/admin/UserEdit.java @@ -144,7 +144,7 @@ public String save() { if (!hasActionErrors()) { getBean().copyTo(user); - if (authMethod == AuthMethod.DB_OPENID) { + if (authMethod == AuthMethod.DB_OIDC) { if (StringUtils.isEmpty(user.getPassword()) && StringUtils.isEmpty(bean.getPassword()) && StringUtils.isEmpty(bean.getOpenIdUrl())) { @@ -158,8 +158,8 @@ public String save() { } // User.password does not allow null, so generate one - if (authMethod.equals(AuthMethod.OPENID) || - (authMethod.equals(AuthMethod.DB_OPENID) && !StringUtils.isEmpty(bean.getOpenIdUrl()))) { + if (authMethod.equals(AuthMethod.OIDC) || + (authMethod.equals(AuthMethod.DB_OIDC) && !StringUtils.isEmpty(bean.getOpenIdUrl()))) { String randomString = RandomStringUtils.randomAlphanumeric(255); user.resetPassword(randomString); } @@ -244,7 +244,7 @@ private void myValidate() { addError("error.add.user.badUserName"); } if ((authMethod == AuthMethod.ROLLERDB || - (authMethod == AuthMethod.DB_OPENID && StringUtils.isEmpty(getBean().getOpenIdUrl()))) + (authMethod == AuthMethod.DB_OIDC && StringUtils.isEmpty(getBean().getOpenIdUrl()))) && StringUtils.isEmpty(getBean().getPassword())) { addError("error.add.user.missingPassword"); } @@ -254,7 +254,7 @@ private void myValidate() { addError("userAdmin.error.userNotFound"); } } - if ((authMethod == AuthMethod.OPENID) && StringUtils.isEmpty(getBean().getOpenIdUrl())) { + if ((authMethod == AuthMethod.OIDC) && StringUtils.isEmpty(getBean().getOpenIdUrl())) { addError("userRegister.error.missingOpenID"); } diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Login.java b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Login.java index 254c6d49d1..ececf469b6 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Login.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Login.java @@ -18,11 +18,18 @@ package org.apache.roller.weblogger.ui.struts2.core; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + import org.apache.roller.weblogger.config.AuthMethod; import org.apache.roller.weblogger.config.WebloggerConfig; +import org.apache.roller.weblogger.ui.core.RollerContext; +import org.apache.roller.weblogger.ui.core.security.RollerClientRegistrationRepository; import org.apache.roller.weblogger.ui.struts2.util.UIAction; -import org.apache.struts2.convention.annotation.AllowedMethods; import org.apache.struts2.interceptor.parameter.StrutsParameter; +import org.springframework.security.oauth2.client.registration.ClientRegistration; /** * Handle user logins. @@ -37,7 +44,7 @@ */ // TODO: make this work @AllowedMethods({"execute"}) public class Login extends UIAction { - + private String error = null; private AuthMethod authMethod = WebloggerConfig.getAuthMethod(); @@ -51,7 +58,7 @@ public Login() { public boolean isUserRequired() { return false; } - + // override default security, we do not require an action weblog @Override public boolean isWeblogRequired() { @@ -62,22 +69,39 @@ public String getAuthMethod() { return authMethod.name(); } + /** + * Providers to offer sign-in buttons for. Only registrations the repository + * could actually resolve are listed, so the page never advertises a provider + * whose discovery endpoint was unreachable. + */ + public List> getOidcProviders() { + List> providers = new ArrayList<>(); + RollerClientRegistrationRepository repository = RollerContext.getClientRegistrationRepository(); + if (repository == null) { + return providers; + } + for (ClientRegistration registration : repository.getRegistrations().values()) { + Map provider = new LinkedHashMap<>(); + provider.put("id", registration.getRegistrationId()); + provider.put("name", registration.getClientName()); + providers.add(provider); + } + return providers; + } + @Override public String execute() { - - // set action error message if there was login error - if(getError() != null) { - if (authMethod == AuthMethod.OPENID) { - addError("error.unmatched.openid"); - } else { - addError("error.password.mismatch"); - } + + // set action error message if there was login error; OAuth2/OIDC + // failures redirect here with error=oidc, form login with error=true + if (getError() != null) { + addError("oidc".equals(getError()) ? "error.oidc.login" : "error.password.mismatch"); } - + return SUCCESS; } - + public String getError() { return error; } @@ -86,5 +110,5 @@ public String getError() { public void setError(String error) { this.error = error; } - + } diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Profile.java b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Profile.java index 1cf8243916..8ca969545c 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Profile.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Profile.java @@ -92,7 +92,7 @@ public String save() { } } - if (authMethod == AuthMethod.DB_OPENID) { + if (authMethod == AuthMethod.DB_OIDC) { if (StringUtils.isEmpty(existingUser.getPassword()) && StringUtils.isEmpty(bean.getPasswordText()) && StringUtils.isEmpty(bean.getOpenIdUrl())) { @@ -106,8 +106,8 @@ public String save() { } // User.password does not allow null, so generate one - if (authMethod.equals(AuthMethod.OPENID) || - (authMethod.equals(AuthMethod.DB_OPENID) && !StringUtils.isEmpty(bean.getOpenIdUrl()))) { + if (authMethod.equals(AuthMethod.OIDC) || + (authMethod.equals(AuthMethod.DB_OIDC) && !StringUtils.isEmpty(bean.getOpenIdUrl()))) { String randomString = RandomStringUtils.randomAlphanumeric(255); existingUser.resetPassword(randomString); } @@ -139,7 +139,7 @@ public void myValidate() { if (!StringUtils.equals(getBean().getPasswordText(), getBean().getPasswordConfirm())) { addError("userRegister.error.mismatchedPasswords"); } - if (authMethod == AuthMethod.OPENID) { + if (authMethod == AuthMethod.OIDC) { addError("userRegister.error.missingOpenID"); } } else { diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/ProfileBean.java b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/ProfileBean.java index 7e7a9703a7..b9cbf032c3 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/ProfileBean.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/ProfileBean.java @@ -113,7 +113,22 @@ public String getOpenIdUrl() { public void setOpenIdUrl(String openIdUrl) { this.openIdUrl = openIdUrl; } - + + /** The issuer half of the {@code issuer#subject} federated identity. */ + public String getOidcIssuer() { + return openIdUrl == null ? null : openIdUrl.split("#", 2)[0]; + } + + /** The subject half of the {@code issuer#subject} federated identity. */ + public String getOidcSubject() { + if (openIdUrl == null) { + return null; + } + String[] parts = openIdUrl.split("#", 2); + return parts.length > 1 ? parts[1] : null; + } + + public String getPasswordText() { return passwordText; } diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Register.java b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Register.java index a8ed278e5f..8c86937f70 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Register.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Register.java @@ -160,7 +160,7 @@ public String execute() { getBean().setScreenName(getServletRequest().getUserPrincipal().getName()); } } - + } catch (Exception ex) { log.error("Error reading SSO user data", ex); addError("error.editing.user", ex.toString()); @@ -351,7 +351,8 @@ public String activate() { public void myValidate() { // if using external auth, we don't want to error on empty password/username from HTML form. - boolean usingSSO = authMethod == AuthMethod.LDAP || authMethod == AuthMethod.CMA; + boolean usingSSO = authMethod == AuthMethod.LDAP || authMethod == AuthMethod.CMA + || authMethod == AuthMethod.OIDC; if (usingSSO) { // store an unused marker in the Roller DB for the passphrase in // the LDAP or CMA cases, as actual passwords are stored externally @@ -379,9 +380,9 @@ public void myValidate() { return; } - // User.password does not allow null, so generate one - if (getAuthMethod().equals(AuthMethod.OPENID.name()) || - (getAuthMethod().equals(AuthMethod.DB_OPENID.name()) && !StringUtils.isEmpty(getBean().getOpenIdUrl()))) { + // User.password does not allow null, so generate one for OIDC users + if (getAuthMethod().equals(AuthMethod.OIDC.name()) || + (getAuthMethod().equals(AuthMethod.DB_OIDC.name()) && !StringUtils.isEmpty(getBean().getOpenIdUrl()))) { String randomString = RandomStringUtils.randomAlphanumeric(255); getBean().setPasswordText(randomString); getBean().setPasswordConfirm(randomString); diff --git a/app/src/main/resources/ApplicationResources.properties b/app/src/main/resources/ApplicationResources.properties index bc61ab836c..006798b16f 100644 --- a/app/src/main/resources/ApplicationResources.properties +++ b/app/src/main/resources/ApplicationResources.properties @@ -459,6 +459,7 @@ error.upload.forbiddenFile=File {0} content-type {1} not allowed error.general=ERROR: Unexpected Exception [{0}] has been logged. error.password.mismatch=Wrong username and password combination error.unmatched.openid=Unknown or invalid OpenID URL +error.oidc.login=OIDC authentication failed error.title.403=Access Denied error.text.403=You do not have the privileges necessary to access the requested page. @@ -666,12 +667,11 @@ issued during the upgrade process: loginPage.title=Welcome to Roller loginPage.prompt=Please login -loginPage.openIdPrompt=Login with OpenID -loginPage.openIdHybridPrompt=Or with username +loginPage.oidcPrompt=Sign in with your identity provider +loginPage.dbOidcPrompt=Or sign in with username and password +loginPage.signInWith=Sign in with {0} loginPage.userName=Username loginPage.password=Password -loginPage.openID=OpenID username -loginPage.loginOpenID=Login loginPage.rememberMe=Remember Me (up to two weeks) loginPage.login=Login loginPage.reset=Reset @@ -1352,7 +1352,7 @@ userSettings.passwordConfirm=Password (Confirm) userSettings.email=Email userSettings.locale=Locale userSettings.timeZone=Timezone -userSettings.openIdUrl=OpenID URL +userSettings.openIdUrl=Federated Identity userSettings.tip.username=Usernames can''t be changed. # ----------------------------------------------------------------- Your profile (profile.jsp) @@ -1391,13 +1391,13 @@ userAdmin.newEntry=New Entry userAdmin.editEntries=Edit Entries userAdmin.manage=Manage -userAdmin.noPasswordForOpenID=Leave password field(s) blank if providing an OpenID. +userAdmin.noPasswordForOidc=Leave password field(s) blank for OIDC-authenticated users. userAdmin.tip.screenName=User''s screen name (with no HTML). userAdmin.tip.fullName=User''s full name (with no HTML). userAdmin.tip.userName=A short one-word username for the user account. \ Please limit it to simple ASCII alphanumeric characters (a-z, A-Z and 0-9), \ and do not use HTML. -userAdmin.tip.openIdUrl=Open ID identifier (in the form of a URL). +userAdmin.tip.openIdUrl=Federated identity (OIDC issuer and subject). userAdmin.tip.password=User''s password. Fill in only to change it to what you enter. userAdmin.tip.email=Valid email address needed for automated notification. userAdmin.tip.enabled=Disabled users are unable to login to Roller. @@ -1441,21 +1441,19 @@ may disable your account if he/she cannot reach you via email. userRegister.heading.authentication=How will you be authenticated? -userRegister.tip.openid.disabled=Enter a password to be used when you login \ +userRegister.tip.password.db=Enter a password to be used when you login \ and confirm that password by entering it a second time. -userRegister.tip.openid.hybrid=You can choose to login via username/password or \ -OpenID. If you choose the latter, leave \ +userRegister.tip.password.dbOidc=You can choose to login via username/password or \ +your identity provider. If you authenticated via your identity provider, leave \ the password fields blank. -userRegister.tip.openid.only=This site uses only OpenID for logins, so please \ -specify your OpenID identifier below. For more information about OpenID see \ -http://openid.net. +userRegister.tip.password.oidc=This site uses an external identity provider for logins. \ +Your account will be linked to your identity provider automatically. -userRegister.tip.password=Your password. -userSettings.tip.password=Your password. Fill-in only if you wish to change it. +userRegister.tip.password=Your password. +userSettings.tip.password=Your password. Fill-in only if you wish to change it. userRegister.tip.passwordConfirm=Confirm your password. -userRegister.tip.openIdUrl=Your OpenID identifier (in the form of a URL). userRegister.heading.locale=What are your locale and timezone settings? diff --git a/app/src/main/resources/org/apache/roller/weblogger/config/roller.properties b/app/src/main/resources/org/apache/roller/weblogger/config/roller.properties index cdb7d5252a..e2c51d3adb 100644 --- a/app/src/main/resources/org/apache/roller/weblogger/config/roller.properties +++ b/app/src/main/resources/org/apache/roller/weblogger/config/roller.properties @@ -313,21 +313,14 @@ cache.salt.timeout=3600 # User management and security settings #----------------------------------------------------------------------------- -# Top-level authentication declaration for Apache Roller. Introduced in Roller 5.1, -# replaces authentication.cma.enabled, authentication.openid, and users.sso.enabled -# from earlier versions. Must be one of the following values: +# Top-level authentication declaration for Apache Roller. Introduced in Roller 5.1. +# Must be one of the following values: # db: use Roller database to store usernames and passwords # ldap: use external LDAP to authenticate (must configure Roller security.xml, # see Roller Wiki for more details) -# openid: users must use OpenID to authenticate -# db-openid: users may choose to authenticate via Roller DB or OpenID but not both. -# Trickier to implement so may not work as well as above methods, test before using. -# cma: container-managed authentication (e.g., Tomcat tomcat-users.xml file). Currently -# unusable, not implemented. -# Note that if you override this value in your roller-custom.properties file, you will also -# need to re-configure the security.xml file in the Roller WAR (under WEB-INF) to use the -# new security method -- check the comments in that latter file for instructions on -# how to do so. +# oidc: users must use an OIDC provider to authenticate (configure oidc.* properties below) +# db-oidc: users may choose to authenticate via Roller DB or OIDC provider. +# cma: container-managed authentication (e.g., Tomcat tomcat-users.xml file). authentication.method=db # Enables HTTPS for login page only @@ -336,10 +329,37 @@ securelogin.enabled=false # With this settings, all users will have HTML posts sanitized. weblogAdminsUntrusted=true -# Empty value used for passphrase in roller_user table when LDAP or CMA used; -# openid presently generates a random (long) password string instead. +# Empty value used for passphrase in roller_user table when LDAP, CMA, or OIDC used. users.passwords.externalAuthValue= +# OIDC provider configuration. To configure a provider, set all three required +# properties using the pattern oidc.{registrationId}.{property}. +# Multiple providers can be configured with different registration IDs. +# Required: client-id, client-secret, issuer-uri +# Optional: client-name (display name, defaults to registrationId), +# scope (comma-separated, defaults to openid,profile,email) +# +# Example for Keycloak: +# oidc.keycloak.client-id=roller +# oidc.keycloak.client-secret=your-client-secret +# oidc.keycloak.issuer-uri=http://localhost:9080/realms/roller +# oidc.keycloak.client-name=Keycloak + +# Create a Roller account automatically the first time someone signs in +# through an OIDC provider. Off by default, like users.ldap.autoProvision: +# without it only accounts an administrator pre-created, or that the provider +# links via a verified email matching an existing account, can sign in. +users.oidc.autoProvision.enabled=false + +# users.firstUserAdmin makes the first registered account an administrator. +# During initial setup, OIDC sign-in is only open to the browser session that +# redeemed the one-time setup token, so the operator's first OIDC account +# keeps that grant and completes setup. If every account is later deleted, +# the next auto-provisioned OIDC account would be whichever provider user +# signs in first, so outside initial setup the grant is off unless enabled +# here. Alternatives: assert an "admin" role claim at the provider. +users.oidc.firstUserAdmin=false + # Password security settings passwds.encryption.enabled=true passwds.encryption.algorithm=bcrypt diff --git a/app/src/main/webapp/WEB-INF/jsps/admin/UserEdit.jsp b/app/src/main/webapp/WEB-INF/jsps/admin/UserEdit.jsp index f9c65a6717..59a86fe625 100644 --- a/app/src/main/webapp/WEB-INF/jsps/admin/UserEdit.jsp +++ b/app/src/main/webapp/WEB-INF/jsps/admin/UserEdit.jsp @@ -36,8 +36,8 @@ - - + +

@@ -68,14 +68,16 @@ label="%{getText('userSettings.fullname')}" tooltip="%{getText('userAdmin.tip.fullName')}" /> - + - - + <%-- editable so an administrator can link an account to its provider + identity (issuer#subject), the fix account_link_required asks for --%> + diff --git a/app/src/main/webapp/WEB-INF/jsps/core/Login.jsp b/app/src/main/webapp/WEB-INF/jsps/core/Login.jsp index 0a3918e990..232ae2cd09 100644 --- a/app/src/main/webapp/WEB-INF/jsps/core/Login.jsp +++ b/app/src/main/webapp/WEB-INF/jsps/core/Login.jsp @@ -33,36 +33,33 @@ } %> - - - -
- + +
- +
- - - - - +
- +
"/>" onsubmit="saveUsername(this)">
- - + + @@ -102,44 +99,14 @@ \ No newline at end of file + diff --git a/app/src/main/webapp/WEB-INF/jsps/core/Profile.jsp b/app/src/main/webapp/WEB-INF/jsps/core/Profile.jsp index 2f91d013e2..4fad0f6956 100644 --- a/app/src/main/webapp/WEB-INF/jsps/core/Profile.jsp +++ b/app/src/main/webapp/WEB-INF/jsps/core/Profile.jsp @@ -19,9 +19,9 @@

- +

- +

@@ -49,7 +49,7 @@ onchange="formChanged()" onkeyup="formChanged()" name="bean.emailAddress" size="40" maxlength="40"/> - + - + + <%-- assigned by the identity provider at login, so shown read-only --%> diff --git a/app/src/main/webapp/WEB-INF/jsps/core/Register.jsp b/app/src/main/webapp/WEB-INF/jsps/core/Register.jsp index 0667e3d131..c8e8ac6174 100644 --- a/app/src/main/webapp/WEB-INF/jsps/core/Register.jsp +++ b/app/src/main/webapp/WEB-INF/jsps/core/Register.jsp @@ -69,19 +69,7 @@

- -

-
- - -

-
- - -

-
- - + - - - - -

@@ -156,22 +136,17 @@ userName = document.register['bean.userName'].value; } - if (authMethod === "ROLLERDB" || authMethod === "DB_OPENID") { + if (authMethod === "ROLLERDB" || authMethod === "DB_OIDC") { passwordText = document.register['bean.passwordText'].value; passwordConfirm = document.register['bean.passwordConfirm'].value; } - if (authMethod === "OPENID" || authMethod === "DB_OPENID") { - openIdUrl = document.register['bean.openIdUrl'].value; - } if (authMethod === "LDAP") { if (emailAddress) disabled = false; - } else if (authMethod === "ROLLERDB") { + } else if (authMethod === "ROLLERDB" || authMethod === "DB_OIDC") { if (emailAddress && userName && passwordText && passwordConfirm) disabled = false; - } else if (authMethod === "OPENID") { - if (emailAddress && openIdUrl) disabled = false; - } else if (authMethod === "DB_OPENID") { - if (emailAddress && ((passwordText && passwordConfirm) || (openIdUrl)) ) disabled = false; + } else if (authMethod === "OIDC") { + if (emailAddress && userName) disabled = false; } if (authMethod !== 'LDAP') { diff --git a/app/src/main/webapp/WEB-INF/security.xml b/app/src/main/webapp/WEB-INF/security.xml index 68a6644b23..0f416d685a 100644 --- a/app/src/main/webapp/WEB-INF/security.xml +++ b/app/src/main/webapp/WEB-INF/security.xml @@ -49,6 +49,13 @@ + + + @@ -82,27 +89,24 @@ class="org.apache.roller.weblogger.ui.core.security.RollerRememberMeAuthenticationProvider"> - + + + + + + + - - - - - - /roller-ui/register.rol - - - /roller-ui/login.rol?error=true - - - /roller-ui/login.rol?error=true - - - + + + + + BootstrapSecurityFilter + /* + REQUEST + FORWARD + INCLUDE + ERROR + ASYNC + + SpringFirewallExceptionFilter @@ -133,15 +145,6 @@ /* REQUEST - - BootstrapSecurityFilter - /* - REQUEST - FORWARD - INCLUDE - ERROR - ASYNC - diff --git a/app/src/test/java/org/apache/roller/weblogger/ui/core/filters/BootstrapSecurityFilterTest.java b/app/src/test/java/org/apache/roller/weblogger/ui/core/filters/BootstrapSecurityFilterTest.java new file mode 100644 index 0000000000..ea996411b3 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/ui/core/filters/BootstrapSecurityFilterTest.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. The ASF licenses this file to You + * under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ + +package org.apache.roller.weblogger.ui.core.filters; + +import java.lang.reflect.Field; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.roller.weblogger.ui.core.security.BootstrapSecurity; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.*; + +class BootstrapSecurityFilterTest { + + private final BootstrapSecurityFilter filter = new BootstrapSecurityFilter(); + private final HttpServletResponse response = mock(HttpServletResponse.class); + private final FilterChain chain = mock(FilterChain.class); + + @AfterEach + void reset() throws Exception { + setCompleted(false); + } + + /** OIDC sign-in can provision the first account, so it waits for the setup token too. */ + @Test + void oidcSignInIsGatedUntilSetupCompletes() throws Exception { + for (String path : new String[] {"/oauth2/authorization/keycloak", "/login/oauth2/code/keycloak"}) { + HttpServletResponse response = mock(HttpServletResponse.class); + filter.doFilter(request(path), response, chain); + verify(response).sendRedirect("/roller/roller-ui/bootstrap-token.rol"); + } + verifyNoInteractions(chain); + } + + @Test + void oidcSignInPassesOnceSetupCompletes() throws Exception { + setCompleted(true); + HttpServletRequest request = request("/login/oauth2/code/keycloak"); + + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + verify(response, never()).sendRedirect(anyString()); + } + + private static HttpServletRequest request(String path) { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getContextPath()).thenReturn("/roller"); + when(request.getRequestURI()).thenReturn("/roller" + path); + return request; + } + + private static void setCompleted(boolean completed) throws Exception { + Field field = BootstrapSecurity.class.getDeclaredField("completed"); + field.setAccessible(true); + field.set(null, completed); + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/ui/core/security/RollerClientRegistrationRepositoryTest.java b/app/src/test/java/org/apache/roller/weblogger/ui/core/security/RollerClientRegistrationRepositoryTest.java new file mode 100644 index 0000000000..6d44aa254e --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/ui/core/security/RollerClientRegistrationRepositoryTest.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. The ASF licenses this file to You + * under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ +package org.apache.roller.weblogger.ui.core.security; + +import java.util.Collections; +import java.util.Vector; + +import org.apache.roller.weblogger.config.AuthMethod; +import org.apache.roller.weblogger.config.WebloggerConfig; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RollerClientRegistrationRepositoryTest { + + @Test + void emptyConfigProducesNoRegistrations() { + try (MockedStatic config = mockStatic(WebloggerConfig.class)) { + config.when(WebloggerConfig::getAuthMethod).thenReturn(AuthMethod.OIDC); + config.when(WebloggerConfig::keys).thenReturn(Collections.emptyEnumeration()); + + RollerClientRegistrationRepository repo = new RollerClientRegistrationRepository(); + + assertNull(repo.findByRegistrationId("google")); + assertFalse(repo.iterator().hasNext()); + assertTrue(repo.getRegistrations().isEmpty()); + } + } + + @Test + void skipsRegistrationWithMissingClientId() { + Vector keys = new Vector<>(); + keys.add("oidc.google.client-id"); + + try (MockedStatic config = mockStatic(WebloggerConfig.class)) { + config.when(WebloggerConfig::getAuthMethod).thenReturn(AuthMethod.OIDC); + config.when(WebloggerConfig::keys).thenReturn(keys.elements()); + config.when(() -> WebloggerConfig.getProperty("oidc.google.client-id")).thenReturn(""); + config.when(() -> WebloggerConfig.getProperty("oidc.google.client-secret")).thenReturn("secret"); + config.when(() -> WebloggerConfig.getProperty("oidc.google.issuer-uri")).thenReturn("https://accounts.google.com"); + config.when(() -> WebloggerConfig.getProperty("oidc.google.client-name", "google")).thenReturn("Google"); + config.when(() -> WebloggerConfig.getProperty("oidc.google.scope", "openid,profile,email")).thenReturn("openid,profile,email"); + + RollerClientRegistrationRepository repo = new RollerClientRegistrationRepository(); + + assertNull(repo.findByRegistrationId("google")); + assertTrue(repo.getRegistrations().isEmpty()); + } + } + + @Test + void skipsRegistrationWithMissingIssuerUri() { + Vector keys = new Vector<>(); + keys.add("oidc.okta.client-id"); + + try (MockedStatic config = mockStatic(WebloggerConfig.class)) { + config.when(WebloggerConfig::getAuthMethod).thenReturn(AuthMethod.OIDC); + config.when(WebloggerConfig::keys).thenReturn(keys.elements()); + config.when(() -> WebloggerConfig.getProperty("oidc.okta.client-id")).thenReturn("my-client-id"); + config.when(() -> WebloggerConfig.getProperty("oidc.okta.client-secret")).thenReturn("secret"); + config.when(() -> WebloggerConfig.getProperty("oidc.okta.issuer-uri")).thenReturn(""); + config.when(() -> WebloggerConfig.getProperty("oidc.okta.client-name", "okta")).thenReturn("Okta"); + config.when(() -> WebloggerConfig.getProperty("oidc.okta.scope", "openid,profile,email")).thenReturn("openid,profile,email"); + + RollerClientRegistrationRepository repo = new RollerClientRegistrationRepository(); + + assertNull(repo.findByRegistrationId("okta")); + assertTrue(repo.getRegistrations().isEmpty()); + } + } + + @Test + void nonOidcPropertiesAreIgnored() { + Vector keys = new Vector<>(); + keys.add("some.other.property"); + keys.add("authentication.method"); + + try (MockedStatic config = mockStatic(WebloggerConfig.class)) { + config.when(WebloggerConfig::getAuthMethod).thenReturn(AuthMethod.OIDC); + config.when(WebloggerConfig::keys).thenReturn(keys.elements()); + + RollerClientRegistrationRepository repo = new RollerClientRegistrationRepository(); + + assertTrue(repo.getRegistrations().isEmpty()); + } + } + + @Test + void noRegistrationsUnlessAuthMethodAllowsOidc() { + Vector keys = new Vector<>(); + keys.add("oidc.google.client-id"); + + try (MockedStatic config = mockStatic(WebloggerConfig.class)) { + config.when(WebloggerConfig::getAuthMethod).thenReturn(AuthMethod.ROLLERDB); + config.when(WebloggerConfig::keys).thenReturn(keys.elements()); + config.when(() -> WebloggerConfig.getProperty("oidc.google.client-id")).thenReturn("my-client-id"); + + RollerClientRegistrationRepository repo = new RollerClientRegistrationRepository(); + + assertNull(repo.findByRegistrationId("google")); + assertTrue(repo.getRegistrations().isEmpty()); + // discovery must not even be attempted + config.verify(WebloggerConfig::keys, never()); + } + } + + @Test + void skipsConfidentialClientWithoutSecret() { + Vector keys = new Vector<>(); + keys.add("oidc.google.client-id"); + + try (MockedStatic config = mockStatic(WebloggerConfig.class)) { + config.when(WebloggerConfig::getAuthMethod).thenReturn(AuthMethod.OIDC); + config.when(WebloggerConfig::keys).thenReturn(keys.elements()); + config.when(() -> WebloggerConfig.getProperty("oidc.google.client-id")).thenReturn("my-client-id"); + config.when(() -> WebloggerConfig.getProperty("oidc.google.client-secret")).thenReturn(null); + config.when(() -> WebloggerConfig.getProperty("oidc.google.issuer-uri")).thenReturn("https://accounts.google.com"); + config.when(() -> WebloggerConfig.getProperty("oidc.google.client-name", "google")).thenReturn("Google"); + config.when(() -> WebloggerConfig.getProperty("oidc.google.scope", "openid,profile,email")).thenReturn("openid,profile,email"); + + RollerClientRegistrationRepository repo = new RollerClientRegistrationRepository(); + + assertTrue(repo.getRegistrations().isEmpty()); + } + } + + @Test + void registrationsMapIsUnmodifiable() { + try (MockedStatic config = mockStatic(WebloggerConfig.class)) { + config.when(WebloggerConfig::getAuthMethod).thenReturn(AuthMethod.OIDC); + config.when(WebloggerConfig::keys).thenReturn(Collections.emptyEnumeration()); + + RollerClientRegistrationRepository repo = new RollerClientRegistrationRepository(); + + assertThrows(UnsupportedOperationException.class, + () -> repo.getRegistrations().put("test", null)); + } + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/ui/core/security/RollerOidcUserServiceTest.java b/app/src/test/java/org/apache/roller/weblogger/ui/core/security/RollerOidcUserServiceTest.java new file mode 100644 index 0000000000..01801773d8 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/ui/core/security/RollerOidcUserServiceTest.java @@ -0,0 +1,583 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. The ASF licenses this file to You + * under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ +package org.apache.roller.weblogger.ui.core.security; + +import java.lang.reflect.Field; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.roller.weblogger.business.PropertiesManager; +import org.apache.roller.weblogger.business.UserManager; +import org.apache.roller.weblogger.business.Weblogger; +import org.apache.roller.weblogger.business.WebloggerFactory; +import org.apache.roller.weblogger.config.AuthMethod; +import org.apache.roller.weblogger.config.WebloggerConfig; +import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; +import org.apache.roller.weblogger.pojos.User; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class RollerOidcUserServiceTest { + + private static final String ISSUER = "https://accounts.example.com"; + private static final String SUBJECT = ISSUER + "#user123"; + + @Mock + private Weblogger roller; + + @Mock + private UserManager userManager; + + @Mock + private User existingUser; + + private RollerOidcUserService service; + + @BeforeEach + void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + service = new RollerOidcUserService(); + // most cases run on an install whose initial setup is done + setSetupCompleted(true); + } + + @AfterEach + void resetSetupGate() throws Exception { + setSetupCompleted(false); + } + + private static void setSetupCompleted(boolean completed) throws Exception { + Field field = BootstrapSecurity.class.getDeclaredField("completed"); + field.setAccessible(true); + field.set(null, completed); + } + + @Test + void toOidcSubjectFormatsIssuerAndSub() { + assertEquals(SUBJECT, RollerOidcUserService.toOidcSubject(oidcUser(Map.of()))); + } + + @Test + void toOidcSubjectHandlesTrailingSlashInIssuer() { + OidcUser user = oidcUser(Map.of("iss", "https://provider.example.com/", "sub", "abc")); + assertEquals("https://provider.example.com/#abc", RollerOidcUserService.toOidcSubject(user)); + } + + /** + * Regression: the returned principal must carry the Roller roles, otherwise + * the very first request after login is denied by the authorization rules. + */ + @Test + void existingUserPrincipalCarriesRollerAuthorities() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(existingUser); + when(existingUser.getEnabled()).thenReturn(Boolean.TRUE); + when(userManager.getRoles(existingUser)).thenReturn(List.of("editor", "admin")); + + OidcUser result = service.resolveUser(oidcUser(Map.of())); + + assertEquals(Set.of("editor", "admin"), authorityNames(result)); + verify(userManager, never()).addUser(any(User.class)); + } + } + + /** + * Regression: Roller looks users up by principal.getName() throughout the + * rendering layer and servlet filters (ParsedRequest, RoleAssignmentFilter, + * Register). An OidcUser's default name is the "sub" claim, an opaque + * provider ID that matches no Roller account, which made every rendered + * weblog page throw an NPE for a signed-in OIDC user. The principal's name + * must be the resolved Roller username. + */ + @Test + void existingUserPrincipalNameIsRollerUsername() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(existingUser); + when(existingUser.getEnabled()).thenReturn(Boolean.TRUE); + when(existingUser.getUserName()).thenReturn("bob"); + when(userManager.getRoles(existingUser)).thenReturn(List.of("editor")); + + OidcUser result = service.resolveUser(oidcUser(Map.of())); + + assertEquals("bob", result.getName()); + } + } + + @Test + void provisionedUserPrincipalNameIsRollerUsername() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getRoles(any(User.class))).thenReturn(List.of("editor")); + + OidcUser result = service.resolveUser(oidcUser(Map.of( + "preferred_username", "jsmith", + "email", "jsmith@example.com"))); + + assertEquals("jsmith", result.getName()); + } + } + + @Test + void newUserPrincipalCarriesRollerAuthorities() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getRoles(any(User.class))).thenReturn(List.of("editor")); + + OidcUser result = service.resolveUser(oidcUser(Map.of( + "preferred_username", "jsmith", + "email", "jsmith@example.com"))); + + assertEquals(Set.of("editor"), authorityNames(result)); + } + } + + @Test + void newUserIsProvisionedFromClaims() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getRoles(any(User.class))).thenReturn(List.of("editor")); + + service.resolveUser(oidcUser(Map.of( + "preferred_username", "jsmith", + "name", "Jane Smith", + "email", "jsmith@example.com"))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userManager).addUser(captor.capture()); + User created = captor.getValue(); + + assertEquals("jsmith", created.getUserName()); + assertEquals("Jane Smith", created.getFullName()); + assertEquals("jsmith@example.com", created.getEmailAddress()); + assertEquals(SUBJECT, created.getOpenIdUrl()); + assertEquals(Boolean.TRUE, created.getEnabled()); + assertNotNull(created.getId()); + assertNotNull(created.getPassword(), "password is NOT NULL in the schema"); + assertNotNull(created.getDateCreated(), "datecreated is NOT NULL in the schema"); + } + } + + @Test + void adminRoleIsGrantedFromFlatRolesClaim() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getRoles(any(User.class))).thenReturn(List.of("editor", "admin")); + + service.resolveUser(oidcUser(Map.of( + "preferred_username", "boss", + "email", "boss@example.com", + "roles", List.of("editor", "admin")))); + + verify(userManager).grantRole(eq("admin"), any(User.class)); + } + } + + @Test + void adminRoleIsGrantedFromKeycloakRealmAccessClaim() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getRoles(any(User.class))).thenReturn(List.of("editor", "admin")); + + service.resolveUser(oidcUser(Map.of( + "preferred_username", "boss", + "email", "boss@example.com", + "realm_access", Map.of("roles", List.of("admin"))))); + + verify(userManager).grantRole(eq("admin"), any(User.class)); + } + } + + @Test + void nonAdminUserDoesNotGetAdminRole() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getRoles(any(User.class))).thenReturn(List.of("editor")); + + service.resolveUser(oidcUser(Map.of( + "preferred_username", "plain", + "email", "plain@example.com", + "roles", List.of("editor")))); + + verify(userManager, never()).grantRole(eq("admin"), any(User.class)); + } + } + + /** + * Regression: addUser() grants "admin" to the very first user, but that row + * is not yet flushed, so grantRole()'s database-backed duplicate check can + * not see it. Without a flush in between the role is inserted twice. + */ + @Test + void pendingRolesAreFlushedBeforeGrantingAdmin() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getRoles(any(User.class))).thenReturn(List.of("editor", "admin")); + + service.resolveUser(oidcUser(Map.of( + "preferred_username", "first", + "email", "first@example.com", + "roles", List.of("admin")))); + + InOrder inOrder = inOrder(userManager, roller); + inOrder.verify(userManager).addUser(any(User.class)); + inOrder.verify(roller).flush(); + inOrder.verify(userManager).grantRole(eq("admin"), any(User.class)); + } + } + + @Test + void disabledUserIsRejected() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(existingUser); + when(existingUser.getEnabled()).thenReturn(Boolean.FALSE); + + OidcUser user = oidcUser(Map.of()); + assertThrows(OAuth2AuthenticationException.class, () -> service.resolveUser(user)); + } + } + + @Test + void missingEmailIsRejected() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + + OidcUser user = oidcUser(Map.of("preferred_username", "noemail")); + assertThrows(OAuth2AuthenticationException.class, () -> service.resolveUser(user)); + } + } + + @Test + void notBootstrappedIsRejected() { + try (MockedStatic factory = mockStatic(WebloggerFactory.class)) { + factory.when(WebloggerFactory::isBootstrapped).thenReturn(false); + + OidcUser user = oidcUser(Map.of()); + assertThrows(OAuth2AuthenticationException.class, () -> service.resolveUser(user)); + } + } + + /** + * The migration path: a site with database users turns on OIDC, and the + * provider asserts a verified email matching the existing account. + */ + @Test + void existingUsernameIsLinkedWhenEmailIsVerified() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getUserByUserName("matt", null)).thenReturn(existingUser); + when(existingUser.getEmailAddress()).thenReturn("matt@example.com"); + when(existingUser.getEnabled()).thenReturn(Boolean.TRUE); + when(userManager.getRoles(existingUser)).thenReturn(List.of("editor")); + + OidcUser result = service.resolveUser(oidcUser(Map.of( + "preferred_username", "matt", + "email", "matt@example.com", + "email_verified", Boolean.TRUE))); + + verify(existingUser).setOpenIdUrl(SUBJECT); + verify(userManager).saveUser(existingUser); + verify(userManager, never()).addUser(any(User.class)); + assertEquals(Set.of("editor"), authorityNames(result)); + } + } + + @Test + void existingUsernameIsNotLinkedWhenEmailIsUnverified() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getUserByUserName("matt", null)).thenReturn(existingUser); + when(existingUser.getEnabled()).thenReturn(Boolean.TRUE); + when(existingUser.getEmailAddress()).thenReturn("matt@example.com"); + + OidcUser user = oidcUser(Map.of( + "preferred_username", "matt", + "email", "matt@example.com", + "email_verified", Boolean.FALSE)); + + assertThrows(OAuth2AuthenticationException.class, () -> service.resolveUser(user)); + verify(userManager, never()).saveUser(any(User.class)); + verify(userManager, never()).addUser(any(User.class)); + } + } + + @Test + void existingUsernameIsNotLinkedWhenEmailDiffers() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getUserByUserName("matt", null)).thenReturn(existingUser); + when(existingUser.getEnabled()).thenReturn(Boolean.TRUE); + when(existingUser.getEmailAddress()).thenReturn("someone-else@example.com"); + + OidcUser user = oidcUser(Map.of( + "preferred_username", "matt", + "email", "matt@example.com", + "email_verified", Boolean.TRUE)); + + assertThrows(OAuth2AuthenticationException.class, () -> service.resolveUser(user)); + verify(userManager, never()).saveUser(any(User.class)); + } + } + + /** + * Regression: a disabled or pending-activation account with the same + * username must refuse the login, not fall through to provisioning and die + * on the username unique constraint. + */ + @Test + void linkRefusedWhenSameNameAccountIsDisabled() throws Exception { + try (MockedStatic factory = bootstrappedRoller()) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getUserByUserName("matt", null)).thenReturn(existingUser); + when(existingUser.getEnabled()).thenReturn(Boolean.FALSE); + + OidcUser user = oidcUser(Map.of( + "preferred_username", "matt", + "email", "matt@example.com", + "email_verified", Boolean.TRUE)); + + OAuth2AuthenticationException ex = + assertThrows(OAuth2AuthenticationException.class, () -> service.resolveUser(user)); + + assertEquals("user_disabled", ex.getError().getErrorCode()); + verify(userManager, never()).saveUser(any(User.class)); + verify(userManager, never()).addUser(any(User.class)); + } + } + + @Test + void autoProvisionDisabledRejectsNewUser() throws Exception { + try (MockedStatic factory = bootstrappedRoller(); + MockedStatic config = autoProvision(false)) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + + OidcUser user = oidcUser(Map.of( + "preferred_username", "newcomer", + "email", "newcomer@example.com")); + + assertThrows(OAuth2AuthenticationException.class, () -> service.resolveUser(user)); + verify(userManager, never()).addUser(any(User.class)); + } + } + + /** + * Regression: provisioning must not depend on the runtime "allow new + * users" toggle, which is off by default and governs form registration. + * The identity provider decides who may sign in, so on a stock install + * every provider user after the first must still get an account. + */ + @Test + void formRegistrationPolicyDoesNotBlockProvisioning() throws Exception { + try (MockedStatic factory = bootstrappedRoller(); + MockedStatic config = registrationEnabled(false)) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getUserCount()).thenReturn(5L); + when(userManager.getRoles(any(User.class))).thenReturn(List.of("editor")); + + service.resolveUser(oidcUser(Map.of( + "preferred_username", "newcomer", + "email", "newcomer@example.com"))); + + verify(userManager).addUser(any(User.class)); + } + } + + @Test + void oidcLoginRejectedWhenAuthMethodDoesNotAllowIt() { + try (MockedStatic config = mockStatic(WebloggerConfig.class)) { + config.when(WebloggerConfig::getAuthMethod).thenReturn(AuthMethod.ROLLERDB); + + OAuth2AuthenticationException ex = assertThrows(OAuth2AuthenticationException.class, + () -> service.loadUser(mock(OidcUserRequest.class))); + + assertEquals("oidc_not_enabled", ex.getError().getErrorCode()); + } + } + + /** + * During initial setup only the session holding the one-time setup token + * can reach OIDC sign-in, so the first account is the operator's: it keeps + * the users.firstUserAdmin grant and completes setup, as a form + * registration does. + */ + @Test + void firstUserDuringInitialSetupKeepsAdminAndCompletesSetup() throws Exception { + setSetupCompleted(false); + PropertiesManager properties = mock(PropertiesManager.class); + when(roller.getPropertiesManager()).thenReturn(properties); + try (MockedStatic factory = bootstrappedRoller(); + MockedStatic config = oidcBootstrapPolicy(false)) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getUserCount()).thenReturn(0L); + when(userManager.getRoles(any(User.class))).thenReturn(List.of("editor", "admin")); + + service.resolveUser(oidcUser(Map.of( + "preferred_username", "operator", + "email", "operator@example.com"))); + + verify(userManager, never()).revokeRole(anyString(), any(User.class)); + verify(properties).saveProperty(argThat(p -> + BootstrapSecurity.COMPLETION_PROPERTY.equals(p.getName()) && "true".equals(p.getValue()))); + assertTrue(BootstrapSecurity.isCompleted()); + } + } + + /** + * Once setup is done, an install with no users left would hand + * users.firstUserAdmin to whichever provider user signs in first. The + * grant must be revoked unless explicitly enabled for OIDC or asserted by + * an admin role claim. + */ + @Test + void firstProvisionedUserLosesBootstrapAdminByDefault() throws Exception { + try (MockedStatic factory = bootstrappedRoller(); + MockedStatic config = oidcBootstrapPolicy(false)) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getUserCount()).thenReturn(0L); + when(userManager.getRoles(any(User.class))).thenReturn(List.of("editor")); + + service.resolveUser(oidcUser(Map.of( + "preferred_username", "first", + "email", "first@example.com"))); + + verify(userManager).revokeRole(eq("admin"), any(User.class)); + } + } + + @Test + void firstProvisionedUserKeepsBootstrapAdminWhenOptedIn() throws Exception { + try (MockedStatic factory = bootstrappedRoller(); + MockedStatic config = oidcBootstrapPolicy(true)) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getUserCount()).thenReturn(0L); + when(userManager.getRoles(any(User.class))).thenReturn(List.of("editor")); + + service.resolveUser(oidcUser(Map.of( + "preferred_username", "first", + "email", "first@example.com"))); + + verify(userManager, never()).revokeRole(anyString(), any(User.class)); + } + } + + @Test + void firstProvisionedUserWithAdminClaimKeepsAdmin() throws Exception { + try (MockedStatic factory = bootstrappedRoller(); + MockedStatic config = oidcBootstrapPolicy(false)) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getUserCount()).thenReturn(0L); + when(userManager.getRoles(any(User.class))).thenReturn(List.of("editor", "admin")); + + service.resolveUser(oidcUser(Map.of( + "preferred_username", "first", + "email", "first@example.com", + "roles", List.of("admin")))); + + verify(userManager).grantRole(eq("admin"), any(User.class)); + verify(userManager, never()).revokeRole(anyString(), any(User.class)); + } + } + + @Test + void laterProvisionedUsersAreNotTouchedByBootstrapPolicy() throws Exception { + try (MockedStatic factory = bootstrappedRoller(); + MockedStatic config = oidcBootstrapPolicy(false)) { + when(userManager.getUserByOpenIdUrl(SUBJECT)).thenReturn(null); + when(userManager.getUserCount()).thenReturn(5L); + when(userManager.getRoles(any(User.class))).thenReturn(List.of("editor")); + + service.resolveUser(oidcUser(Map.of( + "preferred_username", "later", + "email", "later@example.com"))); + + verify(userManager, never()).revokeRole(anyString(), any(User.class)); + } + } + + private MockedStatic oidcBootstrapPolicy(boolean firstUserAdminForOidc) { + MockedStatic config = mockStatic(WebloggerConfig.class); + config.when(() -> WebloggerConfig.getBooleanProperty("users.oidc.autoProvision.enabled")) + .thenReturn(true); + config.when(() -> WebloggerConfig.getBooleanProperty("users.firstUserAdmin")) + .thenReturn(true); + config.when(() -> WebloggerConfig.getBooleanProperty("users.oidc.firstUserAdmin")) + .thenReturn(firstUserAdminForOidc); + return config; + } + + private MockedStatic bootstrappedRoller() { + MockedStatic factory = mockStatic(WebloggerFactory.class); + factory.when(WebloggerFactory::isBootstrapped).thenReturn(true); + factory.when(WebloggerFactory::getWeblogger).thenReturn(roller); + when(roller.getUserManager()).thenReturn(userManager); + return factory; + } + + private MockedStatic registrationEnabled(boolean enabled) { + MockedStatic config = mockStatic(WebloggerRuntimeConfig.class); + config.when(() -> WebloggerRuntimeConfig.getBooleanProperty("users.registration.enabled")) + .thenReturn(enabled); + return config; + } + + private MockedStatic autoProvision(boolean enabled) { + MockedStatic config = mockStatic(WebloggerConfig.class); + config.when(() -> WebloggerConfig.getBooleanProperty("users.oidc.autoProvision.enabled")) + .thenReturn(enabled); + return config; + } + + private Set authorityNames(OidcUser user) { + return user.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + } + + private OidcUser oidcUser(Map extraClaims) { + Map claims = new HashMap<>(); + claims.put("sub", "user123"); + claims.put("iss", ISSUER); + claims.put("aud", List.of("client-id")); + claims.put("iat", Instant.now()); + claims.put("exp", Instant.now().plusSeconds(3600)); + claims.putAll(extraClaims); + + OidcIdToken idToken = new OidcIdToken("token-value", Instant.now(), + Instant.now().plusSeconds(3600), claims); + return new DefaultOidcUser(List.of(), idToken); + } +} diff --git a/app/src/test/resources/roller-custom.properties b/app/src/test/resources/roller-custom.properties index 23a2c28db2..84aa65a4a7 100644 --- a/app/src/test/resources/roller-custom.properties +++ b/app/src/test/resources/roller-custom.properties @@ -42,3 +42,6 @@ tasks.TestTask.class=org.apache.roller.weblogger.business.TestTask tasks.TestTask.startTime=immediate tasks.TestTask.interval=1800 tasks.TestTask.leaseTime=300 + +# tests exercise the provisioning path; the shipped default is false +users.oidc.autoProvision.enabled=true diff --git a/app/src/test/resources/roller-jettyrun.properties b/app/src/test/resources/roller-jettyrun.properties index fcc0547419..4a480a97f2 100644 --- a/app/src/test/resources/roller-jettyrun.properties +++ b/app/src/test/resources/roller-jettyrun.properties @@ -50,3 +50,13 @@ cache.sitewide.enabled=false cache.weblogpage.enabled=false cache.weblogfeed.enabled=false cache.planet.enabled=false + +# Authentication defaults to Roller's own user database, which needs no extra +# services. To develop against OIDC instead, start Keycloak with +# "docker compose up -d keycloak", add "127.0.0.1 keycloak" to your hosts file +# so the issuer resolves the same here and in the browser, then uncomment: +#authentication.method=oidc +#oidc.keycloak.client-id=web_app +#oidc.keycloak.client-secret=web_app +#oidc.keycloak.issuer-uri=http://keycloak:9080/realms/roller +#oidc.keycloak.client-name=Keycloak diff --git a/docker-compose.yml b/docker-compose.yml index db6aec6cf2..0b1301fd23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ # directory of this distribution. -# Example Docker Compose setup for running Roller and PostgreSQL locally +# Example Docker Compose setup for running Roller, PostgreSQL, and Keycloak locally services: @@ -37,7 +37,31 @@ services: - POSTGRES_USER=scott - POSTGRES_PASSWORD=tiger - roller: + keycloak: + image: "quay.io/keycloak/keycloak:26.7.1" + command: "start-dev --import-realm" + volumes: + - ./docker/realm-config:/opt/keycloak/data/import + - ./docker/realm-config/keycloak-health-check.sh:/opt/keycloak/health-check.sh + environment: + - KC_DB=dev-file + - KC_BOOTSTRAP_ADMIN_USERNAME=admin + - KC_BOOTSTRAP_ADMIN_PASSWORD=admin + - KC_HTTP_PORT=9080 + - KC_HTTPS_PORT=9443 + - KC_HEALTH_ENABLED=true + - KC_HTTP_MANAGEMENT_PORT=9990 + ports: + - "127.0.0.1:9080:9080" + - "127.0.0.1:9443:9443" + healthcheck: + test: "bash /opt/keycloak/health-check.sh" + interval: 5s + timeout: 5s + retries: 50 + start_period: 10s + + apache-roller: build: . ports: - "8080:8080" @@ -45,4 +69,15 @@ services: - type: bind source: ./docker/roller-data target: /var/lib/roller/data - + environment: + # override with AUTHENTICATION_METHOD=db-oidc to offer form login too + - AUTHENTICATION_METHOD=${AUTHENTICATION_METHOD:-oidc} + # Roller discovers Keycloak's endpoints at this URL and then redirects + # the browser to them, so the host must resolve both in this container + # and on your machine. Add "127.0.0.1 keycloak" to your hosts file. + - OIDC_ISSUER_URI=http://keycloak:9080/realms/roller + - OIDC_CLIENT_ID=web_app + - OIDC_CLIENT_SECRET=web_app + depends_on: + keycloak: + condition: service_healthy diff --git a/docker/entry-point.sh b/docker/entry-point.sh index 4d78401d8c..c6d6df551b 100755 --- a/docker/entry-point.sh +++ b/docker/entry-point.sh @@ -26,7 +26,21 @@ database.jdbc.driverClass=${DATABASE_JDBC_DRIVERCLASS} database.jdbc.connectionURL=${DATABASE_JDBC_CONNECTIONURL} database.jdbc.username=${DATABASE_JDBC_USERNAME} database.jdbc.password=${DATABASE_JDBC_PASSWORD} +authentication.method=${AUTHENTICATION_METHOD:-db} EOF +# Register an OIDC provider only when one is configured. The issuer must be +# reachable under the same URL from both this container and the browser, +# because the browser is redirected to the endpoints Roller discovers here. +if [ -n "${OIDC_ISSUER_URI}" ]; then +cat >> /usr/local/tomcat/lib/roller-custom.properties << EOF +oidc.${OIDC_PROVIDER_ID:-keycloak}.client-id=${OIDC_CLIENT_ID} +oidc.${OIDC_PROVIDER_ID:-keycloak}.client-secret=${OIDC_CLIENT_SECRET} +oidc.${OIDC_PROVIDER_ID:-keycloak}.issuer-uri=${OIDC_ISSUER_URI} +oidc.${OIDC_PROVIDER_ID:-keycloak}.client-name=${OIDC_CLIENT_NAME:-Keycloak} +users.oidc.autoProvision.enabled=${OIDC_AUTO_PROVISION:-true} +EOF +fi + /usr/local/tomcat/bin/wait-for-it.sh ${DATABASE_HOST} exec /usr/local/tomcat/bin/catalina.sh run diff --git a/docker/realm-config/keycloak-health-check.sh b/docker/realm-config/keycloak-health-check.sh new file mode 100755 index 0000000000..694fdc42a1 --- /dev/null +++ b/docker/realm-config/keycloak-health-check.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. The ASF licenses this file to You +# under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. For additional information regarding +# copyright in this work, please see the NOTICE file in the top level +# directory of this distribution. + +exec 3<>/dev/tcp/localhost/9990 + +echo -e "GET /health/ready HTTP/1.1\r\nHost: localhost:9990\r\nConnection: close\r\n\r\n" >&3 + +timeout --preserve-status 1 cat <&3 | grep -m 1 status | grep -m 1 UP +ERROR=$? + +exec 3<&- +exec 3>&- + +exit $ERROR diff --git a/docker/realm-config/roller-realm.json b/docker/realm-config/roller-realm.json new file mode 100644 index 0000000000..2002a52f7d --- /dev/null +++ b/docker/realm-config/roller-realm.json @@ -0,0 +1,524 @@ +{ + "id": "roller", + "realm": "roller", + "displayName": "Apache Roller", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "defaultSignatureAlgorithm": "RS256", + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "roles": { + "realm": [ + { + "id": "a1b2c3d4-0001-0001-0001-000000000001", + "name": "admin", + "description": "Roller administrator role", + "composite": false, + "clientRole": false, + "containerId": "roller", + "attributes": {} + }, + { + "id": "a1b2c3d4-0001-0001-0001-000000000002", + "name": "editor", + "description": "Roller editor role", + "composite": false, + "clientRole": false, + "containerId": "roller", + "attributes": {} + }, + { + "id": "a1b2c3d4-0001-0001-0001-000000000003", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "roller", + "attributes": {} + }, + { + "id": "a1b2c3d4-0001-0001-0001-000000000004", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "roller", + "attributes": {} + }, + { + "id": "a1b2c3d4-0001-0001-0001-000000000005", + "name": "default-roles-roller", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ], + "client": { + "account": [ + "view-profile", + "manage-account" + ] + } + }, + "clientRole": false, + "containerId": "roller", + "attributes": {} + } + ] + }, + "groups": [ + { + "id": "a1b2c3d4-0002-0001-0001-000000000001", + "name": "Admins", + "path": "/Admins", + "realmRoles": [ + "admin" + ], + "subGroups": [] + }, + { + "id": "a1b2c3d4-0002-0001-0001-000000000002", + "name": "Editors", + "path": "/Editors", + "realmRoles": [ + "editor" + ], + "subGroups": [] + } + ], + "defaultGroups": [ + "/Editors" + ], + "clients": [ + { + "id": "a1b2c3d4-0003-0001-0001-000000000001", + "clientId": "web_app", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "web_app", + "redirectUris": [ + "http://localhost:8080/*", + "http://127.0.0.1:8080/*" + ], + "webOrigins": [ + "http://localhost:8080", + "http://127.0.0.1:8080" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roller", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access" + ] + } + ], + "clientScopes": [ + { + "id": "a1b2c3d4-0004-0001-0001-000000000001", + "name": "roller", + "description": "Roller-specific claims", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "a1b2c3d4-0005-0001-0001-000000000001", + "name": "roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "roles", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "a1b2c3d4-0004-0001-0001-000000000010", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "a1b2c3d4-0005-0001-0001-000000000010", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "a1b2c3d4-0004-0001-0001-000000000011", + "name": "roles", + "description": "OpenID Connect scope for add user realm and client roles to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "a1b2c3d4-0005-0001-0001-000000000011", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String" + } + }, + { + "id": "a1b2c3d4-0005-0001-0001-000000000012", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "a1b2c3d4-0005-0001-0001-000000000013", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "a1b2c3d4-0004-0001-0001-000000000012", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "a1b2c3d4-0005-0001-0001-000000000014", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "a1b2c3d4-0004-0001-0001-000000000002", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "a1b2c3d4-0005-0001-0001-000000000002", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "a1b2c3d4-0005-0001-0001-000000000003", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "a1b2c3d4-0004-0001-0001-000000000003", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "a1b2c3d4-0005-0001-0001-000000000004", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "a1b2c3d4-0005-0001-0001-000000000005", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "a1b2c3d4-0005-0001-0001-000000000006", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "a1b2c3d4-0005-0001-0001-000000000007", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "a1b2c3d4-0005-0001-0001-000000000008", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "a1b2c3d4-0004-0001-0001-000000000004", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + } + }, + { + "id": "a1b2c3d4-0004-0001-0001-000000000005", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + } + }, + { + "id": "a1b2c3d4-0004-0001-0001-000000000006", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + } + ], + "defaultDefaultClientScopes": [ + "web-origins", + "acr", + "roller", + "roles", + "profile", + "email" + ], + "defaultOptionalClientScopes": [ + "address", + "phone", + "offline_access" + ], + "defaultRoles": [ + "offline_access", + "uma_authorization" + ], + "users": [ + { + "id": "a1b2c3d4-0006-0001-0001-000000000001", + "createdTimestamp": 1505479415590, + "username": "admin", + "enabled": true, + "totp": false, + "emailVerified": true, + "firstName": "Admin", + "lastName": "Administrator", + "email": "admin@localhost", + "credentials": [ + { + "id": "a1b2c3d4-0007-0001-0001-000000000001", + "type": "password", + "createdDate": 1505479429154, + "secretData": "{\"value\":\"4pf9K2jWSCcHC+CwsZP/qidN5pSmDUe6AX6wBerSGdBVKkExay8MWKx+EKmaaObZW6FVsD8vdW/ZsyUFD9gJ1Q==\",\"salt\":\"1/qNkZ5kr77jOMOBPBogGw==\"}", + "credentialData": "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}" + } + ], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "offline_access", + "uma_authorization" + ], + "clientRoles": { + "account": [ + "view-profile", + "manage-account" + ] + }, + "notBefore": 0, + "groups": [ + "/Admins", + "/Editors" + ] + }, + { + "id": "a1b2c3d4-0006-0001-0001-000000000002", + "createdTimestamp": 1505479373742, + "username": "user", + "enabled": true, + "totp": false, + "emailVerified": true, + "firstName": "User", + "lastName": "User", + "email": "user@localhost", + "credentials": [ + { + "id": "a1b2c3d4-0007-0001-0001-000000000002", + "type": "password", + "createdDate": 1505479392766, + "secretData": "{\"value\":\"MbKsMgWPnZyImih8s4SaoCSCq+XIY/c6S9F93sXEidHF1TjPWxCqMkec0+o3860CMLXHt3az61cIJOWI0FW9aw==\",\"salt\":\"fmpBI1r8R1u75hDLMUlwBw==\"}", + "credentialData": "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}" + } + ], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "offline_access", + "uma_authorization" + ], + "clientRoles": { + "account": [ + "view-profile", + "manage-account" + ] + }, + "notBefore": 0, + "groups": [ + "/Editors" + ] + } + ] +} \ No newline at end of file