diff --git a/streampipes-commons/src/main/java/org/apache/streampipes/commons/constants/Envs.java b/streampipes-commons/src/main/java/org/apache/streampipes/commons/constants/Envs.java index 672014852e..00765b76b5 100644 --- a/streampipes-commons/src/main/java/org/apache/streampipes/commons/constants/Envs.java +++ b/streampipes-commons/src/main/java/org/apache/streampipes/commons/constants/Envs.java @@ -42,6 +42,8 @@ public enum Envs { SP_CLIENT_USER("SP_CLIENT_USER", DefaultEnvValues.INITIAL_CLIENT_USER_DEFAULT), SP_CLIENT_SECRET("SP_CLIENT_SECRET", DefaultEnvValues.INITIAL_CLIENT_SECRET_DEFAULT), SP_ENCRYPTION_PASSCODE("SP_ENCRYPTION_PASSCODE", DefaultEnvValues.DEFAULT_ENCRYPTION_PASSCODE), + SP_OAUTH_ENABLED("SP_OAUTH_ENABLED", "false"), + SP_OAUTH_REDIRECT_URI("SP_OAUTH_REDIRECT_URI"), SP_DEBUG("SP_DEBUG", "false"), SP_MAX_WAIT_TIME_AT_SHUTDOWN("SP_MAX_WAIT_TIME_AT_SHUTDOWN"), diff --git a/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/DefaultEnvironment.java b/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/DefaultEnvironment.java index 463a3a1d3e..5b6d5428d6 100644 --- a/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/DefaultEnvironment.java +++ b/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/DefaultEnvironment.java @@ -19,10 +19,14 @@ package org.apache.streampipes.commons.environment; import org.apache.streampipes.commons.constants.Envs; +import org.apache.streampipes.commons.environment.model.OAuthConfiguration; +import org.apache.streampipes.commons.environment.parser.OAuthConfigurationParser; import org.apache.streampipes.commons.environment.variable.BooleanEnvironmentVariable; import org.apache.streampipes.commons.environment.variable.IntEnvironmentVariable; import org.apache.streampipes.commons.environment.variable.StringEnvironmentVariable; +import java.util.List; + public class DefaultEnvironment implements Environment { @Override @@ -174,6 +178,21 @@ public StringEnvironmentVariable getEncryptionPasscode() { return new StringEnvironmentVariable(Envs.SP_ENCRYPTION_PASSCODE); } + @Override + public BooleanEnvironmentVariable getOAuthEnabled() { + return new BooleanEnvironmentVariable(Envs.SP_OAUTH_ENABLED); + } + + @Override + public StringEnvironmentVariable getOAuthRedirectUri() { + return new StringEnvironmentVariable(Envs.SP_OAUTH_REDIRECT_URI); + } + + @Override + public List getOAuthConfigurations() { + return new OAuthConfigurationParser().parse(System.getenv()); + } + @Override public StringEnvironmentVariable getKafkaRetentionTimeMs() { return new StringEnvironmentVariable(Envs.SP_KAFKA_RETENTION_MS); diff --git a/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/Environment.java b/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/Environment.java index aabae364b4..1a72910522 100644 --- a/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/Environment.java +++ b/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/Environment.java @@ -18,10 +18,13 @@ package org.apache.streampipes.commons.environment; +import org.apache.streampipes.commons.environment.model.OAuthConfiguration; import org.apache.streampipes.commons.environment.variable.BooleanEnvironmentVariable; import org.apache.streampipes.commons.environment.variable.IntEnvironmentVariable; import org.apache.streampipes.commons.environment.variable.StringEnvironmentVariable; +import java.util.List; + public interface Environment { BooleanEnvironmentVariable getSpDebug(); @@ -91,6 +94,12 @@ public interface Environment { StringEnvironmentVariable getEncryptionPasscode(); + BooleanEnvironmentVariable getOAuthEnabled(); + + StringEnvironmentVariable getOAuthRedirectUri(); + + List getOAuthConfigurations(); + // Messaging StringEnvironmentVariable getKafkaRetentionTimeMs(); diff --git a/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/model/OAuthConfiguration.java b/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/model/OAuthConfiguration.java new file mode 100644 index 0000000000..7ab566f460 --- /dev/null +++ b/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/model/OAuthConfiguration.java @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.commons.environment.model; + +public class OAuthConfiguration { + + private String authorizationUri; + private String clientName; + private String clientId; + private String clientSecret; + private String fullNameAttributeName; + private String issuerUri; + private String jwkSetUri; + private String registrationId; + private String registrationName; + private String[] scopes; + private String tokenUri; + private String userInfoUri; + private String emailAttributeName; + private String userIdAttributeName; + + + public String getRegistrationId() { + return registrationId; + } + + public void setRegistrationId(String registrationId) { + this.registrationId = registrationId; + } + + public String[] getScopes() { + return scopes; + } + + public void setScopes(String[] scopes) { + this.scopes = scopes; + } + + public String getAuthorizationUri() { + return authorizationUri; + } + + public void setAuthorizationUri(String authorizationUri) { + this.authorizationUri = authorizationUri; + } + + public String getTokenUri() { + return tokenUri; + } + + public void setTokenUri(String tokenUri) { + this.tokenUri = tokenUri; + } + + public String getJwkSetUri() { + return jwkSetUri; + } + + public void setJwkSetUri(String jwkSetUri) { + this.jwkSetUri = jwkSetUri; + } + + public String getIssuerUri() { + return issuerUri; + } + + public void setIssuerUri(String issuerUri) { + this.issuerUri = issuerUri; + } + + public String getUserInfoUri() { + return userInfoUri; + } + + public void setUserInfoUri(String userInfoUri) { + this.userInfoUri = userInfoUri; + } + + public String getClientName() { + return clientName; + } + + public void setClientName(String clientName) { + this.clientName = clientName; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } + + public String getEmailAttributeName() { + return emailAttributeName; + } + + public void setEmailAttributeName(String emailAttributeName) { + this.emailAttributeName = emailAttributeName; + } + + public String getFullNameAttributeName() { + return fullNameAttributeName; + } + + public void setFullNameAttributeName(String fullNameAttributeName) { + this.fullNameAttributeName = fullNameAttributeName; + } + + public String getUserIdAttributeName() { + return userIdAttributeName; + } + + public void setUserIdAttributeName(String userIdAttributeName) { + this.userIdAttributeName = userIdAttributeName; + } + + public String getRegistrationName() { + return registrationName; + } + + public void setRegistrationName(String registrationName) { + this.registrationName = registrationName; + } +} diff --git a/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/parser/OAuthConfigurationParser.java b/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/parser/OAuthConfigurationParser.java new file mode 100644 index 0000000000..48168566d2 --- /dev/null +++ b/streampipes-commons/src/main/java/org/apache/streampipes/commons/environment/parser/OAuthConfigurationParser.java @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.commons.environment.parser; + +import org.apache.streampipes.commons.environment.model.OAuthConfiguration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * The {@code OAuthConfigurationParser} class is responsible for parsing OAuth provider configurations + * from environment variables and converting them into a list of {@link OAuthConfiguration} objects. + * + *

This class expects the environment variables to follow a specific naming convention: + * {@code SP_OAUTH_{provider}_{settings}}. The parser identifies each provider by its unique + * identifier (e.g., "github" or "azure") and maps the settings (such as "CLIENT_ID", "CLIENT_SECRET") + * to their corresponding properties in the {@link OAuthConfiguration} object.

+ * + *

Since environment variables cannot be structured as lists, the configuration for each provider + * is derived from prefixed variables. For example, settings for a provider "github" could be + * specified as: + *

+ * The parser then groups these settings into a {@link OAuthConfiguration} object for "github".

+ */ +public class OAuthConfigurationParser { + + private static final Logger LOG = LoggerFactory.getLogger(OAuthConfigurationParser.class); + + + private static final String OAUTH_PREFIX = "SP_OAUTH_PROVIDER"; + + public List parse(Map env) { + Map oAuthConfigurationsMap = new HashMap<>(); + + + env.forEach((key, value) -> { + if (key.startsWith(OAUTH_PREFIX)) { + parseEnvironmentVariable(key, value, oAuthConfigurationsMap); + } + }); + + return new ArrayList<>(oAuthConfigurationsMap.values()); + } + + private void parseEnvironmentVariable( + String key, + String value, + Map oAuthConfigurationsMap + ) { + var parts = getParts(key); + if (parts.length >= 5) { + // containst the identifier of the provider (e.g. azure, github, ...) + var registrationId = getRegistrationId(parts); + var settingName = getSettingName(parts); + + var oAuthConfiguration = getOrCreateOAuthConfiguration(oAuthConfigurationsMap, registrationId); + oAuthConfiguration.setRegistrationId(registrationId); + + switch (settingName) { + case "AUTHORIZATION_URI" -> oAuthConfiguration.setAuthorizationUri(value); + case "CLIENT_NAME" -> oAuthConfiguration.setClientName(value); + case "CLIENT_ID" -> oAuthConfiguration.setClientId(value); + case "CLIENT_SECRET" -> oAuthConfiguration.setClientSecret(value); + case "FULL_NAME_ATTRIBUTE_NAME" -> oAuthConfiguration.setFullNameAttributeName(value); + case "ISSUER_URI" -> oAuthConfiguration.setIssuerUri(value); + case "JWK_SET_URI" -> oAuthConfiguration.setJwkSetUri(value); + case "SCOPES" -> oAuthConfiguration.setScopes(value.split(",")); + case "TOKEN_URI" -> oAuthConfiguration.setTokenUri(value); + case "USER_INFO_URI" -> oAuthConfiguration.setUserInfoUri(value); + case "EMAIL_ATTRIBUTE_NAME" -> oAuthConfiguration.setEmailAttributeName(value); + case "USER_ID_ATTRIBUTE_NAME" -> oAuthConfiguration.setUserIdAttributeName(value); + case "NAME" -> oAuthConfiguration.setRegistrationName(value); + default -> LOG.warn( + "Unknown setting {} for oauth configuration in environment variable {}", + settingName, + key + ); + } + } else { + LOG.warn("Invalid environment variable for oauth configuration: {}", key); + } + } + + private static String[] getParts(String key) { + return key.split("_"); + } + + private static String getSettingName(String[] parts) { + return String.join("_", Arrays.copyOfRange(parts, 4, parts.length)); + } + + private static String getRegistrationId(String[] parts) { + return parts[3].toLowerCase(); + } + + /** + * Retrieves an existing OAuthConfiguration for the given providerId or creates a new one if it does not exist. + * + * @param oAuthConfigurationsMap The map containing existing OAuthConfiguration objects. + * @param registrationId The identifier of the OAuth provider. + * @return The existing or newly created OAuthConfiguration for the given providerId. + */ + private OAuthConfiguration getOrCreateOAuthConfiguration( + Map oAuthConfigurationsMap, + String registrationId + ) { + var oAuthConfiguration = oAuthConfigurationsMap.computeIfAbsent(registrationId, k -> new OAuthConfiguration()); + oAuthConfiguration.setRegistrationId(registrationId); + return oAuthConfiguration; + } +} diff --git a/streampipes-commons/src/test/java/org/apache/streampipes/commons/environment/parser/OAuthConfigurationParserTest.java b/streampipes-commons/src/test/java/org/apache/streampipes/commons/environment/parser/OAuthConfigurationParserTest.java new file mode 100644 index 0000000000..b54c41ddf5 --- /dev/null +++ b/streampipes-commons/src/test/java/org/apache/streampipes/commons/environment/parser/OAuthConfigurationParserTest.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.commons.environment.parser; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class OAuthConfigurationParserTest { + + private final Map env = new HashMap<>() { + { + put("SP_OAUTH_PROVIDER_AZURE_AUTHORIZATION_URI", "authorizationUriA"); + put("SP_OAUTH_PROVIDER_AZURE_CLIENT_NAME", "clientNameA"); + put("SP_OAUTH_PROVIDER_AZURE_CLIENT_ID", "clientIdA"); + put("SP_OAUTH_PROVIDER_AZURE_CLIENT_SECRET", "clientSecretA"); + put("SP_OAUTH_PROVIDER_AZURE_FULL_NAME_ATTRIBUTE_NAME", "fullNameA"); + put("SP_OAUTH_PROVIDER_AZURE_ISSUER_URI", "issuerUriA"); + put("SP_OAUTH_PROVIDER_AZURE_JWK_SET_URI", "jwkSetUriA"); + put("SP_OAUTH_PROVIDER_AZURE_SCOPES", "scope1,scope2"); + put("SP_OAUTH_PROVIDER_AZURE_TOKEN_URI", "tokenUriA"); + put("SP_OAUTH_PROVIDER_AZURE_USER_INFO_URI", "userInfoUriA"); + put("SP_OAUTH_PROVIDER_AZURE_USER_ID_ATTRIBUTE_NAME", "userNameA"); + put("SP_OAUTH_PROVIDER_GITHUB_AUTHORIZATION_URI", "authorizationUriB"); + } + }; + + @Test + public void testParser() { + var config = new OAuthConfigurationParser().parse(env); + + assertEquals(2, config.size()); + + var azureConfig = config.get(1); + assertEquals("azure", azureConfig.getRegistrationId()); + assertEquals("authorizationUriA", azureConfig.getAuthorizationUri()); + assertEquals("clientNameA", azureConfig.getClientName()); + assertEquals("clientIdA", azureConfig.getClientId()); + assertEquals("clientSecretA", azureConfig.getClientSecret()); + assertEquals("fullNameA", azureConfig.getFullNameAttributeName()); + assertEquals("issuerUriA", azureConfig.getIssuerUri()); + assertEquals("jwkSetUriA", azureConfig.getJwkSetUri()); + assertEquals(2, azureConfig.getScopes().length); + assertEquals("scope1", azureConfig.getScopes()[0]); + assertEquals("tokenUriA", azureConfig.getTokenUri()); + assertEquals("userInfoUriA", azureConfig.getUserInfoUri()); + assertEquals("userNameA", azureConfig.getUserIdAttributeName()); + + var gitHubConfig = config.get(0); + assertEquals("github", gitHubConfig.getRegistrationId()); + assertEquals("authorizationUriB", gitHubConfig.getAuthorizationUri()); + assertNull(gitHubConfig.getTokenUri()); + + + } +} diff --git a/streampipes-model-client/pom.xml b/streampipes-model-client/pom.xml index 2c2538ae54..7674d6dc42 100644 --- a/streampipes-model-client/pom.xml +++ b/streampipes-model-client/pom.xml @@ -88,6 +88,9 @@ asClasses true true + + import { Storable } from '@streampipes/platform-services' + cz.habarta.typescript.generator.ext.JsonDeserializationExtension diff --git a/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/UserAccount.java b/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/UserAccount.java index 3c6f5d96eb..6a1ebd34f1 100644 --- a/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/UserAccount.java +++ b/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/UserAccount.java @@ -27,6 +27,8 @@ @TsModel public class UserAccount extends Principal { + public static final String LOCAL = "local"; + protected String fullName; protected String password; @@ -39,6 +41,11 @@ public class UserAccount extends Principal { protected boolean hideTutorial; protected boolean darkMode = false; + /** + * The authentication provider (LOCAL or one of the configured OAuth providers + */ + protected String provider; + public UserAccount() { super(PrincipalType.USER_ACCOUNT); this.hideTutorial = false; @@ -46,6 +53,7 @@ public UserAccount() { this.preferredDataProcessors = new ArrayList<>(); this.preferredDataSinks = new ArrayList<>(); this.preferredDataStreams = new ArrayList<>(); + this.provider = UserAccount.LOCAL; } public static UserAccount from(String username, @@ -156,4 +164,12 @@ public String getPassword() { public void setPassword(String password) { this.password = password; } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } } diff --git a/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/UserRegistrationData.java b/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/UserRegistrationData.java index 5457645e38..92b1a0edd7 100644 --- a/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/UserRegistrationData.java +++ b/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/UserRegistrationData.java @@ -20,5 +20,38 @@ import java.util.List; -public record UserRegistrationData(String username, String password, List roles) { +public class UserRegistrationData { + + private String username; + private String password; + private List roles; + private String provider; + + public UserRegistrationData(String username, + String password, + List roles) { + this.username = username; + this.password = password; + this.roles = roles; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public List getRoles() { + return roles; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } } diff --git a/streampipes-resource-management/src/main/java/org/apache/streampipes/resource/management/UserResourceManager.java b/streampipes-resource-management/src/main/java/org/apache/streampipes/resource/management/UserResourceManager.java index e9e1630be9..9fd8273ffa 100644 --- a/streampipes-resource-management/src/main/java/org/apache/streampipes/resource/management/UserResourceManager.java +++ b/streampipes-resource-management/src/main/java/org/apache/streampipes/resource/management/UserResourceManager.java @@ -90,19 +90,19 @@ public Principal getAdminUser() { public void registerUser(UserRegistrationData data) throws UsernameAlreadyTakenException { try { validateAndRegisterNewUser(data); - createTokenAndSendActivationMail(data.username()); + createTokenAndSendActivationMail(data.getUsername()); } catch (IOException e) { LOG.error("Registration of user could not be completed: {}", e.getMessage()); } } private synchronized void validateAndRegisterNewUser(UserRegistrationData data) { - if (db.checkUserExists(data.username())) { + if (db.checkUserExists(data.getUsername())) { throw new UsernameAlreadyTakenException("Username already taken"); } String encryptedPassword; try { - encryptedPassword = PasswordUtil.encryptPassword(data.password()); + encryptedPassword = PasswordUtil.encryptPassword(data.getPassword()); } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { throw new SpException("Error during password encryption: %s".formatted(e.getMessage())); } @@ -112,9 +112,9 @@ private synchronized void validateAndRegisterNewUser(UserRegistrationData data) private synchronized void createNewUser(UserRegistrationData data, String encryptedPassword) { - List roles = data.roles().stream().map(Role::valueOf).toList(); - UserAccount user = UserAccount.from(data.username(), encryptedPassword, new HashSet<>(roles)); - user.setUsername(data.username()); + List roles = data.getRoles().stream().map(Role::valueOf).toList(); + UserAccount user = UserAccount.from(data.getUsername(), encryptedPassword, new HashSet<>(roles)); + user.setUsername(data.getUsername()); user.setPassword(encryptedPassword); user.setAccountEnabled(false); db.storeUser(user); @@ -169,7 +169,7 @@ public void changePassword(String recoveryCode, PasswordRecoveryToken token = getPasswordRecoveryTokenStorage().getElementById(recoveryCode); Principal user = db.getUser(token.getUsername()); if (user instanceof UserAccount) { - String encryptedPassword = PasswordUtil.encryptPassword(data.password()); + String encryptedPassword = PasswordUtil.encryptPassword(data.getPassword()); ((UserAccount) user).setPassword(encryptedPassword); db.updateUser(user); getPasswordRecoveryTokenStorage().deleteElement(token); @@ -194,4 +194,7 @@ private Environment getEnvironment() { } + public void registerOauthUser(UserAccount userAccount) { + db.storeUser(userAccount); + } } diff --git a/streampipes-rest-shared/src/main/java/org/apache/streampipes/rest/shared/exception/BadRequestException.java b/streampipes-rest-shared/src/main/java/org/apache/streampipes/rest/shared/exception/BadRequestException.java new file mode 100755 index 0000000000..13e811d854 --- /dev/null +++ b/streampipes-rest-shared/src/main/java/org/apache/streampipes/rest/shared/exception/BadRequestException.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.rest.shared.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class BadRequestException extends RuntimeException { + + public BadRequestException(String message) { + super(message); + } + + public BadRequestException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/Authentication.java b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/Authentication.java index 2401ee61da..214651e97f 100644 --- a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/Authentication.java +++ b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/Authentication.java @@ -18,6 +18,7 @@ package org.apache.streampipes.rest.impl; +import org.apache.streampipes.commons.environment.Environments; import org.apache.streampipes.commons.exceptions.UserNotFoundException; import org.apache.streampipes.commons.exceptions.UsernameAlreadyTakenException; import org.apache.streampipes.model.client.user.JwtAuthenticationResponse; @@ -50,6 +51,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.HashMap; +import java.util.List; import java.util.Map; @RestController @@ -98,8 +100,8 @@ public synchronized ResponseEntity doRegister( return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } var enrichedUserRegistrationData = new UserRegistrationData( - userRegistrationData.username(), - userRegistrationData.password(), + userRegistrationData.getUsername(), + userRegistrationData.getPassword(), config.getDefaultUserRoles() ); try { @@ -139,6 +141,7 @@ public ResponseEntity> getAuthSettings() { response.put("allowSelfRegistration", config.isAllowSelfRegistration()); response.put("allowPasswordRecovery", config.isAllowPasswordRecovery()); response.put("linkSettings", config.getLinkSettings()); + response.put("oAuthSettings", makeOAuthSettings()); return ok(response); } @@ -157,4 +160,28 @@ private JwtAuthenticationResponse makeJwtResponse(org.springframework.security.c String jwt = new JwtTokenProvider().createToken(auth); return new JwtAuthenticationResponse(jwt); } + + private UiOAuthSettings makeOAuthSettings() { + var env = Environments.getEnvironment(); + var oAuthConfigs = env.getOAuthConfigurations(); + return new UiOAuthSettings( + env.getOAuthEnabled().getValueOrDefault(), + env.getOAuthRedirectUri().getValueOrDefault(), + oAuthConfigs.stream().map(c -> new OAuthProvider(c.getRegistrationName(), c.getRegistrationId())).toList() + ); + } + + /** + * Record which contains information on the configured OAuth providers required by the login page + * @param enabled indicates if an OAuth provider is configured + * @param redirectUri the redirect URI + * @param supportedProviders A list of configured OAuth providers + */ + private record UiOAuthSettings(boolean enabled, + String redirectUri, + List supportedProviders) { + } + + private record OAuthProvider(String name, String registrationId) { + } } diff --git a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/UserResource.java b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/UserResource.java index ca66f10cc7..f27bc93144 100644 --- a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/UserResource.java +++ b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/UserResource.java @@ -294,6 +294,12 @@ private void updateUser(UserAccount existingUser, boolean adminPrivileges, String property) { user.setPassword(property); + user.setProvider(existingUser.getProvider()); + if (!existingUser.getProvider().equals(UserAccount.LOCAL)) { + // These settings are managed externally + user.setUsername(existingUser.getUsername()); + user.setFullName(existingUser.getFullName()); + } if (!adminPrivileges) { replacePermissions(user, existingUser); } diff --git a/streampipes-rest/src/main/java/org/apache/streampipes/rest/security/OAuth2AuthenticationProcessingException.java b/streampipes-rest/src/main/java/org/apache/streampipes/rest/security/OAuth2AuthenticationProcessingException.java new file mode 100755 index 0000000000..308cf90980 --- /dev/null +++ b/streampipes-rest/src/main/java/org/apache/streampipes/rest/security/OAuth2AuthenticationProcessingException.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.rest.security; + +import org.springframework.security.core.AuthenticationException; + +public class OAuth2AuthenticationProcessingException extends AuthenticationException { + + public OAuth2AuthenticationProcessingException(String msg, Throwable t) { + super(msg, t); + } + + public OAuth2AuthenticationProcessingException(String msg) { + super(msg); + } +} diff --git a/streampipes-service-core-minimal/src/main/java/org/apache/streampipes/service/core/minimal/StreamPipesCoreApplicationMinimal.java b/streampipes-service-core-minimal/src/main/java/org/apache/streampipes/service/core/minimal/StreamPipesCoreApplicationMinimal.java index 5272d82d45..6db1b64dec 100644 --- a/streampipes-service-core-minimal/src/main/java/org/apache/streampipes/service/core/minimal/StreamPipesCoreApplicationMinimal.java +++ b/streampipes-service-core-minimal/src/main/java/org/apache/streampipes/service/core/minimal/StreamPipesCoreApplicationMinimal.java @@ -45,7 +45,10 @@ WebSecurityConfig.class, WelcomePageController.class }) -@ComponentScan({"org.apache.streampipes.rest.*", "org.apache.streampipes.ps"}) +@ComponentScan({ + "org.apache.streampipes.rest.*", + "org.apache.streampipes.ps", + "org.apache.streampipes.service.core.oauth2"}) public class StreamPipesCoreApplicationMinimal extends StreamPipesCoreApplication { public static void main(String[] args) { diff --git a/streampipes-service-core/pom.xml b/streampipes-service-core/pom.xml index 2d7ecf7b1f..9d34826cc2 100644 --- a/streampipes-service-core/pom.xml +++ b/streampipes-service-core/pom.xml @@ -37,6 +37,11 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-oauth2-client + ${spring-boot.version} + org.apache.streampipes diff --git a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/StreamPipesCoreApplication.java b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/StreamPipesCoreApplication.java index 588b194dfb..f9c062b546 100644 --- a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/StreamPipesCoreApplication.java +++ b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/StreamPipesCoreApplication.java @@ -74,7 +74,11 @@ WebSecurityConfig.class, WelcomePageController.class }) -@ComponentScan({"org.apache.streampipes.rest.*", "org.apache.streampipes.ps"}) +@ComponentScan({ + "org.apache.streampipes.rest.*", + "org.apache.streampipes.ps", + "org.apache.streampipes.service.core.oauth2" +}) public class StreamPipesCoreApplication extends StreamPipesServiceBase { private static final Logger LOG = LoggerFactory.getLogger(StreamPipesCoreApplication.class.getCanonicalName()); @@ -147,7 +151,7 @@ public void init() { StorageDispatcher.INSTANCE.getNoSqlStore().getAdapterInstanceStorage(), new AdapterMasterManagement( StorageDispatcher.INSTANCE.getNoSqlStore() - .getAdapterInstanceStorage(), + .getAdapterInstanceStorage(), new SpResourceManager().manageAdapters(), new SpResourceManager().manageDataStreams(), AdapterMetricsManager.INSTANCE.getAdapterMetrics() diff --git a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/WebSecurityConfig.java b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/WebSecurityConfig.java index dc193709d1..2afc3b4a20 100644 --- a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/WebSecurityConfig.java +++ b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/WebSecurityConfig.java @@ -18,13 +18,26 @@ package org.apache.streampipes.service.core; +import org.apache.streampipes.commons.environment.Environment; +import org.apache.streampipes.commons.environment.Environments; import org.apache.streampipes.service.base.security.UnauthorizedRequestEntryPoint; import org.apache.streampipes.service.core.filter.TokenAuthenticationFilter; +import org.apache.streampipes.service.core.oauth2.CustomOAuth2UserService; +import org.apache.streampipes.service.core.oauth2.CustomOidcUserService; +import org.apache.streampipes.service.core.oauth2.HttpCookieOAuth2AuthorizationRequestRepository; +import org.apache.streampipes.service.core.oauth2.OAuth2AccessTokenResponseConverterWithDefaults; +import org.apache.streampipes.service.core.oauth2.OAuth2AuthenticationFailureHandler; +import org.apache.streampipes.service.core.oauth2.OAuth2AuthenticationSuccessHandler; +import org.apache.streampipes.service.core.oauth2.OAuthEnabledCondition; import org.apache.streampipes.user.management.service.SpUserDetailsService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.BeanIds; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; @@ -34,22 +47,51 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.web.client.RestTemplate; + +import java.util.List; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true) public class WebSecurityConfig { + private static final Logger LOG = LoggerFactory.getLogger(WebSecurityConfig.class); + private final UserDetailsService userDetailsService; private final StreamPipesPasswordEncoder passwordEncoder; + private final Environment env; + + @Autowired + private CustomOAuth2UserService customOAuth2UserService; + + @Autowired + CustomOidcUserService customOidcUserService; + + @Autowired + private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; + + @Autowired + private OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler; public WebSecurityConfig(StreamPipesPasswordEncoder passwordEncoder) { this.passwordEncoder = passwordEncoder; this.userDetailsService = new SpUserDetailsService(); + this.env = Environments.getEnvironment(); } @Autowired @@ -59,7 +101,6 @@ public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http .cors() .and() @@ -71,17 +112,46 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .exceptionHandling() .authenticationEntryPoint(new UnauthorizedRequestEntryPoint()) .and() - .authorizeHttpRequests((authz) -> authz - .requestMatchers(UnauthenticatedInterfaces - .get() - .stream() - .map(AntPathRequestMatcher::new) - .toList() - .toArray(new AntPathRequestMatcher[0])) - .permitAll() - .anyRequest() - .authenticated().and() - .addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)); + .authorizeHttpRequests((authz) -> { + try { + authz + .requestMatchers(UnauthenticatedInterfaces + .get() + .stream() + .map(AntPathRequestMatcher::new) + .toList() + .toArray(new AntPathRequestMatcher[0])) + .permitAll() + .anyRequest() + .authenticated(); + + if (env.getOAuthEnabled().getValueOrDefault()) { + LOG.info("Configuring OAuth authentication from environment variables"); + authz + .and() + .oauth2Login() + .authorizationEndpoint() + .authorizationRequestRepository(cookieOAuth2AuthorizationRequestRepository()) + .and() + .redirectionEndpoint() + .and() + .userInfoEndpoint() + .oidcUserService(customOidcUserService) + .userService(customOAuth2UserService) + .and() + .tokenEndpoint() + .accessTokenResponseClient(authorizationCodeTokenResponseClient()) + .and() + .successHandler(oAuth2AuthenticationSuccessHandler) + .failureHandler(oAuth2AuthenticationFailureHandler); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + + http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } @@ -105,4 +175,63 @@ public RequestAttributeSecurityContextRepository getRequestAttributeSecurityCont return new RequestAttributeSecurityContextRepository(); } + @Bean + @Conditional(OAuthEnabledCondition.class) + public HttpCookieOAuth2AuthorizationRequestRepository cookieOAuth2AuthorizationRequestRepository() { + return new HttpCookieOAuth2AuthorizationRequestRepository(); + } + + @Bean + @Conditional(OAuthEnabledCondition.class) + public ClientRegistrationRepository clientRegistrationRepository() { + var registrations = getRegistrations(); + return new InMemoryClientRegistrationRepository(registrations); + } + + private List getRegistrations() { + var oauthConfigs = Environments.getEnvironment().getOAuthConfigurations(); + + return oauthConfigs.stream().map(config -> { + ClientRegistration.Builder builder = this.getBuilder(config.getRegistrationId()); + builder.scope(config.getScopes()); + builder.authorizationUri(config.getAuthorizationUri()); + builder.tokenUri(config.getTokenUri()); + builder.jwkSetUri(config.getJwkSetUri()); + builder.issuerUri(config.getIssuerUri()); + builder.userInfoUri(config.getUserInfoUri()); + builder.clientSecret(config.getClientSecret()); + builder.userNameAttributeName(config.getEmailAttributeName()); + builder.clientName(config.getClientName()); + builder.clientId(config.getClientId()); + return builder.build(); + } + ).toList(); + } + + protected final ClientRegistration.Builder getBuilder(String registrationId) { + ClientRegistration.Builder builder = ClientRegistration.withRegistrationId(registrationId); + builder.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC); + builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE); + builder.redirectUri( + String.format("%s/streampipes-backend/{action}/oauth2/code/{registrationId}", + env.getOAuthRedirectUri().getValueOrDefault() + ) + ); + return builder; + } + + private OAuth2AccessTokenResponseClient authorizationCodeTokenResponseClient() { + var tokenResponseHttpMessageConverter = new OAuth2AccessTokenResponseHttpMessageConverter(); + tokenResponseHttpMessageConverter + .setAccessTokenResponseConverter(new OAuth2AccessTokenResponseConverterWithDefaults()); + var restTemplate = new RestTemplate( + List.of(new FormHttpMessageConverter(), tokenResponseHttpMessageConverter) + ); + restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); + var tokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient(); + tokenResponseClient.setRestOperations(restTemplate); + return tokenResponseClient; + + } + } diff --git a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/CustomOAuth2UserService.java b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/CustomOAuth2UserService.java new file mode 100755 index 0000000000..7715d1c668 --- /dev/null +++ b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.service.core.oauth2; + +import org.apache.streampipes.rest.security.OAuth2AuthenticationProcessingException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.HashMap; + +@Service +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + @Override + public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest); + try { + var attributes = new HashMap<>(oAuth2User.getAttributes()); + var provider = oAuth2UserRequest.getClientRegistration().getRegistrationId(); + return new UserService().processUserRegistration(provider, attributes); + } catch (AuthenticationException e) { + throw e; + } catch (Exception e) { + throw new OAuth2AuthenticationProcessingException(e.getMessage(), e.getCause()); + } + } +} diff --git a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/CustomOidcUserService.java b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/CustomOidcUserService.java new file mode 100755 index 0000000000..881b333379 --- /dev/null +++ b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/CustomOidcUserService.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.service.core.oauth2; + + +import org.apache.streampipes.rest.security.OAuth2AuthenticationProcessingException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; + +@Service +public class CustomOidcUserService extends OidcUserService { + + @Override + public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { + OidcUser oidcUser = super.loadUser(userRequest); + try { + var provider = userRequest.getClientRegistration().getRegistrationId(); + return new UserService().processUserRegistration( + provider, + oidcUser.getAttributes(), + oidcUser.getIdToken(), + oidcUser.getUserInfo() + ); + } catch (AuthenticationException e) { + throw e; + } catch (Exception e) { + throw new OAuth2AuthenticationProcessingException(e.getMessage(), e.getCause()); + } + } +} diff --git a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java new file mode 100755 index 0000000000..f68ef84e90 --- /dev/null +++ b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.service.core.oauth2; + +import org.apache.streampipes.service.core.oauth2.util.CookieUtils; + +import com.nimbusds.oauth2.sdk.util.StringUtils; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + + +@Component +public class HttpCookieOAuth2AuthorizationRequestRepository + implements AuthorizationRequestRepository { + + private static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request"; + public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri"; + private static final int cookieExpireSeconds = 180; + + @Override + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { + return CookieUtils.getCookie( + request, + OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME + ).map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class)) + .orElse(null); + } + + @Override + public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, + HttpServletRequest request, + HttpServletResponse response) { + if (authorizationRequest == null) { + CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME); + return; + } + + CookieUtils.addCookie( + response, + OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, + CookieUtils.serialize(authorizationRequest), + cookieExpireSeconds + ); + + String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME); + if (StringUtils.isNotBlank(redirectUriAfterLogin)) { + CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds); + } + } + + @Override + public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, + HttpServletResponse response) { + return this.loadAuthorizationRequest(request); + } + + public void removeAuthorizationRequestCookies(HttpServletRequest request, + HttpServletResponse response) { + CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME); + } +} diff --git a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AccessTokenResponseConverterWithDefaults.java b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AccessTokenResponseConverterWithDefaults.java new file mode 100755 index 0000000000..b10ca8681d --- /dev/null +++ b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AccessTokenResponseConverterWithDefaults.java @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.service.core.oauth2; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.StringUtils; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class OAuth2AccessTokenResponseConverterWithDefaults + implements Converter, OAuth2AccessTokenResponse> { + private static final Set TOKEN_RESPONSE_PARAMETER_NAMES = Stream + .of( + OAuth2ParameterNames.ACCESS_TOKEN, + OAuth2ParameterNames.TOKEN_TYPE, + OAuth2ParameterNames.EXPIRES_IN, + OAuth2ParameterNames.REFRESH_TOKEN, + OAuth2ParameterNames.SCOPE + ) + .collect(Collectors.toSet()); + + private final OAuth2AccessToken.TokenType defaultAccessTokenType = OAuth2AccessToken.TokenType.BEARER; + + @Override + public OAuth2AccessTokenResponse convert(Map tokenResponseParameters) { + var accessToken = tokenResponseParameters.get(OAuth2ParameterNames.ACCESS_TOKEN); + + OAuth2AccessToken.TokenType accessTokenType = this.defaultAccessTokenType; + var tokenType = OAuth2AccessToken.TokenType.BEARER.getValue(); + if (tokenType.equalsIgnoreCase((String) tokenResponseParameters.get(OAuth2ParameterNames.TOKEN_TYPE))) { + accessTokenType = OAuth2AccessToken.TokenType.BEARER; + } + + long expiresIn = 0; + if (tokenResponseParameters.containsKey(OAuth2ParameterNames.EXPIRES_IN)) { + try { + expiresIn = (int) tokenResponseParameters.get(OAuth2ParameterNames.EXPIRES_IN); + } catch (NumberFormatException ignored) { + } + } + + Set scopes = Collections.emptySet(); + if (tokenResponseParameters.containsKey(OAuth2ParameterNames.SCOPE)) { + var scope = tokenResponseParameters.get(OAuth2ParameterNames.SCOPE); + scopes = Arrays + .stream(StringUtils.delimitedListToStringArray((String) scope, " ")) + .collect(Collectors.toSet()); + } + + Map additionalParameters = new LinkedHashMap<>(); + tokenResponseParameters + .entrySet() + .stream().filter(e -> !TOKEN_RESPONSE_PARAMETER_NAMES.contains(e.getKey())) + .forEach(e -> additionalParameters.put(e.getKey(), e.getValue())); + + return OAuth2AccessTokenResponse + .withToken((String) accessToken) + .tokenType(accessTokenType) + .expiresIn(expiresIn) + .scopes(scopes) + .additionalParameters(additionalParameters) + .build(); + } +} diff --git a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AuthenticationFailureHandler.java b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AuthenticationFailureHandler.java new file mode 100755 index 0000000000..fa86718cdb --- /dev/null +++ b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AuthenticationFailureHandler.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.service.core.oauth2; + +import org.apache.streampipes.service.core.oauth2.util.CookieUtils; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +import static org.apache.streampipes.service.core.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME; + + +@Component +public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Autowired + HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException { + String targetUrl = CookieUtils + .getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME) + .map(Cookie::getValue) + .orElse(("/")); + + targetUrl = UriComponentsBuilder + .fromUriString(targetUrl) + .queryParam("error", exception.getLocalizedMessage()) + .build() + .toUriString(); + + httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AuthenticationSuccessHandler.java b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AuthenticationSuccessHandler.java new file mode 100755 index 0000000000..43d058a992 --- /dev/null +++ b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AuthenticationSuccessHandler.java @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.service.core.oauth2; + +import org.apache.streampipes.commons.environment.Environment; +import org.apache.streampipes.commons.environment.Environments; +import org.apache.streampipes.rest.shared.exception.BadRequestException; +import org.apache.streampipes.service.core.oauth2.util.CookieUtils; +import org.apache.streampipes.user.management.jwt.JwtTokenProvider; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.net.URI; +import java.util.Optional; + +import static org.apache.streampipes.service.core.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME; + +@Component +public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenProvider tokenProvider; + private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; + private final Environment env; + + @Autowired + OAuth2AuthenticationSuccessHandler(HttpCookieOAuth2AuthorizationRequestRepository + httpCookieOAuth2AuthorizationRequestRepository) { + this.tokenProvider = new JwtTokenProvider(); + this.httpCookieOAuth2AuthorizationRequestRepository = httpCookieOAuth2AuthorizationRequestRepository; + this.env = Environments.getEnvironment(); + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + String targetUrl = determineTargetUrl(request, response, authentication); + + if (response.isCommitted()) { + return; + } + + clearAuthenticationAttributes(request, response); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + + @Override + protected String determineTargetUrl(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) { + Optional redirectUri = CookieUtils + .getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME) + .map(Cookie::getValue); + + if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) { + throw new BadRequestException( + "Unauthorized redirect uri found - check the redirect uri in your OAuth config" + ); + } + + String targetUrl = redirectUri.orElse(getDefaultTargetUrl()); + String token = tokenProvider.createToken(authentication); + + return targetUrl + "?token=" + token; + } + + protected void clearAuthenticationAttributes(HttpServletRequest request, + HttpServletResponse response) { + super.clearAuthenticationAttributes(request); + httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response); + } + + private boolean isAuthorizedRedirectUri(String uri) { + URI clientRedirectUri = URI.create(uri); + var authorizedRedirectUri = env.getOAuthRedirectUri(); + if (authorizedRedirectUri.exists()) { + URI authorizedURI = URI.create(authorizedRedirectUri.getValue()); + return authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost()) + && authorizedURI.getPort() == clientRedirectUri.getPort(); + } else { + return false; + } + } +} diff --git a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuthEnabledCondition.java b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuthEnabledCondition.java new file mode 100644 index 0000000000..c96d15dcbc --- /dev/null +++ b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuthEnabledCondition.java @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.service.core.oauth2; + +import org.apache.streampipes.commons.environment.Environment; +import org.apache.streampipes.commons.environment.Environments; + +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +public class OAuthEnabledCondition implements Condition { + + private final Environment env; + + public OAuthEnabledCondition() { + this.env = Environments.getEnvironment(); + } + + @Override + public boolean matches(ConditionContext context, + AnnotatedTypeMetadata metadata) { + return env.getOAuthEnabled().getValueOrDefault(); + } +} diff --git a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OidcUserAccountDetails.java b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OidcUserAccountDetails.java new file mode 100755 index 0000000000..48b3fd5c4f --- /dev/null +++ b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OidcUserAccountDetails.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.service.core.oauth2; + +import org.apache.streampipes.model.client.user.UserAccount; +import org.apache.streampipes.user.management.model.UserAccountDetails; + +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Map; + +public class OidcUserAccountDetails extends UserAccountDetails implements OAuth2User, OidcUser { + + private final OidcIdToken idToken; + private final OidcUserInfo userInfo; + private Map attributes; + + public OidcUserAccountDetails(UserAccount user, + OidcIdToken idToken, + OidcUserInfo userInfo) { + super(user); + this.idToken = idToken; + this.userInfo = userInfo; + } + + public static OidcUserAccountDetails create(UserAccount user, + Map attributes, + OidcIdToken idToken, + OidcUserInfo userInfo) { + OidcUserAccountDetails localUser = new OidcUserAccountDetails(user, idToken, userInfo); + localUser.setAttributes(attributes); + return localUser; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getName() { + return this.details.getFullName(); + } + + @Override + public Map getAttributes() { + return this.attributes; + } + + @Override + public Map getClaims() { + return this.attributes; + } + + @Override + public OidcUserInfo getUserInfo() { + return this.userInfo; + } + + @Override + public OidcIdToken getIdToken() { + return this.idToken; + } +} diff --git a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/UserService.java b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/UserService.java new file mode 100755 index 0000000000..1b78b67238 --- /dev/null +++ b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/UserService.java @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.service.core.oauth2; + +import org.apache.streampipes.commons.environment.Environment; +import org.apache.streampipes.commons.environment.Environments; +import org.apache.streampipes.model.client.user.Role; +import org.apache.streampipes.model.client.user.UserAccount; +import org.apache.streampipes.resource.management.UserResourceManager; +import org.apache.streampipes.rest.security.OAuth2AuthenticationProcessingException; +import org.apache.streampipes.storage.api.IUserStorage; +import org.apache.streampipes.storage.management.StorageDispatcher; + +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; + +public class UserService { + + private final IUserStorage userStorage; + private final Environment env; + + public UserService() { + this.userStorage = StorageDispatcher.INSTANCE.getNoSqlStore().getUserStorageAPI(); + this.env = Environments.getEnvironment(); + } + + public OidcUserAccountDetails processUserRegistration(String registrationId, + Map attributes) { + return processUserRegistration(registrationId, attributes, null, null); + } + + public OidcUserAccountDetails processUserRegistration(String registrationId, + Map attributes, + OidcIdToken idToken, + OidcUserInfo userInfo) { + var oAuthConfigOpt = env.getOAuthConfigurations() + .stream() + .filter(c -> c.getRegistrationId().equals(registrationId)) + .findFirst(); + + if (oAuthConfigOpt.isPresent()) { + var oAuthConfig = oAuthConfigOpt.get(); + var principalId = attributes.get(oAuthConfig.getUserIdAttributeName()).toString(); + var fullName = attributes.get(oAuthConfig.getFullNameAttributeName()); + if (oAuthConfig.getEmailAttributeName().isEmpty()) { + throw new OAuth2AuthenticationProcessingException("Email attribute key not found in attributes"); + } + var email = attributes.get(oAuthConfig.getEmailAttributeName()).toString(); + UserAccount user = (UserAccount) userStorage.getUserById(principalId); + if (user != null) { + if (!user.getProvider().equals(registrationId) && !user.getProvider().equals(UserAccount.LOCAL)) { + throw new OAuth2AuthenticationProcessingException( + String.format("Already signed up with another provider %s", user.getProvider()) + ); + } + } else { + new UserResourceManager().registerOauthUser(toUserAccount(registrationId, principalId, email, fullName)); + user = (UserAccount) userStorage.getUserById(principalId); + } + return OidcUserAccountDetails.create(user, attributes, idToken, userInfo); + } else { + throw new OAuth2AuthenticationProcessingException( + String.format("No config found for provider %s", registrationId) + ); + } + } + + private UserAccount toUserAccount(String registrationId, + String principalId, + String email, + Object fullName) { + List roles = Stream.of(Role.ROLE_ADMIN.toString()).map(Role::valueOf).toList(); + var user = UserAccount.from(email, null, new HashSet<>(roles)); + user.setPrincipalId(principalId); + if (Objects.nonNull(fullName)) { + user.setFullName(fullName.toString()); + } + user.setAccountEnabled(false); + user.setProvider(registrationId); + return user; + } +} diff --git a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/util/CookieUtils.java b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/util/CookieUtils.java new file mode 100755 index 0000000000..00a1312766 --- /dev/null +++ b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/util/CookieUtils.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +package org.apache.streampipes.service.core.oauth2.util; + +import org.springframework.util.SerializationUtils; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.util.Base64; +import java.util.Optional; + +public class CookieUtils { + + public static Optional getCookie(HttpServletRequest request, + String name) { + Cookie[] cookies = request.getCookies(); + + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + return Optional.of(cookie); + } + } + } + + return Optional.empty(); + } + + public static void addCookie(HttpServletResponse response, + String name, + String value, + int maxAge) { + Cookie cookie = new Cookie(name, value); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setMaxAge(maxAge); + response.addCookie(cookie); + } + + public static void deleteCookie(HttpServletRequest request, + HttpServletResponse response, + String name) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + cookie.setValue(""); + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + } + } + } + } + + public static String serialize(Object object) { + return Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize(object)); + } + + public static T deserialize(Cookie cookie, Class clazz) { + return clazz.cast(SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue()))); + } +} diff --git a/ui/projects/streampipes/platform-services/src/lib/model/gen/streampipes-model-client.ts b/ui/projects/streampipes/platform-services/src/lib/model/gen/streampipes-model-client.ts index 6f7f04a72c..9b5f7f279b 100644 --- a/ui/projects/streampipes/platform-services/src/lib/model/gen/streampipes-model-client.ts +++ b/ui/projects/streampipes/platform-services/src/lib/model/gen/streampipes-model-client.ts @@ -16,12 +16,16 @@ * specific language governing permissions and limitations * under the License. */ + /* tslint:disable */ /* eslint-disable */ // @ts-nocheck -// Generated using typescript-generator version 3.2.1263 on 2024-04-22 14:35:38. +// Generated using typescript-generator version 3.2.1263 on 2024-07-29 21:20:28. + +import { Storable } from './streampipes-model'; -export class Group { +export class Group implements Storable { + elementId: string; groupId: string; groupName: string; rev: string; @@ -32,6 +36,7 @@ export class Group { return data; } const instance = target || new Group(); + instance.elementId = data.elementId; instance.groupId = data.groupId; instance.groupName = data.groupName; instance.rev = data.rev; @@ -66,7 +71,8 @@ export class MatchingResultMessage { } } -export class Permission { +export class Permission implements Storable { + elementId: string; grantedAuthorities: PermissionEntry[]; objectClassName: string; objectInstanceId: string; @@ -80,6 +86,7 @@ export class Permission { return data; } const instance = target || new Permission(); + instance.elementId = data.elementId; instance.grantedAuthorities = __getCopyArrayFn( PermissionEntry.fromData, )(data.grantedAuthorities); @@ -193,6 +200,7 @@ export class UserAccount extends Principal { preferredDataProcessors: string[]; preferredDataSinks: string[]; preferredDataStreams: string[]; + provider: string; userApiTokens: UserApiToken[]; static fromData(data: UserAccount, target?: UserAccount): UserAccount { @@ -214,6 +222,7 @@ export class UserAccount extends Principal { instance.preferredDataStreams = __getCopyArrayFn(__identity())( data.preferredDataStreams, ); + instance.provider = data.provider; instance.userApiTokens = __getCopyArrayFn(UserApiToken.fromData)( data.userApiTokens, ); diff --git a/ui/projects/streampipes/platform-services/src/lib/model/gen/streampipes-model.ts b/ui/projects/streampipes/platform-services/src/lib/model/gen/streampipes-model.ts index cf79d51045..29b7a7f837 100644 --- a/ui/projects/streampipes/platform-services/src/lib/model/gen/streampipes-model.ts +++ b/ui/projects/streampipes/platform-services/src/lib/model/gen/streampipes-model.ts @@ -20,7 +20,7 @@ /* tslint:disable */ /* eslint-disable */ // @ts-nocheck -// Generated using typescript-generator version 3.2.1263 on 2024-06-30 09:10:19. +// Generated using typescript-generator version 3.2.1263 on 2024-07-29 21:03:44. export class NamedStreamPipesEntity implements Storable { '@class': diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/warning-box/warning-box.component.html b/ui/projects/streampipes/shared-ui/src/lib/components/warning-box/warning-box.component.html new file mode 100644 index 0000000000..334eb9acc3 --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/warning-box/warning-box.component.html @@ -0,0 +1,21 @@ + + +
+ +
diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/warning-box/warning-box.component.scss b/ui/projects/streampipes/shared-ui/src/lib/components/warning-box/warning-box.component.scss new file mode 100644 index 0000000000..73ce449cf2 --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/warning-box/warning-box.component.scss @@ -0,0 +1,25 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +.warning { + border: 1px solid #dea843; + background: var(--color-bg-2); + padding: 5px; + margin-top: 10px; + margin-bottom: 10px; +} diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/warning-box/warning-box.component.ts b/ui/projects/streampipes/shared-ui/src/lib/components/warning-box/warning-box.component.ts new file mode 100644 index 0000000000..77407d9551 --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/warning-box/warning-box.component.ts @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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. + * + */ + +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'sp-warning-box', + templateUrl: './warning-box.component.html', + styleUrls: ['./warning-box.component.scss'], +}) +export class SpWarningBoxComponent { + @Input() + color = ''; +} diff --git a/ui/projects/streampipes/shared-ui/src/lib/shared-ui.module.ts b/ui/projects/streampipes/shared-ui/src/lib/shared-ui.module.ts index a9131bf1d4..e2e7555e1c 100644 --- a/ui/projects/streampipes/shared-ui/src/lib/shared-ui.module.ts +++ b/ui/projects/streampipes/shared-ui/src/lib/shared-ui.module.ts @@ -43,6 +43,7 @@ import { MatTableModule } from '@angular/material/table'; import { MatPaginator } from '@angular/material/paginator'; import { MatSort } from '@angular/material/sort'; import { SpExceptionDetailsComponent } from './components/sp-exception-message/exception-details/exception-details.component'; +import { SpWarningBoxComponent } from './components/warning-box/warning-box.component'; @NgModule({ declarations: [ @@ -59,6 +60,7 @@ import { SpExceptionDetailsComponent } from './components/sp-exception-message/e SpLabelComponent, SpTableComponent, SplitSectionComponent, + SpWarningBoxComponent, ], imports: [ CommonModule, @@ -89,6 +91,7 @@ import { SpExceptionDetailsComponent } from './components/sp-exception-message/e SpLabelComponent, SpTableComponent, SplitSectionComponent, + SpWarningBoxComponent, ], }) export class SharedUiModule {} diff --git a/ui/projects/streampipes/shared-ui/src/public-api.ts b/ui/projects/streampipes/shared-ui/src/public-api.ts index d954892499..93bd254712 100644 --- a/ui/projects/streampipes/shared-ui/src/public-api.ts +++ b/ui/projects/streampipes/shared-ui/src/public-api.ts @@ -36,6 +36,7 @@ export * from './lib/components/sp-exception-message/exception-details-dialog/ex export * from './lib/components/sp-exception-message/exception-details/exception-details.component'; export * from './lib/components/sp-label/sp-label.component'; export * from './lib/components/sp-table/sp-table.component'; +export * from './lib/components/warning-box/warning-box.component'; export * from './lib/models/sp-navigation.model'; diff --git a/ui/src/app/configuration/security-configuration/edit-user-dialog/edit-user-dialog.component.html b/ui/src/app/configuration/security-configuration/edit-user-dialog/edit-user-dialog.component.html index b28837bc03..f52a07bccd 100644 --- a/ui/src/app/configuration/security-configuration/edit-user-dialog/edit-user-dialog.component.html +++ b/ui/src/app/configuration/security-configuration/edit-user-dialog/edit-user-dialog.component.html @@ -19,6 +19,9 @@
+ + Some settings of externally-managed users cannot be changed. +
Basics @@ -41,7 +44,6 @@ fxFlex type="email" matInput - required data-cy="new-user-email" /> Must be a valid email address. @@ -56,7 +58,6 @@ formControlName="fullName" fxFlex matInput - required data-cy="new-user-full-name" /> diff --git a/ui/src/app/configuration/security-configuration/edit-user-dialog/edit-user-dialog.component.ts b/ui/src/app/configuration/security-configuration/edit-user-dialog/edit-user-dialog.component.ts index 03fa4641bd..170b85d2e7 100644 --- a/ui/src/app/configuration/security-configuration/edit-user-dialog/edit-user-dialog.component.ts +++ b/ui/src/app/configuration/security-configuration/edit-user-dialog/edit-user-dialog.component.ts @@ -57,6 +57,7 @@ export class EditUserDialogComponent implements OnInit { editMode: boolean; isUserAccount: boolean; + isExternalProvider: boolean = false; parentForm: UntypedFormGroup; clonedUser: UserAccount | ServiceAccount; @@ -102,20 +103,24 @@ export class EditUserDialogComponent implements OnInit { ? UserAccount.fromData(this.user, new UserAccount()) : ServiceAccount.fromData(this.user, new ServiceAccount()); this.isUserAccount = this.user instanceof UserAccount; + this.isExternalProvider = + this.user instanceof UserAccount && this.user.provider !== 'local'; this.parentForm = this.fb.group({}); + let usernameValidators = []; + if (this.isUserAccount) { + if ((this.clonedUser as UserAccount).provider === 'local') { + usernameValidators = [Validators.required, Validators.email]; + } else { + usernameValidators = [Validators.email]; + } + } else { + usernameValidators = [Validators.required]; + } this.parentForm.addControl( 'username', - new UntypedFormControl( - this.clonedUser.username, - Validators.required, - ), + new UntypedFormControl(this.clonedUser.username), ); - if (this.isUserAccount) { - this.parentForm.controls['username'].setValidators([ - Validators.required, - Validators.email, - ]); - } + this.parentForm.controls['username'].setValidators(usernameValidators); this.parentForm.addControl( 'accountEnabled', new UntypedFormControl(this.clonedUser.accountEnabled), @@ -158,6 +163,11 @@ export class EditUserDialogComponent implements OnInit { this.parentForm.setValidators(this.checkPasswords); } + if (this.isExternalProvider) { + this.parentForm.controls['username'].disable(); + this.parentForm.controls['fullName'].disable(); + } + this.parentForm.valueChanges.subscribe(v => { this.clonedUser.username = v.username; this.clonedUser.accountLocked = v.accountLocked; diff --git a/ui/src/app/configuration/security-configuration/security-user-configuration/security-user-config.component.html b/ui/src/app/configuration/security-configuration/security-user-configuration/security-user-config.component.html index 492ca0b830..7f3e749bd7 100644 --- a/ui/src/app/configuration/security-configuration/security-user-configuration/security-user-config.component.html +++ b/ui/src/app/configuration/security-configuration/security-user-configuration/security-user-config.component.html @@ -47,6 +47,19 @@

{{ account.username }}

+ + Type + + + + diff --git a/ui/src/app/configuration/security-configuration/security-user-configuration/security-user-config.component.ts b/ui/src/app/configuration/security-configuration/security-user-configuration/security-user-config.component.ts index 3f377904a6..32c74249fc 100644 --- a/ui/src/app/configuration/security-configuration/security-user-configuration/security-user-config.component.ts +++ b/ui/src/app/configuration/security-configuration/security-user-configuration/security-user-config.component.ts @@ -27,7 +27,7 @@ import { Observable } from 'rxjs'; styleUrls: ['./security-user-config.component.scss'], }) export class SecurityUserConfigComponent extends AbstractSecurityPrincipalConfig { - displayedColumns: string[] = ['username', 'fullName', 'edit']; + displayedColumns: string[] = ['username', 'provider', 'fullName', 'edit']; getObservable(): Observable { return this.userAdminService.getAllUserAccounts(); @@ -38,6 +38,8 @@ export class SecurityUserConfigComponent extends AbstractSecurityPrincipalConfig } getNewInstance(): UserAccount { - return new UserAccount(); + const user = new UserAccount(); + user.provider = 'local'; + return user; } } diff --git a/ui/src/app/login/components/login/login.component.html b/ui/src/app/login/components/login/login.component.html index 5cd150ce02..a9a1976609 100644 --- a/ui/src/app/login/components/login/login.component.html +++ b/ui/src/app/login/components/login/login.component.html @@ -47,13 +47,13 @@

Login

/>
-
+
+
+
+ or +
+
+ +
+
diff --git a/ui/src/app/login/components/login/login.component.scss b/ui/src/app/login/components/login/login.component.scss index 3b4edba73f..6d899a1e69 100644 --- a/ui/src/app/login/components/login/login.component.scss +++ b/ui/src/app/login/components/login/login.component.scss @@ -37,3 +37,16 @@ background: #a2ffa2; color: #3e3e3e; } + +.separator { + width: 100%; + text-align: center; + border-bottom: 1px solid var(--color-bg-3); + line-height: 0.1em; + margin: 20px 0 20px; +} + +.separator span { + background: #fff; + padding: 0 10px; +} diff --git a/ui/src/app/login/components/login/login.component.ts b/ui/src/app/login/components/login/login.component.ts index af50a53337..e2cc07b2a5 100644 --- a/ui/src/app/login/components/login/login.component.ts +++ b/ui/src/app/login/components/login/login.component.ts @@ -54,7 +54,7 @@ export class LoginComponent extends BaseLoginPageDirective { this.credentials = {}; } - logIn() { + doLogin() { this.authenticationFailed = false; this.loading = true; this.loginService.login(this.credentials).subscribe( @@ -73,6 +73,12 @@ export class LoginComponent extends BaseLoginPageDirective { } onSettingsAvailable(): void { + const token = this.route.snapshot.queryParamMap.get('token'); + if (token) { + this.authService.oauthLogin(token); + this.loading = false; + this.router.navigate(['']); + } this.parentForm = this.fb.group({}); this.parentForm.addControl( 'username', @@ -89,4 +95,8 @@ export class LoginComponent extends BaseLoginPageDirective { }); this.returnUrl = this.route.snapshot.queryParams.returnUrl || ''; } + + doOAuthLogin(provider: string): void { + window.location.href = `/streampipes-backend/oauth2/authorization/${provider}?redirect_uri=${this.loginSettings.oAuthSettings.redirectUri}/%23/login`; + } } diff --git a/ui/src/app/login/components/login/login.model.ts b/ui/src/app/login/components/login/login.model.ts index fc28a3a4fc..a56f76c941 100644 --- a/ui/src/app/login/components/login/login.model.ts +++ b/ui/src/app/login/components/login/login.model.ts @@ -18,8 +18,20 @@ import { LinkSettings } from '@streampipes/platform-services'; +export interface OAuthProvider { + name: string; + registrationId: string; +} + +export interface OAuthSettings { + enabled: boolean; + redirectUri: string; + supportedProviders: OAuthProvider[]; +} + export interface LoginModel { allowSelfRegistration: boolean; allowPasswordRecovery: boolean; linkSettings: LinkSettings; + oAuthSettings: OAuthSettings; } diff --git a/ui/src/app/profile/components/general/general-profile-settings.component.html b/ui/src/app/profile/components/general/general-profile-settings.component.html index 78c3a35074..778dc7abb0 100644 --- a/ui/src/app/profile/components/general/general-profile-settings.component.html +++ b/ui/src/app/profile/components/general/general-profile-settings.component.html @@ -27,9 +27,13 @@ title="Main Settings" subtitle="Manage your basic profile settings here." > + + Settings for externally-managed users can't be changed. +
{{ userData.username }}