From 35e8d12ad0487c0a337c3271ad99b2a0ad06d987 Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Mon, 19 Oct 2020 20:09:10 +0200 Subject: [PATCH] [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 +- .../internal/profile/ProfileTypeResource.java | 2 +- .../internal/thing/ThingTypeResource.java | 4 +- .../openhab/core/io/rest/sse/SseResource.java | 2 +- .../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 ++++++++++-- 14 files changed, 577 insertions(+), 45 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.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/profile/ProfileTypeResource.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/profile/ProfileTypeResource.java index 51674b25d08..11c5cfc82c6 100644 --- a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/profile/ProfileTypeResource.java +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/profile/ProfileTypeResource.java @@ -99,7 +99,7 @@ public ProfileTypeResource( // } @GET - @RolesAllowed({ Role.USER }) + @RolesAllowed({ Role.USER, Role.ADMIN }) @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "Gets all available profile types.", responses = { @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ProfileTypeDTO.class), uniqueItems = true))) }) diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/thing/ThingTypeResource.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/thing/ThingTypeResource.java index 05339bd6c17..d1bb9e6ef59 100644 --- a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/thing/ThingTypeResource.java +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/thing/ThingTypeResource.java @@ -128,7 +128,7 @@ public ThingTypeResource( // } @GET - @RolesAllowed({ Role.USER }) + @RolesAllowed({ Role.USER, Role.ADMIN }) @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "Gets all available thing types without config description, channels and properties.", responses = { @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = StrippedThingTypeDTO.class), uniqueItems = true))) }) @@ -147,7 +147,7 @@ public Response getAll( } @GET - @RolesAllowed({ Role.USER }) + @RolesAllowed({ Role.USER, Role.ADMIN }) @Path("/{thingTypeUID}") @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "Gets thing type by UID.", responses = { diff --git a/bundles/org.openhab.core.io.rest.sse/src/main/java/org/openhab/core/io/rest/sse/SseResource.java b/bundles/org.openhab.core.io.rest.sse/src/main/java/org/openhab/core/io/rest/sse/SseResource.java index 6ac0f243fae..557b07d0dfd 100644 --- a/bundles/org.openhab.core.io.rest.sse/src/main/java/org/openhab/core/io/rest/sse/SseResource.java +++ b/bundles/org.openhab.core.io.rest.sse/src/main/java/org/openhab/core/io/rest/sse/SseResource.java @@ -85,7 +85,7 @@ @JaxrsApplicationSelect("(" + JaxrsWhiteboardConstants.JAX_RS_NAME + "=" + RESTConstants.JAX_RS_NAME + ")") @JSONRequired @Path(SseResource.PATH_EVENTS) -@RolesAllowed({ Role.USER }) +@RolesAllowed({ Role.USER, Role.ADMIN }) @Tag(name = SseResource.PATH_EVENTS) @Singleton @NonNullByDefault 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