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 78c63d86fd8..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 @@ -17,7 +17,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 authentication to break. + 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