diff --git a/bundles/org.opensmarthouse.core.auth.core/src/main/java/org/openhab/core/internal/auth/UserRegistryImpl.java b/bundles/org.opensmarthouse.core.auth.core/src/main/java/org/openhab/core/internal/auth/UserRegistryImpl.java index 1420566981a..37a1e0c66ff 100644 --- a/bundles/org.opensmarthouse.core.auth.core/src/main/java/org/openhab/core/internal/auth/UserRegistryImpl.java +++ b/bundles/org.opensmarthouse.core.auth.core/src/main/java/org/openhab/core/internal/auth/UserRegistryImpl.java @@ -31,8 +31,11 @@ import org.openhab.core.auth.Credentials; import org.openhab.core.auth.ManagedUser; import org.openhab.core.auth.User; +import org.openhab.core.auth.UserApiToken; +import org.openhab.core.auth.UserApiTokenCredentials; import org.openhab.core.auth.UserProvider; import org.openhab.core.auth.UserRegistry; +import org.openhab.core.auth.UserSession; import org.openhab.core.auth.UsernamePasswordCredentials; import org.openhab.core.common.registry.AbstractRegistry; import org.osgi.framework.BundleContext; @@ -56,7 +59,9 @@ public class UserRegistryImpl extends AbstractRegistry roles) { String passwordSalt = generateSalt(KEY_LENGTH / 8).get(); - String passwordHash = hashPassword(password, passwordSalt).get(); + String passwordHash = hash(password, passwordSalt, PASSWORD_ITERATIONS).get(); ManagedUser user = new ManagedUser(username, passwordSalt, passwordHash); user.setRoles(new HashSet<>(roles)); super.add(user); @@ -106,11 +111,11 @@ private Optional generateSalt(final int length) { return Optional.of(Base64.getEncoder().encodeToString(salt)); } - private Optional hashPassword(String password, String salt) { + private Optional hash(String password, String salt, int iterations) { char[] chars = password.toCharArray(); byte[] bytes = salt.getBytes(); - PBEKeySpec spec = new PBEKeySpec(chars, bytes, ITERATIONS, KEY_LENGTH); + PBEKeySpec spec = new PBEKeySpec(chars, bytes, iterations, KEY_LENGTH); Arrays.fill(chars, Character.MIN_VALUE); @@ -119,7 +124,7 @@ private Optional hashPassword(String password, String salt) { byte[] securePassword = fac.generateSecret(spec).getEncoded(); return Optional.of(Base64.getEncoder().encodeToString(securePassword)); } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { - logger.error("Exception encountered in hashPassword", e); + logger.error("Exception encountered while hashing", e); return Optional.empty(); } finally { spec.clearPassword(); @@ -128,23 +133,132 @@ private Optional hashPassword(String password, String salt) { @Override public Authentication authenticate(Credentials credentials) throws AuthenticationException { - UsernamePasswordCredentials usernamePasswordCreds = (UsernamePasswordCredentials) credentials; - User user = this.get(usernamePasswordCreds.getUsername()); - if (user == null) { - throw new AuthenticationException("User not found: " + usernamePasswordCreds.getUsername()); + if (credentials instanceof UsernamePasswordCredentials) { + UsernamePasswordCredentials usernamePasswordCreds = (UsernamePasswordCredentials) credentials; + User user = get(usernamePasswordCreds.getUsername()); + if (user == null) { + throw new AuthenticationException("User not found: " + usernamePasswordCreds.getUsername()); + } + + ManagedUser managedUser = (ManagedUser) user; + String hashedPassword = hash(usernamePasswordCreds.getPassword(), managedUser.getPasswordSalt(), + PASSWORD_ITERATIONS).get(); + if (!hashedPassword.equals(managedUser.getPasswordHash())) { + throw new AuthenticationException("Wrong password for user " + usernamePasswordCreds.getUsername()); + } + + return new Authentication(managedUser.getName(), managedUser.getRoles().stream().toArray(String[]::new)); + } else if (credentials instanceof UserApiTokenCredentials) { + UserApiTokenCredentials apiTokenCreds = (UserApiTokenCredentials) credentials; + String[] apiTokenParts = apiTokenCreds.getApiToken().split("\\."); + if (apiTokenParts.length != 3 || !APITOKEN_PREFIX.equals(apiTokenParts[0])) { + throw new AuthenticationException("Invalid API token format"); + } + for (User user : getAll()) { + ManagedUser managedUser = (ManagedUser) user; + for (UserApiToken userApiToken : managedUser.getApiTokens()) { + // only check if the name in the token matches + if (!userApiToken.getName().equals(apiTokenParts[1])) { + continue; + } + String[] existingTokenHashAndSalt = userApiToken.getApiToken().split(":"); + String incomingTokenHash = hash(apiTokenCreds.getApiToken(), existingTokenHashAndSalt[1], + APITOKEN_ITERATIONS).get(); + + if (incomingTokenHash.equals(existingTokenHashAndSalt[0])) { + return new Authentication(managedUser.getName(), + managedUser.getRoles().stream().toArray(String[]::new), userApiToken.getScope()); + } + } + } + + throw new AuthenticationException("Unknown API token"); + } + + throw new IllegalArgumentException("Invalid credential type"); + } + + @Override + public void changePassword(User user, String newPassword) { + if (!(user instanceof ManagedUser)) { + throw new IllegalArgumentException("User is not managed: " + user.getName()); + } + + ManagedUser managedUser = (ManagedUser) user; + String passwordSalt = generateSalt(KEY_LENGTH / 8).get(); + String passwordHash = hash(newPassword, passwordSalt, PASSWORD_ITERATIONS).get(); + managedUser.setPasswordSalt(passwordSalt); + managedUser.setPasswordHash(passwordHash); + update(user); + } + + @Override + public void addUserSession(User user, UserSession session) { + if (!(user instanceof ManagedUser)) { + throw new IllegalArgumentException("User is not managed: " + user.getName()); + } + + ManagedUser managedUser = (ManagedUser) user; + managedUser.getSessions().add(session); + update(user); + } + + @Override + public void removeUserSession(User user, UserSession session) { + if (!(user instanceof ManagedUser)) { + throw new IllegalArgumentException("User is not managed: " + user.getName()); } + + ManagedUser managedUser = (ManagedUser) user; + managedUser.getSessions().remove(session); + update(user); + } + + @Override + public void clearSessions(User user) { if (!(user instanceof ManagedUser)) { - throw new AuthenticationException("User is not managed: " + usernamePasswordCreds.getUsername()); + throw new IllegalArgumentException("User is not managed: " + user.getName()); } ManagedUser managedUser = (ManagedUser) user; - String hashedPassword = hashPassword(usernamePasswordCreds.getPassword(), managedUser.getPasswordSalt()).get(); - if (!hashedPassword.equals(managedUser.getPasswordHash())) { - throw new AuthenticationException("Wrong password for user " + usernamePasswordCreds.getUsername()); + managedUser.getSessions().clear(); + update(user); + } + + @Override + public String addUserApiToken(User user, String name, String scope) { + if (!(user instanceof ManagedUser)) { + throw new IllegalArgumentException("User is not managed: " + user.getName()); + } + if (!name.matches("[a-zA-Z0-9]*")) { + throw new IllegalArgumentException("API token name format invalid, alphanumeric characters only"); } - Authentication authentication = new Authentication(managedUser.getName()); - return authentication; + ManagedUser managedUser = (ManagedUser) user; + String tokenSalt = generateSalt(KEY_LENGTH / 8).get(); + byte[] rnd = new byte[64]; + RAND.nextBytes(rnd); + String token = APITOKEN_PREFIX + "." + name + "." + + Base64.getEncoder().encodeToString(rnd).replaceAll("(\\+|/|=)", ""); + String tokenHash = hash(token, tokenSalt, APITOKEN_ITERATIONS).get(); + + UserApiToken userApiToken = new UserApiToken(name, tokenHash + ":" + tokenSalt, scope); + + managedUser.getApiTokens().add(userApiToken); + update(user); + + return token; + } + + @Override + public void removeUserApiToken(User user, UserApiToken userApiToken) { + if (!(user instanceof ManagedUser)) { + throw new IllegalArgumentException("User is not managed: " + user.getName()); + } + + ManagedUser managedUser = (ManagedUser) user; + managedUser.getApiTokens().remove(userApiToken); + update(user); } @Override diff --git a/bundles/org.opensmarthouse.core.auth.core/src/test/java/org/openhab/core/internal/auth/UserRegistryImplTest.java b/bundles/org.opensmarthouse.core.auth.core/src/test/java/org/openhab/core/internal/auth/UserRegistryImplTest.java new file mode 100644 index 00000000000..3d64d31a154 --- /dev/null +++ b/bundles/org.opensmarthouse.core.auth.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); + } +} diff --git a/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/Authentication.java b/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/Authentication.java index e61b13442db..15804ee37d1 100644 --- a/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/Authentication.java +++ b/bundles/org.opensmarthouse.core.auth/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.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/ManagedUser.java b/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/ManagedUser.java index d008e8da4fc..3f132200dad 100644 --- a/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/ManagedUser.java +++ b/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/ManagedUser.java @@ -59,6 +59,24 @@ public String getPasswordHash() { return passwordHash; } + /** + * Alters the password salt. + * + * @param passwordSalt the new password salt + */ + public void setPasswordSalt(String passwordSalt) { + this.passwordSalt = passwordSalt; + } + + /** + * Alters the password hash. + * + * @param passwordHash the new password hash + */ + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + /** * Gets the password salt. * @@ -154,4 +172,9 @@ public List getApiTokens() { public void setApiTokens(List apiTokens) { this.apiTokens = apiTokens; } + + @Override + public String toString() { + return name + " (" + String.join(", ", roles.stream().toArray(String[]::new)) + ")"; + } } diff --git a/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/UserApiToken.java b/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/UserApiToken.java index b46403bc564..401ea9ebb62 100644 --- a/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/UserApiToken.java +++ b/bundles/org.opensmarthouse.core.auth/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.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/UserApiTokenCredentials.java b/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/UserApiTokenCredentials.java new file mode 100644 index 00000000000..7c893e67e31 --- /dev/null +++ b/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/UserApiTokenCredentials.java @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.auth; + +/** + * Credentials which represent a user API token. + * + * @author Yannick Schaus - Initial contribution + */ +public class UserApiTokenCredentials implements Credentials { + + private final String userApiToken; + + /** + * Creates a new instance + * + * @param userApiToken the user API token + */ + public UserApiTokenCredentials(String userApiToken) { + this.userApiToken = userApiToken; + } + + /** + * Retrieves the user API token + * + * @return the token + */ + public String getApiToken() { + return userApiToken; + } +} diff --git a/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/UserRegistry.java b/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/UserRegistry.java index 6a49b24303e..dbdb14b4f70 100644 --- a/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/UserRegistry.java +++ b/bundles/org.opensmarthouse.core.auth/src/main/java/org/openhab/core/auth/UserRegistry.java @@ -38,4 +38,55 @@ public interface UserRegistry extends Registry, AuthenticationProv * @return the new registered {@link User} instance */ public User register(String username, String password, Set roles); + + /** + * Change the password for an {@link User} in this registry. The implementation receives the new password and is + * responsible for their secure storage (for instance by hashing the password). + * + * @param username the username of the existing user + * @param newPassword the new password + */ + public void changePassword(User user, String newPassword); + + /** + * Adds a new session to the user profile + * + * @param user the user + * @param session the session to add + */ + public void addUserSession(User user, UserSession session); + + /** + * Removes the specified session from the user profile + * + * @param user the user + * @param session the session to remove + */ + public void removeUserSession(User user, UserSession session); + + /** + * Clears all sessions from the user profile + * + * @param user the user + */ + public void clearSessions(User user); + + /** + * Adds a new API token to the user profile. The implementation is responsible for storing the token in a secure way + * (for instance by hashing it). + * + * @param user the user + * @param name the name of the API token to create + * @param scope the scope this API token will be valid for + * @return the string that can be used as a Bearer token to match the new API token + */ + public String addUserApiToken(User user, String name, String scope); + + /** + * Removes the specified API token from the user profile + * + * @param user the user + * @param apiToken the API token + */ + public void removeUserApiToken(User user, UserApiToken apiToken); } diff --git a/bundles/org.opensmarthouse.core.io.console/src/main/java/org/openhab/core/io/console/internal/extension/UserConsoleCommandExtension.java b/bundles/org.opensmarthouse.core.io.console/src/main/java/org/openhab/core/io/console/internal/extension/UserConsoleCommandExtension.java new file mode 100644 index 00000000000..1ffab9c4735 --- /dev/null +++ b/bundles/org.opensmarthouse.core.io.console/src/main/java/org/openhab/core/io/console/internal/extension/UserConsoleCommandExtension.java @@ -0,0 +1,194 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.console.internal.extension; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.auth.ManagedUser; +import org.openhab.core.auth.User; +import org.openhab.core.auth.UserApiToken; +import org.openhab.core.auth.UserRegistry; +import org.openhab.core.io.console.Console; +import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension; +import org.openhab.core.io.console.extensions.ConsoleCommandExtension; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * Console command extension to manage users, sessions and API tokens + * + * @author Yannick Schaus - Initial contribution + */ +@Component(service = ConsoleCommandExtension.class) +@NonNullByDefault +public class UserConsoleCommandExtension extends AbstractConsoleCommandExtension { + + private static final String SUBCMD_LIST = "list"; + private static final String SUBCMD_ADD = "add"; + private static final String SUBCMD_REMOVE = "remove"; + private static final String SUBCMD_CHANGEPASSWORD = "changePassword"; + private static final String SUBCMD_LISTAPITOKENS = "listApiTokens"; + private static final String SUBCMD_ADDAPITOKEN = "addApiToken"; + private static final String SUBCMD_RMAPITOKEN = "rmApiToken"; + private static final String SUBCMD_CLEARSESSIONS = "clearSessions"; + + private final UserRegistry userRegistry; + + @Activate + public UserConsoleCommandExtension(final @Reference UserRegistry userRegistry) { + super("users", "Access the user registry."); + this.userRegistry = userRegistry; + } + + @Override + public List getUsages() { + return List.of(buildCommandUsage(SUBCMD_LIST, "lists all users"), + buildCommandUsage(SUBCMD_ADD + " ", + "adds a new user with the specified role"), + buildCommandUsage(SUBCMD_REMOVE + " ", "removes the given user"), + buildCommandUsage(SUBCMD_CHANGEPASSWORD + " ", "changes the password of a user"), + buildCommandUsage(SUBCMD_LISTAPITOKENS, "lists the API tokens for all users"), + buildCommandUsage(SUBCMD_ADDAPITOKEN + " ", + "adds a new API token on behalf of the specified user for the specified scope"), + buildCommandUsage(SUBCMD_RMAPITOKEN + " ", + "removes (revokes) the specified API token"), + buildCommandUsage(SUBCMD_CLEARSESSIONS + " ", + "clear the refresh tokens associated with the user (will sign the user out of all sessions)")); + } + + @Override + public void execute(String[] args, Console console) { + if (args.length > 0) { + String subCommand = args[0]; + switch (subCommand) { + case SUBCMD_LIST: + userRegistry.getAll().forEach(user -> console.println(user.toString())); + break; + case SUBCMD_ADD: + if (args.length == 4) { + User existingUser = userRegistry.get(args[1]); + if (existingUser == null) { + User newUser = userRegistry.register(args[1], args[2], Set.of(args[3])); + console.println(newUser.toString()); + console.println("User created."); + } else { + console.println("The user already exists."); + } + } else { + console.printUsage(findUsage(SUBCMD_ADD)); + } + break; + case SUBCMD_REMOVE: + if (args.length == 2) { + User user = userRegistry.get(args[1]); + if (user != null) { + userRegistry.remove(user.getName()); + console.println("User removed."); + } else { + console.println("User not found."); + } + } else { + console.printUsage(findUsage(SUBCMD_REMOVE)); + } + break; + case SUBCMD_CHANGEPASSWORD: + if (args.length == 3) { + User user = userRegistry.get(args[1]); + if (user != null) { + userRegistry.changePassword(user, args[2]); + console.println("Password changed."); + } else { + console.println("User not found."); + } + } else { + console.printUsage(findUsage(SUBCMD_CHANGEPASSWORD)); + } + break; + case SUBCMD_LISTAPITOKENS: + userRegistry.getAll().forEach(user -> { + ManagedUser managedUser = (ManagedUser) user; + if (!managedUser.getApiTokens().isEmpty()) { + managedUser.getApiTokens() + .forEach(t -> console.println("user=" + user.toString() + ", " + t.toString())); + } + }); + break; + case SUBCMD_ADDAPITOKEN: + if (args.length == 4) { + ManagedUser user = (ManagedUser) userRegistry.get(args[1]); + if (user != null) { + Optional userApiToken = user.getApiTokens().stream() + .filter(t -> args[2].equals(t.getName())).findAny(); + if (userApiToken.isEmpty()) { + String tokenString = userRegistry.addUserApiToken(user, args[2], args[3]); + console.println(tokenString); + } else { + console.println("Cannot create API token: another one with the same name was found."); + } + } else { + console.println("User not found."); + } + } else { + console.printUsage(findUsage(SUBCMD_ADDAPITOKEN)); + } + break; + case SUBCMD_RMAPITOKEN: + if (args.length == 3) { + ManagedUser user = (ManagedUser) userRegistry.get(args[1]); + if (user != null) { + Optional userApiToken = user.getApiTokens().stream() + .filter(t -> args[2].equals(t.getName())).findAny(); + if (userApiToken.isPresent()) { + userRegistry.removeUserApiToken(user, userApiToken.get()); + console.println("API token revoked."); + } else { + console.println("No matching API token found."); + } + } else { + console.println("User not found."); + } + } else { + console.printUsage(findUsage(SUBCMD_RMAPITOKEN)); + } + break; + case SUBCMD_CLEARSESSIONS: + if (args.length == 2) { + User user = userRegistry.get(args[1]); + if (user != null) { + userRegistry.clearSessions(user); + console.println("User sessions cleared."); + } else { + console.println("User not found."); + } + } else { + console.printUsage(findUsage(SUBCMD_CLEARSESSIONS)); + } + break; + default: + console.println("Unknown command '" + subCommand + "'"); + printUsage(console); + break; + } + } else { + printUsage(console); + } + } + + private String findUsage(String cmd) { + return getUsages().stream().filter(u -> u.contains(cmd)).findAny().get(); + } +} diff --git a/bundles/org.opensmarthouse.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AbstractAuthPageServlet.java b/bundles/org.opensmarthouse.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AbstractAuthPageServlet.java new file mode 100644 index 00000000000..9e1f1ad9774 --- /dev/null +++ b/bundles/org.opensmarthouse.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AbstractAuthPageServlet.java @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.http.auth.internal; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.auth.Authentication; +import org.openhab.core.auth.AuthenticationException; +import org.openhab.core.auth.AuthenticationProvider; +import org.openhab.core.auth.User; +import org.openhab.core.auth.UserRegistry; +import org.openhab.core.auth.UsernamePasswordCredentials; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Abstract class for servlets to perform sensible operations requiring user authentication. + * + * @author Yannick Schaus - initial contribution + * + */ +@NonNullByDefault +public abstract class AbstractAuthPageServlet extends HttpServlet { + + protected static final long serialVersionUID = 5340598701104679840L; + + private final Logger logger = LoggerFactory.getLogger(AbstractAuthPageServlet.class); + + protected HttpService httpService; + protected UserRegistry userRegistry; + protected AuthenticationProvider authProvider; + protected @Nullable Instant lastAuthenticationFailure; + protected int authenticationFailureCount = 0; + + protected Map csrfTokens = new HashMap<>(); + + protected String pageTemplate; + + public AbstractAuthPageServlet(BundleContext bundleContext, @Reference HttpService httpService, + @Reference UserRegistry userRegistry, @Reference AuthenticationProvider authProvider) { + this.httpService = httpService; + this.userRegistry = userRegistry; + this.authProvider = authProvider; + + pageTemplate = ""; + URL resource = bundleContext.getBundle().getResource("pages/authorize.html"); + if (resource != null) { + try (InputStream stream = resource.openStream()) { + pageTemplate = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("Cannot load page template", e); + } + } + } + + protected abstract String getPageBody(Map params, String message, boolean hideForm); + + protected abstract String getFormFields(Map params); + + protected String addCsrfToken() { + String csrfToken = UUID.randomUUID().toString().replace("-", ""); + csrfTokens.put(csrfToken, Instant.now()); + // remove old tokens (created earlier than 10 minutes ago) - this gives users a 10-minute window to sign in + csrfTokens.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now().minus(Duration.ofMinutes(10)))); + return csrfToken; + } + + protected void removeCsrfToken(String csrfToken) { + csrfTokens.remove(csrfToken); + } + + protected User login(String username, String password) throws AuthenticationException { + // Enforce a dynamic cooldown period after a failed authentication attempt: the number of + // consecutive failures in seconds + if (lastAuthenticationFailure != null && lastAuthenticationFailure + .isAfter(Instant.now().minus(Duration.ofSeconds(authenticationFailureCount)))) { + throw new AuthenticationException("Too many consecutive login attempts"); + } + + // Authenticate the user with the supplied credentials + UsernamePasswordCredentials credentials = new UsernamePasswordCredentials(username, password); + Authentication auth = authProvider.authenticate(credentials); + logger.debug("Login successful: {}", auth.getUsername()); + lastAuthenticationFailure = null; + authenticationFailureCount = 0; + User user = userRegistry.get(auth.getUsername()); + if (user == null) { + throw new AuthenticationException("User not found"); + } + return user; + } + + protected void processFailedLogin(HttpServletResponse resp, Map params, String message) + throws IOException { + lastAuthenticationFailure = Instant.now(); + authenticationFailureCount += 1; + resp.setContentType("text/html;charset=UTF-8"); + logger.warn("Authentication failed: {}", message); + resp.getWriter().append(getPageBody(params, "Please try again.", false)); // TODO: i18n + resp.getWriter().close(); + } +} diff --git a/bundles/org.opensmarthouse.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AuthorizePageServlet.java b/bundles/org.opensmarthouse.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AuthorizePageServlet.java index 8eb11aee5cf..b41cb314e04 100644 --- a/bundles/org.opensmarthouse.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AuthorizePageServlet.java +++ b/bundles/org.opensmarthouse.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/AuthorizePageServlet.java @@ -13,18 +13,11 @@ package org.openhab.core.io.http.auth.internal; import java.io.IOException; -import java.io.UncheckedIOException; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.time.Instant; -import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.UUID; import javax.servlet.ServletException; -import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.core.HttpHeaders; @@ -32,7 +25,6 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.http.HttpStatus; -import org.openhab.core.auth.Authentication; import org.openhab.core.auth.AuthenticationException; import org.openhab.core.auth.AuthenticationProvider; import org.openhab.core.auth.ManagedUser; @@ -40,7 +32,6 @@ import org.openhab.core.auth.Role; import org.openhab.core.auth.User; import org.openhab.core.auth.UserRegistry; -import org.openhab.core.auth.UsernamePasswordCredentials; import org.osgi.framework.BundleContext; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; @@ -64,222 +55,184 @@ */ @NonNullByDefault @Component(immediate = true) -public class AuthorizePageServlet extends HttpServlet { +public class AuthorizePageServlet extends AbstractAuthPageServlet { private static final long serialVersionUID = 5340598701104679843L; private final Logger logger = LoggerFactory.getLogger(AuthorizePageServlet.class); - private HashMap csrfTokens = new HashMap<>(); - - private HttpService httpService; - private UserRegistry userRegistry; - private AuthenticationProvider authProvider; - @Nullable - private Instant lastAuthenticationFailure; - private int authenticationFailureCount = 0; - - private String pageTemplate; - @Activate public AuthorizePageServlet(BundleContext bundleContext, @Reference HttpService httpService, @Reference UserRegistry userRegistry, @Reference AuthenticationProvider authProvider) { - this.httpService = httpService; - this.userRegistry = userRegistry; - this.authProvider = authProvider; - - pageTemplate = ""; + super(bundleContext, httpService, userRegistry, authProvider); try { - URL resource = bundleContext.getBundle().getResource("pages/authorize.html"); - if (resource != null) { - try { - pageTemplate = new String(resource.openStream().readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - httpService.registerServlet("/auth", this, null, null); - } + httpService.registerServlet("/auth", this, null, null); } catch (NamespaceException | ServletException e) { logger.error("Error during authorization page registration: {}", e.getMessage()); } } @Override - protected void doGet(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) + protected void doGet(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDefault({}) HttpServletResponse resp) throws ServletException, IOException { - if (req != null && resp != null) { - Map params = req.getParameterMap(); + Map params = req.getParameterMap(); - try { - String message = ""; - String scope = (params.containsKey("scope")) ? params.get("scope")[0] : ""; - String clientId = (params.containsKey("client_id")) ? params.get("client_id")[0] : ""; + try { + String message = ""; + String scope = params.containsKey("scope") ? params.get("scope")[0] : ""; + String clientId = params.containsKey("client_id") ? params.get("client_id")[0] : ""; - // Basic sanity check - if (scope.contains("<") || clientId.contains("<")) { - throw new IllegalArgumentException("invalid_request"); - } + // Basic sanity check + if (scope.contains("<") || clientId.contains("<")) { + throw new IllegalArgumentException("invalid_request"); + } - // TODO: i18n - if (isSignupMode()) { - message = "Create a first administrator account to continue."; - } else { - message = String.format("Sign in to grant %s access to %s:", scope, clientId); - } - resp.setContentType("text/html;charset=UTF-8"); - resp.getWriter().append(getPageBody(params, message)); - resp.getWriter().close(); - } catch (Exception e) { - resp.setContentType("text/plain;charset=UTF-8"); - resp.getWriter().append(e.getMessage()); - resp.getWriter().close(); + // TODO: i18n + if (isSignupMode()) { + message = "Create a first administrator account to continue."; + } else { + message = String.format("Sign in to grant %s access to %s:", scope, clientId); } + resp.setContentType("text/html;charset=UTF-8"); + resp.getWriter().append(getPageBody(params, message, false)); + resp.getWriter().close(); + } catch (Exception e) { + resp.setContentType("text/plain;charset=UTF-8"); + resp.getWriter().append(e.getMessage()); + resp.getWriter().close(); } } @Override - protected void doPost(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) + protected void doPost(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDefault({}) HttpServletResponse resp) throws ServletException, IOException { - if (req != null && resp != null) { - Map params = req.getParameterMap(); - try { - if (!params.containsKey(("username"))) { - throw new AuthenticationException("no username"); - } - if (!params.containsKey(("password"))) { - throw new AuthenticationException("no password"); - } - if (!params.containsKey("csrf_token") || !csrfTokens.containsKey(params.get("csrf_token")[0])) { - throw new AuthenticationException("CSRF check failed"); - } - if (!params.containsKey(("redirect_uri"))) { - throw new IllegalArgumentException("invalid_request"); - } - if (!params.containsKey(("response_type"))) { - throw new IllegalArgumentException("unsupported_response_type"); - } - if (!params.containsKey(("client_id"))) { - throw new IllegalArgumentException("unauthorized_client"); - } - if (!params.containsKey(("scope"))) { - throw new IllegalArgumentException("invalid_scope"); - } + Map params = req.getParameterMap(); + try { + if (!params.containsKey("username")) { + throw new AuthenticationException("no username"); + } + if (!params.containsKey("password")) { + throw new AuthenticationException("no password"); + } + if (!params.containsKey("csrf_token") || !csrfTokens.containsKey(params.get("csrf_token")[0])) { + throw new AuthenticationException("CSRF check failed"); + } + if (!params.containsKey("redirect_uri")) { + throw new IllegalArgumentException("invalid_request"); + } + if (!params.containsKey("response_type")) { + throw new IllegalArgumentException("unsupported_response_type"); + } + if (!params.containsKey("client_id")) { + throw new IllegalArgumentException("unauthorized_client"); + } + if (!params.containsKey("scope")) { + throw new IllegalArgumentException("invalid_scope"); + } - csrfTokens.remove(params.get("csrf_token")[0]); + removeCsrfToken(params.get("csrf_token")[0]); - String baseRedirectUri = params.get("redirect_uri")[0]; - String responseType = params.get("response_type")[0]; - String clientId = params.get("redirect_uri")[0]; - String scope = params.get("scope")[0]; + String baseRedirectUri = params.get("redirect_uri")[0]; + String responseType = params.get("response_type")[0]; + String clientId = params.get("redirect_uri")[0]; + String scope = params.get("scope")[0]; - if (!("code".equals(responseType))) { - throw new AuthenticationException("unsupported_response_type"); - } + if (!"code".equals(responseType)) { + throw new AuthenticationException("unsupported_response_type"); + } - if (!clientId.equals(baseRedirectUri)) { - throw new IllegalArgumentException("unauthorized_client"); - } + if (!clientId.equals(baseRedirectUri)) { + throw new IllegalArgumentException("unauthorized_client"); + } - String username = params.get("username")[0]; - String password = params.get("password")[0]; - - User user; - if (isSignupMode()) { - // Create a first administrator account with the supplied credentials - - // first verify the password confirmation and bail out if necessary - if (!params.containsKey("password_repeat") || !password.equals(params.get("password_repeat")[0])) { - resp.setContentType("text/html;charset=UTF-8"); - // TODO: i18n - resp.getWriter().append(getPageBody(params, "Passwords don't match, please try again.")); - resp.getWriter().close(); - return; - } - - user = userRegistry.register(username, password, Set.of(Role.ADMIN)); - logger.info("First user account created: {}", username); - } else { - // Enforce a dynamic cooldown period after a failed authentication attempt: the number of - // consecutive failures in seconds - if (lastAuthenticationFailure != null && lastAuthenticationFailure - .isAfter(Instant.now().minus(Duration.ofSeconds(authenticationFailureCount)))) { - throw new AuthenticationException("Too many consecutive login attempts"); - } - - // Authenticate the user with the supplied credentials - UsernamePasswordCredentials credentials = new UsernamePasswordCredentials(username, password); - Authentication auth = authProvider.authenticate(credentials); - logger.debug("Login successful: {}", auth.getUsername()); - lastAuthenticationFailure = null; - authenticationFailureCount = 0; - user = userRegistry.get(auth.getUsername()); - } + String username = params.get("username")[0]; + String password = params.get("password")[0]; + + User user; + if (isSignupMode()) { + // Create a first administrator account with the supplied credentials - String authorizationCode = UUID.randomUUID().toString().replace("-", ""); - - if (user instanceof ManagedUser) { - String codeChallenge = (params.containsKey("code_challenge")) ? params.get("code_challenge")[0] - : null; - String codeChallengeMethod = (params.containsKey("code_challenge_method")) - ? params.get("code_challenge_method")[0] - : null; - ManagedUser managedUser = (ManagedUser) user; - PendingToken pendingToken = new PendingToken(authorizationCode, clientId, baseRedirectUri, scope, - codeChallenge, codeChallengeMethod); - managedUser.setPendingToken(pendingToken); - userRegistry.update(managedUser); + // first verify the password confirmation and bail out if necessary + if (!params.containsKey("password_repeat") || !password.equals(params.get("password_repeat")[0])) { + resp.setContentType("text/html;charset=UTF-8"); + // TODO: i18n + resp.getWriter().append(getPageBody(params, "Passwords don't match, please try again.", false)); + resp.getWriter().close(); + return; } - String state = params.containsKey("state") ? params.get("state")[0] : null; - resp.addHeader(HttpHeaders.LOCATION, getRedirectUri(baseRedirectUri, authorizationCode, null, state)); + user = userRegistry.register(username, password, Set.of(Role.ADMIN)); + logger.info("First user account created: {}", username); + } else { + user = login(username, password); + } + + String authorizationCode = UUID.randomUUID().toString().replace("-", ""); + + if (user instanceof ManagedUser) { + String codeChallenge = params.containsKey("code_challenge") ? params.get("code_challenge")[0] : null; + String codeChallengeMethod = params.containsKey("code_challenge_method") + ? params.get("code_challenge_method")[0] + : null; + ManagedUser managedUser = (ManagedUser) user; + PendingToken pendingToken = new PendingToken(authorizationCode, clientId, baseRedirectUri, scope, + codeChallenge, codeChallengeMethod); + managedUser.setPendingToken(pendingToken); + userRegistry.update(managedUser); + } + + String state = params.containsKey("state") ? params.get("state")[0] : null; + resp.addHeader(HttpHeaders.LOCATION, getRedirectUri(baseRedirectUri, authorizationCode, null, state)); + resp.setStatus(HttpStatus.MOVED_TEMPORARILY_302); + } catch (AuthenticationException e) { + processFailedLogin(resp, params, e.getMessage()); + } catch (IllegalArgumentException e) { + @Nullable + String baseRedirectUri = params.containsKey("redirect_uri") ? params.get("redirect_uri")[0] : null; + @Nullable + String state = params.containsKey("state") ? params.get("state")[0] : null; + if (baseRedirectUri != null) { + resp.addHeader(HttpHeaders.LOCATION, getRedirectUri(baseRedirectUri, null, e.getMessage(), state)); resp.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - } catch (AuthenticationException e) { - lastAuthenticationFailure = Instant.now(); - authenticationFailureCount += 1; - resp.setContentType("text/html;charset=UTF-8"); - logger.warn("Authentication failed: {}", e.getMessage()); - resp.getWriter().append(getPageBody(params, "Please try again.")); // TODO: i18n + } else { + resp.setContentType("text/plain;charset=UTF-8"); + resp.getWriter().append(e.getMessage()); resp.getWriter().close(); - } catch (IllegalArgumentException e) { - @Nullable - String baseRedirectUri = params.containsKey("redirect_uri") ? params.get("redirect_uri")[0] : null; - @Nullable - String state = params.containsKey("state") ? params.get("state")[0] : null; - if (baseRedirectUri != null) { - resp.addHeader(HttpHeaders.LOCATION, getRedirectUri(baseRedirectUri, null, e.getMessage(), state)); - resp.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - } else { - resp.setContentType("text/plain;charset=UTF-8"); - resp.getWriter().append(e.getMessage()); - resp.getWriter().close(); - } } } } - private String getPageBody(Map params, String message) { + @Override + protected String getPageBody(Map params, String message, boolean hideForm) { String responseBody = pageTemplate.replace("{form_fields}", getFormFields(params)); - String repeatPasswordFieldType = (isSignupMode()) ? "password" : "hidden"; - String buttonLabel = (isSignupMode()) ? "Create Account" : "Sign In"; // TODO: i18n + String repeatPasswordFieldType = isSignupMode() ? "password" : "hidden"; + String buttonLabel = isSignupMode() ? "Create Account" : "Sign In"; // TODO: i18n responseBody = responseBody.replace("{message}", message); + responseBody = responseBody.replace("{formAction}", "/auth"); + responseBody = responseBody.replace("{formClass}", "show"); responseBody = responseBody.replace("{repeatPasswordFieldType}", repeatPasswordFieldType); + responseBody = responseBody.replace("{newPasswordFieldType}", "hidden"); + responseBody = responseBody.replace("{tokenNameFieldType}", "hidden"); + responseBody = responseBody.replace("{tokenScopeFieldType}", "hidden"); responseBody = responseBody.replace("{buttonLabel}", buttonLabel); + responseBody = responseBody.replace("{resultClass}", ""); return responseBody; } - private String getFormFields(Map params) { + @Override + protected String getFormFields(Map params) { String hiddenFormFields = ""; - if (!params.containsKey(("redirect_uri"))) { + if (!params.containsKey("redirect_uri")) { throw new IllegalArgumentException("invalid_request"); } - if (!params.containsKey(("response_type"))) { + if (!params.containsKey("response_type")) { throw new IllegalArgumentException("unsupported_response_type"); } - if (!params.containsKey(("client_id"))) { + if (!params.containsKey("client_id")) { throw new IllegalArgumentException("unauthorized_client"); } - if (!params.containsKey(("scope"))) { + if (!params.containsKey("scope")) { throw new IllegalArgumentException("invalid_scope"); } String csrfToken = addCsrfToken(); @@ -287,9 +240,9 @@ private String getFormFields(Map params) { String responseType = params.get("response_type")[0]; String clientId = params.get("client_id")[0]; String scope = params.get("scope")[0]; - String state = (params.containsKey("state")) ? params.get("state")[0] : null; - String codeChallenge = (params.containsKey("code_challenge")) ? params.get("code_challenge")[0] : null; - String codeChallengeMethod = (params.containsKey("code_challenge_method")) + String state = params.containsKey("state") ? params.get("state")[0] : null; + String codeChallenge = params.containsKey("code_challenge") ? params.get("code_challenge")[0] : null; + String codeChallengeMethod = params.containsKey("code_challenge_method") ? params.get("code_challenge_method")[0] : null; hiddenFormFields += ""; @@ -326,14 +279,6 @@ private String getRedirectUri(String baseRedirectUri, @Nullable String authoriza return redirectUri; } - private String addCsrfToken() { - String csrfToken = UUID.randomUUID().toString().replace("-", ""); - csrfTokens.put(csrfToken, Instant.now()); - // remove old tokens (created earlier than 10 minutes ago) - this gives users a 10-minute window to sign in - csrfTokens.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now().minus(Duration.ofMinutes(10)))); - return csrfToken; - } - private boolean isSignupMode() { return userRegistry.getAll().isEmpty(); } diff --git a/bundles/org.opensmarthouse.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/ChangePasswordPageServlet.java b/bundles/org.opensmarthouse.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/ChangePasswordPageServlet.java new file mode 100644 index 00000000000..2f576ffe53e --- /dev/null +++ b/bundles/org.opensmarthouse.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/ChangePasswordPageServlet.java @@ -0,0 +1,171 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.http.auth.internal; + +import java.io.IOException; +import java.util.Map; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.auth.AuthenticationException; +import org.openhab.core.auth.AuthenticationProvider; +import org.openhab.core.auth.ManagedUser; +import org.openhab.core.auth.User; +import org.openhab.core.auth.UserRegistry; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; +import org.osgi.service.http.NamespaceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A servlet serving a page allowing users to change their password, after confirming their identity by signing in. + * + * @author Yannick Schaus - initial contribution + * + */ +@NonNullByDefault +@Component(immediate = true) +public class ChangePasswordPageServlet extends AbstractAuthPageServlet { + + private static final long serialVersionUID = 5340598701104679843L; + + private final Logger logger = LoggerFactory.getLogger(ChangePasswordPageServlet.class); + + @Activate + public ChangePasswordPageServlet(BundleContext bundleContext, @Reference HttpService httpService, + @Reference UserRegistry userRegistry, @Reference AuthenticationProvider authProvider) { + super(bundleContext, httpService, userRegistry, authProvider); + try { + httpService.registerServlet("/changePassword", this, null, null); + } catch (NamespaceException | ServletException e) { + logger.error("Error during change password page registration: {}", e.getMessage()); + } + } + + @Override + protected void doGet(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDefault({}) HttpServletResponse resp) + throws ServletException, IOException { + Map params = req.getParameterMap(); + + try { + String message = ""; + + resp.setContentType("text/html;charset=UTF-8"); + resp.getWriter().append(getPageBody(params, message, false)); + resp.getWriter().close(); + } catch (Exception e) { + resp.setContentType("text/plain;charset=UTF-8"); + resp.getWriter().append(e.getMessage()); + resp.getWriter().close(); + } + } + + @Override + protected void doPost(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDefault({}) HttpServletResponse resp) + throws ServletException, IOException { + Map params = req.getParameterMap(); + try { + if (!params.containsKey("username")) { + throw new AuthenticationException("no username"); + } + if (!params.containsKey("password")) { + throw new AuthenticationException("no password"); + } + if (!params.containsKey("new_password")) { + throw new AuthenticationException("no new password"); + } + if (!params.containsKey("csrf_token") || !csrfTokens.containsKey(params.get("csrf_token")[0])) { + throw new AuthenticationException("CSRF check failed"); + } + + removeCsrfToken(params.get("csrf_token")[0]); + + String username = params.get("username")[0]; + String password = params.get("password")[0]; + String newPassword = params.get("new_password")[0]; + + if (!params.containsKey("password_repeat") || !newPassword.equals(params.get("password_repeat")[0])) { + resp.setContentType("text/html;charset=UTF-8"); + // TODO: i18n + resp.getWriter().append(getPageBody(params, "Passwords don't match, please try again.", false)); + resp.getWriter().close(); + return; + } + + User user = login(username, password); + + if (user instanceof ManagedUser) { + userRegistry.changePassword(user, newPassword); + } else { + throw new AuthenticationException("User is not managed"); + } + + resp.setContentType("text/html;charset=UTF-8"); + resp.getWriter().append(getResultPageBody(params, "Password changed.")); // TODO: i18n + resp.getWriter().close(); + } catch (AuthenticationException e) { + processFailedLogin(resp, params, e.getMessage()); + } + } + + @Override + protected String getPageBody(Map params, String message, boolean hideForm) { + String responseBody = pageTemplate.replace("{form_fields}", getFormFields(params)); + String buttonLabel = "Change Password"; // TODO: i18n + responseBody = responseBody.replace("{message}", message); + responseBody = responseBody.replace("{formAction}", "/changePassword"); + responseBody = responseBody.replace("{formClass}", hideForm ? "hide" : "show"); + responseBody = responseBody.replace("{repeatPasswordFieldType}", "password"); + responseBody = responseBody.replace("{newPasswordFieldType}", "password"); + responseBody = responseBody.replace("{tokenNameFieldType}", "hidden"); + responseBody = responseBody.replace("{tokenScopeFieldType}", "hidden"); + responseBody = responseBody.replace("{buttonLabel}", buttonLabel); + responseBody = responseBody.replace("{resultClass}", ""); + return responseBody; + } + + protected String getResultPageBody(Map params, String message) { + String responseBody = pageTemplate.replace("{form_fields}", ""); + responseBody = responseBody.replace("{message}", message); + responseBody = responseBody.replace("{formAction}", "/changePassword"); + responseBody = responseBody.replace("{formClass}", "hide"); + responseBody = responseBody.replace("{repeatPasswordFieldType}", "password"); + responseBody = responseBody.replace("{newPasswordFieldType}", "password"); + responseBody = responseBody.replace("{tokenNameFieldType}", "hidden"); + responseBody = responseBody.replace("{tokenScopeFieldType}", "hidden"); + responseBody = responseBody.replace("{resultClass}", "Password"); + return responseBody; + } + + @Override + protected String getFormFields(Map params) { + String hiddenFormFields = ""; + String csrfToken = addCsrfToken(); + hiddenFormFields += ""; + + return hiddenFormFields; + } + + @Deactivate + public void deactivate() { + httpService.unregister("/changePassword"); + } +} diff --git a/bundles/org.opensmarthouse.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/CreateAPITokenPageServlet.java b/bundles/org.opensmarthouse.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/CreateAPITokenPageServlet.java new file mode 100644 index 00000000000..75c4175c4ce --- /dev/null +++ b/bundles/org.opensmarthouse.core.io.http.auth/src/main/java/org/openhab/core/io/http/auth/internal/CreateAPITokenPageServlet.java @@ -0,0 +1,190 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.http.auth.internal; + +import java.io.IOException; +import java.util.Map; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.auth.AuthenticationException; +import org.openhab.core.auth.AuthenticationProvider; +import org.openhab.core.auth.ManagedUser; +import org.openhab.core.auth.User; +import org.openhab.core.auth.UserRegistry; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; +import org.osgi.service.http.NamespaceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A servlet serving a page allowing users to create a new API token, after confirming their identity by signing in. + * + * @author Yannick Schaus - initial contribution + * + */ +@NonNullByDefault +@Component(immediate = true) +public class CreateAPITokenPageServlet extends AbstractAuthPageServlet { + + private static final long serialVersionUID = 5340598701104679843L; + + private final Logger logger = LoggerFactory.getLogger(CreateAPITokenPageServlet.class); + + @Activate + public CreateAPITokenPageServlet(BundleContext bundleContext, @Reference HttpService httpService, + @Reference UserRegistry userRegistry, @Reference AuthenticationProvider authProvider) { + super(bundleContext, httpService, userRegistry, authProvider); + try { + httpService.registerServlet("/createApiToken", this, null, null); + } catch (NamespaceException | ServletException e) { + logger.error("Error during create API token page registration: {}", e.getMessage()); + } + } + + @Override + protected void doGet(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDefault({}) HttpServletResponse resp) + throws ServletException, IOException { + Map params = req.getParameterMap(); + + try { + String message = "Create a new API token to authorize external services."; + + // TODO: i18n + resp.setContentType("text/html;charset=UTF-8"); + resp.getWriter().append(getPageBody(params, message, false)); + resp.getWriter().close(); + } catch (Exception e) { + resp.setContentType("text/plain;charset=UTF-8"); + resp.getWriter().append(e.getMessage()); + resp.getWriter().close(); + } + } + + @Override + protected void doPost(@NonNullByDefault({}) HttpServletRequest req, @NonNullByDefault({}) HttpServletResponse resp) + throws ServletException, IOException { + Map params = req.getParameterMap(); + try { + if (!params.containsKey("username")) { + throw new AuthenticationException("no username"); + } + if (!params.containsKey("password")) { + throw new AuthenticationException("no password"); + } + if (!params.containsKey("token_name")) { + throw new AuthenticationException("no new password"); + } + if (!params.containsKey("csrf_token") || !csrfTokens.containsKey(params.get("csrf_token")[0])) { + throw new AuthenticationException("CSRF check failed"); + } + + removeCsrfToken(params.get("csrf_token")[0]); + + String username = params.get("username")[0]; + String password = params.get("password")[0]; + String tokenName = params.get("token_name")[0]; + String tokenScope = ""; + if (params.containsKey("token_name")) { + tokenScope = params.get("token_scope")[0]; + } + + User user = login(username, password); + String newApiToken; + + if (user instanceof ManagedUser) { + if (((ManagedUser) user).getApiTokens().stream() + .anyMatch(apiToken -> apiToken.getName().equals(tokenName))) { + resp.setContentType("text/html;charset=UTF-8"); + // TODO: i18n + resp.getWriter().append( + getPageBody(params, "A token with the same name already exists, please try again.", false)); + resp.getWriter().close(); + return; + } + + if (!tokenName.matches("[a-zA-Z0-9]*")) { + resp.setContentType("text/html;charset=UTF-8"); + // TODO: i18n + resp.getWriter().append( + getPageBody(params, "Invalid token name, please use alphanumeric characters only.", false)); + resp.getWriter().close(); + return; + } + newApiToken = userRegistry.addUserApiToken(user, tokenName, tokenScope); + } else { + throw new AuthenticationException("User is not managed"); + } + + // TODO: i18n + String resultMessage = "New token created:

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

Please copy it now, it will not be shown again."; + resp.setContentType("text/html;charset=UTF-8"); + resp.getWriter().append(getResultPageBody(params, resultMessage)); // TODO: i18n + resp.getWriter().close(); + } catch (AuthenticationException e) { + processFailedLogin(resp, params, e.getMessage()); + } + } + + @Override + protected String getPageBody(Map params, String message, boolean hideForm) { + String responseBody = pageTemplate.replace("{form_fields}", getFormFields(params)); + String buttonLabel = "Create API Token"; // TODO: i18n + responseBody = responseBody.replace("{message}", message); + responseBody = responseBody.replace("{formAction}", "/createApiToken"); + responseBody = responseBody.replace("{formClass}", hideForm ? "hide" : "show"); + responseBody = responseBody.replace("{repeatPasswordFieldType}", "hidden"); + responseBody = responseBody.replace("{newPasswordFieldType}", "hidden"); + responseBody = responseBody.replace("{tokenNameFieldType}", "text"); + responseBody = responseBody.replace("{tokenScopeFieldType}", "text"); + responseBody = responseBody.replace("{buttonLabel}", buttonLabel); + responseBody = responseBody.replace("{resultClass}", ""); + return responseBody; + } + + protected String getResultPageBody(Map params, String message) { + String responseBody = pageTemplate.replace("{form_fields}", ""); + responseBody = responseBody.replace("{message}", message); + responseBody = responseBody.replace("{formAction}", "/createApiToken"); + responseBody = responseBody.replace("{formClass}", "hide"); + responseBody = responseBody.replace("{repeatPasswordFieldType}", "hidden"); + responseBody = responseBody.replace("{newPasswordFieldType}", "hidden"); + responseBody = responseBody.replace("{tokenNameFieldType}", "text"); + responseBody = responseBody.replace("{tokenScopeFieldType}", "text"); + responseBody = responseBody.replace("{resultClass}", "Password"); + return responseBody; + } + + @Override + protected String getFormFields(Map params) { + String hiddenFormFields = ""; + String csrfToken = addCsrfToken(); + hiddenFormFields += ""; + + return hiddenFormFields; + } + + @Deactivate + public void deactivate() { + httpService.unregister("/createApiToken"); + } +} diff --git a/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthFilter.java b/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthFilter.java index c89b468dff7..1d3389e87cc 100644 --- a/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthFilter.java +++ b/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthFilter.java @@ -17,7 +17,6 @@ import java.util.Map; import javax.annotation.Priority; -import javax.security.sasl.AuthenticationException; import javax.ws.rs.Priorities; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.container.ContainerRequestFilter; @@ -29,7 +28,9 @@ import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.auth.Authentication; +import org.openhab.core.auth.AuthenticationException; import org.openhab.core.auth.User; +import org.openhab.core.auth.UserApiTokenCredentials; import org.openhab.core.auth.UserRegistry; import org.openhab.core.auth.UsernamePasswordCredentials; import org.openhab.core.config.core.ConfigurableService; @@ -52,6 +53,7 @@ * * @author Yannick Schaus - initial contribution * @author Yannick Schaus - Allow basic authentication + * @author Yannick Schaus - Add support for API tokens */ @PreMatching @Component(configurationPid = "org.openhab.restauth", property = Constants.SERVICE_PID + "=org.openhab.restauth") @@ -64,6 +66,7 @@ public class AuthFilter implements ContainerRequestFilter { private final Logger logger = LoggerFactory.getLogger(AuthFilter.class); private static final String ALT_AUTH_HEADER = "X-OPENHAB-TOKEN"; + private static final String API_TOKEN_PREFIX = "oh."; protected static final String CONFIG_URI = "system:restauth"; private static final String CONFIG_ALLOW_BASIC_AUTH = "allowBasicAuth"; @@ -93,50 +96,76 @@ protected void modified(@Nullable Map properties) { } } + private SecurityContext authenticateBearerToken(String token) throws AuthenticationException { + if (token.startsWith(API_TOKEN_PREFIX)) { + UserApiTokenCredentials credentials = new UserApiTokenCredentials(token); + Authentication auth = userRegistry.authenticate(credentials); + User user = userRegistry.get(auth.getUsername()); + if (user == null) { + throw new AuthenticationException("User not found in registry"); + } + return new UserSecurityContext(user, auth, "ApiToken"); + } else { + Authentication auth = jwtHelper.verifyAndParseJwtAccessToken(token); + return new JwtSecurityContext(auth); + } + } + + private SecurityContext authenticateUsernamePassword(String username, String password) + throws AuthenticationException { + UsernamePasswordCredentials credentials = new UsernamePasswordCredentials(username, password); + Authentication auth = userRegistry.authenticate(credentials); + User user = userRegistry.get(auth.getUsername()); + if (user == null) { + throw new AuthenticationException("User not found in registry"); + } + return new UserSecurityContext(user, auth, "Basic"); + } + @Override public void filter(ContainerRequestContext requestContext) throws IOException { try { + String altTokenHeader = requestContext.getHeaderString(ALT_AUTH_HEADER); + if (altTokenHeader != null) { + requestContext.setSecurityContext(authenticateBearerToken(altTokenHeader)); + return; + } + String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); if (authHeader != null) { String[] authParts = authHeader.split(" "); if (authParts.length == 2) { if ("Bearer".equalsIgnoreCase(authParts[0])) { - Authentication auth = jwtHelper.verifyAndParseJwtAccessToken(authParts[1]); - requestContext.setSecurityContext(new JwtSecurityContext(auth)); + requestContext.setSecurityContext(authenticateBearerToken(authParts[1])); return; } else if ("Basic".equalsIgnoreCase(authParts[0])) { - if (!allowBasicAuth) { - throw new AuthenticationException("Basic authentication is not allowed"); - } try { String[] decodedCredentials = new String(Base64.getDecoder().decode(authParts[1]), "UTF-8") .split(":"); - if (decodedCredentials.length != 2) { + if (decodedCredentials.length > 2) { throw new AuthenticationException("Invalid Basic authentication credential format"); } - UsernamePasswordCredentials credentials = new UsernamePasswordCredentials( - decodedCredentials[0], decodedCredentials[1]); - Authentication auth = userRegistry.authenticate(credentials); - User user = userRegistry.get(auth.getUsername()); - if (user == null) { - throw new org.openhab.core.auth.AuthenticationException("User not found in registry"); + switch (decodedCredentials.length) { + case 1: + requestContext.setSecurityContext(authenticateBearerToken(decodedCredentials[0])); + break; + case 2: + if (!allowBasicAuth) { + throw new AuthenticationException( + "Basic authentication with username/password is not allowed"); + } + requestContext.setSecurityContext( + authenticateUsernamePassword(decodedCredentials[0], decodedCredentials[1])); } - requestContext.setSecurityContext(new UserSecurityContext(user, "Basic")); + return; - } catch (org.openhab.core.auth.AuthenticationException e) { + } catch (AuthenticationException e) { throw new AuthenticationException("Invalid Basic authentication credentials", e); } } } } - String altTokenHeader = requestContext.getHeaderString(ALT_AUTH_HEADER); - if (altTokenHeader != null) { - Authentication auth = jwtHelper.verifyAndParseJwtAccessToken(altTokenHeader); - requestContext.setSecurityContext(new JwtSecurityContext(auth)); - return; - } - if (implicitUserRole) { requestContext.setSecurityContext(new AnonymousUserSecurityContext()); } diff --git a/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthenticationSecurityContext.java b/bundles/org.opensmarthouse.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.opensmarthouse.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.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtHelper.java b/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtHelper.java index 8eea84c6d35..48f8b30b19b 100644 --- a/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtHelper.java +++ b/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtHelper.java @@ -23,8 +23,6 @@ import java.util.Collections; import java.util.List; -import javax.security.sasl.AuthenticationException; - import org.eclipse.jdt.annotation.NonNullByDefault; import org.jose4j.jwa.AlgorithmConstraints.ConstraintType; import org.jose4j.jwk.JsonWebKey; @@ -41,6 +39,7 @@ import org.jose4j.lang.JoseException; import org.openhab.core.OpenHAB; import org.openhab.core.auth.Authentication; +import org.openhab.core.auth.AuthenticationException; import org.openhab.core.auth.User; import org.osgi.service.component.annotations.Component; import org.slf4j.Logger; @@ -150,8 +149,8 @@ public Authentication verifyAndParseJwtAccessToken(String jwt) throws Authentica JwtClaims jwtClaims = jwtConsumer.processToClaims(jwt); String username = jwtClaims.getSubject(); List roles = jwtClaims.getStringListClaimValue("role"); - Authentication auth = new Authentication(username, roles.toArray(new String[roles.size()])); - return auth; + String scope = jwtClaims.getStringClaimValue("scope"); + return new Authentication(username, roles.toArray(new String[roles.size()]), scope); } catch (InvalidJwtException | MalformedClaimException e) { throw new AuthenticationException("Error while processing JWT token", e); } diff --git a/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtSecurityContext.java b/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtSecurityContext.java index 3d272f3249b..ae47ec7ba0e 100644 --- a/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/JwtSecurityContext.java +++ b/bundles/org.opensmarthouse.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.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResource.java b/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResource.java index 71d583ce1ea..6f1943820be 100644 --- a/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResource.java +++ b/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/TokenResource.java @@ -22,10 +22,12 @@ import javax.ws.rs.Consumes; import javax.ws.rs.CookieParam; +import javax.ws.rs.DELETE; import javax.ws.rs.FormParam; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; @@ -39,10 +41,12 @@ import javax.ws.rs.core.UriInfo; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.jose4j.base64url.Base64Url; import org.openhab.core.auth.ManagedUser; import org.openhab.core.auth.PendingToken; import org.openhab.core.auth.User; +import org.openhab.core.auth.UserApiToken; import org.openhab.core.auth.UserRegistry; import org.openhab.core.auth.UserSession; import org.openhab.core.io.rest.JSONResponse; @@ -73,6 +77,7 @@ * @author Yannick Schaus - Initial contribution * @author Wouter Born - Migrated to JAX-RS Whiteboard Specification * @author Wouter Born - Migrated to OpenAPI annotations + * @author Yannick Schaus - Add API token operations */ @Component(service = { RESTResource.class, TokenResource.class }) @JaxrsResource @@ -110,7 +115,8 @@ public TokenResource(final @Reference UserRegistry userRegistry, final @Referenc @Produces({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_FORM_URLENCODED }) @Operation(summary = "Get access and refresh tokens.", responses = { - @ApiResponse(responseCode = "200", description = "OK") }) + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "400", description = "Invalid request parameters") }) public Response getToken(@FormParam("grant_type") String grantType, @FormParam("code") String code, @FormParam("redirect_uri") String redirectUri, @FormParam("client_id") String clientId, @FormParam("refresh_token") String refreshToken, @FormParam("code_verifier") String codeVerifier, @@ -138,7 +144,9 @@ public Response getToken(@FormParam("grant_type") String grantType, @FormParam(" @GET @Path("/sessions") @Operation(summary = "List the sessions associated to the authenticated user.", responses = { - @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UserSessionDTO.class))) }) + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UserSessionDTO.class))), + @ApiResponse(responseCode = "401", description = "User is not authenticated"), + @ApiResponse(responseCode = "404", description = "User not found") }) @Produces({ MediaType.APPLICATION_JSON }) public Response getSessions(@Context SecurityContext securityContext) { if (securityContext.getUserPrincipal() == null) { @@ -154,13 +162,62 @@ public Response getSessions(@Context SecurityContext securityContext) { return Response.ok(new Stream2JSONInputStream(sessions)).build(); } + @GET + @Path("/apitokens") + @Operation(summary = "List the API tokens associated to the authenticated user.", responses = { + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UserApiTokenDTO.class))), + @ApiResponse(responseCode = "401", description = "User is not authenticated"), + @ApiResponse(responseCode = "404", description = "User not found") }) + @Produces({ MediaType.APPLICATION_JSON }) + public Response getApiTokens(@Context SecurityContext securityContext) { + if (securityContext.getUserPrincipal() == null) { + return JSONResponse.createErrorResponse(Status.UNAUTHORIZED, "User is not authenticated"); + } + + ManagedUser user = (ManagedUser) userRegistry.get(securityContext.getUserPrincipal().getName()); + if (user == null) { + return JSONResponse.createErrorResponse(Status.NOT_FOUND, "User not found"); + } + + Stream sessions = user.getApiTokens().stream().map(this::toUserApiTokenDTO); + return Response.ok(new Stream2JSONInputStream(sessions)).build(); + } + + @DELETE + @Path("/apitokens/{name}") + @Operation(summary = "Revoke a specified API token associated to the authenticated user.", responses = { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "401", description = "User is not authenticated"), + @ApiResponse(responseCode = "404", description = "User or API token not found") }) + public Response removeApiToken(@Context SecurityContext securityContext, @PathParam("name") String name) { + if (securityContext.getUserPrincipal() == null) { + return JSONResponse.createErrorResponse(Status.UNAUTHORIZED, "User is not authenticated"); + } + + ManagedUser user = (ManagedUser) userRegistry.get(securityContext.getUserPrincipal().getName()); + if (user == null) { + return JSONResponse.createErrorResponse(Status.NOT_FOUND, "User not found"); + } + + Optional userApiToken = user.getApiTokens().stream() + .filter(apiToken -> apiToken.getName().equals(name)).findAny(); + if (userApiToken.isEmpty()) { + return JSONResponse.createErrorResponse(Status.NOT_FOUND, "No API token found with that name"); + } + + userRegistry.removeUserApiToken(user, userApiToken.get()); + return Response.ok().build(); + } + @POST @Path("/logout") @Consumes({ MediaType.APPLICATION_FORM_URLENCODED }) @Operation(summary = "Delete the session associated with a refresh token.", responses = { - @ApiResponse(responseCode = "200", description = "OK") }) - public Response deleteSession(@FormParam("refresh_token") String refreshToken, @FormParam("id") String id, - @Context SecurityContext securityContext) { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "401", description = "User is not authenticated"), + @ApiResponse(responseCode = "404", description = "User or refresh token not found") }) + public Response deleteSession(@Nullable @FormParam("refresh_token") String refreshToken, + @Nullable @FormParam("id") String id, @Context SecurityContext securityContext) { if (securityContext.getUserPrincipal() == null) { return JSONResponse.createErrorResponse(Status.UNAUTHORIZED, "User is not authenticated"); } @@ -195,8 +252,7 @@ public Response deleteSession(@FormParam("refresh_token") String refreshToken, @ } } - user.getSessions().remove(session.get()); - userRegistry.update(user); + userRegistry.removeUserSession(user, session.get()); return response.build(); } @@ -208,11 +264,19 @@ private UserSessionDTO toUserSessionDTO(UserSession session) { session.getLastRefreshTime(), session.getClientId(), session.getScope()); } + private UserApiTokenDTO toUserApiTokenDTO(UserApiToken apiToken) { + return new UserApiTokenDTO(apiToken.getName(), apiToken.getCreatedTime(), apiToken.getScope()); + } + private Response processAuthorizationCodeGrant(String code, String redirectUri, String clientId, - String codeVerifier, boolean useCookie) throws TokenEndpointException, NoSuchAlgorithmException { - // find an user with the authorization code pending - Optional user = userRegistry.getAll().stream().filter(u -> ((ManagedUser) u).getPendingToken() != null - && ((ManagedUser) u).getPendingToken().getAuthorizationCode().equals(code)).findAny(); + @Nullable String codeVerifier, boolean useCookie) throws TokenEndpointException, NoSuchAlgorithmException { + // find a user with the authorization code pending + Optional user = userRegistry.getAll().stream().filter(u -> { + ManagedUser managedUser = (ManagedUser) u; + @Nullable + PendingToken pendingToken = managedUser.getPendingToken(); + return (pendingToken != null && pendingToken.getAuthorizationCode().equals(code)); + }).findAny(); if (!user.isPresent()) { logger.warn("Couldn't find a user with the provided authentication code pending"); @@ -308,8 +372,8 @@ private Response processAuthorizationCodeGrant(String code, String redirectUri, return response.build(); } - private Response processRefreshTokenGrant(String clientId, String refreshToken, Cookie sessionCookie) - throws TokenEndpointException { + private Response processRefreshTokenGrant(String clientId, @Nullable String refreshToken, + @Nullable Cookie sessionCookie) throws TokenEndpointException { if (refreshToken == null) { throw new TokenEndpointException(ErrorType.INVALID_REQUEST); } diff --git a/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserApiTokenDTO.java b/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserApiTokenDTO.java new file mode 100644 index 00000000000..885391d1ea3 --- /dev/null +++ b/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserApiTokenDTO.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2010-2020 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.auth.internal; + +import java.util.Date; + +/** + * A DTO representing a user API token, without the sensible information. + * + * @author Yannick Schaus - initial contribution + */ +public class UserApiTokenDTO { + String name; + Date createdTime; + String scope; + + public UserApiTokenDTO(String name, Date createdTime, String scope) { + super(); + this.name = name; + this.createdTime = createdTime; + this.scope = scope; + } +} diff --git a/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserSecurityContext.java b/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserSecurityContext.java index cd9cb74fc99..449718f86f0 100644 --- a/bundles/org.opensmarthouse.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/UserSecurityContext.java +++ b/bundles/org.opensmarthouse.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.opensmarthouse.core.io.rest.auth/src/main/resources/OH-INF/config/config.xml b/bundles/org.opensmarthouse.core.io.rest.auth/src/main/resources/OH-INF/config/config.xml index 533a6c76bca..bcf149e4c83 100644 --- a/bundles/org.opensmarthouse.core.io.rest.auth/src/main/resources/OH-INF/config/config.xml +++ b/bundles/org.opensmarthouse.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.