From b459cbc0cb5cf5586c796fe882b427eadc03d0d5 Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Mon, 19 Oct 2020 20:09:10 +0200 Subject: [PATCH 01/12] [REST Auth] API tokens & openhab:users console command 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. Signed-off-by: Yannick Schaus --- .../UserConsoleCommandExtension.java | 197 ++++++++++++++++++ .../io/rest/auth/internal/AuthFilter.java | 63 ++++-- .../core/io/rest/auth/internal/JwtHelper.java | 3 +- .../io/rest/auth/internal/TokenResource.java | 54 ++++- .../rest/auth/internal/UserApiTokenDTO.java | 33 +++ .../main/resources/OH-INF/config/config.xml | 3 +- .../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 | 132 ++++++++++-- 11 files changed, 573 insertions(+), 41 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.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 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..738901b2beb --- /dev/null +++ b/bundles/org.openhab.core.io.console/src/main/java/org/openhab/core/io/console/internal/extension/UserConsoleCommandExtension.java @@ -0,0 +1,197 @@ +/** + * 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.Arrays; +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 Arrays.asList(new String[] { 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 keys 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().size() > 0) { + 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]); + // inform the user that the token will not be printed again, and they should save it? + 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.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..22168992a51 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,6 +96,32 @@ 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 org.openhab.core.auth.AuthenticationException("User not found in registry"); + } + return new UserSecurityContext(user, "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 org.openhab.core.auth.AuthenticationException("User not found in registry"); + } + return new UserSecurityContext(user, "Basic"); + } + @Override public void filter(ContainerRequestContext requestContext) throws IOException { try { @@ -101,29 +130,30 @@ public void filter(ContainerRequestContext requestContext) throws IOException { 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); } } @@ -132,8 +162,7 @@ public void filter(ContainerRequestContext requestContext) throws IOException { String altTokenHeader = requestContext.getHeaderString(ALT_AUTH_HEADER); if (altTokenHeader != null) { - Authentication auth = jwtHelper.verifyAndParseJwtAccessToken(altTokenHeader); - requestContext.setSecurityContext(new JwtSecurityContext(auth)); + requestContext.setSecurityContext(authenticateBearerToken(altTokenHeader)); return; } 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..fd4c3ba7a5f 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; 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..e6365381534 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; @@ -43,6 +45,7 @@ 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 +76,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 @@ -154,6 +158,49 @@ 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))) }) + @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", content = @Content(schema = @Schema(implementation = UserApiTokenDTO.class))) }) + 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 }) @@ -195,8 +242,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,6 +254,10 @@ 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 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..4dcc3e36b25 --- /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 session, 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/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/ManagedUser.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/ManagedUser.java index d008e8da4fc..2128c9fd399 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 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..4fecff1ee11 --- /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 user 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..a8020500bcb 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 password 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..2d2a6cf439f 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,8 @@ 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 +110,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 +123,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 +132,121 @@ 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 = this.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 userApiTokenCreds = (UserApiTokenCredentials) credentials; + for (User user : getAll()) { + ManagedUser managedUser = (ManagedUser) user; + String tokenHash = hash(userApiTokenCreds.getApiToken(), managedUser.getPasswordSalt(), + APITOKEN_ITERATIONS).get(); + + if (managedUser.getApiTokens().stream() + .anyMatch(userApiToken -> tokenHash.equals(userApiToken.getApiToken()))) { + return new Authentication(managedUser.getName(), + managedUser.getRoles().stream().toArray(String[]::new)); + } + } + + 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); + this.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); + this.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().add(session); + this.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(); + this.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 salt = managedUser.getPasswordSalt(); + byte[] rnd = new byte[64]; + RAND.nextBytes(rnd); + String token = "oh." + name + "." + Base64.getEncoder().encodeToString(rnd).replaceAll("(\\+|/|=)", ""); + String tokenHash = hash(token, salt, APITOKEN_ITERATIONS).get(); + + UserApiToken userApiToken = new UserApiToken(name, tokenHash, scope); + + managedUser.getApiTokens().add(userApiToken); + this.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); + this.update(user); } @Override From 9d099c7694f8c5ea46551a4c36c9a12a2e9429b1 Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Mon, 19 Oct 2020 22:52:24 +0200 Subject: [PATCH 02/12] 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. Signed-off-by: Yannick Schaus --- .../openhab/core/internal/auth/UserRegistryImpl.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 2d2a6cf439f..b3ee70d0d59 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 @@ -151,8 +151,8 @@ public Authentication authenticate(Credentials credentials) throws Authenticatio UserApiTokenCredentials userApiTokenCreds = (UserApiTokenCredentials) credentials; for (User user : getAll()) { ManagedUser managedUser = (ManagedUser) user; - String tokenHash = hash(userApiTokenCreds.getApiToken(), managedUser.getPasswordSalt(), - APITOKEN_ITERATIONS).get(); + String[] tokenHashAndSalt = userApiTokenCreds.getApiToken().split(":"); + String tokenHash = hash(tokenHashAndSalt[0], tokenHashAndSalt[1], APITOKEN_ITERATIONS).get(); if (managedUser.getApiTokens().stream() .anyMatch(userApiToken -> tokenHash.equals(userApiToken.getApiToken()))) { @@ -224,13 +224,13 @@ public String addUserApiToken(User user, String name, String scope) { } ManagedUser managedUser = (ManagedUser) user; - String salt = managedUser.getPasswordSalt(); + String tokenSalt = generateSalt(KEY_LENGTH / 8).get(); byte[] rnd = new byte[64]; RAND.nextBytes(rnd); String token = "oh." + name + "." + Base64.getEncoder().encodeToString(rnd).replaceAll("(\\+|/|=)", ""); - String tokenHash = hash(token, salt, APITOKEN_ITERATIONS).get(); + String tokenHash = hash(token, tokenSalt, APITOKEN_ITERATIONS).get(); - UserApiToken userApiToken = new UserApiToken(name, tokenHash, scope); + UserApiToken userApiToken = new UserApiToken(name, tokenHash + ":" + tokenSalt, scope); managedUser.getApiTokens().add(userApiToken); this.update(user); From fe67008e945d1a696a32105ac3bbbb212ab7c33b Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Tue, 20 Oct 2020 17:48:05 +0200 Subject: [PATCH 03/12] 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. Signed-off-by: Yannick Schaus --- .../io/rest/auth/internal/AuthFilter.java | 20 ++++++------ .../AuthenticationSecurityContext.java | 31 +++++++++++++++++++ .../core/io/rest/auth/internal/JwtHelper.java | 3 +- .../auth/internal/JwtSecurityContext.java | 7 ++++- .../rest/auth/internal/UserApiTokenDTO.java | 2 +- .../auth/internal/UserSecurityContext.java | 13 ++++++-- .../org/openhab/core/auth/Authentication.java | 27 +++++++++++++++- .../core/auth/UserApiTokenCredentials.java | 2 +- .../core/internal/auth/UserRegistryImpl.java | 30 ++++++++++++------ 9 files changed, 109 insertions(+), 26 deletions(-) create mode 100644 bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthenticationSecurityContext.java 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 22168992a51..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 @@ -102,9 +102,9 @@ private SecurityContext authenticateBearerToken(String token) throws Authenticat 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"); + throw new AuthenticationException("User not found in registry"); } - return new UserSecurityContext(user, "ApiToken"); + return new UserSecurityContext(user, auth, "ApiToken"); } else { Authentication auth = jwtHelper.verifyAndParseJwtAccessToken(token); return new JwtSecurityContext(auth); @@ -117,14 +117,20 @@ private SecurityContext authenticateUsernamePassword(String username, String pas 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"); + throw new AuthenticationException("User not found in registry"); } - return new UserSecurityContext(user, "Basic"); + 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(" "); @@ -160,12 +166,6 @@ public void filter(ContainerRequestContext requestContext) throws IOException { } } - String altTokenHeader = requestContext.getHeaderString(ALT_AUTH_HEADER); - if (altTokenHeader != null) { - requestContext.setSecurityContext(authenticateBearerToken(altTokenHeader)); - 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 fd4c3ba7a5f..a760270fae7 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 @@ -149,7 +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()])); + String scope = jwtClaims.getStringClaimValue("scope"); + Authentication auth = new Authentication(username, roles.toArray(new String[roles.size()]), scope); return auth; } 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/UserApiTokenDTO.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserApiTokenDTO.java index 4dcc3e36b25..885391d1ea3 100644 --- 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 @@ -15,7 +15,7 @@ import java.util.Date; /** - * A DTO representing a user session, without the sensible information. + * A DTO representing a user API token, without the sensible information. * * @author Yannick Schaus - initial contribution */ 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/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/UserApiTokenCredentials.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/UserApiTokenCredentials.java index 4fecff1ee11..7c893e67e31 100644 --- 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 @@ -13,7 +13,7 @@ package org.openhab.core.auth; /** - * Credentials which represent user a user API token. + * Credentials which represent a user API token. * * @author Yannick Schaus - Initial contribution */ 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 b3ee70d0d59..0314d731f13 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 @@ -61,6 +61,7 @@ public class UserRegistryImpl extends AbstractRegistry tokenHash.equals(userApiToken.getApiToken()))) { - return new Authentication(managedUser.getName(), - managedUser.getRoles().stream().toArray(String[]::new)); + 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()); + } } } @@ -227,7 +238,8 @@ public String addUserApiToken(User user, String name, String scope) { String tokenSalt = generateSalt(KEY_LENGTH / 8).get(); byte[] rnd = new byte[64]; RAND.nextBytes(rnd); - String token = "oh." + name + "." + Base64.getEncoder().encodeToString(rnd).replaceAll("(\\+|/|=)", ""); + 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); From 07de0ce0460ccb872e7b09af09575a2c67c39691 Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Tue, 20 Oct 2020 20:10:33 +0200 Subject: [PATCH 04/12] Add self-service pages to change password & create new API token Signed-off-by: Yannick Schaus --- .../pages/authorize.html | 50 ++++- .../internal/AbstractAuthPageServlet.java | 129 ++++++++++++ .../auth/internal/AuthorizePageServlet.java | 86 ++------ .../internal/ChangePasswordPageServlet.java | 176 +++++++++++++++++ .../internal/CreateAPITokenPageServlet.java | 186 ++++++++++++++++++ 5 files changed, 552 insertions(+), 75 deletions(-) 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 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..64c73a4f8a8 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,41 +62,69 @@ 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}
-
- -
+
+ +
+
+ +
+
+ +
+
+ +
+ 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..84790c6f63c --- /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.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; + +@NonNullByDefault +/** + * Abstract class for servlets to perform sensible operations requiring user authentication. + * + * @author Yannick Schaus - initial contribution + * + */ +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; + @Nullable + protected Instant lastAuthenticationFailure; + protected int authenticationFailureCount = 0; + + protected HashMap 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 { + pageTemplate = new String(resource.openStream().readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(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..da450746386 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,41 +55,18 @@ */ @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()); } @@ -127,7 +95,7 @@ protected void doGet(@Nullable HttpServletRequest req, @Nullable HttpServletResp 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().append(getPageBody(params, message, false)); resp.getWriter().close(); } catch (Exception e) { resp.setContentType("text/plain;charset=UTF-8"); @@ -165,7 +133,7 @@ protected void doPost(@Nullable HttpServletRequest req, @Nullable HttpServletRes 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]; @@ -191,7 +159,7 @@ protected void doPost(@Nullable HttpServletRequest req, @Nullable HttpServletRes 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().append(getPageBody(params, "Passwords don't match, please try again.", false)); resp.getWriter().close(); return; } @@ -199,20 +167,7 @@ protected void doPost(@Nullable HttpServletRequest req, @Nullable HttpServletRes 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()); + user = login(username, password); } String authorizationCode = UUID.randomUUID().toString().replace("-", ""); @@ -234,12 +189,7 @@ protected void doPost(@Nullable HttpServletRequest req, @Nullable HttpServletRes resp.addHeader(HttpHeaders.LOCATION, getRedirectUri(baseRedirectUri, authorizationCode, null, 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 - resp.getWriter().close(); + processFailedLogin(resp, params, e.getMessage()); } catch (IllegalArgumentException e) { @Nullable String baseRedirectUri = params.containsKey("redirect_uri") ? params.get("redirect_uri")[0] : null; @@ -257,17 +207,25 @@ protected void doPost(@Nullable HttpServletRequest req, @Nullable HttpServletRes } } - 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 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"))) { @@ -326,14 +284,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..be3c07a57d3 --- /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,176 @@ +/** + * 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.eclipse.jdt.annotation.Nullable; +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 authorization page registration: {}", e.getMessage()); + } + } + + @Override + protected void doGet(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) + throws ServletException, IOException { + if (req != null && resp != null) { + Map params = req.getParameterMap(); + + try { + String message = ""; + + 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) + 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(("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..9e3ac939e00 --- /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,186 @@ +/** + * 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.eclipse.jdt.annotation.Nullable; +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(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) + throws ServletException, IOException { + if (req != null && resp != null) { + 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(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) + throws ServletException, IOException { + if (req != null && resp != null) { + Map params = req.getParameterMap(); + try { + if (!params.containsKey(("username"))) { + throw new AuthenticationException("no username"); + } + if (!params.containsKey(("password"))) { + throw new AuthenticationException("no password"); + } + if (!params.containsKey(("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; + } + 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"); + } +} From 6d9cbf96e22be1b7bbe4884f2477c119158fe6be Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Tue, 20 Oct 2020 20:38:03 +0200 Subject: [PATCH 05/12] Fix SAT error Signed-off-by: Yannick Schaus --- .../core/io/http/auth/internal/AbstractAuthPageServlet.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 84790c6f63c..cd30a1835c9 100644 --- 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 @@ -39,13 +39,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@NonNullByDefault /** * 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; From ea523afb6804cd8674fd1a31805ab4939698db3a Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Thu, 22 Oct 2020 17:26:11 +0200 Subject: [PATCH 06/12] Address review comments Signed-off-by: Yannick Schaus --- .../UserConsoleCommandExtension.java | 13 +- .../pages/authorize.html | 44 ++-- .../internal/AbstractAuthPageServlet.java | 10 +- .../auth/internal/AuthorizePageServlet.java | 217 +++++++++--------- .../internal/ChangePasswordPageServlet.java | 39 ++-- .../internal/CreateAPITokenPageServlet.java | 136 +++++------ .../io/rest/auth/internal/TokenResource.java | 19 +- .../core/internal/auth/UserRegistryImpl.java | 14 +- 8 files changed, 248 insertions(+), 244 deletions(-) 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 index 738901b2beb..726094fb3eb 100644 --- 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 @@ -57,18 +57,18 @@ public UserConsoleCommandExtension(final @Reference UserRegistry userRegistry) { @Override public List getUsages() { - return Arrays.asList(new String[] { buildCommandUsage(SUBCMD_LIST, "lists all users"), + 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 keys for all users"), + 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)") }); + "clear the refresh tokens associated with the user (will sign the user out of all sessions)"); } @Override @@ -122,10 +122,9 @@ public void execute(String[] args, Console console) { case SUBCMD_LISTAPITOKENS: userRegistry.getAll().forEach(user -> { ManagedUser managedUser = (ManagedUser) user; - if (managedUser.getApiTokens().size() > 0) { - managedUser.getApiTokens().forEach(t -> { - console.println("user=" + user.toString() + ", " + t.toString()); - }); + if (!managedUser.getApiTokens().isEmpty()) { + managedUser.getApiTokens() + .forEach(t -> console.println("user=" + user.toString() + ", " + t.toString())); } }); break; 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 64c73a4f8a8..3c994ac1e3c 100644 --- a/bundles/org.openhab.core.io.http.auth/pages/authorize.html +++ b/bundles/org.openhab.core.io.http.auth/pages/authorize.html @@ -43,11 +43,11 @@ } form.hide { - display: none; + display: none; } form.show { - display: initial; + display: initial; } input { @@ -73,7 +73,7 @@ font-weight: 600; min-width: 80px; padding-top: 10px; - padding-bottom: 10px; + padding-bottom: 10px; padding-left: 1rem; padding-right: 1rem; } @@ -83,22 +83,22 @@ } .result { - display: none; + display: none; } .resultPassword { - display: initial; + display: initial; } .resultToken { - display: initial; + display: initial; } -
{message}
+
{message}
{form_fields}
@@ -107,24 +107,24 @@
-
- -
-
- -
-
- -
-
- -
+
+ +
+
+ +
+
+ +
+
+ +
- + 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 index cd30a1835c9..1670af9bd7a 100644 --- 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 @@ -13,6 +13,7 @@ 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; @@ -55,11 +56,10 @@ public abstract class AbstractAuthPageServlet extends HttpServlet { protected HttpService httpService; protected UserRegistry userRegistry; protected AuthenticationProvider authProvider; - @Nullable - protected Instant lastAuthenticationFailure; + protected @Nullable Instant lastAuthenticationFailure; protected int authenticationFailureCount = 0; - protected HashMap csrfTokens = new HashMap<>(); + protected Map csrfTokens = new HashMap<>(); protected String pageTemplate; @@ -72,8 +72,8 @@ public AbstractAuthPageServlet(BundleContext bundleContext, @Reference HttpServi pageTemplate = ""; URL resource = bundleContext.getBundle().getResource("pages/authorize.html"); if (resource != null) { - try { - pageTemplate = new String(resource.openStream().readAllBytes(), StandardCharsets.UTF_8); + try (InputStream stream = resource.openStream()) { + pageTemplate = new String(stream.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { throw new UncheckedIOException(e); } 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 da450746386..b4ca352fa5e 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 @@ -73,136 +73,131 @@ public AuthorizePageServlet(BundleContext bundleContext, @Reference HttpService } @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, false)); - 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"); + } - removeCsrfToken(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.", false)); - resp.getWriter().close(); - return; - } - - user = userRegistry.register(username, password, Set.of(Role.ADMIN)); - logger.info("First user account created: {}", username); - } else { - user = login(username, password); - } + String username = params.get("username")[0]; + String password = params.get("password")[0]; - 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); - } + User user; + if (isSignupMode()) { + // Create a first administrator account with the supplied credentials - 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); - } else { - resp.setContentType("text/plain;charset=UTF-8"); - resp.getWriter().append(e.getMessage()); + // 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; } + + 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); + } else { + resp.setContentType("text/plain;charset=UTF-8"); + resp.getWriter().append(e.getMessage()); + resp.getWriter().close(); } } } 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 index be3c07a57d3..2358626b5e3 100644 --- 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 @@ -20,7 +20,6 @@ import javax.servlet.http.HttpServletResponse; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.auth.AuthenticationException; import org.openhab.core.auth.AuthenticationProvider; import org.openhab.core.auth.ManagedUser; @@ -57,43 +56,41 @@ public ChangePasswordPageServlet(BundleContext bundleContext, @Reference HttpSer try { httpService.registerServlet("/changePassword", this, null, null); } catch (NamespaceException | ServletException e) { - logger.error("Error during authorization page registration: {}", e.getMessage()); + logger.error("Error during change password 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 = ""; - - 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(); - } + 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(@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"))) { + if (!params.containsKey("username")) { throw new AuthenticationException("no username"); } - if (!params.containsKey(("password"))) { + if (!params.containsKey("password")) { throw new AuthenticationException("no password"); } - if (!params.containsKey(("new_password"))) { + if (!params.containsKey("new_password")) { throw new AuthenticationException("no new password"); } if (!params.containsKey("csrf_token") || !csrfTokens.containsKey(params.get("csrf_token")[0])) { @@ -137,7 +134,7 @@ protected String getPageBody(Map params, String message, boole 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("{formClass}", hideForm ? "hide" : "show"); responseBody = responseBody.replace("{repeatPasswordFieldType}", "password"); responseBody = responseBody.replace("{newPasswordFieldType}", "password"); responseBody = responseBody.replace("{tokenNameFieldType}", "hidden"); 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 index 9e3ac939e00..57ebdbb659e 100644 --- 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 @@ -20,7 +20,6 @@ import javax.servlet.http.HttpServletResponse; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.auth.AuthenticationException; import org.openhab.core.auth.AuthenticationProvider; import org.openhab.core.auth.ManagedUser; @@ -62,82 +61,87 @@ public CreateAPITokenPageServlet(BundleContext bundleContext, @Reference HttpSer } @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(); - - 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(); - } + 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(@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(("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"); - } + 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]); + 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]; - } + 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; - } - newApiToken = userRegistry.addUserApiToken(user, tokenName, tokenScope); - } else { - throw new AuthenticationException("User is not managed"); + 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; } - // 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()); + if (!tokenName.matches("[a-zA-Z0-9]*")) { + resp.setContentType("text/html;charset=UTF-8"); + // TODO: i18n + resp.getWriter().append(getPageBody(params, + "Invalid token name already exists, 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()); } } @@ -147,7 +151,7 @@ protected String getPageBody(Map params, String message, boole 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("{formClass}", hideForm ? "hide" : "show"); responseBody = responseBody.replace("{repeatPasswordFieldType}", "hidden"); responseBody = responseBody.replace("{newPasswordFieldType}", "hidden"); responseBody = responseBody.replace("{tokenNameFieldType}", "text"); 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 e6365381534..b7db3600961 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 @@ -114,7 +114,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, @@ -142,7 +143,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) { @@ -161,7 +164,9 @@ public Response getSessions(@Context SecurityContext securityContext) { @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 = "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) { @@ -180,7 +185,9 @@ public Response getApiTokens(@Context SecurityContext securityContext) { @DELETE @Path("/apitokens/{name}") @Operation(summary = "Revoke a specified API token associated to the authenticated user.", responses = { - @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UserApiTokenDTO.class))) }) + @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"); @@ -205,7 +212,9 @@ public Response removeApiToken(@Context SecurityContext securityContext, @PathPa @Path("/logout") @Consumes({ MediaType.APPLICATION_FORM_URLENCODED }) @Operation(summary = "Delete the session associated with a refresh token.", responses = { - @ApiResponse(responseCode = "200", description = "OK") }) + @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(@FormParam("refresh_token") String refreshToken, @FormParam("id") String id, @Context SecurityContext securityContext) { if (securityContext.getUserPrincipal() == null) { 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 0314d731f13..249eaa8978b 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 @@ -135,7 +135,7 @@ private Optional hash(String password, String salt, int iterations) { public Authentication authenticate(Credentials credentials) throws AuthenticationException { if (credentials instanceof UsernamePasswordCredentials) { UsernamePasswordCredentials usernamePasswordCreds = (UsernamePasswordCredentials) credentials; - User user = this.get(usernamePasswordCreds.getUsername()); + User user = get(usernamePasswordCreds.getUsername()); if (user == null) { throw new AuthenticationException("User not found: " + usernamePasswordCreds.getUsername()); } @@ -189,7 +189,7 @@ public void changePassword(User user, String newPassword) { String passwordHash = hash(newPassword, passwordSalt, PASSWORD_ITERATIONS).get(); managedUser.setPasswordSalt(passwordSalt); managedUser.setPasswordHash(passwordHash); - this.update(user); + update(user); } @Override @@ -200,7 +200,7 @@ public void addUserSession(User user, UserSession session) { ManagedUser managedUser = (ManagedUser) user; managedUser.getSessions().add(session); - this.update(user); + update(user); } @Override @@ -211,7 +211,7 @@ public void removeUserSession(User user, UserSession session) { ManagedUser managedUser = (ManagedUser) user; managedUser.getSessions().add(session); - this.update(user); + update(user); } @Override @@ -222,7 +222,7 @@ public void clearSessions(User user) { ManagedUser managedUser = (ManagedUser) user; managedUser.getSessions().clear(); - this.update(user); + update(user); } @Override @@ -245,7 +245,7 @@ public String addUserApiToken(User user, String name, String scope) { UserApiToken userApiToken = new UserApiToken(name, tokenHash + ":" + tokenSalt, scope); managedUser.getApiTokens().add(userApiToken); - this.update(user); + update(user); return token; } @@ -258,7 +258,7 @@ public void removeUserApiToken(User user, UserApiToken userApiToken) { ManagedUser managedUser = (ManagedUser) user; managedUser.getApiTokens().remove(userApiToken); - this.update(user); + update(user); } @Override From 62c4e6f4278093449966c9b5d13d733d4a30824e Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Thu, 22 Oct 2020 17:34:33 +0200 Subject: [PATCH 07/12] Missing closing paren Signed-off-by: Yannick Schaus --- .../internal/extension/UserConsoleCommandExtension.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index 726094fb3eb..eaf1f06c3ca 100644 --- 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 @@ -12,7 +12,6 @@ */ package org.openhab.core.io.console.internal.extension; -import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.Set; @@ -68,7 +67,7 @@ public List getUsages() { 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)"); + "clear the refresh tokens associated with the user (will sign the user out of all sessions)")); } @Override From f051da17a1892a50746392ce911944bfaab3ac27 Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Thu, 22 Oct 2020 17:36:51 +0200 Subject: [PATCH 08/12] Fix error message when token name doesn't match pattern Signed-off-by: Yannick Schaus --- .../core/io/http/auth/internal/CreateAPITokenPageServlet.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 57ebdbb659e..75c4175c4ce 100644 --- 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 @@ -124,8 +124,8 @@ protected void doPost(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDe if (!tokenName.matches("[a-zA-Z0-9]*")) { resp.setContentType("text/html;charset=UTF-8"); // TODO: i18n - resp.getWriter().append(getPageBody(params, - "Invalid token name already exists, please use alphanumeric characters only.", false)); + resp.getWriter().append( + getPageBody(params, "Invalid token name, please use alphanumeric characters only.", false)); resp.getWriter().close(); return; } From 0bbaa29c7a5ec6d0969eec95901376d62ad9ea7b Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Fri, 23 Oct 2020 02:51:09 +0200 Subject: [PATCH 09/12] Address more review comments Signed-off-by: Yannick Schaus --- .../UserConsoleCommandExtension.java | 1 - .../internal/AbstractAuthPageServlet.java | 2 +- .../auth/internal/AuthorizePageServlet.java | 28 +++---- .../internal/ChangePasswordPageServlet.java | 78 +++++++++---------- .../core/io/rest/auth/internal/JwtHelper.java | 3 +- .../org/openhab/core/auth/ManagedUser.java | 2 +- .../org/openhab/core/auth/UserRegistry.java | 2 +- 7 files changed, 56 insertions(+), 60 deletions(-) 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 index eaf1f06c3ca..1ffab9c4735 100644 --- 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 @@ -135,7 +135,6 @@ public void execute(String[] args, Console console) { .filter(t -> args[2].equals(t.getName())).findAny(); if (userApiToken.isEmpty()) { String tokenString = userRegistry.addUserApiToken(user, args[2], args[3]); - // inform the user that the token will not be printed again, and they should save it? console.println(tokenString); } else { console.println("Cannot create API token: another one with the same name was found."); 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 index 1670af9bd7a..9e1f1ad9774 100644 --- 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 @@ -75,7 +75,7 @@ public AbstractAuthPageServlet(BundleContext bundleContext, @Reference HttpServi try (InputStream stream = resource.openStream()) { pageTemplate = new String(stream.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { - throw new UncheckedIOException(e); + throw new UncheckedIOException("Cannot load page template", e); } } } 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 b4ca352fa5e..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 @@ -79,8 +79,8 @@ protected void doGet(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDef try { String message = ""; - String scope = (params.containsKey("scope")) ? params.get("scope")[0] : ""; - String clientId = (params.containsKey("client_id")) ? params.get("client_id")[0] : ""; + 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("<")) { @@ -137,7 +137,7 @@ protected void doPost(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDe String clientId = params.get("redirect_uri")[0]; String scope = params.get("scope")[0]; - if (!("code".equals(responseType))) { + if (!"code".equals(responseType)) { throw new AuthenticationException("unsupported_response_type"); } @@ -170,8 +170,8 @@ protected void doPost(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDe 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")) + 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; @@ -205,8 +205,8 @@ protected void doPost(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDe @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"); @@ -223,16 +223,16 @@ protected String getPageBody(Map params, String message, boole 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(); @@ -240,9 +240,9 @@ protected 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 += ""; 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 index 2358626b5e3..2f576ffe53e 100644 --- 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 @@ -81,50 +81,48 @@ protected void doGet(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDef @Override 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("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"); - } + 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"); - resp.getWriter().append(getResultPageBody(params, "Password changed.")); // TODO: i18n + // TODO: i18n + resp.getWriter().append(getPageBody(params, "Passwords don't match, please try again.", false)); resp.getWriter().close(); - } catch (AuthenticationException e) { - processFailedLogin(resp, params, e.getMessage()); + 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()); } } 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 a760270fae7..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 @@ -150,8 +150,7 @@ public Authentication verifyAndParseJwtAccessToken(String jwt) throws Authentica String username = jwtClaims.getSubject(); List roles = jwtClaims.getStringListClaimValue("role"); String scope = jwtClaims.getStringClaimValue("scope"); - Authentication auth = new Authentication(username, roles.toArray(new String[roles.size()]), scope); - return auth; + 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/src/main/java/org/openhab/core/auth/ManagedUser.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/ManagedUser.java index 2128c9fd399..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 @@ -71,7 +71,7 @@ public void setPasswordSalt(String passwordSalt) { /** * Alters the password hash. * - * @param the new password hash + * @param passwordHash the new password hash */ public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; 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 a8020500bcb..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 @@ -44,7 +44,7 @@ public interface UserRegistry extends Registry, AuthenticationProv * responsible for their secure storage (for instance by hashing the password). * * @param username the username of the existing user - * @param password the new password + * @param newPassword the new password */ public void changePassword(User user, String newPassword); From d5bf4a4eb4954709e9d373500e1d1631c8b0a142 Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Fri, 23 Oct 2020 21:30:24 +0200 Subject: [PATCH 10/12] Fix removeUserSession in UserRegistryImpl Signed-off-by: Yannick Schaus --- .../java/org/openhab/core/internal/auth/UserRegistryImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 249eaa8978b..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 @@ -210,7 +210,7 @@ public void removeUserSession(User user, UserSession session) { } ManagedUser managedUser = (ManagedUser) user; - managedUser.getSessions().add(session); + managedUser.getSessions().remove(session); update(user); } From e486af4c668f1015b47170bebad0a6018b1a17d2 Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Fri, 23 Oct 2020 23:35:28 +0200 Subject: [PATCH 11/12] Add UserRegistryImplTest Signed-off-by: Yannick Schaus --- .../internal/auth/UserRegistryImplTest.java | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 bundles/org.openhab.core/src/test/java/org/openhab/core/internal/auth/UserRegistryImplTest.java 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); + } +} From ef5f4a75bec594e654861d7596b7a56164074038 Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Sun, 25 Oct 2020 10:51:33 +0100 Subject: [PATCH 12/12] Fix null annotations warnings in TokenResource Signed-off-by: Yannick Schaus --- .../io/rest/auth/internal/TokenResource.java | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) 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 b7db3600961..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 @@ -41,6 +41,7 @@ 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; @@ -215,8 +216,8 @@ public Response removeApiToken(@Context SecurityContext securityContext, @PathPa @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(@FormParam("refresh_token") String refreshToken, @FormParam("id") String id, - @Context SecurityContext securityContext) { + 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"); } @@ -268,10 +269,14 @@ private UserApiTokenDTO toUserApiTokenDTO(UserApiToken apiToken) { } 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"); @@ -367,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); }