Skip to content

Commit

Permalink
[REST Auth] API tokens & openhab:users console command (openhab#1735)
Browse files Browse the repository at this point in the history
This adds API tokens as a new credential type. Their format is:
`oh.<name>.<random chars>`

The "oh." prefix is used to tell them apart from a JWT access token,
because they're both used as a Bearer authorization scheme, but there
is no semantic value attached to any of the other parts.

They are stored hashed in the user's profile, and can be listed, added
or removed managed with the new `openhab:users` console command.

Currently the scopes are still not checked, but ultimately they could
be, for instance a scope of e.g. `user admin.items` would mean that the
API token can be used to perform user operations like retrieving info
or sending a command, _and_ managing the items, but nothing else -
even if the user has more permissions because of their role (which
will of course still be checked).

Tokens are normally passed in the Authorization header with the Bearer
scheme, or the X-OPENHAB-TOKEN header, like access tokens.
As a special exception, API tokens can also be used with the Basic
authorization scheme, **even if the allowBasicAuth** option is not
enabled in the "API Security" service, because there's no additional
security risk in allowing that. In that case, the token should be
passed as the username and the password MUST be empty.

In short, this means that all these curl commands will work:
- `curl -H 'Authorization: Bearer <token>' http://localhost:8080/rest/inbox`
- `curl -H 'X-OPENHAB-TOKEN: <token>' http://localhost:8080/rest/inbox`
- `curl -u '<token>[:]' http://localhost:8080/rest/inbox`
- `curl http://<token>@localhost:8080/rest/inbox`

2 REST API operations were adding to the AuthResource, to allow
authenticated users to list their tokens or remove (revoke) one.
Self-service for creating a token or changing the password is more
sensitive so these should be handled with a servlet and pages devoid
of any JavaScript instead of REST API calls, therefore for now they'll
have to be done with the console.

This also fixes regressions introduced with openhab#1713 - the operations
annotated with @RolesAllowed({ Role.USER }) only were not authorized
for administrators anymore.

* Generate a unique salt for each token

Reusing the password salt is bad practice, and changing the
password changes the salt as well which makes all tokens
invalid.

Put the salt in the same field as the hash (concatenated
with a separator) to avoid modifying the JSON DB schema.

* Fix API token authentication, make scope available to security context

The X-OPENHAB-TOKEN header now has priority over the Authorization
header to credentials, if both are set.

* Add self-service pages to change password & create new API token

Signed-off-by: Yannick Schaus <github@schaus.net>
GitOrigin-RevId: 8b52cab
  • Loading branch information
ghys authored and splatch committed Jul 11, 2023
1 parent 6134965 commit 48c62df
Show file tree
Hide file tree
Showing 20 changed files with 1,431 additions and 245 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -56,7 +59,9 @@ public class UserRegistryImpl extends AbstractRegistry<User, String, UserProvide

private final Logger logger = LoggerFactory.getLogger(UserRegistryImpl.class);

private static final int ITERATIONS = 65536;
private static final int PASSWORD_ITERATIONS = 65536;
private static final int APITOKEN_ITERATIONS = 1024;
private static final String APITOKEN_PREFIX = "oh";
private static final int KEY_LENGTH = 512;
private static final String ALGORITHM = "PBKDF2WithHmacSHA512";
private static final SecureRandom RAND = new SecureRandom();
Expand Down Expand Up @@ -87,7 +92,7 @@ protected void unsetManagedProvider(ManagedUserProvider managedProvider) {
@Override
public User register(String username, String password, Set<String> 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);
Expand All @@ -106,11 +111,11 @@ private Optional<String> generateSalt(final int length) {
return Optional.of(Base64.getEncoder().encodeToString(salt));
}

private Optional<String> hashPassword(String password, String salt) {
private Optional<String> 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);

Expand All @@ -119,7 +124,7 @@ private Optional<String> 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();
Expand All @@ -128,23 +133,132 @@ private Optional<String> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ServiceListener> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,26 @@
/**
* 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<String> roles;
private String scope;

/**
* no-args constructor required by gson
*/
protected Authentication() {
this.username = null;
this.roles = null;
this.scope = null;
}

/**
Expand All @@ -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
*
Expand All @@ -65,4 +81,13 @@ public String getUsername() {
public Set<String> getRoles() {
return roles;
}

/**
* Retrieves the scope this authentication is valid for
*
* @return a scope
*/
public String getScope() {
return scope;
}
}
Loading

0 comments on commit 48c62df

Please sign in to comment.