From 8b52cab5efbe2798369a30adb42b65ec059ecbec Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Sun, 25 Oct 2020 12:04:40 +0100 Subject: [PATCH] [REST Auth] API tokens & openhab:users console command (#1735) This adds API tokens as a new credential type. Their format is: `oh..` The "oh." prefix is used to tell them apart from a JWT access token, because they're both used as a Bearer authorization scheme, but there is no semantic value attached to any of the other parts. They are stored hashed in the user's profile, and can be listed, added or removed managed with the new `openhab:users` console command. Currently the scopes are still not checked, but ultimately they could be, for instance a scope of e.g. `user admin.items` would mean that the API token can be used to perform user operations like retrieving info or sending a command, _and_ managing the items, but nothing else - even if the user has more permissions because of their role (which will of course still be checked). Tokens are normally passed in the Authorization header with the Bearer scheme, or the X-OPENHAB-TOKEN header, like access tokens. As a special exception, API tokens can also be used with the Basic authorization scheme, **even if the allowBasicAuth** option is not enabled in the "API Security" service, because there's no additional security risk in allowing that. In that case, the token should be passed as the username and the password MUST be empty. In short, this means that all these curl commands will work: - `curl -H 'Authorization: Bearer ' http://localhost:8080/rest/inbox` - `curl -H 'X-OPENHAB-TOKEN: ' http://localhost:8080/rest/inbox` - `curl -u '[:]' http://localhost:8080/rest/inbox` - `curl http://@localhost:8080/rest/inbox` 2 REST API operations were adding to the AuthResource, to allow authenticated users to list their tokens or remove (revoke) one. Self-service for creating a token or changing the password is more sensitive so these should be handled with a servlet and pages devoid of any JavaScript instead of REST API calls, therefore for now they'll have to be done with the console. This also fixes regressions introduced with #1713 - the operations annotated with @RolesAllowed({ Role.USER }) only were not authorized for administrators anymore. * Generate a unique salt for each token Reusing the password salt is bad practice, and changing the password changes the salt as well which makes all tokens invalid. Put the salt in the same field as the hash (concatenated with a separator) to avoid modifying the JSON DB schema. * Fix API token authentication, make scope available to security context The X-OPENHAB-TOKEN header now has priority over the Authorization header to credentials, if both are set. * Add self-service pages to change password & create new API token Signed-off-by: Yannick Schaus --- .../UserConsoleCommandExtension.java | 194 +++++++++++ .../pages/authorize.html | 46 ++- .../internal/AbstractAuthPageServlet.java | 129 ++++++++ .../auth/internal/AuthorizePageServlet.java | 309 +++++++----------- .../internal/ChangePasswordPageServlet.java | 171 ++++++++++ .../internal/CreateAPITokenPageServlet.java | 190 +++++++++++ .../io/rest/auth/internal/AuthFilter.java | 73 +++-- .../AuthenticationSecurityContext.java | 31 ++ .../core/io/rest/auth/internal/JwtHelper.java | 7 +- .../auth/internal/JwtSecurityContext.java | 7 +- .../io/rest/auth/internal/TokenResource.java | 90 ++++- .../rest/auth/internal/UserApiTokenDTO.java | 33 ++ .../auth/internal/UserSecurityContext.java | 13 +- .../main/resources/OH-INF/config/config.xml | 3 +- .../org/openhab/core/auth/Authentication.java | 27 +- .../org/openhab/core/auth/ManagedUser.java | 23 ++ .../org/openhab/core/auth/UserApiToken.java | 14 +- .../core/auth/UserApiTokenCredentials.java | 41 +++ .../org/openhab/core/auth/UserRegistry.java | 51 +++ .../core/internal/auth/UserRegistryImpl.java | 144 +++++++- .../internal/auth/UserRegistryImplTest.java | 126 +++++++ 21 files changed, 1472 insertions(+), 250 deletions(-) create mode 100644 bundles/org.openhab.core.io.console/src/main/java/org/openhab/core/io/console/internal/extension/UserConsoleCommandExtension.java create mode 100644 bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AbstractAuthPageServlet.java create mode 100644 bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/ChangePasswordPageServlet.java create mode 100644 bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/CreateAPITokenPageServlet.java create mode 100644 bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthenticationSecurityContext.java create mode 100644 bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserApiTokenDTO.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserApiTokenCredentials.java create mode 100644 bundles/org.openhab.core/src/test/java/org/openhab/core/internal/auth/UserRegistryImplTest.java diff --git a/bundles/org.openhab.core.io.console/src/main/java/org/openhab/core/io/console/internal/extension/UserConsoleCommandExtension.java b/bundles/org.openhab.core.io.console/src/main/java/org/openhab/core/io/console/internal/extension/UserConsoleCommandExtension.java new file mode 100644 index 00000000000..1ffab9c4735 --- /dev/null +++ b/bundles/org.openhab.core.io.console/src/main/java/org/openhab/core/io/console/internal/extension/UserConsoleCommandExtension.java @@ -0,0 +1,194 @@ +/** + * 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.console.internal.extension; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.auth.ManagedUser; +import org.openhab.core.auth.User; +import org.openhab.core.auth.UserApiToken; +import org.openhab.core.auth.UserRegistry; +import org.openhab.core.io.console.Console; +import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension; +import org.openhab.core.io.console.extensions.ConsoleCommandExtension; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * Console command extension to manage users, sessions and API tokens + * + * @author Yannick Schaus - Initial contribution + */ +@Component(service = ConsoleCommandExtension.class) +@NonNullByDefault +public class UserConsoleCommandExtension extends AbstractConsoleCommandExtension { + + private static final String SUBCMD_LIST = "list"; + private static final String SUBCMD_ADD = "add"; + private static final String SUBCMD_REMOVE = "remove"; + private static final String SUBCMD_CHANGEPASSWORD = "changePassword"; + private static final String SUBCMD_LISTAPITOKENS = "listApiTokens"; + private static final String SUBCMD_ADDAPITOKEN = "addApiToken"; + private static final String SUBCMD_RMAPITOKEN = "rmApiToken"; + private static final String SUBCMD_CLEARSESSIONS = "clearSessions"; + + private final UserRegistry userRegistry; + + @Activate + public UserConsoleCommandExtension(final @Reference UserRegistry userRegistry) { + super("users", "Access the user registry."); + this.userRegistry = userRegistry; + } + + @Override + public List getUsages() { + return List.of(buildCommandUsage(SUBCMD_LIST, "lists all users"), + buildCommandUsage(SUBCMD_ADD + " ", + "adds a new user with the specified role"), + buildCommandUsage(SUBCMD_REMOVE + " ", "removes the given user"), + buildCommandUsage(SUBCMD_CHANGEPASSWORD + " ", "changes the password of a user"), + buildCommandUsage(SUBCMD_LISTAPITOKENS, "lists the API tokens for all users"), + buildCommandUsage(SUBCMD_ADDAPITOKEN + " ", + "adds a new API token on behalf of the specified user for the specified scope"), + buildCommandUsage(SUBCMD_RMAPITOKEN + " ", + "removes (revokes) the specified API token"), + buildCommandUsage(SUBCMD_CLEARSESSIONS + " ", + "clear the refresh tokens associated with the user (will sign the user out of all sessions)")); + } + + @Override + public void execute(String[] args, Console console) { + if (args.length > 0) { + String subCommand = args[0]; + switch (subCommand) { + case SUBCMD_LIST: + userRegistry.getAll().forEach(user -> console.println(user.toString())); + break; + case SUBCMD_ADD: + if (args.length == 4) { + User existingUser = userRegistry.get(args[1]); + if (existingUser == null) { + User newUser = userRegistry.register(args[1], args[2], Set.of(args[3])); + console.println(newUser.toString()); + console.println("User created."); + } else { + console.println("The user already exists."); + } + } else { + console.printUsage(findUsage(SUBCMD_ADD)); + } + break; + case SUBCMD_REMOVE: + if (args.length == 2) { + User user = userRegistry.get(args[1]); + if (user != null) { + userRegistry.remove(user.getName()); + console.println("User removed."); + } else { + console.println("User not found."); + } + } else { + console.printUsage(findUsage(SUBCMD_REMOVE)); + } + break; + case SUBCMD_CHANGEPASSWORD: + if (args.length == 3) { + User user = userRegistry.get(args[1]); + if (user != null) { + userRegistry.changePassword(user, args[2]); + console.println("Password changed."); + } else { + console.println("User not found."); + } + } else { + console.printUsage(findUsage(SUBCMD_CHANGEPASSWORD)); + } + break; + case SUBCMD_LISTAPITOKENS: + userRegistry.getAll().forEach(user -> { + ManagedUser managedUser = (ManagedUser) user; + if (!managedUser.getApiTokens().isEmpty()) { + managedUser.getApiTokens() + .forEach(t -> console.println("user=" + user.toString() + ", " + t.toString())); + } + }); + break; + case SUBCMD_ADDAPITOKEN: + if (args.length == 4) { + ManagedUser user = (ManagedUser) userRegistry.get(args[1]); + if (user != null) { + Optional userApiToken = user.getApiTokens().stream() + .filter(t -> args[2].equals(t.getName())).findAny(); + if (userApiToken.isEmpty()) { + String tokenString = userRegistry.addUserApiToken(user, args[2], args[3]); + console.println(tokenString); + } else { + console.println("Cannot create API token: another one with the same name was found."); + } + } else { + console.println("User not found."); + } + } else { + console.printUsage(findUsage(SUBCMD_ADDAPITOKEN)); + } + break; + case SUBCMD_RMAPITOKEN: + if (args.length == 3) { + ManagedUser user = (ManagedUser) userRegistry.get(args[1]); + if (user != null) { + Optional userApiToken = user.getApiTokens().stream() + .filter(t -> args[2].equals(t.getName())).findAny(); + if (userApiToken.isPresent()) { + userRegistry.removeUserApiToken(user, userApiToken.get()); + console.println("API token revoked."); + } else { + console.println("No matching API token found."); + } + } else { + console.println("User not found."); + } + } else { + console.printUsage(findUsage(SUBCMD_RMAPITOKEN)); + } + break; + case SUBCMD_CLEARSESSIONS: + if (args.length == 2) { + User user = userRegistry.get(args[1]); + if (user != null) { + userRegistry.clearSessions(user); + console.println("User sessions cleared."); + } else { + console.println("User not found."); + } + } else { + console.printUsage(findUsage(SUBCMD_CLEARSESSIONS)); + } + break; + default: + console.println("Unknown command '" + subCommand + "'"); + printUsage(console); + break; + } + } else { + printUsage(console); + } + } + + private String findUsage(String cmd) { + return getUsages().stream().filter(u -> u.contains(cmd)).findAny().get(); + } +} diff --git a/bundles/org.openhab.core.io.http.auth/pages/authorize.html b/bundles/org.openhab.core.io.http.auth/pages/authorize.html index 869a1ea35cb..3c994ac1e3c 100644 --- a/bundles/org.openhab.core.io.http.auth/pages/authorize.html +++ b/bundles/org.openhab.core.io.http.auth/pages/authorize.html @@ -42,6 +42,14 @@ margin-bottom: 2rem; } +form.hide { + display: none; +} + +form.show { + display: initial; +} + input { border: 0; padding: 10px; @@ -54,29 +62,45 @@ margin-bottom: 1rem; } -input.submit { +.submit { cursor: pointer; margin-top: 2rem; background-color: rgb(33, 150, 243); + text-decoration: none; color: white; border-radius: 4px; font-size: 14px; font-weight: 600; + min-width: 80px; + padding-top: 10px; + padding-bottom: 10px; padding-left: 1rem; padding-right: 1rem; } - + input.submit:hover { background-color: rgb(72, 168, 245); } + +.result { + display: none; +} + +.resultPassword { + display: initial; +} + +.resultToken { + display: initial; +} -
+
{message}
+ {form_fields} -
{message}
@@ -84,11 +108,23 @@
- + +
+
+ +
+
+ +
+
+
+ diff --git a/bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AbstractAuthPageServlet.java b/bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AbstractAuthPageServlet.java new file mode 100644 index 00000000000..9e1f1ad9774 --- /dev/null +++ b/bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AbstractAuthPageServlet.java @@ -0,0 +1,129 @@ +/** + * 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.InputStream; +import java.io.UncheckedIOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletResponse; + +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.User; +import org.openhab.core.auth.UserRegistry; +import org.openhab.core.auth.UsernamePasswordCredentials; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Abstract class for servlets to perform sensible operations requiring user authentication. + * + * @author Yannick Schaus - initial contribution + * + */ +@NonNullByDefault +public abstract class AbstractAuthPageServlet extends HttpServlet { + + protected static final long serialVersionUID = 5340598701104679840L; + + private final Logger logger = LoggerFactory.getLogger(AbstractAuthPageServlet.class); + + protected HttpService httpService; + protected UserRegistry userRegistry; + protected AuthenticationProvider authProvider; + protected @Nullable Instant lastAuthenticationFailure; + protected int authenticationFailureCount = 0; + + protected Map csrfTokens = new HashMap<>(); + + protected String pageTemplate; + + public AbstractAuthPageServlet(BundleContext bundleContext, @Reference HttpService httpService, + @Reference UserRegistry userRegistry, @Reference AuthenticationProvider authProvider) { + this.httpService = httpService; + this.userRegistry = userRegistry; + this.authProvider = authProvider; + + pageTemplate = ""; + URL resource = bundleContext.getBundle().getResource("pages/authorize.html"); + if (resource != null) { + try (InputStream stream = resource.openStream()) { + pageTemplate = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("Cannot load page template", e); + } + } + } + + protected abstract String getPageBody(Map params, String message, boolean hideForm); + + protected abstract String getFormFields(Map params); + + protected 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; + } + + protected void removeCsrfToken(String csrfToken) { + csrfTokens.remove(csrfToken); + } + + protected User login(String username, String password) throws AuthenticationException { + // 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 user = userRegistry.get(auth.getUsername()); + if (user == null) { + throw new AuthenticationException("User not found"); + } + return user; + } + + protected void processFailedLogin(HttpServletResponse resp, Map params, String message) + throws IOException { + lastAuthenticationFailure = Instant.now(); + authenticationFailureCount += 1; + resp.setContentType("text/html;charset=UTF-8"); + logger.warn("Authentication failed: {}", message); + resp.getWriter().append(getPageBody(params, "Please try again.", false)); // TODO: i18n + resp.getWriter().close(); + } +} 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 index 8eb11aee5cf..b41cb314e04 100644 --- 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 @@ -13,18 +13,11 @@ package org.openhab.core.io.http.auth.internal; import java.io.IOException; -import java.io.UncheckedIOException; -import java.net.URL; -import java.nio.charset.StandardCharsets; -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; @@ -32,7 +25,6 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.http.HttpStatus; -import org.openhab.core.auth.Authentication; import org.openhab.core.auth.AuthenticationException; import org.openhab.core.auth.AuthenticationProvider; import org.openhab.core.auth.ManagedUser; @@ -40,7 +32,6 @@ 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; @@ -64,222 +55,184 @@ */ @NonNullByDefault @Component(immediate = true) -public class AuthorizePageServlet extends HttpServlet { +public class AuthorizePageServlet extends AbstractAuthPageServlet { 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 = ""; + super(bundleContext, httpService, userRegistry, authProvider); try { - URL resource = bundleContext.getBundle().getResource("pages/authorize.html"); - if (resource != null) { - try { - pageTemplate = new String(resource.openStream().readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - httpService.registerServlet("/auth", this, null, null); - } + 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) + protected void doGet(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDefault({}) HttpServletResponse resp) throws ServletException, IOException { - if (req != null && resp != null) { - Map params = req.getParameterMap(); + 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] : ""; + 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"); - } + // 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(); + // 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, false)); + 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) + protected void doPost(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDefault({}) 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"); - } + 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]); + removeCsrfToken(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]; + 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 (!"code".equals(responseType)) { + throw new AuthenticationException("unsupported_response_type"); + } - if (!clientId.equals(baseRedirectUri)) { - throw new IllegalArgumentException("unauthorized_client"); - } + 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 username = params.get("username")[0]; + String password = params.get("password")[0]; + + User user; + if (isSignupMode()) { + // Create a first administrator account with the supplied credentials - 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); + // 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.", false)); + resp.getWriter().close(); + return; } - String state = params.containsKey("state") ? params.get("state")[0] : null; - resp.addHeader(HttpHeaders.LOCATION, getRedirectUri(baseRedirectUri, authorizationCode, null, state)); + user = userRegistry.register(username, password, Set.of(Role.ADMIN)); + logger.info("First user account created: {}", username); + } else { + user = login(username, password); + } + + 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.MOVED_TEMPORARILY_302); + } catch (AuthenticationException e) { + processFailedLogin(resp, params, e.getMessage()); + } 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.MOVED_TEMPORARILY_302); - } 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 + } else { + resp.setContentType("text/plain;charset=UTF-8"); + resp.getWriter().append(e.getMessage()); 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.MOVED_TEMPORARILY_302); - } else { - resp.setContentType("text/plain;charset=UTF-8"); - resp.getWriter().append(e.getMessage()); - resp.getWriter().close(); - } } } } - private String getPageBody(Map params, String message) { + @Override + protected String getPageBody(Map params, String message, boolean hideForm) { String responseBody = pageTemplate.replace("{form_fields}", getFormFields(params)); - String repeatPasswordFieldType = (isSignupMode()) ? "password" : "hidden"; - String buttonLabel = (isSignupMode()) ? "Create Account" : "Sign In"; // TODO: i18n + String repeatPasswordFieldType = isSignupMode() ? "password" : "hidden"; + String buttonLabel = isSignupMode() ? "Create Account" : "Sign In"; // TODO: i18n responseBody = responseBody.replace("{message}", message); + responseBody = responseBody.replace("{formAction}", "/auth"); + responseBody = responseBody.replace("{formClass}", "show"); responseBody = responseBody.replace("{repeatPasswordFieldType}", repeatPasswordFieldType); + responseBody = responseBody.replace("{newPasswordFieldType}", "hidden"); + responseBody = responseBody.replace("{tokenNameFieldType}", "hidden"); + responseBody = responseBody.replace("{tokenScopeFieldType}", "hidden"); responseBody = responseBody.replace("{buttonLabel}", buttonLabel); + responseBody = responseBody.replace("{resultClass}", ""); return responseBody; } - private String getFormFields(Map params) { + @Override + protected String getFormFields(Map params) { String hiddenFormFields = ""; - if (!params.containsKey(("redirect_uri"))) { + if (!params.containsKey("redirect_uri")) { throw new IllegalArgumentException("invalid_request"); } - if (!params.containsKey(("response_type"))) { + if (!params.containsKey("response_type")) { throw new IllegalArgumentException("unsupported_response_type"); } - if (!params.containsKey(("client_id"))) { + if (!params.containsKey("client_id")) { throw new IllegalArgumentException("unauthorized_client"); } - if (!params.containsKey(("scope"))) { + if (!params.containsKey("scope")) { throw new IllegalArgumentException("invalid_scope"); } String csrfToken = addCsrfToken(); @@ -287,9 +240,9 @@ private String getFormFields(Map params) { 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")) + 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 += ""; @@ -326,14 +279,6 @@ private String getRedirectUri(String baseRedirectUri, @Nullable String authoriza 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(); } diff --git a/bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/ChangePasswordPageServlet.java b/bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/ChangePasswordPageServlet.java new file mode 100644 index 00000000000..2f576ffe53e --- /dev/null +++ b/bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/ChangePasswordPageServlet.java @@ -0,0 +1,171 @@ +/** + * 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.util.Map; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.auth.AuthenticationException; +import org.openhab.core.auth.AuthenticationProvider; +import org.openhab.core.auth.ManagedUser; +import org.openhab.core.auth.User; +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.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 a page allowing users to change their password, after confirming their identity by signing in. + * + * @author Yannick Schaus - initial contribution + * + */ +@NonNullByDefault +@Component(immediate = true) +public class ChangePasswordPageServlet extends AbstractAuthPageServlet { + + private static final long serialVersionUID = 5340598701104679843L; + + private final Logger logger = LoggerFactory.getLogger(ChangePasswordPageServlet.class); + + @Activate + public ChangePasswordPageServlet(BundleContext bundleContext, @Reference HttpService httpService, + @Reference UserRegistry userRegistry, @Reference AuthenticationProvider authProvider) { + super(bundleContext, httpService, userRegistry, authProvider); + try { + httpService.registerServlet("/changePassword", this, null, null); + } catch (NamespaceException | ServletException e) { + logger.error("Error during change password page registration: {}", e.getMessage()); + } + } + + @Override + protected void doGet(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDefault({}) HttpServletResponse resp) + throws ServletException, IOException { + Map params = req.getParameterMap(); + + try { + String message = ""; + + resp.setContentType("text/html;charset=UTF-8"); + resp.getWriter().append(getPageBody(params, message, false)); + 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(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDefault({}) HttpServletResponse resp) + throws ServletException, IOException { + 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("new_password")) { + throw new AuthenticationException("no new password"); + } + if (!params.containsKey("csrf_token") || !csrfTokens.containsKey(params.get("csrf_token")[0])) { + throw new AuthenticationException("CSRF check failed"); + } + + removeCsrfToken(params.get("csrf_token")[0]); + + String username = params.get("username")[0]; + String password = params.get("password")[0]; + String newPassword = params.get("new_password")[0]; + + if (!params.containsKey("password_repeat") || !newPassword.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.", false)); + resp.getWriter().close(); + return; + } + + User user = login(username, password); + + if (user instanceof ManagedUser) { + userRegistry.changePassword(user, newPassword); + } else { + throw new AuthenticationException("User is not managed"); + } + + resp.setContentType("text/html;charset=UTF-8"); + resp.getWriter().append(getResultPageBody(params, "Password changed.")); // TODO: i18n + resp.getWriter().close(); + } catch (AuthenticationException e) { + processFailedLogin(resp, params, e.getMessage()); + } + } + + @Override + protected String getPageBody(Map params, String message, boolean hideForm) { + String responseBody = pageTemplate.replace("{form_fields}", getFormFields(params)); + String buttonLabel = "Change Password"; // TODO: i18n + responseBody = responseBody.replace("{message}", message); + responseBody = responseBody.replace("{formAction}", "/changePassword"); + responseBody = responseBody.replace("{formClass}", hideForm ? "hide" : "show"); + responseBody = responseBody.replace("{repeatPasswordFieldType}", "password"); + responseBody = responseBody.replace("{newPasswordFieldType}", "password"); + responseBody = responseBody.replace("{tokenNameFieldType}", "hidden"); + responseBody = responseBody.replace("{tokenScopeFieldType}", "hidden"); + responseBody = responseBody.replace("{buttonLabel}", buttonLabel); + responseBody = responseBody.replace("{resultClass}", ""); + return responseBody; + } + + protected String getResultPageBody(Map params, String message) { + String responseBody = pageTemplate.replace("{form_fields}", ""); + responseBody = responseBody.replace("{message}", message); + responseBody = responseBody.replace("{formAction}", "/changePassword"); + responseBody = responseBody.replace("{formClass}", "hide"); + responseBody = responseBody.replace("{repeatPasswordFieldType}", "password"); + responseBody = responseBody.replace("{newPasswordFieldType}", "password"); + responseBody = responseBody.replace("{tokenNameFieldType}", "hidden"); + responseBody = responseBody.replace("{tokenScopeFieldType}", "hidden"); + responseBody = responseBody.replace("{resultClass}", "Password"); + return responseBody; + } + + @Override + protected String getFormFields(Map params) { + String hiddenFormFields = ""; + String csrfToken = addCsrfToken(); + hiddenFormFields += ""; + + return hiddenFormFields; + } + + @Deactivate + public void deactivate() { + httpService.unregister("/changePassword"); + } +} diff --git a/bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/CreateAPITokenPageServlet.java b/bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/CreateAPITokenPageServlet.java new file mode 100644 index 00000000000..75c4175c4ce --- /dev/null +++ b/bundles/org.openhab.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/CreateAPITokenPageServlet.java @@ -0,0 +1,190 @@ +/** + * 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.util.Map; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.auth.AuthenticationException; +import org.openhab.core.auth.AuthenticationProvider; +import org.openhab.core.auth.ManagedUser; +import org.openhab.core.auth.User; +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.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 a page allowing users to create a new API token, after confirming their identity by signing in. + * + * @author Yannick Schaus - initial contribution + * + */ +@NonNullByDefault +@Component(immediate = true) +public class CreateAPITokenPageServlet extends AbstractAuthPageServlet { + + private static final long serialVersionUID = 5340598701104679843L; + + private final Logger logger = LoggerFactory.getLogger(CreateAPITokenPageServlet.class); + + @Activate + public CreateAPITokenPageServlet(BundleContext bundleContext, @Reference HttpService httpService, + @Reference UserRegistry userRegistry, @Reference AuthenticationProvider authProvider) { + super(bundleContext, httpService, userRegistry, authProvider); + try { + httpService.registerServlet("/createApiToken", this, null, null); + } catch (NamespaceException | ServletException e) { + logger.error("Error during create API token page registration: {}", e.getMessage()); + } + } + + @Override + protected void doGet(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDefault({}) HttpServletResponse resp) + throws ServletException, IOException { + Map params = req.getParameterMap(); + + try { + String message = "Create a new API token to authorize external services."; + + // TODO: i18n + resp.setContentType("text/html;charset=UTF-8"); + resp.getWriter().append(getPageBody(params, message, false)); + 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(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDefault({}) HttpServletResponse resp) + throws ServletException, IOException { + 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("token_name")) { + throw new AuthenticationException("no new password"); + } + if (!params.containsKey("csrf_token") || !csrfTokens.containsKey(params.get("csrf_token")[0])) { + throw new AuthenticationException("CSRF check failed"); + } + + removeCsrfToken(params.get("csrf_token")[0]); + + String username = params.get("username")[0]; + String password = params.get("password")[0]; + String tokenName = params.get("token_name")[0]; + String tokenScope = ""; + if (params.containsKey("token_name")) { + tokenScope = params.get("token_scope")[0]; + } + + User user = login(username, password); + String newApiToken; + + if (user instanceof ManagedUser) { + if (((ManagedUser) user).getApiTokens().stream() + .anyMatch(apiToken -> apiToken.getName().equals(tokenName))) { + resp.setContentType("text/html;charset=UTF-8"); + // TODO: i18n + resp.getWriter().append( + getPageBody(params, "A token with the same name already exists, please try again.", false)); + resp.getWriter().close(); + return; + } + + if (!tokenName.matches("[a-zA-Z0-9]*")) { + resp.setContentType("text/html;charset=UTF-8"); + // TODO: i18n + resp.getWriter().append( + getPageBody(params, "Invalid token name, please use alphanumeric characters only.", false)); + resp.getWriter().close(); + return; + } + newApiToken = userRegistry.addUserApiToken(user, tokenName, tokenScope); + } else { + throw new AuthenticationException("User is not managed"); + } + + // TODO: i18n + String resultMessage = "New token created:

" + newApiToken + ""; + resultMessage += "

Please copy it now, it will not be shown again."; + resp.setContentType("text/html;charset=UTF-8"); + resp.getWriter().append(getResultPageBody(params, resultMessage)); // TODO: i18n + resp.getWriter().close(); + } catch (AuthenticationException e) { + processFailedLogin(resp, params, e.getMessage()); + } + } + + @Override + protected String getPageBody(Map params, String message, boolean hideForm) { + String responseBody = pageTemplate.replace("{form_fields}", getFormFields(params)); + String buttonLabel = "Create API Token"; // TODO: i18n + responseBody = responseBody.replace("{message}", message); + responseBody = responseBody.replace("{formAction}", "/createApiToken"); + responseBody = responseBody.replace("{formClass}", hideForm ? "hide" : "show"); + responseBody = responseBody.replace("{repeatPasswordFieldType}", "hidden"); + responseBody = responseBody.replace("{newPasswordFieldType}", "hidden"); + responseBody = responseBody.replace("{tokenNameFieldType}", "text"); + responseBody = responseBody.replace("{tokenScopeFieldType}", "text"); + responseBody = responseBody.replace("{buttonLabel}", buttonLabel); + responseBody = responseBody.replace("{resultClass}", ""); + return responseBody; + } + + protected String getResultPageBody(Map params, String message) { + String responseBody = pageTemplate.replace("{form_fields}", ""); + responseBody = responseBody.replace("{message}", message); + responseBody = responseBody.replace("{formAction}", "/createApiToken"); + responseBody = responseBody.replace("{formClass}", "hide"); + responseBody = responseBody.replace("{repeatPasswordFieldType}", "hidden"); + responseBody = responseBody.replace("{newPasswordFieldType}", "hidden"); + responseBody = responseBody.replace("{tokenNameFieldType}", "text"); + responseBody = responseBody.replace("{tokenScopeFieldType}", "text"); + responseBody = responseBody.replace("{resultClass}", "Password"); + return responseBody; + } + + @Override + protected String getFormFields(Map params) { + String hiddenFormFields = ""; + String csrfToken = addCsrfToken(); + hiddenFormFields += ""; + + return hiddenFormFields; + } + + @Deactivate + public void deactivate() { + httpService.unregister("/createApiToken"); + } +} 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 index c89b468dff7..1d3389e87cc 100644 --- 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 @@ -17,7 +17,6 @@ import java.util.Map; import javax.annotation.Priority; -import javax.security.sasl.AuthenticationException; import javax.ws.rs.Priorities; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.container.ContainerRequestFilter; @@ -29,7 +28,9 @@ import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.auth.Authentication; +import org.openhab.core.auth.AuthenticationException; import org.openhab.core.auth.User; +import org.openhab.core.auth.UserApiTokenCredentials; import org.openhab.core.auth.UserRegistry; import org.openhab.core.auth.UsernamePasswordCredentials; import org.openhab.core.config.core.ConfigurableService; @@ -52,6 +53,7 @@ * * @author Yannick Schaus - initial contribution * @author Yannick Schaus - Allow basic authentication + * @author Yannick Schaus - Add support for API tokens */ @PreMatching @Component(configurationPid = "org.openhab.restauth", property = Constants.SERVICE_PID + "=org.openhab.restauth") @@ -64,6 +66,7 @@ public class AuthFilter implements ContainerRequestFilter { private final Logger logger = LoggerFactory.getLogger(AuthFilter.class); private static final String ALT_AUTH_HEADER = "X-OPENHAB-TOKEN"; + private static final String API_TOKEN_PREFIX = "oh."; protected static final String CONFIG_URI = "system:restauth"; private static final String CONFIG_ALLOW_BASIC_AUTH = "allowBasicAuth"; @@ -93,50 +96,76 @@ protected void modified(@Nullable Map properties) { } } + private SecurityContext authenticateBearerToken(String token) throws AuthenticationException { + if (token.startsWith(API_TOKEN_PREFIX)) { + UserApiTokenCredentials credentials = new UserApiTokenCredentials(token); + Authentication auth = userRegistry.authenticate(credentials); + User user = userRegistry.get(auth.getUsername()); + if (user == null) { + throw new AuthenticationException("User not found in registry"); + } + return new UserSecurityContext(user, auth, "ApiToken"); + } else { + Authentication auth = jwtHelper.verifyAndParseJwtAccessToken(token); + return new JwtSecurityContext(auth); + } + } + + private SecurityContext authenticateUsernamePassword(String username, String password) + throws AuthenticationException { + UsernamePasswordCredentials credentials = new UsernamePasswordCredentials(username, password); + Authentication auth = userRegistry.authenticate(credentials); + User user = userRegistry.get(auth.getUsername()); + if (user == null) { + throw new AuthenticationException("User not found in registry"); + } + return new UserSecurityContext(user, auth, "Basic"); + } + @Override public void filter(ContainerRequestContext requestContext) throws IOException { try { + String altTokenHeader = requestContext.getHeaderString(ALT_AUTH_HEADER); + if (altTokenHeader != null) { + requestContext.setSecurityContext(authenticateBearerToken(altTokenHeader)); + return; + } + String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); if (authHeader != null) { String[] authParts = authHeader.split(" "); if (authParts.length == 2) { if ("Bearer".equalsIgnoreCase(authParts[0])) { - Authentication auth = jwtHelper.verifyAndParseJwtAccessToken(authParts[1]); - requestContext.setSecurityContext(new JwtSecurityContext(auth)); + requestContext.setSecurityContext(authenticateBearerToken(authParts[1])); return; } else if ("Basic".equalsIgnoreCase(authParts[0])) { - if (!allowBasicAuth) { - throw new AuthenticationException("Basic authentication is not allowed"); - } try { String[] decodedCredentials = new String(Base64.getDecoder().decode(authParts[1]), "UTF-8") .split(":"); - if (decodedCredentials.length != 2) { + if (decodedCredentials.length > 2) { throw new AuthenticationException("Invalid Basic authentication credential format"); } - UsernamePasswordCredentials credentials = new UsernamePasswordCredentials( - decodedCredentials[0], decodedCredentials[1]); - Authentication auth = userRegistry.authenticate(credentials); - User user = userRegistry.get(auth.getUsername()); - if (user == null) { - throw new org.openhab.core.auth.AuthenticationException("User not found in registry"); + switch (decodedCredentials.length) { + case 1: + requestContext.setSecurityContext(authenticateBearerToken(decodedCredentials[0])); + break; + case 2: + if (!allowBasicAuth) { + throw new AuthenticationException( + "Basic authentication with username/password is not allowed"); + } + requestContext.setSecurityContext( + authenticateUsernamePassword(decodedCredentials[0], decodedCredentials[1])); } - requestContext.setSecurityContext(new UserSecurityContext(user, "Basic")); + return; - } catch (org.openhab.core.auth.AuthenticationException e) { + } catch (AuthenticationException e) { throw new AuthenticationException("Invalid Basic authentication credentials", e); } } } } - String altTokenHeader = requestContext.getHeaderString(ALT_AUTH_HEADER); - if (altTokenHeader != null) { - Authentication auth = jwtHelper.verifyAndParseJwtAccessToken(altTokenHeader); - requestContext.setSecurityContext(new JwtSecurityContext(auth)); - return; - } - if (implicitUserRole) { requestContext.setSecurityContext(new AnonymousUserSecurityContext()); } diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthenticationSecurityContext.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthenticationSecurityContext.java new file mode 100644 index 00000000000..28033bc76fc --- /dev/null +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthenticationSecurityContext.java @@ -0,0 +1,31 @@ +/** + * 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 javax.ws.rs.core.SecurityContext; + +import org.openhab.core.auth.Authentication; + +/** + * A {@link SecurityContext} holding an instance of {@link Authentication} + * + * @author Yannick Schaus - initial contribution + */ +public interface AuthenticationSecurityContext extends SecurityContext { + /** + * Retrieves the {@link Authentication} associated with this context + * + * @return the authentication instance + */ + public Authentication getAuthentication(); +} 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 index 8eea84c6d35..48f8b30b19b 100644 --- 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 @@ -23,8 +23,6 @@ import java.util.Collections; import java.util.List; -import javax.security.sasl.AuthenticationException; - import org.eclipse.jdt.annotation.NonNullByDefault; import org.jose4j.jwa.AlgorithmConstraints.ConstraintType; import org.jose4j.jwk.JsonWebKey; @@ -41,6 +39,7 @@ import org.jose4j.lang.JoseException; import org.openhab.core.OpenHAB; import org.openhab.core.auth.Authentication; +import org.openhab.core.auth.AuthenticationException; import org.openhab.core.auth.User; import org.osgi.service.component.annotations.Component; import org.slf4j.Logger; @@ -150,8 +149,8 @@ public Authentication verifyAndParseJwtAccessToken(String jwt) throws Authentica 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; + String scope = jwtClaims.getStringClaimValue("scope"); + return new Authentication(username, roles.toArray(new String[roles.size()]), scope); } catch (InvalidJwtException | MalformedClaimException e) { throw new AuthenticationException("Error while processing JWT token", e); } 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 index 3d272f3249b..ae47ec7ba0e 100644 --- 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 @@ -28,7 +28,7 @@ * @author Yannick Schaus - initial contribution */ @NonNullByDefault -public class JwtSecurityContext implements SecurityContext { +public class JwtSecurityContext implements AuthenticationSecurityContext { Authentication authentication; @@ -55,4 +55,9 @@ public boolean isSecure() { public String getAuthenticationScheme() { return "JWT"; } + + @Override + public Authentication getAuthentication() { + return authentication; + } } 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 index 71d583ce1ea..6f1943820be 100644 --- 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 @@ -22,10 +22,12 @@ import javax.ws.rs.Consumes; import javax.ws.rs.CookieParam; +import javax.ws.rs.DELETE; import javax.ws.rs.FormParam; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; @@ -39,10 +41,12 @@ import javax.ws.rs.core.UriInfo; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; 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.UserApiToken; import org.openhab.core.auth.UserRegistry; import org.openhab.core.auth.UserSession; import org.openhab.core.io.rest.JSONResponse; @@ -73,6 +77,7 @@ * @author Yannick Schaus - Initial contribution * @author Wouter Born - Migrated to JAX-RS Whiteboard Specification * @author Wouter Born - Migrated to OpenAPI annotations + * @author Yannick Schaus - Add API token operations */ @Component(service = { RESTResource.class, TokenResource.class }) @JaxrsResource @@ -110,7 +115,8 @@ public TokenResource(final @Reference UserRegistry userRegistry, final @Referenc @Produces({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_FORM_URLENCODED }) @Operation(summary = "Get access and refresh tokens.", responses = { - @ApiResponse(responseCode = "200", description = "OK") }) + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "400", description = "Invalid request parameters") }) 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, @@ -138,7 +144,9 @@ public Response getToken(@FormParam("grant_type") String grantType, @FormParam(" @GET @Path("/sessions") @Operation(summary = "List the sessions associated to the authenticated user.", responses = { - @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UserSessionDTO.class))) }) + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UserSessionDTO.class))), + @ApiResponse(responseCode = "401", description = "User is not authenticated"), + @ApiResponse(responseCode = "404", description = "User not found") }) @Produces({ MediaType.APPLICATION_JSON }) public Response getSessions(@Context SecurityContext securityContext) { if (securityContext.getUserPrincipal() == null) { @@ -154,13 +162,62 @@ public Response getSessions(@Context SecurityContext securityContext) { return Response.ok(new Stream2JSONInputStream(sessions)).build(); } + @GET + @Path("/apitokens") + @Operation(summary = "List the API tokens associated to the authenticated user.", responses = { + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UserApiTokenDTO.class))), + @ApiResponse(responseCode = "401", description = "User is not authenticated"), + @ApiResponse(responseCode = "404", description = "User not found") }) + @Produces({ MediaType.APPLICATION_JSON }) + public Response getApiTokens(@Context SecurityContext securityContext) { + if (securityContext.getUserPrincipal() == null) { + return JSONResponse.createErrorResponse(Status.UNAUTHORIZED, "User is not authenticated"); + } + + ManagedUser user = (ManagedUser) userRegistry.get(securityContext.getUserPrincipal().getName()); + if (user == null) { + return JSONResponse.createErrorResponse(Status.NOT_FOUND, "User not found"); + } + + Stream sessions = user.getApiTokens().stream().map(this::toUserApiTokenDTO); + return Response.ok(new Stream2JSONInputStream(sessions)).build(); + } + + @DELETE + @Path("/apitokens/{name}") + @Operation(summary = "Revoke a specified API token associated to the authenticated user.", responses = { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "401", description = "User is not authenticated"), + @ApiResponse(responseCode = "404", description = "User or API token not found") }) + public Response removeApiToken(@Context SecurityContext securityContext, @PathParam("name") String name) { + if (securityContext.getUserPrincipal() == null) { + return JSONResponse.createErrorResponse(Status.UNAUTHORIZED, "User is not authenticated"); + } + + ManagedUser user = (ManagedUser) userRegistry.get(securityContext.getUserPrincipal().getName()); + if (user == null) { + return JSONResponse.createErrorResponse(Status.NOT_FOUND, "User not found"); + } + + Optional userApiToken = user.getApiTokens().stream() + .filter(apiToken -> apiToken.getName().equals(name)).findAny(); + if (userApiToken.isEmpty()) { + return JSONResponse.createErrorResponse(Status.NOT_FOUND, "No API token found with that name"); + } + + userRegistry.removeUserApiToken(user, userApiToken.get()); + return Response.ok().build(); + } + @POST @Path("/logout") @Consumes({ MediaType.APPLICATION_FORM_URLENCODED }) @Operation(summary = "Delete the session associated with a refresh token.", responses = { - @ApiResponse(responseCode = "200", description = "OK") }) - public Response deleteSession(@FormParam("refresh_token") String refreshToken, @FormParam("id") String id, - @Context SecurityContext securityContext) { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "401", description = "User is not authenticated"), + @ApiResponse(responseCode = "404", description = "User or refresh token not found") }) + public Response deleteSession(@Nullable @FormParam("refresh_token") String refreshToken, + @Nullable @FormParam("id") String id, @Context SecurityContext securityContext) { if (securityContext.getUserPrincipal() == null) { return JSONResponse.createErrorResponse(Status.UNAUTHORIZED, "User is not authenticated"); } @@ -195,8 +252,7 @@ public Response deleteSession(@FormParam("refresh_token") String refreshToken, @ } } - user.getSessions().remove(session.get()); - userRegistry.update(user); + userRegistry.removeUserSession(user, session.get()); return response.build(); } @@ -208,11 +264,19 @@ private UserSessionDTO toUserSessionDTO(UserSession session) { session.getLastRefreshTime(), session.getClientId(), session.getScope()); } + private UserApiTokenDTO toUserApiTokenDTO(UserApiToken apiToken) { + return new UserApiTokenDTO(apiToken.getName(), apiToken.getCreatedTime(), apiToken.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(); + @Nullable String codeVerifier, boolean useCookie) throws TokenEndpointException, NoSuchAlgorithmException { + // find a user with the authorization code pending + Optional user = userRegistry.getAll().stream().filter(u -> { + ManagedUser managedUser = (ManagedUser) u; + @Nullable + PendingToken pendingToken = managedUser.getPendingToken(); + return (pendingToken != null && pendingToken.getAuthorizationCode().equals(code)); + }).findAny(); if (!user.isPresent()) { logger.warn("Couldn't find a user with the provided authentication code pending"); @@ -308,8 +372,8 @@ private Response processAuthorizationCodeGrant(String code, String redirectUri, return response.build(); } - private Response processRefreshTokenGrant(String clientId, String refreshToken, Cookie sessionCookie) - throws TokenEndpointException { + private Response processRefreshTokenGrant(String clientId, @Nullable String refreshToken, + @Nullable Cookie sessionCookie) throws TokenEndpointException { if (refreshToken == null) { throw new TokenEndpointException(ErrorType.INVALID_REQUEST); } diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserApiTokenDTO.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserApiTokenDTO.java new file mode 100644 index 00000000000..885391d1ea3 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserApiTokenDTO.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.Date; + +/** + * A DTO representing a user API token, without the sensible information. + * + * @author Yannick Schaus - initial contribution + */ +public class UserApiTokenDTO { + String name; + Date createdTime; + String scope; + + public UserApiTokenDTO(String name, Date createdTime, String scope) { + super(); + this.name = name; + this.createdTime = createdTime; + this.scope = scope; + } +} diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserSecurityContext.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserSecurityContext.java index cd9cb74fc99..449718f86f0 100644 --- a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserSecurityContext.java +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserSecurityContext.java @@ -18,6 +18,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.auth.Authentication; import org.openhab.core.auth.User; /** @@ -27,19 +28,22 @@ * @author Yannick Schaus - initial contribution */ @NonNullByDefault -public class UserSecurityContext implements SecurityContext { +public class UserSecurityContext implements AuthenticationSecurityContext { private User user; + private Authentication authentication; private String authenticationScheme; /** * Constructs a security context from an instance of {@link User} * * @param user the user + * @param the related {@link Authentication} * @param authenticationScheme the scheme that was used to authenticate the user, e.g. "Basic" */ - public UserSecurityContext(User user, String authenticationScheme) { + public UserSecurityContext(User user, Authentication authentication, String authenticationScheme) { this.user = user; + this.authentication = authentication; this.authenticationScheme = authenticationScheme; } @@ -62,4 +66,9 @@ public boolean isSecure() { public String getAuthenticationScheme() { return authenticationScheme; } + + @Override + public Authentication getAuthentication() { + return authentication; + } } diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.core.io.rest.auth/src/main/resources/OH-INF/config/config.xml index 533a6c76bca..bcf149e4c83 100644 --- a/bundles/org.openhab.core.io.rest.auth/src/main/resources/OH-INF/config/config.xml +++ b/bundles/org.openhab.core.io.rest.auth/src/main/resources/OH-INF/config/config.xml @@ -16,7 +16,8 @@ true By default, operations requiring the "user" role are available when unauthenticated. Disabling this - option will enforce authorization for these operations. Warning: This causes clients that do not support + option will enforce authorization for these operations. Warning: This causes clients that do not + support authentication to break. diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/Authentication.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/Authentication.java index e61b13442db..15804ee37d1 100644 --- a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/Authentication.java +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/Authentication.java @@ -19,15 +19,18 @@ /** * Definition of authentication given to username after verification of credentials by authentication provider. * - * Each authentication must at least point to some identity (username) and roles. + * Each authentication must at least point to some identity (username), roles, and may also be valid for a specific + * scope only. * * @author Ɓukasz Dywicki - Initial contribution * @author Kai Kreuzer - Added JavaDoc and switched from array to Set + * @author Yannick Schaus - Add scope */ public class Authentication { private String username; private Set roles; + private String scope; /** * no-args constructor required by gson @@ -35,6 +38,7 @@ public class Authentication { protected Authentication() { this.username = null; this.roles = null; + this.scope = null; } /** @@ -48,6 +52,18 @@ public Authentication(String username, String... roles) { this.roles = new HashSet<>(Arrays.asList(roles)); } + /** + * Creates a new instance with a specific scope + * + * @param username name of the user associated to this authentication instance + * @param roles a variable list of roles that the user possesses. + * @param scope a scope this authentication is valid for + */ + public Authentication(String username, String[] roles, String scope) { + this(username, roles); + this.scope = scope; + } + /** * Retrieves the name of the authenticated user * @@ -65,4 +81,13 @@ public String getUsername() { public Set getRoles() { return roles; } + + /** + * Retrieves the scope this authentication is valid for + * + * @return a scope + */ + public String getScope() { + return scope; + } } 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 index d008e8da4fc..3f132200dad 100644 --- 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 @@ -59,6 +59,24 @@ public String getPasswordHash() { return passwordHash; } + /** + * Alters the password salt. + * + * @param passwordSalt the new password salt + */ + public void setPasswordSalt(String passwordSalt) { + this.passwordSalt = passwordSalt; + } + + /** + * Alters the password hash. + * + * @param passwordHash the new password hash + */ + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + /** * Gets the password salt. * @@ -154,4 +172,9 @@ public List getApiTokens() { public void setApiTokens(List apiTokens) { this.apiTokens = apiTokens; } + + @Override + public String toString() { + return name + " (" + String.join(", ", roles.stream().toArray(String[]::new)) + ")"; + } } 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 index b46403bc564..401ea9ebb62 100644 --- 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 @@ -15,8 +15,8 @@ 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. + * An API token represents long-term credentials generated by (or for) a user, giving the bearer access for a certain + * scope on behalf of this user. * * @author Yannick Schaus - initial contribution * @@ -31,7 +31,7 @@ public class UserApiToken { * Constructs an API token. * * @param name the name of the token, for identification purposes - * @param apiToken the token + * @param apiToken the serialization of the token * @param scope the scope this token is valid for */ public UserApiToken(String name, String apiToken, String scope) { @@ -52,7 +52,8 @@ public String getName() { } /** - * Gets the API token which can be passed in requests as a "Bearer" token in the Authorization HTTP header. + * Get the serialization of the opaque API token which can be passed in requests as a "Bearer" token in the + * Authorization HTTP header. * * @return the API token */ @@ -77,4 +78,9 @@ public Date getCreatedTime() { public String getScope() { return scope; } + + @Override + public String toString() { + return "name=" + name + ", createdTime=" + createdTime + ", scope=" + scope; + } } diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserApiTokenCredentials.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserApiTokenCredentials.java new file mode 100644 index 00000000000..7c893e67e31 --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserApiTokenCredentials.java @@ -0,0 +1,41 @@ +/** + * 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; + +/** + * Credentials which represent a user API token. + * + * @author Yannick Schaus - Initial contribution + */ +public class UserApiTokenCredentials implements Credentials { + + private final String userApiToken; + + /** + * Creates a new instance + * + * @param userApiToken the user API token + */ + public UserApiTokenCredentials(String userApiToken) { + this.userApiToken = userApiToken; + } + + /** + * Retrieves the user API token + * + * @return the token + */ + public String getApiToken() { + return userApiToken; + } +} 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 index 6a49b24303e..dbdb14b4f70 100644 --- 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 @@ -38,4 +38,55 @@ public interface UserRegistry extends Registry, AuthenticationProv * @return the new registered {@link User} instance */ public User register(String username, String password, Set roles); + + /** + * Change the password for an {@link User} in this registry. The implementation receives the new password and is + * responsible for their secure storage (for instance by hashing the password). + * + * @param username the username of the existing user + * @param newPassword the new password + */ + public void changePassword(User user, String newPassword); + + /** + * Adds a new session to the user profile + * + * @param user the user + * @param session the session to add + */ + public void addUserSession(User user, UserSession session); + + /** + * Removes the specified session from the user profile + * + * @param user the user + * @param session the session to remove + */ + public void removeUserSession(User user, UserSession session); + + /** + * Clears all sessions from the user profile + * + * @param user the user + */ + public void clearSessions(User user); + + /** + * Adds a new API token to the user profile. The implementation is responsible for storing the token in a secure way + * (for instance by hashing it). + * + * @param user the user + * @param name the name of the API token to create + * @param scope the scope this API token will be valid for + * @return the string that can be used as a Bearer token to match the new API token + */ + public String addUserApiToken(User user, String name, String scope); + + /** + * Removes the specified API token from the user profile + * + * @param user the user + * @param apiToken the API token + */ + public void removeUserApiToken(User user, UserApiToken apiToken); } 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 index 1420566981a..37a1e0c66ff 100644 --- 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 @@ -31,8 +31,11 @@ import org.openhab.core.auth.Credentials; import org.openhab.core.auth.ManagedUser; import org.openhab.core.auth.User; +import org.openhab.core.auth.UserApiToken; +import org.openhab.core.auth.UserApiTokenCredentials; import org.openhab.core.auth.UserProvider; import org.openhab.core.auth.UserRegistry; +import org.openhab.core.auth.UserSession; import org.openhab.core.auth.UsernamePasswordCredentials; import org.openhab.core.common.registry.AbstractRegistry; import org.osgi.framework.BundleContext; @@ -56,7 +59,9 @@ public class UserRegistryImpl extends AbstractRegistry roles) { String passwordSalt = generateSalt(KEY_LENGTH / 8).get(); - String passwordHash = hashPassword(password, passwordSalt).get(); + String passwordHash = hash(password, passwordSalt, PASSWORD_ITERATIONS).get(); ManagedUser user = new ManagedUser(username, passwordSalt, passwordHash); user.setRoles(new HashSet<>(roles)); super.add(user); @@ -106,11 +111,11 @@ private Optional generateSalt(final int length) { return Optional.of(Base64.getEncoder().encodeToString(salt)); } - private Optional hashPassword(String password, String salt) { + private Optional hash(String password, String salt, int iterations) { char[] chars = password.toCharArray(); byte[] bytes = salt.getBytes(); - PBEKeySpec spec = new PBEKeySpec(chars, bytes, ITERATIONS, KEY_LENGTH); + PBEKeySpec spec = new PBEKeySpec(chars, bytes, iterations, KEY_LENGTH); Arrays.fill(chars, Character.MIN_VALUE); @@ -119,7 +124,7 @@ private Optional hashPassword(String password, String salt) { byte[] securePassword = fac.generateSecret(spec).getEncoded(); return Optional.of(Base64.getEncoder().encodeToString(securePassword)); } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { - logger.error("Exception encountered in hashPassword", e); + logger.error("Exception encountered while hashing", e); return Optional.empty(); } finally { spec.clearPassword(); @@ -128,23 +133,132 @@ private Optional hashPassword(String password, String salt) { @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 (credentials instanceof UsernamePasswordCredentials) { + UsernamePasswordCredentials usernamePasswordCreds = (UsernamePasswordCredentials) credentials; + User user = get(usernamePasswordCreds.getUsername()); + if (user == null) { + throw new AuthenticationException("User not found: " + usernamePasswordCreds.getUsername()); + } + + ManagedUser managedUser = (ManagedUser) user; + String hashedPassword = hash(usernamePasswordCreds.getPassword(), managedUser.getPasswordSalt(), + PASSWORD_ITERATIONS).get(); + if (!hashedPassword.equals(managedUser.getPasswordHash())) { + throw new AuthenticationException("Wrong password for user " + usernamePasswordCreds.getUsername()); + } + + return new Authentication(managedUser.getName(), managedUser.getRoles().stream().toArray(String[]::new)); + } else if (credentials instanceof UserApiTokenCredentials) { + UserApiTokenCredentials apiTokenCreds = (UserApiTokenCredentials) credentials; + String[] apiTokenParts = apiTokenCreds.getApiToken().split("\\."); + if (apiTokenParts.length != 3 || !APITOKEN_PREFIX.equals(apiTokenParts[0])) { + throw new AuthenticationException("Invalid API token format"); + } + for (User user : getAll()) { + ManagedUser managedUser = (ManagedUser) user; + for (UserApiToken userApiToken : managedUser.getApiTokens()) { + // only check if the name in the token matches + if (!userApiToken.getName().equals(apiTokenParts[1])) { + continue; + } + String[] existingTokenHashAndSalt = userApiToken.getApiToken().split(":"); + String incomingTokenHash = hash(apiTokenCreds.getApiToken(), existingTokenHashAndSalt[1], + APITOKEN_ITERATIONS).get(); + + if (incomingTokenHash.equals(existingTokenHashAndSalt[0])) { + return new Authentication(managedUser.getName(), + managedUser.getRoles().stream().toArray(String[]::new), userApiToken.getScope()); + } + } + } + + throw new AuthenticationException("Unknown API token"); + } + + throw new IllegalArgumentException("Invalid credential type"); + } + + @Override + public void changePassword(User user, String newPassword) { + if (!(user instanceof ManagedUser)) { + throw new IllegalArgumentException("User is not managed: " + user.getName()); + } + + ManagedUser managedUser = (ManagedUser) user; + String passwordSalt = generateSalt(KEY_LENGTH / 8).get(); + String passwordHash = hash(newPassword, passwordSalt, PASSWORD_ITERATIONS).get(); + managedUser.setPasswordSalt(passwordSalt); + managedUser.setPasswordHash(passwordHash); + update(user); + } + + @Override + public void addUserSession(User user, UserSession session) { + if (!(user instanceof ManagedUser)) { + throw new IllegalArgumentException("User is not managed: " + user.getName()); + } + + ManagedUser managedUser = (ManagedUser) user; + managedUser.getSessions().add(session); + update(user); + } + + @Override + public void removeUserSession(User user, UserSession session) { + if (!(user instanceof ManagedUser)) { + throw new IllegalArgumentException("User is not managed: " + user.getName()); } + + ManagedUser managedUser = (ManagedUser) user; + managedUser.getSessions().remove(session); + update(user); + } + + @Override + public void clearSessions(User user) { if (!(user instanceof ManagedUser)) { - throw new AuthenticationException("User is not managed: " + usernamePasswordCreds.getUsername()); + throw new IllegalArgumentException("User is not managed: " + user.getName()); } 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()); + managedUser.getSessions().clear(); + update(user); + } + + @Override + public String addUserApiToken(User user, String name, String scope) { + if (!(user instanceof ManagedUser)) { + throw new IllegalArgumentException("User is not managed: " + user.getName()); + } + if (!name.matches("[a-zA-Z0-9]*")) { + throw new IllegalArgumentException("API token name format invalid, alphanumeric characters only"); } - Authentication authentication = new Authentication(managedUser.getName()); - return authentication; + ManagedUser managedUser = (ManagedUser) user; + String tokenSalt = generateSalt(KEY_LENGTH / 8).get(); + byte[] rnd = new byte[64]; + RAND.nextBytes(rnd); + String token = APITOKEN_PREFIX + "." + name + "." + + Base64.getEncoder().encodeToString(rnd).replaceAll("(\\+|/|=)", ""); + String tokenHash = hash(token, tokenSalt, APITOKEN_ITERATIONS).get(); + + UserApiToken userApiToken = new UserApiToken(name, tokenHash + ":" + tokenSalt, scope); + + managedUser.getApiTokens().add(userApiToken); + update(user); + + return token; + } + + @Override + public void removeUserApiToken(User user, UserApiToken userApiToken) { + if (!(user instanceof ManagedUser)) { + throw new IllegalArgumentException("User is not managed: " + user.getName()); + } + + ManagedUser managedUser = (ManagedUser) user; + managedUser.getApiTokens().remove(userApiToken); + update(user); } @Override diff --git a/bundles/org.openhab.core/src/test/java/org/openhab/core/internal/auth/UserRegistryImplTest.java b/bundles/org.openhab.core/src/test/java/org/openhab/core/internal/auth/UserRegistryImplTest.java new file mode 100644 index 00000000000..3d64d31a154 --- /dev/null +++ b/bundles/org.openhab.core/src/test/java/org/openhab/core/internal/auth/UserRegistryImplTest.java @@ -0,0 +1,126 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.openhab.core.auth.ManagedUser; +import org.openhab.core.auth.User; +import org.openhab.core.auth.UserApiTokenCredentials; +import org.openhab.core.auth.UserSession; +import org.openhab.core.auth.UsernamePasswordCredentials; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceEvent; +import org.osgi.framework.ServiceListener; +import org.osgi.framework.ServiceReference; + +/** + * @author Yannick Schaus - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +public class UserRegistryImplTest { + + @SuppressWarnings("rawtypes") + private @Mock ServiceReference managedProviderRef; + private @Mock BundleContext bundleContext; + private @Mock ManagedUserProvider managedProvider; + + private UserRegistryImpl registry; + private ServiceListener providerTracker; + + @BeforeEach + @SuppressWarnings("unchecked") + public void setup() throws Exception { + when(bundleContext.getService(same(managedProviderRef))).thenReturn(managedProvider); + + registry = new UserRegistryImpl(bundleContext, Map.of()); + registry.setManagedProvider(managedProvider); + registry.waitForCompletedAsyncActivationTasks(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ServiceListener.class); + verify(bundleContext).addServiceListener(captor.capture(), any()); + providerTracker = captor.getValue(); + providerTracker.serviceChanged(new ServiceEvent(ServiceEvent.REGISTERED, managedProviderRef)); + } + + @Test + public void testGetEmpty() throws Exception { + User res = registry.get("none"); + assertNull(res); + } + + @Test + public void testUserManagement() throws Exception { + User user = registry.register("username", "password", Set.of("administrator")); + registry.added(managedProvider, user); + assertNotNull(user); + registry.authenticate(new UsernamePasswordCredentials("username", "password")); + registry.changePassword(user, "password2"); + registry.authenticate(new UsernamePasswordCredentials("username", "password2")); + registry.remove(user.getName()); + registry.removed(managedProvider, user); + user = registry.get("username"); + assertNull(user); + } + + @Test + public void testSessions() throws Exception { + ManagedUser user = (ManagedUser) registry.register("username", "password", Set.of("administrator")); + registry.added(managedProvider, user); + assertNotNull(user); + UserSession session1 = new UserSession(UUID.randomUUID().toString(), "s1", "urn:test", "urn:test", "scope"); + UserSession session2 = new UserSession(UUID.randomUUID().toString(), "s2", "urn:test", "urn:test", "scope2"); + UserSession session3 = new UserSession(UUID.randomUUID().toString(), "s3", "urn:test", "urn:test", "scope3"); + registry.addUserSession(user, session1); + registry.addUserSession(user, session2); + registry.addUserSession(user, session3); + assertEquals(user.getSessions().size(), 3); + registry.removeUserSession(user, session3); + assertEquals(user.getSessions().size(), 2); + registry.clearSessions(user); + assertEquals(user.getSessions().size(), 0); + } + + @Test + public void testApiTokens() throws Exception { + ManagedUser user = (ManagedUser) registry.register("username", "password", Set.of("administrator")); + registry.added(managedProvider, user); + assertNotNull(user); + String token1 = registry.addUserApiToken(user, "token1", "scope1"); + String token2 = registry.addUserApiToken(user, "token2", "scope2"); + String token3 = registry.addUserApiToken(user, "token3", "scope3"); + assertEquals(user.getApiTokens().size(), 3); + registry.authenticate(new UserApiTokenCredentials(token1)); + registry.authenticate(new UserApiTokenCredentials(token2)); + registry.authenticate(new UserApiTokenCredentials(token3)); + registry.removeUserApiToken(user, + user.getApiTokens().stream().filter(t -> t.getName().equals("token1")).findAny().get()); + registry.removeUserApiToken(user, + user.getApiTokens().stream().filter(t -> t.getName().equals("token2")).findAny().get()); + registry.removeUserApiToken(user, + user.getApiTokens().stream().filter(t -> t.getName().equals("token3")).findAny().get()); + assertEquals(user.getApiTokens().size(), 0); + } +}