From fe4e276b689d8115e8f2fdc11c4aad8d0c7274cc Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Mon, 23 Mar 2020 22:36:11 +0100 Subject: [PATCH] Implementation of a JWT-based OAuth2 flow for the admin API (#1389) * Initial implementation of a JWT-based OAuth2 flow for the admin API Implements #1388. Signed-off-by: Yannick Schaus --- bom/compile/pom.xml | 8 + bom/runtime/pom.xml | 8 + .../internal/JaasAuthenticationProvider.java | 26 +- .../ManagedUserLoginConfiguration.java | 34 ++ .../jaas/internal/ManagedUserLoginModule.java | 86 +++++ .../rest/internal/RuleResource.java | 4 + .../pages/authorize.html | 77 ++++ bundles/org.openhab.core.io.http.auth/pom.xml | 26 +- .../auth/internal/AuthorizePageServlet.java | 345 ++++++++++++++++++ bundles/org.openhab.core.io.rest.auth/bnd.bnd | 2 + bundles/org.openhab.core.io.rest.auth/pom.xml | 18 + .../core/io/rest/auth/internal/Activator.java | 46 +++ .../io/rest/auth/internal/AuthFilter.java | 87 +++++ .../core/io/rest/auth/internal/JwtHelper.java | 159 ++++++++ .../auth/internal/JwtSecurityContext.java | 58 +++ .../RolesAllowedDynamicFeatureImpl.java | 135 +++++++ .../auth/internal/TokenEndpointException.java | 71 ++++ .../io/rest/auth/internal/TokenResource.java | 345 ++++++++++++++++++ .../rest/auth/internal/TokenResponseDTO.java | 52 +++ .../auth/internal/TokenResponseErrorDTO.java | 35 ++ .../core/io/rest/auth/internal/UserDTO.java | 33 ++ .../io/rest/auth/internal/UserSessionDTO.java | 37 ++ .../link/ItemChannelLinkResource.java | 3 +- .../core/io/rest/ui/internal/UIResource.java | 5 + bundles/org.openhab.core.karaf/pom.xml | 11 + .../jaas/ManagedUserBackingEngine.java | 133 +++++++ .../jaas/ManagedUserBackingEngineFactory.java | 52 +++ .../karaf/internal/jaas/ManagedUserRealm.java | 76 ++++ .../core/auth/AuthenticationProvider.java | 3 + .../org/openhab/core/auth/GenericUser.java | 67 ++++ .../org/openhab/core/auth/ManagedUser.java | 157 ++++++++ .../org/openhab/core/auth/PendingToken.java | 120 ++++++ .../main/java/org/openhab/core/auth/User.java | 36 ++ .../org/openhab/core/auth/UserApiToken.java | 80 ++++ .../org/openhab/core/auth/UserProvider.java | 27 ++ .../org/openhab/core/auth/UserRegistry.java | 42 +++ .../org/openhab/core/auth/UserSession.java | 147 ++++++++ .../internal/auth/ManagedUserProvider.java | 49 +++ .../core/internal/auth/UserRegistryImpl.java | 155 ++++++++ .../openhab-core/src/main/feature/feature.xml | 4 + .../openhab-tp/src/main/feature/feature.xml | 5 + 41 files changed, 2855 insertions(+), 9 deletions(-) create mode 100644 bundles/org.openhab.core.auth.jaas/src/main/java/org/openhab/core/auth/jaas/internal/ManagedUserLoginConfiguration.java create mode 100644 bundles/org.openhab.core.auth.jaas/src/main/java/org/openhab/core/auth/jaas/internal/ManagedUserLoginModule.java create mode 100644 bundles/org.openhab.core.io.http.auth/pages/authorize.html create mode 100644 bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AuthorizePageServlet.java create mode 100644 bundles/org.openhab.core.io.rest.auth/bnd.bnd create mode 100644 bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/Activator.java create mode 100644 bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthFilter.java create mode 100644 bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtHelper.java create mode 100644 bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtSecurityContext.java create mode 100644 bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/RolesAllowedDynamicFeatureImpl.java create mode 100644 bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenEndpointException.java create mode 100644 bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResource.java create mode 100644 bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResponseDTO.java create mode 100644 bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResponseErrorDTO.java create mode 100644 bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserDTO.java create mode 100644 bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserSessionDTO.java create mode 100644 bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/jaas/ManagedUserBackingEngine.java create mode 100644 bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/jaas/ManagedUserBackingEngineFactory.java create mode 100644 bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/jaas/ManagedUserRealm.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/auth/GenericUser.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/auth/ManagedUser.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/auth/PendingToken.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/auth/User.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserApiToken.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserProvider.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserRegistry.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserSession.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/internal/auth/ManagedUserProvider.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/internal/auth/UserRegistryImpl.java diff --git a/bom/compile/pom.xml b/bom/compile/pom.xml index eac488277..0f5d83ed0 100644 --- a/bom/compile/pom.xml +++ b/bom/compile/pom.xml @@ -315,6 +315,14 @@ 1.3.23_2 + + + org.bitbucket.b_c + jose4j + 0.7.0 + compile + + diff --git a/bom/runtime/pom.xml b/bom/runtime/pom.xml index eeddb88ea..fbe7144d8 100644 --- a/bom/runtime/pom.xml +++ b/bom/runtime/pom.xml @@ -896,6 +896,14 @@ 0.5.8 + + + + org.bitbucket.b_c + jose4j + 0.7.0 + compile + diff --git a/bundles/org.openhab.core.auth.jaas/src/main/java/org/openhab/core/auth/jaas/internal/JaasAuthenticationProvider.java b/bundles/org.openhab.core.auth.jaas/src/main/java/org/openhab/core/auth/jaas/internal/JaasAuthenticationProvider.java index a43b2178a..ee305aa32 100644 --- a/bundles/org.openhab.core.auth.jaas/src/main/java/org/openhab/core/auth/jaas/internal/JaasAuthenticationProvider.java +++ b/bundles/org.openhab.core.auth.jaas/src/main/java/org/openhab/core/auth/jaas/internal/JaasAuthenticationProvider.java @@ -14,6 +14,7 @@ package org.openhab.core.auth.jaas.internal; import java.io.IOException; import java.security.Principal; +import java.util.Collections; import java.util.Map; import java.util.Set; @@ -30,6 +31,7 @@ import org.openhab.core.auth.Authentication; import org.openhab.core.auth.AuthenticationException; import org.openhab.core.auth.AuthenticationProvider; import org.openhab.core.auth.Credentials; +import org.openhab.core.auth.GenericUser; import org.openhab.core.auth.UsernamePasswordCredentials; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; @@ -44,16 +46,18 @@ import org.osgi.service.component.annotations.Modified; * * @author Łukasz Dywicki - Initial contribution * @author Kai Kreuzer - Removed ManagedService and used DS configuration instead + * @author Yannick Schaus - provides a configuration with the ManagedUserLoginModule as a sufficient login module */ @Component(configurationPid = "org.openhab.jaas") public class JaasAuthenticationProvider implements AuthenticationProvider { + private final String DEFAULT_REALM = "openhab"; private String realmName; @Override public Authentication authenticate(final Credentials credentials) throws AuthenticationException { if (realmName == null) { // configuration is not yet ready or set - return null; + realmName = DEFAULT_REALM; } if (!(credentials instanceof UsernamePasswordCredentials)) { @@ -64,8 +68,14 @@ public class JaasAuthenticationProvider implements AuthenticationProvider { final String name = userCredentials.getUsername(); final char[] password = userCredentials.getPassword().toCharArray(); + final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); try { - LoginContext loginContext = new LoginContext(realmName, new CallbackHandler() { + + Principal userPrincipal = new GenericUser(name); + Subject subject = new Subject(true, Set.of(userPrincipal), Collections.emptySet(), Set.of(userCredentials)); + + Thread.currentThread().setContextClassLoader(ManagedUserLoginModule.class.getClassLoader()); + LoginContext loginContext = new LoginContext(realmName, subject, new CallbackHandler() { @Override public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException { for (Callback callback : callbacks) { @@ -78,12 +88,14 @@ public class JaasAuthenticationProvider implements AuthenticationProvider { } } } - }); + }, new ManagedUserLoginConfiguration()); loginContext.login(); return getAuthentication(name, loginContext.getSubject()); } catch (LoginException e) { - throw new AuthenticationException("Could not obtain authentication over login context", e); + throw new AuthenticationException(e.getMessage()); + } finally { + Thread.currentThread().setContextClassLoader(contextClassLoader); } } @@ -112,7 +124,7 @@ public class JaasAuthenticationProvider implements AuthenticationProvider { @Modified protected void modified(Map properties) { if (properties == null) { - realmName = null; + realmName = DEFAULT_REALM; return; } @@ -124,8 +136,8 @@ public class JaasAuthenticationProvider implements AuthenticationProvider { realmName = propertyValue.toString(); } } else { - // value could be unset, we should reset it value - realmName = null; + // value could be unset, we should reset its value + realmName = DEFAULT_REALM; } } diff --git a/bundles/org.openhab.core.auth.jaas/src/main/java/org/openhab/core/auth/jaas/internal/ManagedUserLoginConfiguration.java b/bundles/org.openhab.core.auth.jaas/src/main/java/org/openhab/core/auth/jaas/internal/ManagedUserLoginConfiguration.java new file mode 100644 index 000000000..13067556f --- /dev/null +++ b/bundles/org.openhab.core.auth.jaas/src/main/java/org/openhab/core/auth/jaas/internal/ManagedUserLoginConfiguration.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.auth.jaas.internal; + +import java.util.HashMap; + +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.AppConfigurationEntry.LoginModuleControlFlag; +import javax.security.auth.login.Configuration; + +/** + * Describes a JAAS configuration with the {@link ManagedUserLoginModule} as a sufficient login module. + * + * @author Yannick Schaus - initial contribution + */ +public class ManagedUserLoginConfiguration extends Configuration { + + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(String name) { + return new AppConfigurationEntry[] { new AppConfigurationEntry(ManagedUserLoginModule.class.getCanonicalName(), + LoginModuleControlFlag.SUFFICIENT, new HashMap()) }; + } + +} diff --git a/bundles/org.openhab.core.auth.jaas/src/main/java/org/openhab/core/auth/jaas/internal/ManagedUserLoginModule.java b/bundles/org.openhab.core.auth.jaas/src/main/java/org/openhab/core/auth/jaas/internal/ManagedUserLoginModule.java new file mode 100644 index 000000000..31ff32224 --- /dev/null +++ b/bundles/org.openhab.core.auth.jaas/src/main/java/org/openhab/core/auth/jaas/internal/ManagedUserLoginModule.java @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.auth.jaas.internal; + +import java.util.Map; + +import javax.security.auth.Subject; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.login.LoginException; +import javax.security.auth.spi.LoginModule; + +import org.openhab.core.auth.AuthenticationException; +import org.openhab.core.auth.Credentials; +import org.openhab.core.auth.UserRegistry; +import org.osgi.framework.BundleContext; +import org.osgi.framework.FrameworkUtil; +import org.osgi.framework.ServiceReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This {@link LoginModule} delegates the authentication to a {@link UserRegistry} + * + * @author Yannick Schaus - initial contribution + */ +public class ManagedUserLoginModule implements LoginModule { + + private final Logger logger = LoggerFactory.getLogger(ManagedUserLoginModule.class); + + private UserRegistry userRegistry; + + private Subject subject; + + @Override + public void initialize(Subject subject, CallbackHandler callbackHandler, Map sharedState, + Map options) { + this.subject = subject; + } + + @Override + public boolean login() throws LoginException { + try { + // try to get the UserRegistry instance + BundleContext bundleContext = FrameworkUtil.getBundle(UserRegistry.class).getBundleContext(); + ServiceReference serviceReference = bundleContext.getServiceReference(UserRegistry.class); + + userRegistry = bundleContext.getService(serviceReference); + } catch (Exception e) { + logger.error("Cannot initialize the ManagedLoginModule", e); + throw new LoginException("Authorization failed"); + } + + try { + Credentials credentials = (Credentials) this.subject.getPrivateCredentials().iterator().next(); + userRegistry.authenticate(credentials); + return true; + } catch (AuthenticationException e) { + throw new LoginException(e.getMessage()); + } + } + + @Override + public boolean commit() throws LoginException { + return true; + } + + @Override + public boolean abort() throws LoginException { + return false; + } + + @Override + public boolean logout() throws LoginException { + return false; + } +} diff --git a/bundles/org.openhab.core.automation.rest/src/main/java/org/openhab/core/automation/rest/internal/RuleResource.java b/bundles/org.openhab.core.automation.rest/src/main/java/org/openhab/core/automation/rest/internal/RuleResource.java index 4aa1d0d84..783726db2 100644 --- a/bundles/org.openhab.core.automation.rest/src/main/java/org/openhab/core/automation/rest/internal/RuleResource.java +++ b/bundles/org.openhab.core.automation.rest/src/main/java/org/openhab/core/automation/rest/internal/RuleResource.java @@ -22,6 +22,7 @@ import java.util.Map; import java.util.function.Predicate; import java.util.stream.Collectors; +import javax.annotation.security.RolesAllowed; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.GET; @@ -37,6 +38,7 @@ import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.UriInfo; +import org.openhab.core.auth.Role; import org.openhab.core.automation.Action; import org.openhab.core.automation.Condition; import org.openhab.core.automation.Module; @@ -84,6 +86,7 @@ import io.swagger.annotations.ResponseHeader; @Path("rules") @Api("rules") @Component +@RolesAllowed({ Role.ADMIN }) public class RuleResource implements RESTResource { private final Logger logger = LoggerFactory.getLogger(RuleResource.class); @@ -272,6 +275,7 @@ public class RuleResource implements RESTResource { } @POST + @RolesAllowed({ Role.USER, Role.ADMIN }) @Path("/{ruleUID}/runnow") @Consumes(MediaType.TEXT_PLAIN) @ApiOperation(value = "Executes actions of the rule.") diff --git a/bundles/org.openhab.core.io.http.auth/pages/authorize.html b/bundles/org.openhab.core.io.http.auth/pages/authorize.html new file mode 100644 index 000000000..12865e26b --- /dev/null +++ b/bundles/org.openhab.core.io.http.auth/pages/authorize.html @@ -0,0 +1,77 @@ + + +openHAB + + + + + + + + + + + + + + + +
+ {form_fields} +
{message}
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + diff --git a/bundles/org.openhab.core.io.http.auth/pom.xml b/bundles/org.openhab.core.io.http.auth/pom.xml index 1e34b5674..d2a9e5bcf 100644 --- a/bundles/org.openhab.core.io.http.auth/pom.xml +++ b/bundles/org.openhab.core.io.http.auth/pom.xml @@ -26,5 +26,29 @@ ${project.version} - + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + + add-resource + + generate-resources + + + + pages + pages + + + + + + + + diff --git a/bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AuthorizePageServlet.java b/bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AuthorizePageServlet.java new file mode 100644 index 000000000..6277b0c13 --- /dev/null +++ b/bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AuthorizePageServlet.java @@ -0,0 +1,345 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.http.auth.internal; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URL; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.HttpHeaders; + +import org.apache.commons.httpclient.HttpStatus; +import org.apache.commons.io.IOUtils; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.auth.Authentication; +import org.openhab.core.auth.AuthenticationException; +import org.openhab.core.auth.AuthenticationProvider; +import org.openhab.core.auth.ManagedUser; +import org.openhab.core.auth.PendingToken; +import org.openhab.core.auth.Role; +import org.openhab.core.auth.User; +import org.openhab.core.auth.UserRegistry; +import org.openhab.core.auth.UsernamePasswordCredentials; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; +import org.osgi.service.http.NamespaceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A servlet serving the authorization page part of the OAuth2 authorization code flow. + * + * The page can register the first administrator account when there are no users yet in the {@link UserRegistry}, and + * authenticates the user otherwise. It also presents the scope that is about to be granted to the client, so the user + * can review what kind of access is being authorized. If successful, it redirects the client back to the URI which was + * specified and creates an authorization code stored for later in the user's profile. + * + * @author Yannick Schaus - initial contribution + * + */ +@NonNullByDefault +@Component(immediate = true) +public class AuthorizePageServlet extends HttpServlet { + + private static final long serialVersionUID = 5340598701104679843L; + + private final Logger logger = LoggerFactory.getLogger(AuthorizePageServlet.class); + + private HashMap csrfTokens = new HashMap<>(); + + private HttpService httpService; + private UserRegistry userRegistry; + private AuthenticationProvider authProvider; + @Nullable + private Instant lastAuthenticationFailure; + private int authenticationFailureCount = 0; + + private String pageTemplate; + + @Activate + public AuthorizePageServlet(BundleContext bundleContext, @Reference HttpService httpService, + @Reference UserRegistry userRegistry, @Reference AuthenticationProvider authProvider) { + this.httpService = httpService; + this.userRegistry = userRegistry; + this.authProvider = authProvider; + + pageTemplate = ""; + try { + URL resource = bundleContext.getBundle().getResource("pages/authorize.html"); + if (resource != null) { + try { + pageTemplate = IOUtils.toString(resource.openStream()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + httpService.registerServlet("/auth", this, null, null); + } + } catch (NamespaceException | ServletException e) { + logger.error("Error during authorization page registration: {}", e.getMessage()); + } + } + + @Override + protected void doGet(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) + throws ServletException, IOException { + if (req != null && resp != null) { + Map params = req.getParameterMap(); + + try { + String message = ""; + String scope = (params.containsKey("scope")) ? params.get("scope")[0] : ""; + String clientId = (params.containsKey("client_id")) ? params.get("client_id")[0] : ""; + + // Basic sanity check + if (scope.contains("<") || clientId.contains("<")) { + throw new IllegalArgumentException("invalid_request"); + } + + // TODO: i18n + if (isSignupMode()) { + message = "Create a first administrator account to continue."; + } else { + message = String.format("Sign in to grant %s access to %s:", scope, clientId); + } + resp.setContentType("text/html;charset=UTF-8"); + resp.getWriter().append(getPageBody(params, message)); + resp.getWriter().close(); + } catch (Exception e) { + resp.setContentType("text/plain;charset=UTF-8"); + resp.getWriter().append(e.getMessage()); + resp.getWriter().close(); + } + } + } + + @Override + protected void doPost(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) + throws ServletException, IOException { + if (req != null && resp != null) { + Map params = req.getParameterMap(); + try { + if (!params.containsKey(("username"))) { + throw new AuthenticationException("no username"); + } + if (!params.containsKey(("password"))) { + throw new AuthenticationException("no password"); + } + if (!params.containsKey("csrf_token") || !csrfTokens.containsKey(params.get("csrf_token")[0])) { + throw new AuthenticationException("CSRF check failed"); + } + if (!params.containsKey(("redirect_uri"))) { + throw new IllegalArgumentException("invalid_request"); + } + if (!params.containsKey(("response_type"))) { + throw new IllegalArgumentException("unsupported_response_type"); + } + if (!params.containsKey(("client_id"))) { + throw new IllegalArgumentException("unauthorized_client"); + } + if (!params.containsKey(("scope"))) { + throw new IllegalArgumentException("invalid_scope"); + } + + csrfTokens.remove(params.get("csrf_token")[0]); + + String baseRedirectUri = params.get("redirect_uri")[0]; + String responseType = params.get("response_type")[0]; + String clientId = params.get("redirect_uri")[0]; + String scope = params.get("scope")[0]; + + if (!("code".equals(responseType))) { + throw new AuthenticationException("unsupported_response_type"); + } + + if (!clientId.equals(baseRedirectUri)) { + throw new IllegalArgumentException("unauthorized_client"); + } + + String username = params.get("username")[0]; + String password = params.get("password")[0]; + + User user; + if (isSignupMode()) { + // Create a first administrator account with the supplied credentials + + // first verify the password confirmation and bail out if necessary + if (!params.containsKey("password_repeat") || !password.equals(params.get("password_repeat")[0])) { + resp.setContentType("text/html;charset=UTF-8"); + // TODO: i18n + resp.getWriter().append(getPageBody(params, "Passwords don't match, please try again.")); + resp.getWriter().close(); + return; + } + + user = userRegistry.register(username, password, Set.of(Role.ADMIN)); + logger.info("First user account created: {}", username); + } else { + // Enforce a dynamic cooldown period after a failed authentication attempt: the number of + // consecutive failures in seconds + if (lastAuthenticationFailure != null && lastAuthenticationFailure + .isAfter(Instant.now().minus(Duration.ofSeconds(authenticationFailureCount)))) { + throw new AuthenticationException("Too many consecutive login attempts"); + } + + // Authenticate the user with the supplied credentials + UsernamePasswordCredentials credentials = new UsernamePasswordCredentials(username, password); + Authentication auth = authProvider.authenticate(credentials); + logger.debug("Login successful: {}", auth.getUsername()); + lastAuthenticationFailure = null; + authenticationFailureCount = 0; + user = userRegistry.get(auth.getUsername()); + } + + String authorizationCode = UUID.randomUUID().toString().replace("-", ""); + + if (user instanceof ManagedUser) { + String codeChallenge = (params.containsKey("code_challenge")) ? params.get("code_challenge")[0] + : null; + String codeChallengeMethod = (params.containsKey("code_challenge_method")) + ? params.get("code_challenge_method")[0] + : null; + ManagedUser managedUser = (ManagedUser) user; + PendingToken pendingToken = new PendingToken(authorizationCode, clientId, baseRedirectUri, scope, + codeChallenge, codeChallengeMethod); + managedUser.setPendingToken(pendingToken); + userRegistry.update(managedUser); + } + + String state = params.containsKey("state") ? params.get("state")[0] : null; + resp.addHeader(HttpHeaders.LOCATION, getRedirectUri(baseRedirectUri, authorizationCode, null, state)); + resp.setStatus(HttpStatus.SC_MOVED_TEMPORARILY); + } catch (AuthenticationException e) { + lastAuthenticationFailure = Instant.now(); + authenticationFailureCount += 1; + resp.setContentType("text/html;charset=UTF-8"); + logger.warn("Authentication failed: {}", e.getMessage()); + resp.getWriter().append(getPageBody(params, "Please try again.")); // TODO: i18n + resp.getWriter().close(); + } catch (IllegalArgumentException e) { + @Nullable + String baseRedirectUri = params.containsKey("redirect_uri") ? params.get("redirect_uri")[0] : null; + @Nullable + String state = params.containsKey("state") ? params.get("state")[0] : null; + if (baseRedirectUri != null) { + resp.addHeader(HttpHeaders.LOCATION, getRedirectUri(baseRedirectUri, null, e.getMessage(), state)); + resp.setStatus(HttpStatus.SC_MOVED_TEMPORARILY); + } else { + resp.setContentType("text/plain;charset=UTF-8"); + resp.getWriter().append(e.getMessage()); + resp.getWriter().close(); + } + } + } + } + + private String getPageBody(Map params, String message) { + String responseBody = pageTemplate.replace("{form_fields}", getFormFields(params)); + String repeatPasswordFieldType = (isSignupMode()) ? "password" : "hidden"; + String buttonLabel = (isSignupMode()) ? "Create Account" : "Sign In"; // TODO: i18n + responseBody = responseBody.replace("{message}", message); + responseBody = responseBody.replace("{repeatPasswordFieldType}", repeatPasswordFieldType); + responseBody = responseBody.replace("{buttonLabel}", buttonLabel); + return responseBody; + } + + private String getFormFields(Map params) { + String hiddenFormFields = ""; + + if (!params.containsKey(("redirect_uri"))) { + throw new IllegalArgumentException("invalid_request"); + } + if (!params.containsKey(("response_type"))) { + throw new IllegalArgumentException("unsupported_response_type"); + } + if (!params.containsKey(("client_id"))) { + throw new IllegalArgumentException("unauthorized_client"); + } + if (!params.containsKey(("scope"))) { + throw new IllegalArgumentException("invalid_scope"); + } + String csrfToken = addCsrfToken(); + String redirectUri = params.get("redirect_uri")[0]; + String responseType = params.get("response_type")[0]; + String clientId = params.get("client_id")[0]; + String scope = params.get("scope")[0]; + String state = (params.containsKey("state")) ? params.get("state")[0] : null; + String codeChallenge = (params.containsKey("code_challenge")) ? params.get("code_challenge")[0] : null; + String codeChallengeMethod = (params.containsKey("code_challenge_method")) + ? params.get("code_challenge_method")[0] + : null; + hiddenFormFields += ""; + hiddenFormFields += ""; + hiddenFormFields += ""; + hiddenFormFields += ""; + hiddenFormFields += ""; + if (state != null) { + hiddenFormFields += ""; + } + if (codeChallenge != null && codeChallengeMethod != null) { + hiddenFormFields += ""; + hiddenFormFields += ""; + } + + return hiddenFormFields; + } + + private String getRedirectUri(String baseRedirectUri, @Nullable String authorizationCode, @Nullable String error, + @Nullable String state) { + String redirectUri = baseRedirectUri; + + if (authorizationCode != null) { + redirectUri += "?code=" + authorizationCode; + } else if (error != null) { + redirectUri += "?error=" + error; + } + + if (state != null) { + redirectUri += "&state=" + state; + } + + return redirectUri; + } + + private String addCsrfToken() { + String csrfToken = UUID.randomUUID().toString().replace("-", ""); + csrfTokens.put(csrfToken, Instant.now()); + // remove old tokens (created earlier than 10 minutes ago) - this gives users a 10-minute window to sign in + csrfTokens.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now().minus(Duration.ofMinutes(10)))); + return csrfToken; + } + + private boolean isSignupMode() { + return userRegistry.getAll().isEmpty(); + } + + @Deactivate + public void deactivate() { + httpService.unregister("/auth"); + } +} diff --git a/bundles/org.openhab.core.io.rest.auth/bnd.bnd b/bundles/org.openhab.core.io.rest.auth/bnd.bnd new file mode 100644 index 000000000..e8153ce34 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.auth/bnd.bnd @@ -0,0 +1,2 @@ +Bundle-SymbolicName: ${project.artifactId} +Bundle-Activator: org.openhab.core.io.rest.auth.internal.Activator diff --git a/bundles/org.openhab.core.io.rest.auth/pom.xml b/bundles/org.openhab.core.io.rest.auth/pom.xml index 1b84b07e6..b19f077d5 100644 --- a/bundles/org.openhab.core.io.rest.auth/pom.xml +++ b/bundles/org.openhab.core.io.rest.auth/pom.xml @@ -14,4 +14,22 @@ openHAB Core :: Bundles :: Authentication Support for the REST Interface + + + org.openhab.core.bundles + org.openhab.core.auth.jaas + ${project.version} + + + org.openhab.core.bundles + org.openhab.core.io.rest + ${project.version} + + + org.openhab.core.bundles + org.openhab.core.config.core + ${project.version} + + + diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/Activator.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/Activator.java new file mode 100644 index 000000000..1410485a8 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/Activator.java @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.auth.internal; + +import org.osgi.framework.BundleActivator; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceRegistration; + +/** + * Registers dynamic features contained in this bundle. + * + * @author Yannick Schaus - initial contribution + */ +public class Activator implements BundleActivator { + private static Activator instance; + + private ServiceRegistration rolesAllowedDynamicFeatureRegistration; + + public static Activator getInstance() { + return instance; + } + + @Override + public void start(BundleContext context) throws Exception { + rolesAllowedDynamicFeatureRegistration = context.registerService(RolesAllowedDynamicFeatureImpl.class.getName(), + new RolesAllowedDynamicFeatureImpl(), null); + } + + @Override + public void stop(BundleContext context) throws Exception { + instance = null; + if (rolesAllowedDynamicFeatureRegistration != null) { + rolesAllowedDynamicFeatureRegistration.unregister(); + } + } +} diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthFilter.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthFilter.java new file mode 100644 index 000000000..13a63367b --- /dev/null +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthFilter.java @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.auth.internal; + +import java.io.IOException; + +import javax.annotation.Priority; +import javax.security.sasl.AuthenticationException; +import javax.ws.rs.NotAuthorizedException; +import javax.ws.rs.Priorities; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.container.PreMatching; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.SecurityContext; +import javax.ws.rs.ext.Provider; + +import org.openhab.core.auth.Authentication; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * This filter is responsible for parsing a token provided with a request, and hydrating a {@link SecurityContext} from + * the claims contained in the token. + * + * @author Yannick Schaus - initial contribution + */ +@PreMatching +@Priority(Priorities.AUTHENTICATION) +@Provider +@Component(immediate = true, service = AuthFilter.class) +public class AuthFilter implements ContainerRequestFilter { + private static final String COOKIE_AUTH_HEADER = "X-OPENHAB-AUTH-HEADER"; + private static final String ALT_AUTH_HEADER = "X-OPENHAB-TOKEN"; + + @Reference + private JwtHelper jwtHelper; + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + try { + String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + + if (authHeader != null) { + String[] authParts = authHeader.split(" "); + if (authParts.length == 2) { + if ("Bearer".equals(authParts[0])) { + Authentication auth = jwtHelper.verifyAndParseJwtAccessToken(authParts[1]); + requestContext.setSecurityContext(new JwtSecurityContext(auth)); + return; + } + } + } + + if (requestContext.getCookies().containsKey(COOKIE_AUTH_HEADER)) { + String altTokenHeader = requestContext.getHeaderString(ALT_AUTH_HEADER); + if (altTokenHeader != null) { + Authentication auth = jwtHelper.verifyAndParseJwtAccessToken(altTokenHeader); + requestContext.setSecurityContext(new JwtSecurityContext(auth)); + return; + } + } + + // support the api_key query parameter of the Swagger UI + if (requestContext.getUriInfo().getRequestUri().toString().contains("api_key=")) { + String apiKey = requestContext.getUriInfo().getQueryParameters(true).getFirst("api_key"); + if (apiKey != null) { + Authentication auth = jwtHelper.verifyAndParseJwtAccessToken(apiKey); + requestContext.setSecurityContext(new JwtSecurityContext(auth)); + return; + } + } + } catch (AuthenticationException e) { + throw new NotAuthorizedException("Invalid token"); + } + } +} diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtHelper.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtHelper.java new file mode 100644 index 000000000..6987f5005 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtHelper.java @@ -0,0 +1,159 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.auth.internal; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import javax.security.sasl.AuthenticationException; + +import org.apache.commons.io.IOUtils; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.jose4j.jwa.AlgorithmConstraints.ConstraintType; +import org.jose4j.jwk.JsonWebKey; +import org.jose4j.jwk.JsonWebKey.OutputControlLevel; +import org.jose4j.jwk.RsaJsonWebKey; +import org.jose4j.jwk.RsaJwkGenerator; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.consumer.JwtConsumer; +import org.jose4j.jwt.consumer.JwtConsumerBuilder; +import org.jose4j.lang.JoseException; +import org.openhab.core.auth.Authentication; +import org.openhab.core.auth.User; +import org.openhab.core.config.core.ConfigConstants; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class helps with JWT tokens' building, signing, verifying and parsing. + * + * @author Yannick Schaus - initial contribution + */ +@NonNullByDefault +@Component(immediate = true, service = JwtHelper.class) +public class JwtHelper { + private final Logger logger = LoggerFactory.getLogger(JwtHelper.class); + + private static final String KEY_FILE_PATH = ConfigConstants.getUserDataFolder() + File.separator + "secrets" + + File.separator + "rsa_json_web_key.json"; + + private static final String ISSUER_NAME = "openhab"; + private static final String AUDIENCE = "openhab"; + + private RsaJsonWebKey jwtWebKey; + + public JwtHelper() { + try { + jwtWebKey = loadOrGenerateKey(); + } catch (Exception e) { + logger.error("Error while initializing the JWT helper", e); + throw new RuntimeException(e.getMessage()); + } + } + + private RsaJsonWebKey generateNewKey() throws JoseException, FileNotFoundException, IOException { + RsaJsonWebKey newKey = RsaJwkGenerator.generateJwk(2048); + + File file = new File(KEY_FILE_PATH); + file.getParentFile().mkdirs(); + + String keyJson = newKey.toJson(OutputControlLevel.INCLUDE_PRIVATE); + + IOUtils.write(keyJson, new FileOutputStream(file)); + return newKey; + } + + private RsaJsonWebKey loadOrGenerateKey() throws FileNotFoundException, JoseException, IOException { + try { + List lines = IOUtils.readLines(new FileInputStream(KEY_FILE_PATH)); + return (RsaJsonWebKey) JsonWebKey.Factory.newJwk(lines.get(0)); + } catch (IOException | JoseException e) { + RsaJsonWebKey key = generateNewKey(); + logger.debug("Created JWT signature key in {}", KEY_FILE_PATH); + return key; + } + } + + /** + * Builds a new access token. + * + * @param user the user (subject) to build the token, it will also add the roles as claims + * @param clientId the client ID the token is for + * @param scope the scope the token is valid for + * @param tokenLifetime the lifetime of the token in minutes before it expires + * + * @return a base64-encoded signed JWT token to be passed as a bearer token in API requests + */ + public String getJwtAccessToken(User user, String clientId, String scope, int tokenLifetime) { + try { + JwtClaims jwtClaims = new JwtClaims(); + jwtClaims.setIssuer(ISSUER_NAME); + jwtClaims.setAudience(AUDIENCE); + jwtClaims.setExpirationTimeMinutesInTheFuture(tokenLifetime); + jwtClaims.setGeneratedJwtId(); + jwtClaims.setIssuedAtToNow(); + jwtClaims.setNotBeforeMinutesInThePast(2); + jwtClaims.setSubject(user.getName()); + jwtClaims.setClaim("client_id", clientId); + jwtClaims.setClaim("scope", scope); + jwtClaims.setStringListClaim("role", + new ArrayList<>((user.getRoles() != null) ? user.getRoles() : Collections.emptySet())); + + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(jwtClaims.toJson()); + jws.setKey(jwtWebKey.getPrivateKey()); + jws.setKeyIdHeaderValue(jwtWebKey.getKeyId()); + jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256); + String jwt = jws.getCompactSerialization(); + + return jwt; + } catch (Exception e) { + logger.error("Error while writing JWT token", e); + throw new RuntimeException(e.getMessage()); + } + } + + /** + * Performs verifications on a JWT token, then parses it into a {@link AuthenticationException} instance + * + * @param jwt the base64-encoded JWT token from the request + * @return the {@link Authentication} derived from the information in the token + * @throws AuthenticationException + */ + public Authentication verifyAndParseJwtAccessToken(String jwt) throws AuthenticationException { + JwtConsumer jwtConsumer = new JwtConsumerBuilder().setRequireExpirationTime().setAllowedClockSkewInSeconds(30) + .setRequireSubject().setExpectedIssuer(ISSUER_NAME).setExpectedAudience(AUDIENCE) + .setVerificationKey(jwtWebKey.getKey()) + .setJwsAlgorithmConstraints(ConstraintType.WHITELIST, AlgorithmIdentifiers.RSA_USING_SHA256).build(); + + try { + JwtClaims jwtClaims = jwtConsumer.processToClaims(jwt); + String username = jwtClaims.getSubject(); + List roles = jwtClaims.getStringListClaimValue("role"); + Authentication auth = new Authentication(username, roles.toArray(new String[roles.size()])); + return auth; + } catch (Exception e) { + logger.error("Error while processing JWT token", e); + throw new AuthenticationException(e.getMessage()); + } + } +} diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtSecurityContext.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtSecurityContext.java new file mode 100644 index 000000000..3d272f324 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtSecurityContext.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.auth.internal; + +import java.security.Principal; + +import javax.ws.rs.core.SecurityContext; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.auth.Authentication; +import org.openhab.core.auth.GenericUser; + +/** + * This {@link SecurityContext} contains information about a user, roles and authorizations granted to a client as + * parsed from the contents of a JSON Web Token + * + * @author Yannick Schaus - initial contribution + */ +@NonNullByDefault +public class JwtSecurityContext implements SecurityContext { + + Authentication authentication; + + public JwtSecurityContext(Authentication authentication) { + this.authentication = authentication; + } + + @Override + public Principal getUserPrincipal() { + return new GenericUser(authentication.getUsername()); + } + + @Override + public boolean isUserInRole(@Nullable String role) { + return authentication.getRoles().contains(role); + } + + @Override + public boolean isSecure() { + return true; + } + + @Override + public String getAuthenticationScheme() { + return "JWT"; + } +} diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/RolesAllowedDynamicFeatureImpl.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/RolesAllowedDynamicFeatureImpl.java new file mode 100644 index 000000000..99a88736e --- /dev/null +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/RolesAllowedDynamicFeatureImpl.java @@ -0,0 +1,135 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.auth.internal; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.util.Arrays; +import java.util.Optional; + +import javax.annotation.Priority; +import javax.annotation.security.DenyAll; +import javax.annotation.security.PermitAll; +import javax.annotation.security.RolesAllowed; +import javax.ws.rs.ForbiddenException; +import javax.ws.rs.NotAuthorizedException; +import javax.ws.rs.Priorities; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.container.DynamicFeature; +import javax.ws.rs.container.ResourceInfo; +import javax.ws.rs.core.FeatureContext; +import javax.ws.rs.ext.Provider; + +import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature; +import org.glassfish.jersey.server.model.AnnotatedMethod; +import org.openhab.core.auth.Role; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@link DynamicFeature} supporting the {@code javax.annotation.security.RolesAllowed}, + * {@code javax.annotation.security.PermitAll} and {@code javax.annotation.security.DenyAll} + * on resource methods and sub-resource methods. + * + * Ported from {@link RolesAllowedDynamicFeature} with modifications. + * + * @author Paul Sandoz - initial contribution + * @author Martin Matula - initial contribution + * @author Yannick Schaus - port to openHAB with modifications + */ +@Provider +public class RolesAllowedDynamicFeatureImpl implements DynamicFeature { + private final Logger logger = LoggerFactory.getLogger(RolesAllowedDynamicFeatureImpl.class); + + @Override + public void configure(ResourceInfo resourceInfo, FeatureContext configuration) { + final AnnotatedMethod am = new AnnotatedMethod(resourceInfo.getResourceMethod()); + try { + // DenyAll on the method take precedence over RolesAllowed and PermitAll + if (am.isAnnotationPresent(DenyAll.class)) { + configuration.register(new RolesAllowedRequestFilter()); + return; + } + + // RolesAllowed on the method takes precedence over PermitAll + Optional ra = Arrays.stream(am.getAnnotations()) + .filter(a -> a.annotationType().getName().equals(RolesAllowed.class.getName())).findFirst(); + if (ra.isPresent()) { + configuration.register(new RolesAllowedRequestFilter(((RolesAllowed) ra.get()).value())); + return; + } + + // PermitAll takes precedence over RolesAllowed on the class + if (am.isAnnotationPresent(PermitAll.class)) { + // Do nothing. + return; + } + + // DenyAll can't be attached to classes + + // RolesAllowed on the class takes precedence over PermitAll + ra = Arrays.stream(resourceInfo.getResourceClass().getAnnotations()) + .filter(a -> a.annotationType().getName().equals(RolesAllowed.class.getName())).findFirst(); + if (ra.isPresent()) { + configuration.register(new RolesAllowedRequestFilter(((RolesAllowed) ra.get()).value())); + } + } catch (Exception e) { + logger.error("Error while configuring the roles", e); + } + } + + @Priority(Priorities.AUTHORIZATION) // authorization filter - should go after any authentication filters + private static class RolesAllowedRequestFilter implements ContainerRequestFilter { + + private final boolean denyAll; + private final String[] rolesAllowed; + + RolesAllowedRequestFilter() { + this.denyAll = true; + this.rolesAllowed = null; + } + + RolesAllowedRequestFilter(final String[] rolesAllowed) { + this.denyAll = false; + this.rolesAllowed = (rolesAllowed != null) ? rolesAllowed : new String[] {}; + } + + @Override + public void filter(final ContainerRequestContext requestContext) throws IOException { + if (!denyAll) { + // TODO: temporarily, until the complete authorization story is implemented, we consider operations + // allowed for user roles to be permitted unrestricted (even to unauthenticated users) + if (Arrays.asList(rolesAllowed).contains(Role.USER)) { + return; + } + + if (rolesAllowed.length > 0 && !isAuthenticated(requestContext)) { + throw new NotAuthorizedException("User is not authenticated"); + } + + for (final String role : rolesAllowed) { + if (requestContext.getSecurityContext().isUserInRole(role)) { + return; + } + } + } + + throw new ForbiddenException("User is authenticated but doesn't have access to this resource"); + } + + private static boolean isAuthenticated(final ContainerRequestContext requestContext) { + return requestContext.getSecurityContext().getUserPrincipal() != null; + } + } +} diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenEndpointException.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenEndpointException.java new file mode 100644 index 000000000..088cd8666 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenEndpointException.java @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.auth.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.auth.AuthenticationException; + +/** + * An exception when the token endpoint encounters an error and must return an error response, according to RFC 6749 + * Section 5.2. + * + * {@linkplain https://tools.ietf.org/html/rfc6749#section-5.2} + * + * @author Yannick Schaus - initial contribution + */ +@NonNullByDefault +public class TokenEndpointException extends AuthenticationException { + private static final long serialVersionUID = 610324537843397832L; + + /** + * Represents the error types which are supported in token issuing error responses. + * + * @author Yannick Schaus - initial contribution + */ + public enum ErrorType { + INVALID_REQUEST("invalid_request"), + INVALID_GRANT("invalid_grant"), + INVALID_CLIENT("invalid_client"), + INVALID_SCOPE("invalid_scope"), + UNAUTHORIZED_CLIENT("unauthorized_client"), + UNSUPPORTED_GRANT_TYPE("unsupported_grant_type"); + + private String error; + + ErrorType(String error) { + this.error = error; + } + + public String getError() { + return error; + } + } + + /** + * Constructs a {@link TokenEndpointException} for the specified error type. + * + * @param errorType the error type + */ + public TokenEndpointException(ErrorType errorType) { + super(errorType.getError()); + } + + /** + * Gets a {@link TokenResponseErrorDTO} representing the exception + * + * @return the error response object + */ + public TokenResponseErrorDTO getErrorDTO() { + return new TokenResponseErrorDTO(getMessage()); + } +} diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResource.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResource.java new file mode 100644 index 000000000..3a6973799 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResource.java @@ -0,0 +1,345 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.auth.internal; + +import java.net.URI; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Date; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; + +import javax.ws.rs.Consumes; +import javax.ws.rs.CookieParam; +import javax.ws.rs.FormParam; +import javax.ws.rs.GET; +import javax.ws.rs.NotAuthorizedException; +import javax.ws.rs.NotFoundException; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Cookie; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.NewCookie; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.ResponseBuilder; +import javax.ws.rs.core.Response.Status; +import javax.ws.rs.core.SecurityContext; +import javax.ws.rs.core.UriInfo; + +import org.jose4j.base64url.Base64Url; +import org.openhab.core.auth.ManagedUser; +import org.openhab.core.auth.PendingToken; +import org.openhab.core.auth.User; +import org.openhab.core.auth.UserRegistry; +import org.openhab.core.auth.UserSession; +import org.openhab.core.io.rest.RESTResource; +import org.openhab.core.io.rest.Stream2JSONInputStream; +import org.openhab.core.io.rest.auth.internal.TokenEndpointException.ErrorType; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; + +/** + * This class is used to issue JWT tokens to clients. + * + * @author Yannick Schaus - Initial contribution + */ +@Path(TokenResource.PATH_AUTH) +@Api(value = TokenResource.PATH_AUTH) +@Component(service = { RESTResource.class, TokenResource.class }) +public class TokenResource implements RESTResource { + private final Logger logger = LoggerFactory.getLogger(TokenResource.class); + + /** The URI path to this resource */ + public static final String PATH_AUTH = "auth"; + + /** The name of the HTTP-only cookie holding the session ID */ + public static final String SESSIONID_COOKIE_NAME = "X-OPENHAB-SESSIONID"; + + /** The default lifetime of tokens in minutes before they expire */ + public static final int TOKEN_LIFETIME = 60; + + @Context + private UriInfo uriInfo; + + private UserRegistry userRegistry; + private JwtHelper jwtHelper; + + @Activate + public TokenResource(final @Reference UserRegistry userRegistry, final @Reference JwtHelper jwtHelper) { + this.userRegistry = userRegistry; + this.jwtHelper = jwtHelper; + } + + @POST + @Path("/token") + @Produces({ MediaType.APPLICATION_JSON }) + @Consumes({ MediaType.APPLICATION_FORM_URLENCODED }) + @ApiOperation(value = "Get access and refresh tokens.") + @ApiResponses(value = { @ApiResponse(code = 200, message = "OK") }) + public Response getToken(@FormParam("grant_type") String grantType, @FormParam("code") String code, + @FormParam("redirect_uri") String redirectUri, @FormParam("client_id") String clientId, + @FormParam("refresh_token") String refreshToken, @FormParam("code_verifier") String codeVerifier, + @QueryParam("useCookie") boolean useCookie, @CookieParam(SESSIONID_COOKIE_NAME) Cookie sessionCookie) { + try { + switch (grantType) { + case "authorization_code": + return processAuthorizationCodeGrant(code, redirectUri, clientId, codeVerifier, useCookie); + + case "refresh_token": + return processRefreshTokenGrant(clientId, refreshToken, sessionCookie); + + default: + throw new TokenEndpointException(ErrorType.UNSUPPORTED_GRANT_TYPE); + } + } catch (TokenEndpointException e) { + logger.warn("Token issuing failed: {}", e.getMessage()); + return Response.status(Status.BAD_REQUEST).entity(e.getErrorDTO()).build(); + } catch (Exception e) { + logger.error("Error while authenticating", e); + return Response.status(Status.BAD_REQUEST).build(); + } + } + + @GET + @Path("/sessions") + @ApiOperation(value = "List the sessions associated to the authenticated user.") + @ApiResponses(value = { @ApiResponse(code = 200, message = "OK", response = UserSessionDTO.class) }) + @Produces({ MediaType.APPLICATION_JSON }) + public Response getSessions(@Context SecurityContext securityContext) { + if (securityContext.getUserPrincipal() == null) { + throw new NotAuthorizedException("User not authenticated"); + } + + ManagedUser user = (ManagedUser) userRegistry.get(securityContext.getUserPrincipal().getName()); + if (user == null) { + throw new NotFoundException("User not found"); + } + + Stream sessions = user.getSessions().stream().map(this::toUserSessionDTO); + return Response.ok(new Stream2JSONInputStream(sessions)).build(); + } + + @POST + @Path("/logout") + @Consumes({ MediaType.APPLICATION_FORM_URLENCODED }) + @ApiOperation(value = "Delete the session associated with a refresh token.") + @ApiResponses(value = { @ApiResponse(code = 200, message = "OK") }) + public Response deleteSession(@FormParam("refresh_token") String refreshToken, @FormParam("id") String id, + @Context SecurityContext securityContext) { + if (securityContext.getUserPrincipal() == null) { + throw new NotAuthorizedException("User not authenticated"); + } + + ManagedUser user = (ManagedUser) userRegistry.get(securityContext.getUserPrincipal().getName()); + if (user == null) { + throw new NotFoundException("User not found"); + } + + Optional session; + if (refreshToken != null) { + session = user.getSessions().stream().filter(s -> s.getRefreshToken().equals(refreshToken)).findAny(); + } else if (id != null) { + session = user.getSessions().stream().filter(s -> s.getSessionId().startsWith(id + "-")).findAny(); + } else { + throw new IllegalArgumentException("no refresh_token or id specified"); + } + if (session.isEmpty()) { + throw new NotFoundException("Session not found"); + } + + ResponseBuilder response = Response.ok(); + + if (session.get().hasSessionCookie()) { + URI domainUri; + try { + domainUri = new URI(session.get().getRedirectUri()); + NewCookie newCookie = new NewCookie(SESSIONID_COOKIE_NAME, "", "/", domainUri.getHost(), null, 0, false, + true); + response.cookie(newCookie); + } catch (Exception e) { + } + } + + user.getSessions().remove(session.get()); + userRegistry.update(user); + + return response.build(); + } + + private UserSessionDTO toUserSessionDTO(UserSession session) { + // we only divulge the prefix of the session ID to the client (otherwise an XSS attacker may find the + // session ID for a stolen refresh token easily by using the sessions endpoint). + return new UserSessionDTO(session.getSessionId().split("-")[0], session.getCreatedTime(), + session.getLastRefreshTime(), session.getClientId(), session.getScope()); + } + + private Response processAuthorizationCodeGrant(String code, String redirectUri, String clientId, + String codeVerifier, boolean useCookie) throws TokenEndpointException, NoSuchAlgorithmException { + // find an user with the authorization code pending + Optional user = userRegistry.getAll().stream().filter(u -> ((ManagedUser) u).getPendingToken() != null + && ((ManagedUser) u).getPendingToken().getAuthorizationCode().equals(code)).findAny(); + + if (!user.isPresent()) { + logger.warn("Couldn't find a user with the provided authentication code pending"); + throw new TokenEndpointException(ErrorType.INVALID_GRANT); + } + + ManagedUser managedUser = (ManagedUser) user.get(); + PendingToken pendingToken = managedUser.getPendingToken(); + if (pendingToken == null) { + throw new TokenEndpointException(ErrorType.INVALID_GRANT); + } + if (!pendingToken.getClientId().equals(clientId)) { + logger.warn("client_id '{}' doesn't match pending token information '{}'", clientId, + pendingToken.getClientId()); + throw new TokenEndpointException(ErrorType.INVALID_GRANT); + } + if (!pendingToken.getRedirectUri().equals(redirectUri)) { + logger.warn("redirect_uri '{}' doesn't match pending token information '{}'", redirectUri, + pendingToken.getRedirectUri()); + throw new TokenEndpointException(ErrorType.INVALID_GRANT); + } + + // create a new session ID and refresh token + String sessionId = UUID.randomUUID().toString(); + String newRefreshToken = UUID.randomUUID().toString().replace("-", ""); + String scope = pendingToken.getScope(); + + // if there is PKCE information in the pending token, check that first + String codeChallengeMethod = pendingToken.getCodeChallengeMethod(); + if (codeChallengeMethod != null) { + String codeChallenge = pendingToken.getCodeChallenge(); + if (codeChallenge == null || codeVerifier == null) { + logger.warn("the PKCE code challenge or code verifier information is missing"); + throw new TokenEndpointException(ErrorType.INVALID_GRANT); + } + switch (codeChallengeMethod) { + case "plain": + if (!codeVerifier.equals(codeChallenge)) { + logger.warn("PKCE verification failed"); + throw new TokenEndpointException(ErrorType.INVALID_GRANT); + } + break; + case "S256": + MessageDigest sha256Digest = MessageDigest.getInstance("SHA-256"); + String computedCodeChallenge = Base64Url.encode(sha256Digest.digest(codeVerifier.getBytes())); + if (!computedCodeChallenge.equals(codeChallenge)) { + logger.warn("PKCE verification failed"); + throw new TokenEndpointException(ErrorType.INVALID_GRANT); + } + break; + default: + logger.warn("PKCE transformation algorithm '{}' not supported", codeChallengeMethod); + throw new TokenEndpointException(ErrorType.INVALID_REQUEST); + } + } + + // create an access token + String accessToken = jwtHelper.getJwtAccessToken(managedUser, clientId, scope, TOKEN_LIFETIME); + + UserSession newSession = new UserSession(sessionId, newRefreshToken, clientId, redirectUri, scope); + + ResponseBuilder response = Response.ok( + new TokenResponseDTO(accessToken, "bearer", TOKEN_LIFETIME * 60, newRefreshToken, scope, managedUser)); + + // if the client has requested an http-only cookie for the session, set it + if (useCookie) { + try { + // this feature is only available for root redirect URIs: the targeted client is the main + // UI; even though the cookie will be set for the entire domain (i.e. no path) so that + // other servlets can make use of it + URI domainUri = new URI(redirectUri); + if (!("".equals(domainUri.getPath()) || "/".equals(domainUri.getPath()))) { + throw new IllegalArgumentException( + "Will not honor the request to set a session cookie for this client, because it's only allowed for root redirect URIs"); + } + NewCookie newCookie = new NewCookie(SESSIONID_COOKIE_NAME, sessionId, "/", domainUri.getHost(), null, + 2147483647, false, true); + response.cookie(newCookie); + + // also mark the session as supported by a cookie + newSession.setSessionCookie(true); + } catch (Exception e) { + logger.warn("Error while setting a session cookie: {}", e.getMessage()); + throw new TokenEndpointException(ErrorType.UNAUTHORIZED_CLIENT); + } + } + + // add the new session to the user profile and clear the pending token information + managedUser.getSessions().add(newSession); + managedUser.setPendingToken(null); + userRegistry.update(managedUser); + + return response.build(); + } + + private Response processRefreshTokenGrant(String clientId, String refreshToken, Cookie sessionCookie) + throws TokenEndpointException { + if (refreshToken == null) { + throw new TokenEndpointException(ErrorType.INVALID_REQUEST); + } + + // find an user associated with the provided refresh token + Optional refreshTokenUser = userRegistry.getAll().stream().filter( + u -> ((ManagedUser) u).getSessions().stream().anyMatch(s -> refreshToken.equals(s.getRefreshToken()))) + .findAny(); + + if (!refreshTokenUser.isPresent()) { + logger.warn("Couldn't find a user with a session matching the provided refresh_token"); + throw new TokenEndpointException(ErrorType.INVALID_GRANT); + } + + // get the session from the refresh token + ManagedUser refreshTokenManagedUser = (ManagedUser) refreshTokenUser.get(); + UserSession session = refreshTokenManagedUser.getSessions().stream() + .filter(s -> s.getRefreshToken().equals(refreshToken)).findAny().get(); + + // if the cookie flag is present on the session, verify that the cookie is present and corresponds + // to this session + if (session.hasSessionCookie()) { + if (sessionCookie == null || !sessionCookie.getValue().equals(session.getSessionId())) { + logger.warn("Not refreshing token for session {} of user {}, missing or invalid session cookie", + session.getSessionId(), refreshTokenManagedUser.getName()); + throw new TokenEndpointException(ErrorType.INVALID_GRANT); + } + } + + // issue a new access token + String refreshedAccessToken = jwtHelper.getJwtAccessToken(refreshTokenManagedUser, clientId, session.getScope(), + TOKEN_LIFETIME); + + logger.debug("Refreshing session {} of user {}", session.getSessionId(), refreshTokenManagedUser.getName()); + + ResponseBuilder refreshResponse = Response.ok(new TokenResponseDTO(refreshedAccessToken, "bearer", + TOKEN_LIFETIME * 60, refreshToken, session.getScope(), refreshTokenManagedUser)); + + // update the last refresh time of the session in the user's profile + session.setLastRefreshTime(new Date()); + userRegistry.update(refreshTokenManagedUser); + + return refreshResponse.build(); + } + +} diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResponseDTO.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResponseDTO.java new file mode 100644 index 000000000..82d4fd718 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResponseDTO.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.auth.internal; + +import org.openhab.core.auth.User; + +/** + * A DTO object for a successful token endpoint response, as per RFC 6749, Section 5.1. + * + * {@linkplain https://tools.ietf.org/html/rfc6749#section-5.1} + * + * @author Yannick Schaus - initial contribution + */ +public class TokenResponseDTO { + public String access_token; + public String token_type; + public Integer expires_in; + public String refresh_token; + public String scope; + public UserDTO user; + + /** + * Builds a successful response containing token information. + * + * @param access_token the access token + * @param token_type the type of the token, normally "bearer" + * @param expires_in the expiration time of the access token in seconds + * @param refresh_token the refresh token which can be used to get additional tokens + * @param scope the request scope + * @param user the user object, an additional parameter not part of the specification + */ + public TokenResponseDTO(String access_token, String token_type, Integer expires_in, String refresh_token, + String scope, User user) { + super(); + this.access_token = access_token; + this.token_type = token_type; + this.expires_in = expires_in; + this.refresh_token = refresh_token; + this.scope = scope; + this.user = new UserDTO(user); + } +} diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResponseErrorDTO.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResponseErrorDTO.java new file mode 100644 index 000000000..263f607a4 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResponseErrorDTO.java @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.auth.internal; + +/** + * A DTO object for an unsuccessful token endpoint response, as per RFC 6749, Section 5.2. + * + * {@linkplain https://tools.ietf.org/html/rfc6749#section-5.2} + * + * @author Yannick Schaus - initial contribution + */ +public class TokenResponseErrorDTO { + public String error; + public String error_description; + public String error_uri; + + /** + * Builds a token endpoint response for a specific error + * + * @param the error + */ + public TokenResponseErrorDTO(String error) { + this.error = error; + } +} diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserDTO.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserDTO.java new file mode 100644 index 000000000..b713e6c11 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserDTO.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.auth.internal; + +import java.util.Collection; + +import org.openhab.core.auth.User; + +/** + * A DTO representing a {@link User}. + * + * @author Yannick Schaus - initial contribution + */ +public class UserDTO { + String name; + Collection roles; + + public UserDTO(User user) { + super(); + this.name = user.getName(); + this.roles = user.getRoles(); + } +} diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserSessionDTO.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserSessionDTO.java new file mode 100644 index 000000000..07888c6ba --- /dev/null +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserSessionDTO.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.auth.internal; + +import java.util.Date; + +/** + * A DTO representing a user session, without the sensible information. + * + * @author Yannick Schaus - initial contribution + */ +public class UserSessionDTO { + String sessionId; + Date createdTime; + Date lastRefreshTime; + String clientId; + String scope; + + public UserSessionDTO(String sessionId, Date createdTime, Date lastRefreshTime, String clientId, String scope) { + super(); + this.sessionId = sessionId; + this.createdTime = createdTime; + this.lastRefreshTime = lastRefreshTime; + this.clientId = clientId; + this.scope = scope; + } +} diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/link/ItemChannelLinkResource.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/link/ItemChannelLinkResource.java index 714e2c7c7..74d15fa1c 100644 --- a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/link/ItemChannelLinkResource.java +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/link/ItemChannelLinkResource.java @@ -62,7 +62,6 @@ import io.swagger.annotations.ApiResponses; * @author Yannick Schaus - Added filters to getAll */ @Path(ItemChannelLinkResource.PATH_LINKS) -@RolesAllowed({ Role.ADMIN }) @Api(value = ItemChannelLinkResource.PATH_LINKS) @Component(service = { RESTResource.class, ItemChannelLinkResource.class }) public class ItemChannelLinkResource implements RESTResource { @@ -115,6 +114,7 @@ public class ItemChannelLinkResource implements RESTResource { } @PUT + @RolesAllowed({ Role.ADMIN }) @Path("/{itemName}/{channelUID}") @ApiOperation(value = "Links item to a channel.") @ApiResponses(value = { @ApiResponse(code = 200, message = "OK"), @@ -147,6 +147,7 @@ public class ItemChannelLinkResource implements RESTResource { } @DELETE + @RolesAllowed({ Role.ADMIN }) @Path("/{itemName}/{channelUID}") @ApiOperation(value = "Unlinks item from a channel.") @ApiResponses(value = { @ApiResponse(code = 200, message = "OK"), diff --git a/bundles/org.openhab.core.io.rest.ui/src/main/java/org/openhab/core/io/rest/ui/internal/UIResource.java b/bundles/org.openhab.core.io.rest.ui/src/main/java/org/openhab/core/io/rest/ui/internal/UIResource.java index cd5737858..4cd7d9d43 100644 --- a/bundles/org.openhab.core.io.rest.ui/src/main/java/org/openhab/core/io/rest/ui/internal/UIResource.java +++ b/bundles/org.openhab.core.io.rest.ui/src/main/java/org/openhab/core/io/rest/ui/internal/UIResource.java @@ -15,6 +15,7 @@ package org.openhab.core.io.rest.ui.internal; import java.security.InvalidParameterException; import java.util.stream.Stream; +import javax.annotation.security.RolesAllowed; import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.POST; @@ -28,6 +29,7 @@ import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.UriInfo; +import org.openhab.core.auth.Role; import org.openhab.core.io.rest.RESTResource; import org.openhab.core.io.rest.Stream2JSONInputStream; import org.openhab.core.io.rest.ui.TileDTO; @@ -107,6 +109,7 @@ public class UIResource implements RESTResource { } @POST + @RolesAllowed({ Role.ADMIN }) @Path("/components/{namespace}") @Produces({ MediaType.APPLICATION_JSON }) @ApiOperation(value = "Add an UI component in the specified namespace.") @@ -119,6 +122,7 @@ public class UIResource implements RESTResource { } @PUT + @RolesAllowed({ Role.ADMIN }) @Path("/components/{namespace}/{componentUID}") @Produces({ MediaType.APPLICATION_JSON }) @ApiOperation(value = "Update a specific UI component in the specified namespace.") @@ -141,6 +145,7 @@ public class UIResource implements RESTResource { } @DELETE + @RolesAllowed({ Role.ADMIN }) @Path("/components/{namespace}/{componentUID}") @Produces({ MediaType.APPLICATION_JSON }) @ApiOperation(value = "Remove a specific UI component in the specified namespace.") diff --git a/bundles/org.openhab.core.karaf/pom.xml b/bundles/org.openhab.core.karaf/pom.xml index 512cdd5db..f9da9b5fa 100644 --- a/bundles/org.openhab.core.karaf/pom.xml +++ b/bundles/org.openhab.core.karaf/pom.xml @@ -15,6 +15,11 @@ openHAB Core :: Bundles :: Karaf Integration + + org.openhab.core.bundles + org.openhab.core + ${project.version} + org.openhab.core.bundles org.openhab.core.boot @@ -39,6 +44,12 @@ ${karaf.compile.version} provided + + org.apache.karaf.jaas + org.apache.karaf.jaas.modules + ${karaf.compile.version} + provided + diff --git a/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/jaas/ManagedUserBackingEngine.java b/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/jaas/ManagedUserBackingEngine.java new file mode 100644 index 000000000..920d77308 --- /dev/null +++ b/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/jaas/ManagedUserBackingEngine.java @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.karaf.internal.jaas; + +import java.security.Principal; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.karaf.jaas.boot.principal.GroupPrincipal; +import org.apache.karaf.jaas.boot.principal.RolePrincipal; +import org.apache.karaf.jaas.boot.principal.UserPrincipal; +import org.apache.karaf.jaas.modules.BackingEngine; +import org.openhab.core.auth.ManagedUser; +import org.openhab.core.auth.Role; +import org.openhab.core.auth.User; +import org.openhab.core.auth.UserRegistry; + +/** + * A Karaf backing engine for the {@link UserRegistry} + * + * @author Yannick Schaus - initial contribution + */ +public class ManagedUserBackingEngine implements BackingEngine { + + UserRegistry userRegistry; + + public ManagedUserBackingEngine(UserRegistry userRegistry) { + this.userRegistry = userRegistry; + } + + @Override + public void addUser(String username, String password) { + userRegistry.register(username, password, new HashSet(Set.of(Role.USER))); + } + + @Override + public void deleteUser(String username) { + userRegistry.remove(username); + } + + @Override + public List listUsers() { + return userRegistry.getAll().stream().map(u -> new UserPrincipal(u.getName())).collect(Collectors.toList()); + } + + @Override + public UserPrincipal lookupUser(String username) { + User user = userRegistry.get(username); + if (user != null) { + return new UserPrincipal(user.getName()); + } + return null; + } + + @Override + public List listGroups(UserPrincipal user) { + return Collections.emptyList(); + } + + @Override + public Map listGroups() { + return Collections.emptyMap(); + } + + @Override + public void addGroup(String username, String group) { + throw new UnsupportedOperationException(); + } + + @Override + public void createGroup(String group) { + throw new UnsupportedOperationException(); + } + + @Override + public void deleteGroup(String username, String group) { + throw new UnsupportedOperationException(); + } + + @Override + public List listRoles(Principal principal) { + User user = userRegistry.get(principal.getName()); + if (user != null) { + return user.getRoles().stream().map(r -> new RolePrincipal(r)).collect(Collectors.toList()); + } + return Collections.emptyList(); + } + + @Override + public void addRole(String username, String role) { + User user = userRegistry.get(username); + if (user instanceof ManagedUser) { + ManagedUser managedUser = (ManagedUser) user; + managedUser.getRoles().add(role); + userRegistry.update(managedUser); + } + } + + @Override + public void deleteRole(String username, String role) { + User user = userRegistry.get(username); + if (user instanceof ManagedUser) { + ManagedUser managedUser = (ManagedUser) user; + managedUser.getRoles().remove(role); + userRegistry.update(managedUser); + } + } + + @Override + public void addGroupRole(String group, String role) { + throw new UnsupportedOperationException(); + } + + @Override + public void deleteGroupRole(String group, String role) { + throw new UnsupportedOperationException(); + } + +} diff --git a/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/jaas/ManagedUserBackingEngineFactory.java b/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/jaas/ManagedUserBackingEngineFactory.java new file mode 100644 index 000000000..af1ae520e --- /dev/null +++ b/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/jaas/ManagedUserBackingEngineFactory.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.karaf.internal.jaas; + +import java.util.Map; + +import javax.inject.Singleton; + +import org.apache.karaf.jaas.modules.BackingEngine; +import org.apache.karaf.jaas.modules.BackingEngineFactory; +import org.openhab.core.auth.UserRegistry; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * A Karaf backing engine factory for the {@link UserRegistry} + * + * @author Yannick Schaus - initial contribution + */ +@Singleton +@Component(service = BackingEngineFactory.class) +public class ManagedUserBackingEngineFactory implements BackingEngineFactory { + + UserRegistry userRegistry; + + @Activate + public ManagedUserBackingEngineFactory(@Reference UserRegistry userRegistry) { + this.userRegistry = userRegistry; + } + + @Override + public String getModuleClass() { + return ManagedUserRealm.MODULE_CLASS; + } + + @Override + public BackingEngine build(Map options) { + return new ManagedUserBackingEngine(userRegistry); + } + +} diff --git a/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/jaas/ManagedUserRealm.java b/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/jaas/ManagedUserRealm.java new file mode 100644 index 000000000..d57a06a64 --- /dev/null +++ b/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/jaas/ManagedUserRealm.java @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.karaf.internal.jaas; + +import java.util.HashMap; +import java.util.Map; + +import javax.inject.Singleton; +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.AppConfigurationEntry.LoginModuleControlFlag; +import javax.security.auth.spi.LoginModule; + +import org.apache.karaf.jaas.boot.ProxyLoginModule; +import org.apache.karaf.jaas.config.JaasRealm; +import org.apache.karaf.shell.api.action.lifecycle.Service; +import org.openhab.core.auth.UserRegistry; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A JAAS realm description for the {@link UserRegistry} based login module. + * + * @author Yannick Schaus - initial contribution + */ +@Singleton +@Component(service = JaasRealm.class) +@Service +public class ManagedUserRealm implements JaasRealm { + + public static final String REALM_NAME = "openhab"; + public static final String MODULE_CLASS = "org.openhab.core.auth.jaas.internal.ManagedUserLoginModule"; + + private final Logger logger = LoggerFactory.getLogger(ManagedUserRealm.class); + + BundleContext bundleContext; + + @Activate + public ManagedUserRealm(BundleContext bundleContext, @Reference LoginModule loginModule) { + this.bundleContext = bundleContext; + logger.debug("Using login module {} for the openhab realm", loginModule.getClass().getCanonicalName()); + } + + @Override + public String getName() { + return REALM_NAME; + } + + @Override + public int getRank() { + return 1; + } + + @Override + public AppConfigurationEntry[] getEntries() { + Map options = new HashMap<>(); + options.put(ProxyLoginModule.PROPERTY_MODULE, MODULE_CLASS); + + return new AppConfigurationEntry[] { + new AppConfigurationEntry(MODULE_CLASS, LoginModuleControlFlag.SUFFICIENT, options) }; + } + +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/AuthenticationProvider.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/AuthenticationProvider.java index 196fe8e1d..4d38445f7 100644 --- a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/AuthenticationProvider.java +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/AuthenticationProvider.java @@ -12,12 +12,15 @@ */ package org.openhab.core.auth; +import org.eclipse.jdt.annotation.NonNullByDefault; + /** * Realizations of this type are responsible for checking validity of various credentials and giving back authentication * which defines access scope for authenticated user or system. * * @author Łukasz Dywicki - Initial contribution */ +@NonNullByDefault public interface AuthenticationProvider { /** diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/GenericUser.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/GenericUser.java new file mode 100644 index 000000000..e302e980e --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/GenericUser.java @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.auth; + +import java.util.HashSet; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Represents a generic {@link User} with a set of roles + * + * @author Yannick Schaus - initial contribution + * + */ +@NonNullByDefault +public class GenericUser implements User { + + private String name; + private Set roles; + + /** + * Constructs a user attributed with a set of roles. + * + * @param name the username (account name) + * @param roles the roles attributed to this user + */ + public GenericUser(String name, Set roles) { + this.name = name; + this.roles = roles; + } + + /** + * Constructs a user with no roles. + * + * @param name the username (account name) + */ + public GenericUser(String name) { + this(name, new HashSet<>()); + } + + @Override + public String getName() { + return name; + } + + @Override + public @NonNull String getUID() { + return name; + } + + @Override + public Set getRoles() { + return roles; + } +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/ManagedUser.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/ManagedUser.java new file mode 100644 index 000000000..d008e8da4 --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/ManagedUser.java @@ -0,0 +1,157 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.auth; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * A {@link User} sourced from a managed {@link UserProvider}. + * + * @author Yannick Schaus - initial contribution + */ +@NonNullByDefault +public class ManagedUser implements User { + + private String name; + private String passwordHash; + private String passwordSalt; + private Set roles = new HashSet<>(); + private @Nullable PendingToken pendingToken = null; + private List sessions = new ArrayList<>(); + private List apiTokens = new ArrayList<>(); + + /** + * Constructs a user with a password hash & salt provided by the caller. + * + * @param name the username (account name) + * @param passwordSalt the salt to compute the password hash + * @param passwordHash the result of the hashing of the salted password + */ + public ManagedUser(String name, String passwordSalt, String passwordHash) { + super(); + this.name = name; + this.passwordSalt = passwordSalt; + this.passwordHash = passwordHash; + } + + /** + * Gets the password hash. + * + * @return the password hash + */ + public String getPasswordHash() { + return passwordHash; + } + + /** + * Gets the password salt. + * + * @return the password salt + */ + public String getPasswordSalt() { + return passwordSalt; + } + + @Override + public String getName() { + return name; + } + + /** + * Alters the user's account name + * + * @param name the new account name + */ + public void setName(String name) { + this.name = name; + } + + @Override + public String getUID() { + return name; + } + + @Override + public Set getRoles() { + return roles; + } + + /** + * Alters the user's set of roles. + * + * @param roles the new roles + */ + public void setRoles(Set roles) { + this.roles = roles; + } + + /** + * Gets the pending token information for this user, if any. + * + * @return the pending token information or null if there is none + */ + public @Nullable PendingToken getPendingToken() { + return pendingToken; + } + + /** + * Sets or clears the pending token information for this user. + * + * @param pendingToken the pending token information or null to clear it + */ + public void setPendingToken(@Nullable PendingToken pendingToken) { + this.pendingToken = pendingToken; + } + + /** + * Gets the current persistent sessions for this user. + * + * @return the list of sessions + */ + public List getSessions() { + return sessions; + } + + /** + * Replaces the list of sessions by a new one. + * + * @param sessions the new list of sessions + */ + public void setSessions(List sessions) { + this.sessions = sessions; + } + + /** + * Gets the long-term API tokens for this user + * + * @return the API tokens + */ + public List getApiTokens() { + return apiTokens; + } + + /** + * Replaces the list of API tokens by a new one. + * + * @param apiTokens the new API tokens + */ + public void setApiTokens(List apiTokens) { + this.apiTokens = apiTokens; + } +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/PendingToken.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/PendingToken.java new file mode 100644 index 000000000..81770725b --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/PendingToken.java @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.auth; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The pending information used in a OAuth2 authorization flow, set after the user has authorized the client to access + * resources, and has been redirected to the callback URI with an authorization code. The information will be used when + * it calls the token endpoint to exchange the authorization code for a access token and a refresh token. + * + * The authorization code for a token is sensible information while it is valid, therefore the client is supposed to + * call the token endpoint to perform the exchange it immediately after receiving it. The information should remain in + * the @link {@link ManagedUser} profile for a limited time only. + * + * Additionally, and optionally, information about the code challenge as specified by PKCE (RFC 7636) can be stored + * along with the code. + * + * @author Yannick Schaus - initial contribution + * + */ +@NonNullByDefault +public class PendingToken { + private String authorizationCode; + private String clientId; + private String redirectUri; + private String scope; + + @Nullable + private String codeChallenge; + @Nullable + private String codeChallengeMethod; + + /** + * Constructs a pending token. + * + * @param authorizationCode the authorization code provided to the client + * @param clientId the client ID making the request + * @param redirectUri the provided redirect URI + * @param scope the requested scopes + * @param codeChallenge the code challenge (optional) + * @param codeChallengeMethod the code challenge method (optional) + */ + public PendingToken(String authorizationCode, String clientId, String redirectUri, String scope, + @Nullable String codeChallenge, @Nullable String codeChallengeMethod) { + super(); + this.authorizationCode = authorizationCode; + this.clientId = clientId; + this.redirectUri = redirectUri; + this.scope = scope; + this.codeChallenge = codeChallenge; + this.codeChallengeMethod = codeChallengeMethod; + } + + /** + * Gets the authorization code provided to the client. + * + * @return the authorization code + */ + public String getAuthorizationCode() { + return authorizationCode; + } + + /** + * Gets the ID of the client requesting the upcoming token. + * + * @return the client ID + */ + public String getClientId() { + return clientId; + } + + /** + * Gets the redirect URI of the client requesting the upcoming token. + * + * @return the redirect URI + */ + public String getRedirectUri() { + return redirectUri; + } + + /** + * Gets the requested scopes for the upcoming token. + * + * @return the requested scopes + */ + public String getScope() { + return scope; + } + + /** + * Gets the code challenge + * + * @return the code challenge + */ + public @Nullable String getCodeChallenge() { + return codeChallenge; + } + + /** + * Gets the code challenge method + * + * @return the code challenge method + */ + public @Nullable String getCodeChallengeMethod() { + return codeChallengeMethod; + } + +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/User.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/User.java new file mode 100644 index 000000000..7f11c9b58 --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/User.java @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.auth; + +import java.security.Principal; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.common.registry.Identifiable; + +/** + * A user represents an individual, physical person using the system. + * + * @author Yannick Schaus - initial contribution + */ +@NonNullByDefault +public interface User extends Principal, Identifiable { + + /** + * Gets the roles attributed to the user. + * + * @see Role + * @return role attributed to the user + */ + public Set getRoles(); +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserApiToken.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserApiToken.java new file mode 100644 index 000000000..b46403bc5 --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserApiToken.java @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.auth; + +import java.util.Date; + +/** + * An API token represents long-term credentials generated by an user, giving the bearer access to the API on behalf of + * this user for a certain scope. + * + * @author Yannick Schaus - initial contribution + * + */ +public class UserApiToken { + String name; + String apiToken; + Date createdTime; + String scope; + + /** + * Constructs an API token. + * + * @param name the name of the token, for identification purposes + * @param apiToken the token + * @param scope the scope this token is valid for + */ + public UserApiToken(String name, String apiToken, String scope) { + super(); + this.name = name; + this.apiToken = apiToken; + this.createdTime = new Date(); + this.scope = scope; + } + + /** + * Gets the name identifying the token + * + * @return the API token + */ + public String getName() { + return name; + } + + /** + * Gets the API token which can be passed in requests as a "Bearer" token in the Authorization HTTP header. + * + * @return the API token + */ + public String getApiToken() { + return apiToken; + } + + /** + * Gets the time when this token was created. + * + * @return the date of creation + */ + public Date getCreatedTime() { + return createdTime; + } + + /** + * Gets the scope this token is valid for. + * + * @return the scope + */ + public String getScope() { + return scope; + } +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserProvider.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserProvider.java new file mode 100644 index 000000000..9faba4d6e --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserProvider.java @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.auth; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.common.registry.Provider; + +/** + * A interface for a {@link Provider} of {@link User} entities + * + * @author Yannick Schaus - initial contribution + * + */ +@NonNullByDefault +public interface UserProvider extends Provider { + +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserRegistry.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserRegistry.java new file mode 100644 index 000000000..297355145 --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserRegistry.java @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.auth; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.common.registry.Registry; + +/** + * An interface for a generic {@link Registry} of {@link User} entities. User registries can also be used as + * {@link AuthenticationProvider}. + * + * @author Yannick Schaus - initial contribution + * + */ +@NonNullByDefault +public interface UserRegistry extends Registry, AuthenticationProvider { + + /** + * Adds a new {@link User} in this registry. The implementation receives the clear text credentials and is + * responsible for their secure storage (for instance by hashing the password), then return the newly created + * {@link User} instance. + * + * @param username the username of the new user + * @param password the user password + * @param roles the roles attributed to the new user + * + * @return the new registered {@link User} instance + */ + public User register(String username, String password, Set roles); +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserSession.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserSession.java new file mode 100644 index 000000000..674908f1d --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserSession.java @@ -0,0 +1,147 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.auth; + +import java.util.Date; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * A persistent session for a {@link ManagedUser}, which holds a refresh token used by a client to get short-lived + * access tokens for API requests authorization. + * + * @author Yannick Schaus - initial contribution + * + */ +@NonNullByDefault +public class UserSession { + String sessionId; + String refreshToken; + Date createdTime; + Date lastRefreshTime; + String clientId; + String redirectUri; + String scope; + boolean sessionCookie; + + /** + * Constructs a new session. + * + * @param sessionId an unique ID for the session + * @param refreshToken the refresh token associated to the session + * @param clientId the client ID associated to the session + * @param redirectUri the callback URI provided when the client was authorized by the user + * @param scope the granted scope provided when the client was authorized by the user + */ + public UserSession(String sessionId, String refreshToken, String clientId, String redirectUri, String scope) { + super(); + this.sessionId = sessionId; + this.refreshToken = refreshToken; + this.createdTime = new Date(); + this.lastRefreshTime = new Date(); + this.clientId = clientId; + this.redirectUri = redirectUri; + this.scope = scope; + } + + /** + * Gets the ID of the session. + * + * @return the session ID + */ + public String getSessionId() { + return sessionId; + } + + /** + * Gets the refresh token for the session. + * + * @return the refresh token + */ + public String getRefreshToken() { + return refreshToken; + } + + /** + * Gets the creation time of the session. + * + * @return the creation time + */ + public Date getCreatedTime() { + return createdTime; + } + + /** + * Gets the time when the refresh token was last used to get a new access token. + * + * @return the last refresh time + */ + public Date getLastRefreshTime() { + return lastRefreshTime; + } + + /** + * Sets the time when the refresh token was last used to get a new access token. + * + * @param lastRefreshTime the last refresh time + */ + public void setLastRefreshTime(Date lastRefreshTime) { + this.lastRefreshTime = lastRefreshTime; + } + + /** + * Gets the scope requested when authorizing this session. + * + * @return the session scope + */ + public String getScope() { + return scope; + } + + /** + * Gets the ID of the client this session was created for + * + * @return the client ID + */ + public String getClientId() { + return clientId; + } + + /** + * Gets the redirect URI which was used to perform the authorization flow. + * + * @return the redirect URI + */ + public String getRedirectUri() { + return redirectUri; + } + + /** + * Specifies whether this session is supported by a session cookie, to mitigate the impact of refresh token + * leakage. + * + * @return whether or not a cookie has been set + */ + public boolean hasSessionCookie() { + return sessionCookie; + } + + /** + * Sets the session cookie flag for this session. + * + * @param sessionCookie the cookie flag + */ + public void setSessionCookie(boolean sessionCookie) { + this.sessionCookie = sessionCookie; + } +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/auth/ManagedUserProvider.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/auth/ManagedUserProvider.java new file mode 100644 index 000000000..37967e949 --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/auth/ManagedUserProvider.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.internal.auth; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.auth.ManagedUser; +import org.openhab.core.auth.User; +import org.openhab.core.common.registry.DefaultAbstractManagedProvider; +import org.openhab.core.common.registry.ManagedProvider; +import org.openhab.core.storage.StorageService; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * A {@link ManagedProvider} for {@link ManagedUser} entities + * + * @author Yannick Schaus - initial contribution + */ +@NonNullByDefault +@Component(service = ManagedUserProvider.class, immediate = true) +public class ManagedUserProvider extends DefaultAbstractManagedProvider { + + @Activate + public ManagedUserProvider(final @Reference StorageService storageService) { + super(storageService); + } + + @Override + protected String getStorageName() { + return "users"; + } + + @Override + protected String keyToString(String key) { + return key; + } + +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/auth/UserRegistryImpl.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/auth/UserRegistryImpl.java new file mode 100644 index 000000000..3acb187b6 --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/auth/UserRegistryImpl.java @@ -0,0 +1,155 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.internal.auth; + +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.auth.Authentication; +import org.openhab.core.auth.AuthenticationException; +import org.openhab.core.auth.Credentials; +import org.openhab.core.auth.ManagedUser; +import org.openhab.core.auth.User; +import org.openhab.core.auth.UserProvider; +import org.openhab.core.auth.UserRegistry; +import org.openhab.core.auth.UsernamePasswordCredentials; +import org.openhab.core.common.registry.AbstractRegistry; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.ReferencePolicy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The implementation of a {@link UserRegistry} for {@link ManagedUser} entities. + * + * @author Yannick Schaus - initial contribution + */ +@NonNullByDefault +@Component(service = UserRegistry.class, immediate = true) +public class UserRegistryImpl extends AbstractRegistry implements UserRegistry { + + private final Logger logger = LoggerFactory.getLogger(UserRegistryImpl.class); + + private static final int ITERATIONS = 65536; + private static final int KEY_LENGTH = 512; + private static final String ALGORITHM = "PBKDF2WithHmacSHA512"; + private static final SecureRandom RAND = new SecureRandom(); + + @Activate + public UserRegistryImpl(BundleContext context, Map properties) { + super(UserProvider.class); + super.activate(context); + } + + @Override + @Deactivate + protected void deactivate() { + super.deactivate(); + } + + @Reference(cardinality = ReferenceCardinality.OPTIONAL, policy = ReferencePolicy.DYNAMIC) + protected void setManagedProvider(ManagedUserProvider managedProvider) { + super.setManagedProvider(managedProvider); + super.addProvider(managedProvider); + } + + protected void unsetManagedProvider(ManagedUserProvider managedProvider) { + super.unsetManagedProvider(managedProvider); + super.removeProvider(managedProvider); + } + + @Override + public User register(String username, String password, Set roles) { + String passwordSalt = generateSalt(KEY_LENGTH / 8).get(); + String passwordHash = hashPassword(password, passwordSalt).get(); + ManagedUser user = new ManagedUser(username, passwordSalt, passwordHash); + user.setRoles(new HashSet<>(roles)); + super.add(user); + return user; + } + + private Optional generateSalt(final int length) { + if (length < 1) { + logger.error("error in generateSalt: length must be > 0"); + return Optional.empty(); + } + + byte[] salt = new byte[length]; + RAND.nextBytes(salt); + + return Optional.of(Base64.getEncoder().encodeToString(salt)); + } + + private Optional hashPassword(String password, String salt) { + char[] chars = password.toCharArray(); + byte[] bytes = salt.getBytes(); + + PBEKeySpec spec = new PBEKeySpec(chars, bytes, ITERATIONS, KEY_LENGTH); + + Arrays.fill(chars, Character.MIN_VALUE); + + try { + SecretKeyFactory fac = SecretKeyFactory.getInstance(ALGORITHM); + byte[] securePassword = fac.generateSecret(spec).getEncoded(); + return Optional.of(Base64.getEncoder().encodeToString(securePassword)); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + logger.error("Exception encountered in hashPassword", e); + return Optional.empty(); + } finally { + spec.clearPassword(); + } + } + + @Override + public Authentication authenticate(Credentials credentials) throws AuthenticationException { + UsernamePasswordCredentials usernamePasswordCreds = (UsernamePasswordCredentials) credentials; + User user = this.get(usernamePasswordCreds.getUsername()); + if (user == null) { + throw new AuthenticationException("User not found: " + usernamePasswordCreds.getUsername()); + } + if (!(user instanceof ManagedUser)) { + throw new AuthenticationException("User is not managed: " + usernamePasswordCreds.getUsername()); + } + + ManagedUser managedUser = (ManagedUser) user; + String hashedPassword = hashPassword(usernamePasswordCreds.getPassword(), managedUser.getPasswordSalt()).get(); + if (!hashedPassword.equals(managedUser.getPasswordHash())) { + throw new AuthenticationException("Wrong password for user " + usernamePasswordCreds.getUsername()); + } + + Authentication authentication = new Authentication(managedUser.getName()); + return authentication; + } + + @Override + public boolean supports(Class type) { + return (UsernamePasswordCredentials.class.isAssignableFrom(type)); + } + +} diff --git a/features/karaf/openhab-core/src/main/feature/feature.xml b/features/karaf/openhab-core/src/main/feature/feature.xml index e5ec16588..7d52a29b2 100644 --- a/features/karaf/openhab-core/src/main/feature/feature.xml +++ b/features/karaf/openhab-core/src/main/feature/feature.xml @@ -138,6 +138,7 @@ openhab-core-base + openhab-core-auth-jaas mvn:org.openhab.core.bundles/org.openhab.core.io.http.auth/${project.version} @@ -153,7 +154,10 @@ openhab-core-base + openhab-core-auth-jaas mvn:org.openhab.core.bundles/org.openhab.core.io.rest.auth/${project.version} + openhab.tp;filter:="(feature=jose4j)" + openhab.tp-jose4j diff --git a/features/karaf/openhab-tp/src/main/feature/feature.xml b/features/karaf/openhab-tp/src/main/feature/feature.xml index fde2dae99..481d92ac5 100644 --- a/features/karaf/openhab-tp/src/main/feature/feature.xml +++ b/features/karaf/openhab-tp/src/main/feature/feature.xml @@ -176,6 +176,11 @@ mvn:org.jmdns/jmdns/3.5.5 + + openhab.tp;feature=jose4j;version=0.7.0 + mvn:org.bitbucket.b_c/jose4j/0.7.0 + + openhab.tp;feature=jupnp;version=2.5.2 http