Implementation of a JWT-based OAuth2 flow for the admin API (#1389)

* Initial implementation of a JWT-based OAuth2 flow for the admin API

Implements #1388.

Signed-off-by: Yannick Schaus <github@schaus.net>
This commit is contained in:
Yannick Schaus 2020-03-23 22:36:11 +01:00 committed by GitHub
parent a06eb598d7
commit fe4e276b68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 2855 additions and 9 deletions

View File

@ -315,6 +315,14 @@
<version>1.3.23_2</version>
</dependency>
<!-- jose4j -->
<dependency>
<groupId>org.bitbucket.b_c</groupId>
<artifactId>jose4j</artifactId>
<version>0.7.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@ -896,6 +896,14 @@
<version>0.5.8</version>
<!-- <scope>compile</scope> -->
</dependency>
<!-- jose4j -->
<dependency>
<groupId>org.bitbucket.b_c</groupId>
<artifactId>jose4j</artifactId>
<version>0.7.0</version>
<scope>compile</scope>
</dependency>
</dependencies>

View File

@ -14,6 +14,7 @@ package org.openhab.core.auth.jaas.internal;
import java.io.IOException;
import java.security.Principal;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
@ -30,6 +31,7 @@ import org.openhab.core.auth.Authentication;
import org.openhab.core.auth.AuthenticationException;
import org.openhab.core.auth.AuthenticationProvider;
import org.openhab.core.auth.Credentials;
import org.openhab.core.auth.GenericUser;
import org.openhab.core.auth.UsernamePasswordCredentials;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
@ -44,16 +46,18 @@ import org.osgi.service.component.annotations.Modified;
*
* @author Łukasz Dywicki - Initial contribution
* @author Kai Kreuzer - Removed ManagedService and used DS configuration instead
* @author Yannick Schaus - provides a configuration with the ManagedUserLoginModule as a sufficient login module
*/
@Component(configurationPid = "org.openhab.jaas")
public class JaasAuthenticationProvider implements AuthenticationProvider {
private final String DEFAULT_REALM = "openhab";
private String realmName;
@Override
public Authentication authenticate(final Credentials credentials) throws AuthenticationException {
if (realmName == null) { // configuration is not yet ready or set
return null;
realmName = DEFAULT_REALM;
}
if (!(credentials instanceof UsernamePasswordCredentials)) {
@ -64,8 +68,14 @@ public class JaasAuthenticationProvider implements AuthenticationProvider {
final String name = userCredentials.getUsername();
final char[] password = userCredentials.getPassword().toCharArray();
final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
try {
LoginContext loginContext = new LoginContext(realmName, new CallbackHandler() {
Principal userPrincipal = new GenericUser(name);
Subject subject = new Subject(true, Set.of(userPrincipal), Collections.emptySet(), Set.of(userCredentials));
Thread.currentThread().setContextClassLoader(ManagedUserLoginModule.class.getClassLoader());
LoginContext loginContext = new LoginContext(realmName, subject, new CallbackHandler() {
@Override
public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
for (Callback callback : callbacks) {
@ -78,12 +88,14 @@ public class JaasAuthenticationProvider implements AuthenticationProvider {
}
}
}
});
}, new ManagedUserLoginConfiguration());
loginContext.login();
return getAuthentication(name, loginContext.getSubject());
} catch (LoginException e) {
throw new AuthenticationException("Could not obtain authentication over login context", e);
throw new AuthenticationException(e.getMessage());
} finally {
Thread.currentThread().setContextClassLoader(contextClassLoader);
}
}
@ -112,7 +124,7 @@ public class JaasAuthenticationProvider implements AuthenticationProvider {
@Modified
protected void modified(Map<String, Object> properties) {
if (properties == null) {
realmName = null;
realmName = DEFAULT_REALM;
return;
}
@ -124,8 +136,8 @@ public class JaasAuthenticationProvider implements AuthenticationProvider {
realmName = propertyValue.toString();
}
} else {
// value could be unset, we should reset it value
realmName = null;
// value could be unset, we should reset its value
realmName = DEFAULT_REALM;
}
}

View File

@ -0,0 +1,34 @@
/**
* 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.jaas.internal;
import java.util.HashMap;
import javax.security.auth.login.AppConfigurationEntry;
import javax.security.auth.login.AppConfigurationEntry.LoginModuleControlFlag;
import javax.security.auth.login.Configuration;
/**
* Describes a JAAS configuration with the {@link ManagedUserLoginModule} as a sufficient login module.
*
* @author Yannick Schaus - initial contribution
*/
public class ManagedUserLoginConfiguration extends Configuration {
@Override
public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
return new AppConfigurationEntry[] { new AppConfigurationEntry(ManagedUserLoginModule.class.getCanonicalName(),
LoginModuleControlFlag.SUFFICIENT, new HashMap<String, Object>()) };
}
}

View File

@ -0,0 +1,86 @@
/**
* 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.jaas.internal;
import java.util.Map;
import javax.security.auth.Subject;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.login.LoginException;
import javax.security.auth.spi.LoginModule;
import org.openhab.core.auth.AuthenticationException;
import org.openhab.core.auth.Credentials;
import org.openhab.core.auth.UserRegistry;
import org.osgi.framework.BundleContext;
import org.osgi.framework.FrameworkUtil;
import org.osgi.framework.ServiceReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This {@link LoginModule} delegates the authentication to a {@link UserRegistry}
*
* @author Yannick Schaus - initial contribution
*/
public class ManagedUserLoginModule implements LoginModule {
private final Logger logger = LoggerFactory.getLogger(ManagedUserLoginModule.class);
private UserRegistry userRegistry;
private Subject subject;
@Override
public void initialize(Subject subject, CallbackHandler callbackHandler, Map<String, ?> sharedState,
Map<String, ?> options) {
this.subject = subject;
}
@Override
public boolean login() throws LoginException {
try {
// try to get the UserRegistry instance
BundleContext bundleContext = FrameworkUtil.getBundle(UserRegistry.class).getBundleContext();
ServiceReference<UserRegistry> serviceReference = bundleContext.getServiceReference(UserRegistry.class);
userRegistry = bundleContext.getService(serviceReference);
} catch (Exception e) {
logger.error("Cannot initialize the ManagedLoginModule", e);
throw new LoginException("Authorization failed");
}
try {
Credentials credentials = (Credentials) this.subject.getPrivateCredentials().iterator().next();
userRegistry.authenticate(credentials);
return true;
} catch (AuthenticationException e) {
throw new LoginException(e.getMessage());
}
}
@Override
public boolean commit() throws LoginException {
return true;
}
@Override
public boolean abort() throws LoginException {
return false;
}
@Override
public boolean logout() throws LoginException {
return false;
}
}

View File

@ -22,6 +22,7 @@ import java.util.Map;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import javax.annotation.security.RolesAllowed;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
@ -37,6 +38,7 @@ import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import javax.ws.rs.core.UriInfo;
import org.openhab.core.auth.Role;
import org.openhab.core.automation.Action;
import org.openhab.core.automation.Condition;
import org.openhab.core.automation.Module;
@ -84,6 +86,7 @@ import io.swagger.annotations.ResponseHeader;
@Path("rules")
@Api("rules")
@Component
@RolesAllowed({ Role.ADMIN })
public class RuleResource implements RESTResource {
private final Logger logger = LoggerFactory.getLogger(RuleResource.class);
@ -272,6 +275,7 @@ public class RuleResource implements RESTResource {
}
@POST
@RolesAllowed({ Role.USER, Role.ADMIN })
@Path("/{ruleUID}/runnow")
@Consumes(MediaType.TEXT_PLAIN)
@ApiOperation(value = "Executes actions of the rule.")

File diff suppressed because one or more lines are too long

View File

@ -26,5 +26,29 @@
<version>${project.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>add-resource</goal>
</goals>
<phase>generate-resources</phase>
<configuration>
<resources>
<resource>
<directory>pages</directory>
<targetPath>pages</targetPath>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,345 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.io.http.auth.internal;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URL;
import java.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;
import org.apache.commons.httpclient.HttpStatus;
import org.apache.commons.io.IOUtils;
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.ManagedUser;
import org.openhab.core.auth.PendingToken;
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;
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 the authorization page part of the OAuth2 authorization code flow.
*
* The page can register the first administrator account when there are no users yet in the {@link UserRegistry}, and
* authenticates the user otherwise. It also presents the scope that is about to be granted to the client, so the user
* can review what kind of access is being authorized. If successful, it redirects the client back to the URI which was
* specified and creates an authorization code stored for later in the user's profile.
*
* @author Yannick Schaus - initial contribution
*
*/
@NonNullByDefault
@Component(immediate = true)
public class AuthorizePageServlet extends HttpServlet {
private static final long serialVersionUID = 5340598701104679843L;
private final Logger logger = LoggerFactory.getLogger(AuthorizePageServlet.class);
private HashMap<String, Instant> 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 = "";
try {
URL resource = bundleContext.getBundle().getResource("pages/authorize.html");
if (resource != null) {
try {
pageTemplate = IOUtils.toString(resource.openStream());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
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)
throws ServletException, IOException {
if (req != null && resp != null) {
Map<String, String[]> 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] : "";
// 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 <b>%s</b> access to <b>%s</b>:", 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();
}
}
}
@Override
protected void doPost(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp)
throws ServletException, IOException {
if (req != null && resp != null) {
Map<String, String[]> 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]);
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 (!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 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.SC_MOVED_TEMPORARILY);
} catch (AuthenticationException e) {
lastAuthenticationFailure = Instant.now();
authenticationFailureCount += 1;
resp.setContentType("text/html;charset=UTF-8");
logger.warn("Authentication failed: {}", e.getMessage());
resp.getWriter().append(getPageBody(params, "Please try again.")); // TODO: i18n
resp.getWriter().close();
} 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.SC_MOVED_TEMPORARILY);
} else {
resp.setContentType("text/plain;charset=UTF-8");
resp.getWriter().append(e.getMessage());
resp.getWriter().close();
}
}
}
}
private String getPageBody(Map<String, String[]> params, String message) {
String responseBody = pageTemplate.replace("{form_fields}", getFormFields(params));
String repeatPasswordFieldType = (isSignupMode()) ? "password" : "hidden";
String buttonLabel = (isSignupMode()) ? "Create Account" : "Sign In"; // TODO: i18n
responseBody = responseBody.replace("{message}", message);
responseBody = responseBody.replace("{repeatPasswordFieldType}", repeatPasswordFieldType);
responseBody = responseBody.replace("{buttonLabel}", buttonLabel);
return responseBody;
}
private String getFormFields(Map<String, String[]> params) {
String hiddenFormFields = "";
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");
}
String csrfToken = addCsrfToken();
String redirectUri = params.get("redirect_uri")[0];
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"))
? params.get("code_challenge_method")[0]
: null;
hiddenFormFields += "<input type=\"hidden\" name=\"csrf_token\" value=\"" + csrfToken + "\">";
hiddenFormFields += "<input type=\"hidden\" name=\"redirect_uri\" value=\"" + redirectUri + "\">";
hiddenFormFields += "<input type=\"hidden\" name=\"response_type\" value=\"" + responseType + "\">";
hiddenFormFields += "<input type=\"hidden\" name=\"client_id\" value=\"" + clientId + "\">";
hiddenFormFields += "<input type=\"hidden\" name=\"scope\" value=\"" + scope + "\">";
if (state != null) {
hiddenFormFields += "<input type=\"hidden\" name=\"state\" value=\"" + state + "\">";
}
if (codeChallenge != null && codeChallengeMethod != null) {
hiddenFormFields += "<input type=\"hidden\" name=\"code_challenge\" value=\"" + codeChallenge + "\">";
hiddenFormFields += "<input type=\"hidden\" name=\"code_challenge_method\" value=\"" + codeChallengeMethod
+ "\">";
}
return hiddenFormFields;
}
private String getRedirectUri(String baseRedirectUri, @Nullable String authorizationCode, @Nullable String error,
@Nullable String state) {
String redirectUri = baseRedirectUri;
if (authorizationCode != null) {
redirectUri += "?code=" + authorizationCode;
} else if (error != null) {
redirectUri += "?error=" + error;
}
if (state != null) {
redirectUri += "&state=" + state;
}
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();
}
@Deactivate
public void deactivate() {
httpService.unregister("/auth");
}
}

View File

@ -0,0 +1,2 @@
Bundle-SymbolicName: ${project.artifactId}
Bundle-Activator: org.openhab.core.io.rest.auth.internal.Activator

View File

@ -14,4 +14,22 @@
<name>openHAB Core :: Bundles :: Authentication Support for the REST Interface</name>
<dependencies>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.auth.jaas</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.io.rest</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.core</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,46 @@
/**
* 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 org.osgi.framework.BundleActivator;
import org.osgi.framework.BundleContext;
import org.osgi.framework.ServiceRegistration;
/**
* Registers dynamic features contained in this bundle.
*
* @author Yannick Schaus - initial contribution
*/
public class Activator implements BundleActivator {
private static Activator instance;
private ServiceRegistration rolesAllowedDynamicFeatureRegistration;
public static Activator getInstance() {
return instance;
}
@Override
public void start(BundleContext context) throws Exception {
rolesAllowedDynamicFeatureRegistration = context.registerService(RolesAllowedDynamicFeatureImpl.class.getName(),
new RolesAllowedDynamicFeatureImpl(), null);
}
@Override
public void stop(BundleContext context) throws Exception {
instance = null;
if (rolesAllowedDynamicFeatureRegistration != null) {
rolesAllowedDynamicFeatureRegistration.unregister();
}
}
}

View File

@ -0,0 +1,87 @@
/**
* 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.io.IOException;
import javax.annotation.Priority;
import javax.security.sasl.AuthenticationException;
import javax.ws.rs.NotAuthorizedException;
import javax.ws.rs.Priorities;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.container.PreMatching;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.SecurityContext;
import javax.ws.rs.ext.Provider;
import org.openhab.core.auth.Authentication;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* This filter is responsible for parsing a token provided with a request, and hydrating a {@link SecurityContext} from
* the claims contained in the token.
*
* @author Yannick Schaus - initial contribution
*/
@PreMatching
@Priority(Priorities.AUTHENTICATION)
@Provider
@Component(immediate = true, service = AuthFilter.class)
public class AuthFilter implements ContainerRequestFilter {
private static final String COOKIE_AUTH_HEADER = "X-OPENHAB-AUTH-HEADER";
private static final String ALT_AUTH_HEADER = "X-OPENHAB-TOKEN";
@Reference
private JwtHelper jwtHelper;
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
try {
String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
if (authHeader != null) {
String[] authParts = authHeader.split(" ");
if (authParts.length == 2) {
if ("Bearer".equals(authParts[0])) {
Authentication auth = jwtHelper.verifyAndParseJwtAccessToken(authParts[1]);
requestContext.setSecurityContext(new JwtSecurityContext(auth));
return;
}
}
}
if (requestContext.getCookies().containsKey(COOKIE_AUTH_HEADER)) {
String altTokenHeader = requestContext.getHeaderString(ALT_AUTH_HEADER);
if (altTokenHeader != null) {
Authentication auth = jwtHelper.verifyAndParseJwtAccessToken(altTokenHeader);
requestContext.setSecurityContext(new JwtSecurityContext(auth));
return;
}
}
// support the api_key query parameter of the Swagger UI
if (requestContext.getUriInfo().getRequestUri().toString().contains("api_key=")) {
String apiKey = requestContext.getUriInfo().getQueryParameters(true).getFirst("api_key");
if (apiKey != null) {
Authentication auth = jwtHelper.verifyAndParseJwtAccessToken(apiKey);
requestContext.setSecurityContext(new JwtSecurityContext(auth));
return;
}
}
} catch (AuthenticationException e) {
throw new NotAuthorizedException("Invalid token");
}
}
}

View File

@ -0,0 +1,159 @@
/**
* 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.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javax.security.sasl.AuthenticationException;
import org.apache.commons.io.IOUtils;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.jose4j.jwa.AlgorithmConstraints.ConstraintType;
import org.jose4j.jwk.JsonWebKey;
import org.jose4j.jwk.JsonWebKey.OutputControlLevel;
import org.jose4j.jwk.RsaJsonWebKey;
import org.jose4j.jwk.RsaJwkGenerator;
import org.jose4j.jws.AlgorithmIdentifiers;
import org.jose4j.jws.JsonWebSignature;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.jwt.consumer.JwtConsumer;
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
import org.jose4j.lang.JoseException;
import org.openhab.core.auth.Authentication;
import org.openhab.core.auth.User;
import org.openhab.core.config.core.ConfigConstants;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This class helps with JWT tokens' building, signing, verifying and parsing.
*
* @author Yannick Schaus - initial contribution
*/
@NonNullByDefault
@Component(immediate = true, service = JwtHelper.class)
public class JwtHelper {
private final Logger logger = LoggerFactory.getLogger(JwtHelper.class);
private static final String KEY_FILE_PATH = ConfigConstants.getUserDataFolder() + File.separator + "secrets"
+ File.separator + "rsa_json_web_key.json";
private static final String ISSUER_NAME = "openhab";
private static final String AUDIENCE = "openhab";
private RsaJsonWebKey jwtWebKey;
public JwtHelper() {
try {
jwtWebKey = loadOrGenerateKey();
} catch (Exception e) {
logger.error("Error while initializing the JWT helper", e);
throw new RuntimeException(e.getMessage());
}
}
private RsaJsonWebKey generateNewKey() throws JoseException, FileNotFoundException, IOException {
RsaJsonWebKey newKey = RsaJwkGenerator.generateJwk(2048);
File file = new File(KEY_FILE_PATH);
file.getParentFile().mkdirs();
String keyJson = newKey.toJson(OutputControlLevel.INCLUDE_PRIVATE);
IOUtils.write(keyJson, new FileOutputStream(file));
return newKey;
}
private RsaJsonWebKey loadOrGenerateKey() throws FileNotFoundException, JoseException, IOException {
try {
List<String> lines = IOUtils.readLines(new FileInputStream(KEY_FILE_PATH));
return (RsaJsonWebKey) JsonWebKey.Factory.newJwk(lines.get(0));
} catch (IOException | JoseException e) {
RsaJsonWebKey key = generateNewKey();
logger.debug("Created JWT signature key in {}", KEY_FILE_PATH);
return key;
}
}
/**
* Builds a new access token.
*
* @param user the user (subject) to build the token, it will also add the roles as claims
* @param clientId the client ID the token is for
* @param scope the scope the token is valid for
* @param tokenLifetime the lifetime of the token in minutes before it expires
*
* @return a base64-encoded signed JWT token to be passed as a bearer token in API requests
*/
public String getJwtAccessToken(User user, String clientId, String scope, int tokenLifetime) {
try {
JwtClaims jwtClaims = new JwtClaims();
jwtClaims.setIssuer(ISSUER_NAME);
jwtClaims.setAudience(AUDIENCE);
jwtClaims.setExpirationTimeMinutesInTheFuture(tokenLifetime);
jwtClaims.setGeneratedJwtId();
jwtClaims.setIssuedAtToNow();
jwtClaims.setNotBeforeMinutesInThePast(2);
jwtClaims.setSubject(user.getName());
jwtClaims.setClaim("client_id", clientId);
jwtClaims.setClaim("scope", scope);
jwtClaims.setStringListClaim("role",
new ArrayList<>((user.getRoles() != null) ? user.getRoles() : Collections.emptySet()));
JsonWebSignature jws = new JsonWebSignature();
jws.setPayload(jwtClaims.toJson());
jws.setKey(jwtWebKey.getPrivateKey());
jws.setKeyIdHeaderValue(jwtWebKey.getKeyId());
jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256);
String jwt = jws.getCompactSerialization();
return jwt;
} catch (Exception e) {
logger.error("Error while writing JWT token", e);
throw new RuntimeException(e.getMessage());
}
}
/**
* Performs verifications on a JWT token, then parses it into a {@link AuthenticationException} instance
*
* @param jwt the base64-encoded JWT token from the request
* @return the {@link Authentication} derived from the information in the token
* @throws AuthenticationException
*/
public Authentication verifyAndParseJwtAccessToken(String jwt) throws AuthenticationException {
JwtConsumer jwtConsumer = new JwtConsumerBuilder().setRequireExpirationTime().setAllowedClockSkewInSeconds(30)
.setRequireSubject().setExpectedIssuer(ISSUER_NAME).setExpectedAudience(AUDIENCE)
.setVerificationKey(jwtWebKey.getKey())
.setJwsAlgorithmConstraints(ConstraintType.WHITELIST, AlgorithmIdentifiers.RSA_USING_SHA256).build();
try {
JwtClaims jwtClaims = jwtConsumer.processToClaims(jwt);
String username = jwtClaims.getSubject();
List<String> roles = jwtClaims.getStringListClaimValue("role");
Authentication auth = new Authentication(username, roles.toArray(new String[roles.size()]));
return auth;
} catch (Exception e) {
logger.error("Error while processing JWT token", e);
throw new AuthenticationException(e.getMessage());
}
}
}

View File

@ -0,0 +1,58 @@
/**
* 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.security.Principal;
import javax.ws.rs.core.SecurityContext;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.auth.Authentication;
import org.openhab.core.auth.GenericUser;
/**
* This {@link SecurityContext} contains information about a user, roles and authorizations granted to a client as
* parsed from the contents of a JSON Web Token
*
* @author Yannick Schaus - initial contribution
*/
@NonNullByDefault
public class JwtSecurityContext implements SecurityContext {
Authentication authentication;
public JwtSecurityContext(Authentication authentication) {
this.authentication = authentication;
}
@Override
public Principal getUserPrincipal() {
return new GenericUser(authentication.getUsername());
}
@Override
public boolean isUserInRole(@Nullable String role) {
return authentication.getRoles().contains(role);
}
@Override
public boolean isSecure() {
return true;
}
@Override
public String getAuthenticationScheme() {
return "JWT";
}
}

View File

@ -0,0 +1,135 @@
/**
* 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.io.IOException;
import java.lang.annotation.Annotation;
import java.util.Arrays;
import java.util.Optional;
import javax.annotation.Priority;
import javax.annotation.security.DenyAll;
import javax.annotation.security.PermitAll;
import javax.annotation.security.RolesAllowed;
import javax.ws.rs.ForbiddenException;
import javax.ws.rs.NotAuthorizedException;
import javax.ws.rs.Priorities;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.container.DynamicFeature;
import javax.ws.rs.container.ResourceInfo;
import javax.ws.rs.core.FeatureContext;
import javax.ws.rs.ext.Provider;
import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature;
import org.glassfish.jersey.server.model.AnnotatedMethod;
import org.openhab.core.auth.Role;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A {@link DynamicFeature} supporting the {@code javax.annotation.security.RolesAllowed},
* {@code javax.annotation.security.PermitAll} and {@code javax.annotation.security.DenyAll}
* on resource methods and sub-resource methods.
*
* Ported from {@link RolesAllowedDynamicFeature} with modifications.
*
* @author Paul Sandoz - initial contribution
* @author Martin Matula - initial contribution
* @author Yannick Schaus - port to openHAB with modifications
*/
@Provider
public class RolesAllowedDynamicFeatureImpl implements DynamicFeature {
private final Logger logger = LoggerFactory.getLogger(RolesAllowedDynamicFeatureImpl.class);
@Override
public void configure(ResourceInfo resourceInfo, FeatureContext configuration) {
final AnnotatedMethod am = new AnnotatedMethod(resourceInfo.getResourceMethod());
try {
// DenyAll on the method take precedence over RolesAllowed and PermitAll
if (am.isAnnotationPresent(DenyAll.class)) {
configuration.register(new RolesAllowedRequestFilter());
return;
}
// RolesAllowed on the method takes precedence over PermitAll
Optional<Annotation> ra = Arrays.stream(am.getAnnotations())
.filter(a -> a.annotationType().getName().equals(RolesAllowed.class.getName())).findFirst();
if (ra.isPresent()) {
configuration.register(new RolesAllowedRequestFilter(((RolesAllowed) ra.get()).value()));
return;
}
// PermitAll takes precedence over RolesAllowed on the class
if (am.isAnnotationPresent(PermitAll.class)) {
// Do nothing.
return;
}
// DenyAll can't be attached to classes
// RolesAllowed on the class takes precedence over PermitAll
ra = Arrays.stream(resourceInfo.getResourceClass().getAnnotations())
.filter(a -> a.annotationType().getName().equals(RolesAllowed.class.getName())).findFirst();
if (ra.isPresent()) {
configuration.register(new RolesAllowedRequestFilter(((RolesAllowed) ra.get()).value()));
}
} catch (Exception e) {
logger.error("Error while configuring the roles", e);
}
}
@Priority(Priorities.AUTHORIZATION) // authorization filter - should go after any authentication filters
private static class RolesAllowedRequestFilter implements ContainerRequestFilter {
private final boolean denyAll;
private final String[] rolesAllowed;
RolesAllowedRequestFilter() {
this.denyAll = true;
this.rolesAllowed = null;
}
RolesAllowedRequestFilter(final String[] rolesAllowed) {
this.denyAll = false;
this.rolesAllowed = (rolesAllowed != null) ? rolesAllowed : new String[] {};
}
@Override
public void filter(final ContainerRequestContext requestContext) throws IOException {
if (!denyAll) {
// TODO: temporarily, until the complete authorization story is implemented, we consider operations
// allowed for user roles to be permitted unrestricted (even to unauthenticated users)
if (Arrays.asList(rolesAllowed).contains(Role.USER)) {
return;
}
if (rolesAllowed.length > 0 && !isAuthenticated(requestContext)) {
throw new NotAuthorizedException("User is not authenticated");
}
for (final String role : rolesAllowed) {
if (requestContext.getSecurityContext().isUserInRole(role)) {
return;
}
}
}
throw new ForbiddenException("User is authenticated but doesn't have access to this resource");
}
private static boolean isAuthenticated(final ContainerRequestContext requestContext) {
return requestContext.getSecurityContext().getUserPrincipal() != null;
}
}
}

View File

@ -0,0 +1,71 @@
/**
* 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 org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.auth.AuthenticationException;
/**
* An exception when the token endpoint encounters an error and must return an error response, according to RFC 6749
* Section 5.2.
*
* {@linkplain https://tools.ietf.org/html/rfc6749#section-5.2}
*
* @author Yannick Schaus - initial contribution
*/
@NonNullByDefault
public class TokenEndpointException extends AuthenticationException {
private static final long serialVersionUID = 610324537843397832L;
/**
* Represents the error types which are supported in token issuing error responses.
*
* @author Yannick Schaus - initial contribution
*/
public enum ErrorType {
INVALID_REQUEST("invalid_request"),
INVALID_GRANT("invalid_grant"),
INVALID_CLIENT("invalid_client"),
INVALID_SCOPE("invalid_scope"),
UNAUTHORIZED_CLIENT("unauthorized_client"),
UNSUPPORTED_GRANT_TYPE("unsupported_grant_type");
private String error;
ErrorType(String error) {
this.error = error;
}
public String getError() {
return error;
}
}
/**
* Constructs a {@link TokenEndpointException} for the specified error type.
*
* @param errorType the error type
*/
public TokenEndpointException(ErrorType errorType) {
super(errorType.getError());
}
/**
* Gets a {@link TokenResponseErrorDTO} representing the exception
*
* @return the error response object
*/
public TokenResponseErrorDTO getErrorDTO() {
return new TokenResponseErrorDTO(getMessage());
}
}

View File

@ -0,0 +1,345 @@
/**
* 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.net.URI;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Stream;
import javax.ws.rs.Consumes;
import javax.ws.rs.CookieParam;
import javax.ws.rs.FormParam;
import javax.ws.rs.GET;
import javax.ws.rs.NotAuthorizedException;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Cookie;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.NewCookie;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.ResponseBuilder;
import javax.ws.rs.core.Response.Status;
import javax.ws.rs.core.SecurityContext;
import javax.ws.rs.core.UriInfo;
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.UserRegistry;
import org.openhab.core.auth.UserSession;
import org.openhab.core.io.rest.RESTResource;
import org.openhab.core.io.rest.Stream2JSONInputStream;
import org.openhab.core.io.rest.auth.internal.TokenEndpointException.ErrorType;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
/**
* This class is used to issue JWT tokens to clients.
*
* @author Yannick Schaus - Initial contribution
*/
@Path(TokenResource.PATH_AUTH)
@Api(value = TokenResource.PATH_AUTH)
@Component(service = { RESTResource.class, TokenResource.class })
public class TokenResource implements RESTResource {
private final Logger logger = LoggerFactory.getLogger(TokenResource.class);
/** The URI path to this resource */
public static final String PATH_AUTH = "auth";
/** The name of the HTTP-only cookie holding the session ID */
public static final String SESSIONID_COOKIE_NAME = "X-OPENHAB-SESSIONID";
/** The default lifetime of tokens in minutes before they expire */
public static final int TOKEN_LIFETIME = 60;
@Context
private UriInfo uriInfo;
private UserRegistry userRegistry;
private JwtHelper jwtHelper;
@Activate
public TokenResource(final @Reference UserRegistry userRegistry, final @Reference JwtHelper jwtHelper) {
this.userRegistry = userRegistry;
this.jwtHelper = jwtHelper;
}
@POST
@Path("/token")
@Produces({ MediaType.APPLICATION_JSON })
@Consumes({ MediaType.APPLICATION_FORM_URLENCODED })
@ApiOperation(value = "Get access and refresh tokens.")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
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,
@QueryParam("useCookie") boolean useCookie, @CookieParam(SESSIONID_COOKIE_NAME) Cookie sessionCookie) {
try {
switch (grantType) {
case "authorization_code":
return processAuthorizationCodeGrant(code, redirectUri, clientId, codeVerifier, useCookie);
case "refresh_token":
return processRefreshTokenGrant(clientId, refreshToken, sessionCookie);
default:
throw new TokenEndpointException(ErrorType.UNSUPPORTED_GRANT_TYPE);
}
} catch (TokenEndpointException e) {
logger.warn("Token issuing failed: {}", e.getMessage());
return Response.status(Status.BAD_REQUEST).entity(e.getErrorDTO()).build();
} catch (Exception e) {
logger.error("Error while authenticating", e);
return Response.status(Status.BAD_REQUEST).build();
}
}
@GET
@Path("/sessions")
@ApiOperation(value = "List the sessions associated to the authenticated user.")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK", response = UserSessionDTO.class) })
@Produces({ MediaType.APPLICATION_JSON })
public Response getSessions(@Context SecurityContext securityContext) {
if (securityContext.getUserPrincipal() == null) {
throw new NotAuthorizedException("User not authenticated");
}
ManagedUser user = (ManagedUser) userRegistry.get(securityContext.getUserPrincipal().getName());
if (user == null) {
throw new NotFoundException("User not found");
}
Stream<UserSessionDTO> sessions = user.getSessions().stream().map(this::toUserSessionDTO);
return Response.ok(new Stream2JSONInputStream(sessions)).build();
}
@POST
@Path("/logout")
@Consumes({ MediaType.APPLICATION_FORM_URLENCODED })
@ApiOperation(value = "Delete the session associated with a refresh token.")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response deleteSession(@FormParam("refresh_token") String refreshToken, @FormParam("id") String id,
@Context SecurityContext securityContext) {
if (securityContext.getUserPrincipal() == null) {
throw new NotAuthorizedException("User not authenticated");
}
ManagedUser user = (ManagedUser) userRegistry.get(securityContext.getUserPrincipal().getName());
if (user == null) {
throw new NotFoundException("User not found");
}
Optional<UserSession> session;
if (refreshToken != null) {
session = user.getSessions().stream().filter(s -> s.getRefreshToken().equals(refreshToken)).findAny();
} else if (id != null) {
session = user.getSessions().stream().filter(s -> s.getSessionId().startsWith(id + "-")).findAny();
} else {
throw new IllegalArgumentException("no refresh_token or id specified");
}
if (session.isEmpty()) {
throw new NotFoundException("Session not found");
}
ResponseBuilder response = Response.ok();
if (session.get().hasSessionCookie()) {
URI domainUri;
try {
domainUri = new URI(session.get().getRedirectUri());
NewCookie newCookie = new NewCookie(SESSIONID_COOKIE_NAME, "", "/", domainUri.getHost(), null, 0, false,
true);
response.cookie(newCookie);
} catch (Exception e) {
}
}
user.getSessions().remove(session.get());
userRegistry.update(user);
return response.build();
}
private UserSessionDTO toUserSessionDTO(UserSession session) {
// we only divulge the prefix of the session ID to the client (otherwise an XSS attacker may find the
// session ID for a stolen refresh token easily by using the sessions endpoint).
return new UserSessionDTO(session.getSessionId().split("-")[0], session.getCreatedTime(),
session.getLastRefreshTime(), session.getClientId(), session.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> user = userRegistry.getAll().stream().filter(u -> ((ManagedUser) u).getPendingToken() != null
&& ((ManagedUser) u).getPendingToken().getAuthorizationCode().equals(code)).findAny();
if (!user.isPresent()) {
logger.warn("Couldn't find a user with the provided authentication code pending");
throw new TokenEndpointException(ErrorType.INVALID_GRANT);
}
ManagedUser managedUser = (ManagedUser) user.get();
PendingToken pendingToken = managedUser.getPendingToken();
if (pendingToken == null) {
throw new TokenEndpointException(ErrorType.INVALID_GRANT);
}
if (!pendingToken.getClientId().equals(clientId)) {
logger.warn("client_id '{}' doesn't match pending token information '{}'", clientId,
pendingToken.getClientId());
throw new TokenEndpointException(ErrorType.INVALID_GRANT);
}
if (!pendingToken.getRedirectUri().equals(redirectUri)) {
logger.warn("redirect_uri '{}' doesn't match pending token information '{}'", redirectUri,
pendingToken.getRedirectUri());
throw new TokenEndpointException(ErrorType.INVALID_GRANT);
}
// create a new session ID and refresh token
String sessionId = UUID.randomUUID().toString();
String newRefreshToken = UUID.randomUUID().toString().replace("-", "");
String scope = pendingToken.getScope();
// if there is PKCE information in the pending token, check that first
String codeChallengeMethod = pendingToken.getCodeChallengeMethod();
if (codeChallengeMethod != null) {
String codeChallenge = pendingToken.getCodeChallenge();
if (codeChallenge == null || codeVerifier == null) {
logger.warn("the PKCE code challenge or code verifier information is missing");
throw new TokenEndpointException(ErrorType.INVALID_GRANT);
}
switch (codeChallengeMethod) {
case "plain":
if (!codeVerifier.equals(codeChallenge)) {
logger.warn("PKCE verification failed");
throw new TokenEndpointException(ErrorType.INVALID_GRANT);
}
break;
case "S256":
MessageDigest sha256Digest = MessageDigest.getInstance("SHA-256");
String computedCodeChallenge = Base64Url.encode(sha256Digest.digest(codeVerifier.getBytes()));
if (!computedCodeChallenge.equals(codeChallenge)) {
logger.warn("PKCE verification failed");
throw new TokenEndpointException(ErrorType.INVALID_GRANT);
}
break;
default:
logger.warn("PKCE transformation algorithm '{}' not supported", codeChallengeMethod);
throw new TokenEndpointException(ErrorType.INVALID_REQUEST);
}
}
// create an access token
String accessToken = jwtHelper.getJwtAccessToken(managedUser, clientId, scope, TOKEN_LIFETIME);
UserSession newSession = new UserSession(sessionId, newRefreshToken, clientId, redirectUri, scope);
ResponseBuilder response = Response.ok(
new TokenResponseDTO(accessToken, "bearer", TOKEN_LIFETIME * 60, newRefreshToken, scope, managedUser));
// if the client has requested an http-only cookie for the session, set it
if (useCookie) {
try {
// this feature is only available for root redirect URIs: the targeted client is the main
// UI; even though the cookie will be set for the entire domain (i.e. no path) so that
// other servlets can make use of it
URI domainUri = new URI(redirectUri);
if (!("".equals(domainUri.getPath()) || "/".equals(domainUri.getPath()))) {
throw new IllegalArgumentException(
"Will not honor the request to set a session cookie for this client, because it's only allowed for root redirect URIs");
}
NewCookie newCookie = new NewCookie(SESSIONID_COOKIE_NAME, sessionId, "/", domainUri.getHost(), null,
2147483647, false, true);
response.cookie(newCookie);
// also mark the session as supported by a cookie
newSession.setSessionCookie(true);
} catch (Exception e) {
logger.warn("Error while setting a session cookie: {}", e.getMessage());
throw new TokenEndpointException(ErrorType.UNAUTHORIZED_CLIENT);
}
}
// add the new session to the user profile and clear the pending token information
managedUser.getSessions().add(newSession);
managedUser.setPendingToken(null);
userRegistry.update(managedUser);
return response.build();
}
private Response processRefreshTokenGrant(String clientId, String refreshToken, Cookie sessionCookie)
throws TokenEndpointException {
if (refreshToken == null) {
throw new TokenEndpointException(ErrorType.INVALID_REQUEST);
}
// find an user associated with the provided refresh token
Optional<User> refreshTokenUser = userRegistry.getAll().stream().filter(
u -> ((ManagedUser) u).getSessions().stream().anyMatch(s -> refreshToken.equals(s.getRefreshToken())))
.findAny();
if (!refreshTokenUser.isPresent()) {
logger.warn("Couldn't find a user with a session matching the provided refresh_token");
throw new TokenEndpointException(ErrorType.INVALID_GRANT);
}
// get the session from the refresh token
ManagedUser refreshTokenManagedUser = (ManagedUser) refreshTokenUser.get();
UserSession session = refreshTokenManagedUser.getSessions().stream()
.filter(s -> s.getRefreshToken().equals(refreshToken)).findAny().get();
// if the cookie flag is present on the session, verify that the cookie is present and corresponds
// to this session
if (session.hasSessionCookie()) {
if (sessionCookie == null || !sessionCookie.getValue().equals(session.getSessionId())) {
logger.warn("Not refreshing token for session {} of user {}, missing or invalid session cookie",
session.getSessionId(), refreshTokenManagedUser.getName());
throw new TokenEndpointException(ErrorType.INVALID_GRANT);
}
}
// issue a new access token
String refreshedAccessToken = jwtHelper.getJwtAccessToken(refreshTokenManagedUser, clientId, session.getScope(),
TOKEN_LIFETIME);
logger.debug("Refreshing session {} of user {}", session.getSessionId(), refreshTokenManagedUser.getName());
ResponseBuilder refreshResponse = Response.ok(new TokenResponseDTO(refreshedAccessToken, "bearer",
TOKEN_LIFETIME * 60, refreshToken, session.getScope(), refreshTokenManagedUser));
// update the last refresh time of the session in the user's profile
session.setLastRefreshTime(new Date());
userRegistry.update(refreshTokenManagedUser);
return refreshResponse.build();
}
}

View File

@ -0,0 +1,52 @@
/**
* 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 org.openhab.core.auth.User;
/**
* A DTO object for a successful token endpoint response, as per RFC 6749, Section 5.1.
*
* {@linkplain https://tools.ietf.org/html/rfc6749#section-5.1}
*
* @author Yannick Schaus - initial contribution
*/
public class TokenResponseDTO {
public String access_token;
public String token_type;
public Integer expires_in;
public String refresh_token;
public String scope;
public UserDTO user;
/**
* Builds a successful response containing token information.
*
* @param access_token the access token
* @param token_type the type of the token, normally "bearer"
* @param expires_in the expiration time of the access token in seconds
* @param refresh_token the refresh token which can be used to get additional tokens
* @param scope the request scope
* @param user the user object, an additional parameter not part of the specification
*/
public TokenResponseDTO(String access_token, String token_type, Integer expires_in, String refresh_token,
String scope, User user) {
super();
this.access_token = access_token;
this.token_type = token_type;
this.expires_in = expires_in;
this.refresh_token = refresh_token;
this.scope = scope;
this.user = new UserDTO(user);
}
}

View File

@ -0,0 +1,35 @@
/**
* 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;
/**
* A DTO object for an unsuccessful token endpoint response, as per RFC 6749, Section 5.2.
*
* {@linkplain https://tools.ietf.org/html/rfc6749#section-5.2}
*
* @author Yannick Schaus - initial contribution
*/
public class TokenResponseErrorDTO {
public String error;
public String error_description;
public String error_uri;
/**
* Builds a token endpoint response for a specific error
*
* @param the error
*/
public TokenResponseErrorDTO(String error) {
this.error = error;
}
}

View File

@ -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.Collection;
import org.openhab.core.auth.User;
/**
* A DTO representing a {@link User}.
*
* @author Yannick Schaus - initial contribution
*/
public class UserDTO {
String name;
Collection<String> roles;
public UserDTO(User user) {
super();
this.name = user.getName();
this.roles = user.getRoles();
}
}

View File

@ -0,0 +1,37 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.io.rest.auth.internal;
import java.util.Date;
/**
* A DTO representing a user session, without the sensible information.
*
* @author Yannick Schaus - initial contribution
*/
public class UserSessionDTO {
String sessionId;
Date createdTime;
Date lastRefreshTime;
String clientId;
String scope;
public UserSessionDTO(String sessionId, Date createdTime, Date lastRefreshTime, String clientId, String scope) {
super();
this.sessionId = sessionId;
this.createdTime = createdTime;
this.lastRefreshTime = lastRefreshTime;
this.clientId = clientId;
this.scope = scope;
}
}

View File

@ -62,7 +62,6 @@ import io.swagger.annotations.ApiResponses;
* @author Yannick Schaus - Added filters to getAll
*/
@Path(ItemChannelLinkResource.PATH_LINKS)
@RolesAllowed({ Role.ADMIN })
@Api(value = ItemChannelLinkResource.PATH_LINKS)
@Component(service = { RESTResource.class, ItemChannelLinkResource.class })
public class ItemChannelLinkResource implements RESTResource {
@ -115,6 +114,7 @@ public class ItemChannelLinkResource implements RESTResource {
}
@PUT
@RolesAllowed({ Role.ADMIN })
@Path("/{itemName}/{channelUID}")
@ApiOperation(value = "Links item to a channel.")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK"),
@ -147,6 +147,7 @@ public class ItemChannelLinkResource implements RESTResource {
}
@DELETE
@RolesAllowed({ Role.ADMIN })
@Path("/{itemName}/{channelUID}")
@ApiOperation(value = "Unlinks item from a channel.")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK"),

View File

@ -15,6 +15,7 @@ package org.openhab.core.io.rest.ui.internal;
import java.security.InvalidParameterException;
import java.util.stream.Stream;
import javax.annotation.security.RolesAllowed;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
@ -28,6 +29,7 @@ import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import javax.ws.rs.core.UriInfo;
import org.openhab.core.auth.Role;
import org.openhab.core.io.rest.RESTResource;
import org.openhab.core.io.rest.Stream2JSONInputStream;
import org.openhab.core.io.rest.ui.TileDTO;
@ -107,6 +109,7 @@ public class UIResource implements RESTResource {
}
@POST
@RolesAllowed({ Role.ADMIN })
@Path("/components/{namespace}")
@Produces({ MediaType.APPLICATION_JSON })
@ApiOperation(value = "Add an UI component in the specified namespace.")
@ -119,6 +122,7 @@ public class UIResource implements RESTResource {
}
@PUT
@RolesAllowed({ Role.ADMIN })
@Path("/components/{namespace}/{componentUID}")
@Produces({ MediaType.APPLICATION_JSON })
@ApiOperation(value = "Update a specific UI component in the specified namespace.")
@ -141,6 +145,7 @@ public class UIResource implements RESTResource {
}
@DELETE
@RolesAllowed({ Role.ADMIN })
@Path("/components/{namespace}/{componentUID}")
@Produces({ MediaType.APPLICATION_JSON })
@ApiOperation(value = "Remove a specific UI component in the specified namespace.")

View File

@ -15,6 +15,11 @@
<name>openHAB Core :: Bundles :: Karaf Integration</name>
<dependencies>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.boot</artifactId>
@ -39,6 +44,12 @@
<version>${karaf.compile.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.karaf.jaas</groupId>
<artifactId>org.apache.karaf.jaas.modules</artifactId>
<version>${karaf.compile.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,133 @@
/**
* 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.karaf.internal.jaas;
import java.security.Principal;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.karaf.jaas.boot.principal.GroupPrincipal;
import org.apache.karaf.jaas.boot.principal.RolePrincipal;
import org.apache.karaf.jaas.boot.principal.UserPrincipal;
import org.apache.karaf.jaas.modules.BackingEngine;
import org.openhab.core.auth.ManagedUser;
import org.openhab.core.auth.Role;
import org.openhab.core.auth.User;
import org.openhab.core.auth.UserRegistry;
/**
* A Karaf backing engine for the {@link UserRegistry}
*
* @author Yannick Schaus - initial contribution
*/
public class ManagedUserBackingEngine implements BackingEngine {
UserRegistry userRegistry;
public ManagedUserBackingEngine(UserRegistry userRegistry) {
this.userRegistry = userRegistry;
}
@Override
public void addUser(String username, String password) {
userRegistry.register(username, password, new HashSet<String>(Set.of(Role.USER)));
}
@Override
public void deleteUser(String username) {
userRegistry.remove(username);
}
@Override
public List<UserPrincipal> listUsers() {
return userRegistry.getAll().stream().map(u -> new UserPrincipal(u.getName())).collect(Collectors.toList());
}
@Override
public UserPrincipal lookupUser(String username) {
User user = userRegistry.get(username);
if (user != null) {
return new UserPrincipal(user.getName());
}
return null;
}
@Override
public List<GroupPrincipal> listGroups(UserPrincipal user) {
return Collections.emptyList();
}
@Override
public Map<GroupPrincipal, String> listGroups() {
return Collections.emptyMap();
}
@Override
public void addGroup(String username, String group) {
throw new UnsupportedOperationException();
}
@Override
public void createGroup(String group) {
throw new UnsupportedOperationException();
}
@Override
public void deleteGroup(String username, String group) {
throw new UnsupportedOperationException();
}
@Override
public List<RolePrincipal> listRoles(Principal principal) {
User user = userRegistry.get(principal.getName());
if (user != null) {
return user.getRoles().stream().map(r -> new RolePrincipal(r)).collect(Collectors.toList());
}
return Collections.emptyList();
}
@Override
public void addRole(String username, String role) {
User user = userRegistry.get(username);
if (user instanceof ManagedUser) {
ManagedUser managedUser = (ManagedUser) user;
managedUser.getRoles().add(role);
userRegistry.update(managedUser);
}
}
@Override
public void deleteRole(String username, String role) {
User user = userRegistry.get(username);
if (user instanceof ManagedUser) {
ManagedUser managedUser = (ManagedUser) user;
managedUser.getRoles().remove(role);
userRegistry.update(managedUser);
}
}
@Override
public void addGroupRole(String group, String role) {
throw new UnsupportedOperationException();
}
@Override
public void deleteGroupRole(String group, String role) {
throw new UnsupportedOperationException();
}
}

View File

@ -0,0 +1,52 @@
/**
* 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.karaf.internal.jaas;
import java.util.Map;
import javax.inject.Singleton;
import org.apache.karaf.jaas.modules.BackingEngine;
import org.apache.karaf.jaas.modules.BackingEngineFactory;
import org.openhab.core.auth.UserRegistry;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* A Karaf backing engine factory for the {@link UserRegistry}
*
* @author Yannick Schaus - initial contribution
*/
@Singleton
@Component(service = BackingEngineFactory.class)
public class ManagedUserBackingEngineFactory implements BackingEngineFactory {
UserRegistry userRegistry;
@Activate
public ManagedUserBackingEngineFactory(@Reference UserRegistry userRegistry) {
this.userRegistry = userRegistry;
}
@Override
public String getModuleClass() {
return ManagedUserRealm.MODULE_CLASS;
}
@Override
public BackingEngine build(Map<String, ?> options) {
return new ManagedUserBackingEngine(userRegistry);
}
}

View File

@ -0,0 +1,76 @@
/**
* 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.karaf.internal.jaas;
import java.util.HashMap;
import java.util.Map;
import javax.inject.Singleton;
import javax.security.auth.login.AppConfigurationEntry;
import javax.security.auth.login.AppConfigurationEntry.LoginModuleControlFlag;
import javax.security.auth.spi.LoginModule;
import org.apache.karaf.jaas.boot.ProxyLoginModule;
import org.apache.karaf.jaas.config.JaasRealm;
import org.apache.karaf.shell.api.action.lifecycle.Service;
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.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A JAAS realm description for the {@link UserRegistry} based login module.
*
* @author Yannick Schaus - initial contribution
*/
@Singleton
@Component(service = JaasRealm.class)
@Service
public class ManagedUserRealm implements JaasRealm {
public static final String REALM_NAME = "openhab";
public static final String MODULE_CLASS = "org.openhab.core.auth.jaas.internal.ManagedUserLoginModule";
private final Logger logger = LoggerFactory.getLogger(ManagedUserRealm.class);
BundleContext bundleContext;
@Activate
public ManagedUserRealm(BundleContext bundleContext, @Reference LoginModule loginModule) {
this.bundleContext = bundleContext;
logger.debug("Using login module {} for the openhab realm", loginModule.getClass().getCanonicalName());
}
@Override
public String getName() {
return REALM_NAME;
}
@Override
public int getRank() {
return 1;
}
@Override
public AppConfigurationEntry[] getEntries() {
Map<String, Object> options = new HashMap<>();
options.put(ProxyLoginModule.PROPERTY_MODULE, MODULE_CLASS);
return new AppConfigurationEntry[] {
new AppConfigurationEntry(MODULE_CLASS, LoginModuleControlFlag.SUFFICIENT, options) };
}
}

View File

@ -12,12 +12,15 @@
*/
package org.openhab.core.auth;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Realizations of this type are responsible for checking validity of various credentials and giving back authentication
* which defines access scope for authenticated user or system.
*
* @author Łukasz Dywicki - Initial contribution
*/
@NonNullByDefault
public interface AuthenticationProvider {
/**

View File

@ -0,0 +1,67 @@
/**
* 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;
import java.util.HashSet;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Represents a generic {@link User} with a set of roles
*
* @author Yannick Schaus - initial contribution
*
*/
@NonNullByDefault
public class GenericUser implements User {
private String name;
private Set<String> roles;
/**
* Constructs a user attributed with a set of roles.
*
* @param name the username (account name)
* @param roles the roles attributed to this user
*/
public GenericUser(String name, Set<String> roles) {
this.name = name;
this.roles = roles;
}
/**
* Constructs a user with no roles.
*
* @param name the username (account name)
*/
public GenericUser(String name) {
this(name, new HashSet<>());
}
@Override
public String getName() {
return name;
}
@Override
public @NonNull String getUID() {
return name;
}
@Override
public Set<String> getRoles() {
return roles;
}
}

View File

@ -0,0 +1,157 @@
/**
* 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;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* A {@link User} sourced from a managed {@link UserProvider}.
*
* @author Yannick Schaus - initial contribution
*/
@NonNullByDefault
public class ManagedUser implements User {
private String name;
private String passwordHash;
private String passwordSalt;
private Set<String> roles = new HashSet<>();
private @Nullable PendingToken pendingToken = null;
private List<UserSession> sessions = new ArrayList<>();
private List<UserApiToken> apiTokens = new ArrayList<>();
/**
* Constructs a user with a password hash & salt provided by the caller.
*
* @param name the username (account name)
* @param passwordSalt the salt to compute the password hash
* @param passwordHash the result of the hashing of the salted password
*/
public ManagedUser(String name, String passwordSalt, String passwordHash) {
super();
this.name = name;
this.passwordSalt = passwordSalt;
this.passwordHash = passwordHash;
}
/**
* Gets the password hash.
*
* @return the password hash
*/
public String getPasswordHash() {
return passwordHash;
}
/**
* Gets the password salt.
*
* @return the password salt
*/
public String getPasswordSalt() {
return passwordSalt;
}
@Override
public String getName() {
return name;
}
/**
* Alters the user's account name
*
* @param name the new account name
*/
public void setName(String name) {
this.name = name;
}
@Override
public String getUID() {
return name;
}
@Override
public Set<String> getRoles() {
return roles;
}
/**
* Alters the user's set of roles.
*
* @param roles the new roles
*/
public void setRoles(Set<String> roles) {
this.roles = roles;
}
/**
* Gets the pending token information for this user, if any.
*
* @return the pending token information or null if there is none
*/
public @Nullable PendingToken getPendingToken() {
return pendingToken;
}
/**
* Sets or clears the pending token information for this user.
*
* @param pendingToken the pending token information or null to clear it
*/
public void setPendingToken(@Nullable PendingToken pendingToken) {
this.pendingToken = pendingToken;
}
/**
* Gets the current persistent sessions for this user.
*
* @return the list of sessions
*/
public List<UserSession> getSessions() {
return sessions;
}
/**
* Replaces the list of sessions by a new one.
*
* @param sessions the new list of sessions
*/
public void setSessions(List<UserSession> sessions) {
this.sessions = sessions;
}
/**
* Gets the long-term API tokens for this user
*
* @return the API tokens
*/
public List<UserApiToken> getApiTokens() {
return apiTokens;
}
/**
* Replaces the list of API tokens by a new one.
*
* @param apiTokens the new API tokens
*/
public void setApiTokens(List<UserApiToken> apiTokens) {
this.apiTokens = apiTokens;
}
}

View File

@ -0,0 +1,120 @@
/**
* 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;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The pending information used in a OAuth2 authorization flow, set after the user has authorized the client to access
* resources, and has been redirected to the callback URI with an authorization code. The information will be used when
* it calls the token endpoint to exchange the authorization code for a access token and a refresh token.
*
* The authorization code for a token is sensible information while it is valid, therefore the client is supposed to
* call the token endpoint to perform the exchange it immediately after receiving it. The information should remain in
* the @link {@link ManagedUser} profile for a limited time only.
*
* Additionally, and optionally, information about the code challenge as specified by PKCE (RFC 7636) can be stored
* along with the code.
*
* @author Yannick Schaus - initial contribution
*
*/
@NonNullByDefault
public class PendingToken {
private String authorizationCode;
private String clientId;
private String redirectUri;
private String scope;
@Nullable
private String codeChallenge;
@Nullable
private String codeChallengeMethod;
/**
* Constructs a pending token.
*
* @param authorizationCode the authorization code provided to the client
* @param clientId the client ID making the request
* @param redirectUri the provided redirect URI
* @param scope the requested scopes
* @param codeChallenge the code challenge (optional)
* @param codeChallengeMethod the code challenge method (optional)
*/
public PendingToken(String authorizationCode, String clientId, String redirectUri, String scope,
@Nullable String codeChallenge, @Nullable String codeChallengeMethod) {
super();
this.authorizationCode = authorizationCode;
this.clientId = clientId;
this.redirectUri = redirectUri;
this.scope = scope;
this.codeChallenge = codeChallenge;
this.codeChallengeMethod = codeChallengeMethod;
}
/**
* Gets the authorization code provided to the client.
*
* @return the authorization code
*/
public String getAuthorizationCode() {
return authorizationCode;
}
/**
* Gets the ID of the client requesting the upcoming token.
*
* @return the client ID
*/
public String getClientId() {
return clientId;
}
/**
* Gets the redirect URI of the client requesting the upcoming token.
*
* @return the redirect URI
*/
public String getRedirectUri() {
return redirectUri;
}
/**
* Gets the requested scopes for the upcoming token.
*
* @return the requested scopes
*/
public String getScope() {
return scope;
}
/**
* Gets the code challenge
*
* @return the code challenge
*/
public @Nullable String getCodeChallenge() {
return codeChallenge;
}
/**
* Gets the code challenge method
*
* @return the code challenge method
*/
public @Nullable String getCodeChallengeMethod() {
return codeChallengeMethod;
}
}

View File

@ -0,0 +1,36 @@
/**
* 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;
import java.security.Principal;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.common.registry.Identifiable;
/**
* A user represents an individual, physical person using the system.
*
* @author Yannick Schaus - initial contribution
*/
@NonNullByDefault
public interface User extends Principal, Identifiable<String> {
/**
* Gets the roles attributed to the user.
*
* @see Role
* @return role attributed to the user
*/
public Set<String> getRoles();
}

View File

@ -0,0 +1,80 @@
/**
* 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;
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.
*
* @author Yannick Schaus - initial contribution
*
*/
public class UserApiToken {
String name;
String apiToken;
Date createdTime;
String scope;
/**
* Constructs an API token.
*
* @param name the name of the token, for identification purposes
* @param apiToken the token
* @param scope the scope this token is valid for
*/
public UserApiToken(String name, String apiToken, String scope) {
super();
this.name = name;
this.apiToken = apiToken;
this.createdTime = new Date();
this.scope = scope;
}
/**
* Gets the name identifying the token
*
* @return the API token
*/
public String getName() {
return name;
}
/**
* Gets the API token which can be passed in requests as a "Bearer" token in the Authorization HTTP header.
*
* @return the API token
*/
public String getApiToken() {
return apiToken;
}
/**
* Gets the time when this token was created.
*
* @return the date of creation
*/
public Date getCreatedTime() {
return createdTime;
}
/**
* Gets the scope this token is valid for.
*
* @return the scope
*/
public String getScope() {
return scope;
}
}

View File

@ -0,0 +1,27 @@
/**
* 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;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.common.registry.Provider;
/**
* A interface for a {@link Provider} of {@link User} entities
*
* @author Yannick Schaus - initial contribution
*
*/
@NonNullByDefault
public interface UserProvider extends Provider<User> {
}

View File

@ -0,0 +1,42 @@
/**
* 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;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.common.registry.Registry;
/**
* An interface for a generic {@link Registry} of {@link User} entities. User registries can also be used as
* {@link AuthenticationProvider}.
*
* @author Yannick Schaus - initial contribution
*
*/
@NonNullByDefault
public interface UserRegistry extends Registry<User, String>, AuthenticationProvider {
/**
* Adds a new {@link User} in this registry. The implementation receives the clear text credentials and is
* responsible for their secure storage (for instance by hashing the password), then return the newly created
* {@link User} instance.
*
* @param username the username of the new user
* @param password the user password
* @param roles the roles attributed to the new user
*
* @return the new registered {@link User} instance
*/
public User register(String username, String password, Set<String> roles);
}

View File

@ -0,0 +1,147 @@
/**
* 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;
import java.util.Date;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* A persistent session for a {@link ManagedUser}, which holds a refresh token used by a client to get short-lived
* access tokens for API requests authorization.
*
* @author Yannick Schaus - initial contribution
*
*/
@NonNullByDefault
public class UserSession {
String sessionId;
String refreshToken;
Date createdTime;
Date lastRefreshTime;
String clientId;
String redirectUri;
String scope;
boolean sessionCookie;
/**
* Constructs a new session.
*
* @param sessionId an unique ID for the session
* @param refreshToken the refresh token associated to the session
* @param clientId the client ID associated to the session
* @param redirectUri the callback URI provided when the client was authorized by the user
* @param scope the granted scope provided when the client was authorized by the user
*/
public UserSession(String sessionId, String refreshToken, String clientId, String redirectUri, String scope) {
super();
this.sessionId = sessionId;
this.refreshToken = refreshToken;
this.createdTime = new Date();
this.lastRefreshTime = new Date();
this.clientId = clientId;
this.redirectUri = redirectUri;
this.scope = scope;
}
/**
* Gets the ID of the session.
*
* @return the session ID
*/
public String getSessionId() {
return sessionId;
}
/**
* Gets the refresh token for the session.
*
* @return the refresh token
*/
public String getRefreshToken() {
return refreshToken;
}
/**
* Gets the creation time of the session.
*
* @return the creation time
*/
public Date getCreatedTime() {
return createdTime;
}
/**
* Gets the time when the refresh token was last used to get a new access token.
*
* @return the last refresh time
*/
public Date getLastRefreshTime() {
return lastRefreshTime;
}
/**
* Sets the time when the refresh token was last used to get a new access token.
*
* @param lastRefreshTime the last refresh time
*/
public void setLastRefreshTime(Date lastRefreshTime) {
this.lastRefreshTime = lastRefreshTime;
}
/**
* Gets the scope requested when authorizing this session.
*
* @return the session scope
*/
public String getScope() {
return scope;
}
/**
* Gets the ID of the client this session was created for
*
* @return the client ID
*/
public String getClientId() {
return clientId;
}
/**
* Gets the redirect URI which was used to perform the authorization flow.
*
* @return the redirect URI
*/
public String getRedirectUri() {
return redirectUri;
}
/**
* Specifies whether this session is supported by a session cookie, to mitigate the impact of refresh token
* leakage.
*
* @return whether or not a cookie has been set
*/
public boolean hasSessionCookie() {
return sessionCookie;
}
/**
* Sets the session cookie flag for this session.
*
* @param sessionCookie the cookie flag
*/
public void setSessionCookie(boolean sessionCookie) {
this.sessionCookie = sessionCookie;
}
}

View File

@ -0,0 +1,49 @@
/**
* 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 org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.auth.ManagedUser;
import org.openhab.core.auth.User;
import org.openhab.core.common.registry.DefaultAbstractManagedProvider;
import org.openhab.core.common.registry.ManagedProvider;
import org.openhab.core.storage.StorageService;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* A {@link ManagedProvider} for {@link ManagedUser} entities
*
* @author Yannick Schaus - initial contribution
*/
@NonNullByDefault
@Component(service = ManagedUserProvider.class, immediate = true)
public class ManagedUserProvider extends DefaultAbstractManagedProvider<User, String> {
@Activate
public ManagedUserProvider(final @Reference StorageService storageService) {
super(storageService);
}
@Override
protected String getStorageName() {
return "users";
}
@Override
protected String keyToString(String key) {
return key;
}
}

View File

@ -0,0 +1,155 @@
/**
* 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 java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.auth.Authentication;
import org.openhab.core.auth.AuthenticationException;
import org.openhab.core.auth.Credentials;
import org.openhab.core.auth.ManagedUser;
import org.openhab.core.auth.User;
import org.openhab.core.auth.UserProvider;
import org.openhab.core.auth.UserRegistry;
import org.openhab.core.auth.UsernamePasswordCredentials;
import org.openhab.core.common.registry.AbstractRegistry;
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.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The implementation of a {@link UserRegistry} for {@link ManagedUser} entities.
*
* @author Yannick Schaus - initial contribution
*/
@NonNullByDefault
@Component(service = UserRegistry.class, immediate = true)
public class UserRegistryImpl extends AbstractRegistry<User, String, UserProvider> implements UserRegistry {
private final Logger logger = LoggerFactory.getLogger(UserRegistryImpl.class);
private static final int ITERATIONS = 65536;
private static final int KEY_LENGTH = 512;
private static final String ALGORITHM = "PBKDF2WithHmacSHA512";
private static final SecureRandom RAND = new SecureRandom();
@Activate
public UserRegistryImpl(BundleContext context, Map<String, Object> properties) {
super(UserProvider.class);
super.activate(context);
}
@Override
@Deactivate
protected void deactivate() {
super.deactivate();
}
@Reference(cardinality = ReferenceCardinality.OPTIONAL, policy = ReferencePolicy.DYNAMIC)
protected void setManagedProvider(ManagedUserProvider managedProvider) {
super.setManagedProvider(managedProvider);
super.addProvider(managedProvider);
}
protected void unsetManagedProvider(ManagedUserProvider managedProvider) {
super.unsetManagedProvider(managedProvider);
super.removeProvider(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();
ManagedUser user = new ManagedUser(username, passwordSalt, passwordHash);
user.setRoles(new HashSet<>(roles));
super.add(user);
return user;
}
private Optional<String> generateSalt(final int length) {
if (length < 1) {
logger.error("error in generateSalt: length must be > 0");
return Optional.empty();
}
byte[] salt = new byte[length];
RAND.nextBytes(salt);
return Optional.of(Base64.getEncoder().encodeToString(salt));
}
private Optional<String> hashPassword(String password, String salt) {
char[] chars = password.toCharArray();
byte[] bytes = salt.getBytes();
PBEKeySpec spec = new PBEKeySpec(chars, bytes, ITERATIONS, KEY_LENGTH);
Arrays.fill(chars, Character.MIN_VALUE);
try {
SecretKeyFactory fac = SecretKeyFactory.getInstance(ALGORITHM);
byte[] securePassword = fac.generateSecret(spec).getEncoded();
return Optional.of(Base64.getEncoder().encodeToString(securePassword));
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
logger.error("Exception encountered in hashPassword", e);
return Optional.empty();
} finally {
spec.clearPassword();
}
}
@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 (!(user instanceof ManagedUser)) {
throw new AuthenticationException("User is not managed: " + usernamePasswordCreds.getUsername());
}
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());
}
Authentication authentication = new Authentication(managedUser.getName());
return authentication;
}
@Override
public boolean supports(Class<? extends Credentials> type) {
return (UsernamePasswordCredentials.class.isAssignableFrom(type));
}
}

View File

@ -138,6 +138,7 @@
<feature name="openhab-core-io-http-auth" version="${project.version}">
<feature>openhab-core-base</feature>
<feature>openhab-core-auth-jaas</feature>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.http.auth/${project.version}</bundle>
</feature>
@ -153,7 +154,10 @@
<feature name="openhab-core-io-rest-auth" version="${project.version}">
<feature>openhab-core-base</feature>
<feature>openhab-core-auth-jaas</feature>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.rest.auth/${project.version}</bundle>
<requirement>openhab.tp;filter:="(feature=jose4j)"</requirement>
<feature dependency="true">openhab.tp-jose4j</feature>
</feature>
<feature name="openhab-core-io-rest-log" version="${project.version}">

View File

@ -176,6 +176,11 @@
<bundle>mvn:org.jmdns/jmdns/3.5.5</bundle>
</feature>
<feature name="openhab.tp-jose4j" description="jose4j JWT library" version="${project.version}">
<capability>openhab.tp;feature=jose4j;version=0.7.0</capability>
<bundle>mvn:org.bitbucket.b_c/jose4j/0.7.0</bundle>
</feature>
<feature name="openhab.tp-jupnp" description="UPnP/DLNA library for Java" version="${project.version}">
<capability>openhab.tp;feature=jupnp;version=2.5.2</capability>
<feature dependency="true">http</feature>