Detailed access control and user management by reverse proxy - it works

Tags: #<Tag:0x00007f61845167c8>

Dear community,

when I began to deal with openhab the lack of access control was really annoying me, but the open architecture made me stay with it.
Access control to give certain users only restricted access was mandatory for my use case. I acquired an appropriate solution for my requirements that I want to share with you.

As a new user here I “can only put 2 links in a post”. :confused: As a workaround links are placed at the bottom for bold items.

Summary

  • Access control by reverse proxy
  • Restricting access to the REST API (e. g. used by mobile apps)
  • Not working with Basic UI, Paper UI and other frontends not using the REST API
  • High effort for an individual rule set
  • It has a taste of hack

A reverse proxy that openhab works behind is definetly a good idea for security reasons like SSL/TLS as described in Securing access to openHAB. Furthermore it can be configured to restrict access per user. Effectively that is done by removing unwanted parts from an openhab json response before it is sent to the client.
I use the openhab app and watched its requests sent to openhab and their responses. For example at first it requests the available sitemaps by calling /rest/sitemaps. By removing any sitemaps from the response that are not designated for the user he only sees what he should see.

The following is related to Apache used as reverse proxy (for nginx it might be similar).

Any user must have its own domain for access like alice.mydomain.com and bob.mydomain.com. (More users can adress a single domain if they are supposed to have the same access restrictions.) This is necessary because Apache first decides what is requested and after that it gives access (and modifies the response) or not. It is not possible to configure something like if user = alice then this and if user = bob then that. So every “entrance” (domain) defines what a user can see and what he is allowed to do here. (Without authentication for this entrace he will directly be thrown out.)
If a DynDNS service is used it should offer a few sub domains (such as noip.com offers 3 for free) and your router or whatever DynDNS client is used must also be able to feed more than one DynDNS name.
(This should also work: Get an account for one or more DynDNS services. Setup a cron job for calling regularly the update URL for each subdomain like https://youruser:yourpassword@dynupdate.any-dyndns-service.com/update?hostname=alice.yourdomain.com. Stick to the terms of usage to not get blocked.)

The (sub) domains are mapped to virtual hosts in apache.

<IfModule ssl_module>
<VirtualHost *:443>
	ServerAdmin webmaster@localhost
	ServerName alice.mydomain.com

	ErrorLog ${APACHE_LOG_DIR}/alice.mydomain.com_error.log
	LogLevel warn
	CustomLog ${APACHE_LOG_DIR}/alice.mydomain.com_access.log combined

   	# SSL config goes here

	<IfModule proxy_module>

		# proxy config goes here

The core work ist done by jq, a neat json processor which is included in many linux distribution repositories. It is called by the mod_ext_filter apache module passing the openhab response and a filter to it. It returns the result appropriate to the given filter.
Each api function = url location has its own process definition (deny, grant at all, grant and let jq modify the response).

		# here begins the rule set

		# globally deny access to openhab, in particular to "open" frontends like Basic UI, Paper UI...
		<Location "/">
		    Require all denied
		</Location>

		# give access for a user to the REST API
		<Location "/rest">
		    AuthType Basic
		    AuthName "Access for Alice"
		    AuthBasicProvider file
		    AuthUserFile "/etc/openhab2/openhab.htpasswd"
		    Require user alice
		</Location>

		# give access to request available sitemaps and ...
		<LocationMatch "^/rest/sitemaps/?$">
		    AuthType Basic
		    AuthName "Access for Alice"
		    AuthBasicProvider file
		    AuthUserFile "/etc/openhab2/openhab.htpasswd"
		    Require user alice

		    # ... apply the filter to respond only those sitemaps designated for the user
		    SetOutputFilter filter-alice-sitemaps
		</LocationMatch>

		# give full access (no response modification) to a particular sitemap
		<Location "/rest/sitemaps/alice_sitemap">
		    AuthType Basic
		    AuthName "Access for Alice"
		    AuthBasicProvider file
		    AuthUserFile "/etc/openhab2/openhab.htpasswd"
		    Require user alice
		</Location>

		# more location rules needed here for the other functions
		# see http://demo.openhab.org:8080/doc/index.html and the log for this virtual host when accessed by an openhab client

	    </IfModule>
    </IfModule>

</VirtualHost>
</IfModule>

What is missing so far is the filter definition. (Just place it above all the other stuff!)

ExtFilterDefine filter-alice-sitemaps mode=output cmd="/usr/bin/jq -c '[.[]|select(.name==\"alice_sitemap\")]'"

# Explanation of this filter
#               filter-alice-sitemaps: the filter name
#                                     mode=output: apply the filter to the response which will be delivered to the client
#                                                 cmd="/usr/bin/jq -c...: call jq
#                                                                      [ return all matches as an array     ]
#                                                                       .[] take each element of the original array
#                                                                          |select(.name==\"alice_sitemap\") retain only those of them containing a key "name" with the value "alice_sitemap"

Example: If this is the openhab response for a sitemaps request rest/sitemaps
(notice all links with alice.mydomain.com)

[
  {
    "name": "admin_sitemap",
    "label": "Admin",
    "link": "https://alice.mydomain.com/rest/sitemaps/admin_sitemap",
    "homepage": {
      "link": "https://alice.mydomain.com/rest/sitemaps/admin_sitemap/admin_sitemap",
      "leaf": false,
      "timeout": false,
      "widgets": []
    }
  },
  {
    "name": "alice_sitemap",
    "label": "Alice",
    "link": "https://alice.mydomain.com/rest/sitemaps/alice_sitemap",
    "homepage": {
      "link": "https://alice.mydomain.com/rest/sitemaps/alice_sitemap/alice_sitemap",
      "leaf": false,
      "timeout": false,
      "widgets": []
    }
  },
  {
    "name": "bob_sitemap",
    "label": "Bob",
    "link": "https://alice.mydomain.com/rest/sitemaps/bob_sitemap",
    "homepage": {
      "link": "https://alice.mydomain.com/rest/sitemaps/bob_sitemap/bob_sitemap",
      "leaf": false,
      "timeout": false,
      "widgets": []
    }
  }
]

the result is

[
  {
    "name": "alice_sitemap",
    "label": "Alice",
    "link": "https://alice.mydomain.com/rest/sitemaps/alice_sitemap",
    "homepage": {
      "link": "https://alice.mydomain.com/rest/sitemaps/alice_sitemap/alice_sitemap",
      "leaf": false,
      "timeout": false,
      "widgets": []
    }
  }
]

Just try it on jqplay.org without the backslahes [.[]|select(.name=="alice_sitemap")]

Filters for Things, Items and others are omitted here but they are necessary for denying access to them. I can supply my rules on demand.

Much stuff, maybe this will help someone.

At last the workaround link list
Securing access to openHAB
https://www.openhab.org/docs/installation/security.html
openhab app
https://www.openhab.org/docs/apps/android.html
Sitemaps
https://www.openhab.org/docs/configuration/sitemaps.html
jq
https://stedolan.github.io/jq/
mod_ext_filter
https://httpd.apache.org/docs/2.4/mod/mod_ext_filter.html

9 Likes

For my cloud instance, I do a whitelist approach through iptables. Simple, effective, and stack agnostic.

1 Like

After running this solution for a full winter season my conclusion is that it works accuratly, securely, reliably and silently.
Three different parties control their respective heating without any intervention among each other.

Hi,

Based on the above solution, I have been working at creating a complete solution for both App and UI access through a reverse nginx proxy and trying to “limit” access by associating a password per sitemap, plus a master user having access to all sitemaps.

Here is the result:

nginx configuration file in reverse proxy setup giving access to basicui and App only, all other interfaces disabled:

server {
	listen 80;
	server_name myserver.dynu.net;
	return 301 https://$server_name$request_uri;
}

server	{
	listen 443 ssl;
	server_name myserver.dynu.net;

	ssl_certificate /etc/letsencrypt/live/myserver.dynu.net/fullchain.pem; # managed by Certbot
	ssl_certificate_key /etc/letsencrypt/live/myserver.dynu.net/privkey.pem; # managed by Certbot
	include /etc/letsencrypt/options-ssl-nginx.conf;
	ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

	access_log /var/log/nginx/oh_ssl.access.log;
	error_log /var/log/nginx/oh_ssl.error.log;

	auth_basic "Sitemap";
	auth_basic_user_file /etc/nginx/ohpass;

	# Disable unwanted UIs
	location ~ /(habpanel|classicui|paperui|doc|habmin) {
		return 403;
	}

	# Redirect to expected UIs (need to do it in two steps to process auth_basic before the return 302
	# authentified user is then used as the sitemap name
	location = / {
		try_files DUMMY @return302;
	}
	location @return302 {
		return 302 /basicui/app?sitemap=$remote_user;
	}

	# User management at sitemap level -> strip openhab json file through filter script (see below)
	location = /rest/sitemaps {
		proxy_set_header        X-Forwarded-Host        $http_host;
		proxy_pass http://127.0.0.1/cgi-bin/filter?user=$remote_user;
	}

	# Proxying the request to OpenHab
	location / {

		# Double test to limit access to the right UI and the right sitemap
		if ($uri = "/basicui/app") { 
			set $test  "${test}A"; 
		} 
		if ($arg_sitemap != $remote_user) {
			set $test  "${test}B"; 
		} 
		if ($remote_user = "master") {
			set $test  "";
		}
		if ($test = AB) { 
			return 403;
		}

		# Proxying the request
		proxy_pass			http://192.168.4.4:8083/;

		proxy_set_header	Host				$http_host;
		proxy_set_header	X-Real-IP			$remote_addr;
		proxy_set_header	X-Forwarded-For		$proxy_add_x_forwarded_for;
		proxy_set_header	X-Forwarded-Proto	$scheme;

		proxy_http_version 1.1;
		#proxy_set_header Connection "";

		client_max_body_size       10m;
		client_body_buffer_size    128k;

		proxy_connect_timeout      90;
		proxy_send_timeout         90;
		proxy_read_timeout         90;

		proxy_buffer_size          4k;
		proxy_buffers              4 32k;
		proxy_busy_buffers_size    64k;
		proxy_temp_file_write_size 64k;

		# OpenHab authentication (base64 of user:password)
		#proxy_set_header Authorization "Basic ZWxzYTpteWVsc2FwcHAK";

		proxy_intercept_errors on;
    	}
}

The file /etc/nginx/ohpass contains the couple sitemaps/password, created through:

htpasswd -c /etc/nginx/ohpass master
htpasswd /etc/nginx/ohpass sitemap1
htpasswd /etc/nginx/ohpass sitemap2
...

sitemap1, sitemap2 are the filename of the different sitemaps (without the extension).

As the equivalent of mod_ext_filter is not available on nginx, the simplest solution I found was to call a script through the cgi-bin interface. Here is the corresponding filter script:

#!/bin/sh

eval $(echo "$QUERY_STRING"|awk -F'&' '{for(i=1;i<=NF;i++){print $i}}') # import the GET variables
read QUERY_STRING
eval $(echo "$QUERY_STRING"|awk -F'&' '{for(i=1;i<=NF;i++){print $i}}') # import the POST variables

if [ "$user" = "master" ]; then
	exp="."
else
	exp="[.[]|select(.name==\"$user\")]"
fi

echo Content-type: application/json
echo

curl --header "X-Forwarded-Host: $HTTP_X_FORWARDED_HOST" -s http://localhost:8083/rest/sitemaps | /usr/bin/jq -c "$exp"

The script is conceptually very similar to the one from Daniel, adding the exception of the master user having access to all sitemaps.

Once this is set-up, basicui is directly accessible through http://myserver.dynu.net (redirected automatically to SSL access and the full UI URL). User is prompted for a sitemap name and a password, and then get access to the UI. The same URL/sitemap name/password can be used in the App with the same limitations (ie either user is master and sees all, or user is sitemap name and limited to that given sitemap).

I have been testing that setup for some time and found it so far quite safe, with a clear separation by user and no unauthorized access to restricted sitemaps (I have for instance created a specific sitemap for my kids so that they can open the portal and switch on/off the alarm, but don’t want them to have access to the watering system or other critical automation at home).

I hope this will help anyone willing to set up access control to his/her OpenHab installation.

3 Likes

Hi @Laurent_Chauvin
Looks great.
I just have one issue with the “master” user.
I have no idea where to place the “filter” script.
I tried in “/var/www/cgi-bin/” but all other sites than the available sitemap overview are failing (http 403).

I figured out where to place the filter script, but I still get http403 if I try to access the sitemaps.
2nd issue is, does this really work in the iOS app ?

This is my nginx config:

removed to prevent dupliciate code

my openhab server runs on “https://10.0.1.5:8444/

Request to “http://localhost:8080/cgi-bin/filter?user=master” returns all sitemaps:

[remo@home ~]$ curl -v http://localhost:8080/cgi-bin/filter?user=master
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /cgi-bin/filter?user=master HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.58.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Date: Fri, 01 May 2020 11:57:00 GMT
< Server: Apache/2.4.6 (CentOS) OpenSSL/1.0.2k-fips PHP/7.1.33
< Transfer-Encoding: chunked
< Content-Type: application/json
< 
[{"name":"Fabio","label":"Fabio","link":"http://10.0.1.5:8082/rest/sitemaps/Fabio","homepage":{"link":"http://10.0.1.5:8082/rest/sitemaps/Fabio/Fabio","leaf":false,"timeout":false,"widgets":[]}},{"name":"meinHaus","label":"MyHome","link":"http://10.0.1.5:8082/rest/sitemaps/meinHaus","homepage":{"link":"http://10.0.1.5:8082/rest/sitemaps/meinHaus/meinHaus","leaf":false,"timeout":false,"widgets":[]}},{"name":"Dario","label":"Dario","link":"http://10.0.1.5:8082/rest/sitemaps/Dario","homepage":{"link":"http://10.0.1.5:8082/rest/sitemaps/Dario/Dario","leaf":false,"timeout":false,"widgets":[]}},{"name":"Sandra","label":"Sandra","link":"http://10.0.1.5:8082/rest/sitemaps/Sandra","homepage":{"link":"http://10.0.1.5:8082/rest/sitemaps/Sandra/Sandra","leaf":false,"timeout":false,"widgets":[]}},{"name":"_default","label":"Home","link":"http://10.0.1.5:8082/rest/sitemaps/_default","homepage":{"link":"http://10.0.1.5:8082/rest/sitemaps/_default/_default","leaf":false,"timeout":false,"widgets":[]}}]

filter file:

#!/bin/sh

eval $(echo "$QUERY_STRING"|awk -F'&' '{for(i=1;i<=NF;i++){print $i}}') # import the GET variables
read QUERY_STRING
eval $(echo "$QUERY_STRING"|awk -F'&' '{for(i=1;i<=NF;i++){print $i}}') # import the POST variables

if [ "$user" = "master" ]; then
	exp="."
else
	exp="[.[]|select(.name==\"$user\")]"
fi

echo Content-type: application/json
echo

curl -k -s https//10.0.1.5:8444/rest/sitemaps | /usr/bin/jq -c "$exp"

Hi Remo,

Am a bit surprised because you say your server runs on 8444 but the sitemap comes back with 8082 for the port to be used to access the sitemap. Are you sure of your set-up?

For the iOS app, I just discovered that it works perfectly locally but not remotely. I am working on finding a fix. I’ll post as soon as I find it.

And for the location of the filter, I think you put it in the right place as you get an answer (with the caveat of the port number).

L.

Ok, got it now. I forgot to forward the right host when querying the sitemap to OpenHab. So two modifs, one in the nginx config file to forward the requested host:

    # User management at sitemap level -> strip openhab json file
    location = /rest/sitemaps {
            resolver 127.0.0.1 [::1];
            proxy_set_header        X-Forwarded-Host        $http_host;
            proxy_pass http://localhost/cgi-bin/ohsitemap/filter?user=$remote_user;

    }

and one in the filter to provide the original host to openhab to that the answer to the request will come with the proper host:

curl --header "X-Forwarded-Host: $HTTP_X_FORWARDED_HOST" -s http://localhost:8083/rest/sitemaps | /usr/bin/jq -c "$exp"

Et voilà, now works locally and remotelly. Original post edited to include the changes.

I use for all queries https, now it looks good from my point of view.
But I still get the HTTP403 with the master user when access any of the sitemaps.

remote_user=meinHaus
curl -k -s https://localhost:443/cgi-bin/filter?user=$remote_user
[{"name":"meinHaus","label":"MyHome","link":"https://localhost:8444/rest/sitemaps/meinHaus","homepage":{"link":"https://localhost:8444/rest/sitemaps/meinHaus/meinHaus","leaf":false,"timeout":false,"widgets":[]}}]

remote_user=master
curl -k -s https://localhost:443/cgi-bin/filter?user=$remote_user
[{"name":"Fabio","label":"Fabio","link":"https://localhost:8444/rest/sitemaps/Fabio","homepage":{"link":"https://localhost:8444/rest/sitemaps/Fabio/Fabio","leaf":false,"timeout":false,"widgets":[]}},{"name":"meinHaus","label":"MyHome","link":"https://localhost:8444/rest/sitemaps/meinHaus","homepage":{"link":"https://localhost:8444/rest/sitemaps/meinHaus/meinHaus","leaf":false,"timeout":false,"widgets":[]}},{"name":"Dario","label":"Dario","link":"https://localhost:8444/rest/sitemaps/Dario","homepage":{"link":"https://localhost:8444/rest/sitemaps/Dario/Dario","leaf":false,"timeout":false,"widgets":[]}},{"name":"Sandra","label":"Sandra","link":"https://localhost:8444/rest/sitemaps/Sandra","homepage":{"link":"https://localhost:8444/rest/sitemaps/Sandra/Sandra","leaf":false,"timeout":false,"widgets":[]}},{"name":"_default","label":"Home","link":"https://localhost:8444/rest/sitemaps/_default","homepage":{"link":"https://localhost:8444/rest/sitemaps/_default/_default","leaf":false,"timeout":false,"widgets":[]}}]

The second issue is with the native iOS app.
There I get these errors:

2020/05/02 18:20:23 [error] 10503#0: send() failed (111: Connection refused) while resolving, resolver: 127.0.0.1:53
2020/05/02 18:20:28 [error] 10503#0: send() failed (111: Connection refused) while resolving, resolver: [::1]:53

It looks like the DNS resolving gets wrong.
It takes the first “Address” found instead the real endpoint.

nslookup oXXXXXXXXX.ddns.net

Server: 10.0.1.1
Address: 10.0.1.1#53

Non-authoritative answer:

Name: oXXXXXXXX.ddns.net
Address: 1xx.1x.2x.1xx

Any ideas for the 403 and the resolving issue ?

I solved the 403 issue for the master user by adding these lines into nginx.conf:

		} 
		if ($remote_user = "master") {
			set $test  "${test}C"; 
		} 

The issue with the iOS app is still there, will investigate further.

It works from local and remote via the browser (also on iOS) but not on the native app.
No idea how I can solve that …

Yes, you are right for the master user. I fixed that one as well but forgot to post it. My mistake, sorry for that. You can by the way simplify this rule by just putting set $test “”;

Original post edited.

For the iOS app, the trick maybe on the REST call. Did you update the filter script and the Nginx configuration with the X-Forwarded-Host variable as indicated in my previous message?

Can you post the result of calling the sitemap from the rest interface remotely from the Safari browser, ie typing something like:

https://your.site/rest/sitemaps

This should give a pretty good idea of the issue, if any.

Hi Laurent
Thx for your patience …
I have the “X-Forwarded-Host” additions in my nginx.conf and filter file.

First, the access to the sitemaps via iOS Safari browser works from remote.
Here is my try with “https://mydomain:8443/rest/sitemaps” in iOS browser (Safari) from remote.

-> HTTP 502 Bad Gateway

error.log

2020/05/04 14:01:56 [error] 9172#0: send() failed (111: Connection refused) while resolving, resolver: 127.0.0.1:53
2020/05/04 14:01:56 [error] 9172#0: send() failed (111: Connection refused) while resolving, resolver: [::1]:53
2020/05/04 14:02:01 [error] 9172#0: send() failed (111: Connection refused) while resolving, resolver: 127.0.0.1:53
2020/05/04 14:02:06 [error] 9172#0: send() failed (111: Connection refused) while resolving, resolver: [::1]:53
2020/05/04 14:02:11 [error] 9172#0: send() failed (111: Connection refused) while resolving, resolver: 127.0.0.1:53
2020/05/04 14:02:16 [error] 9172#0: send() failed (111: Connection refused) while resolving, resolver: [::1]:53
2020/05/04 14:02:21 [error] 9172#0: send() failed (111: Connection refused) while resolving, resolver: 127.0.0.1:53

oh_ssl.error.log

2020/05/04 14:02:26 [error] 9172#0: *135 localhost could not be resolved (110: Operation timed out), client: xxx.xxx.xxx.xxx, server: mydomain, request: "GET /rest/sitemaps HTTP/1.1", host: "mydomain:8443"

oh_ssl.access.log

xxx.xxx.xxx.xxx - - [04/May/2020:14:02:25 +0200] "GET /rest/sitemaps HTTP/1.1" 401 179 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/605.1.15 (KHTML, like Gecko)"
xxx.xxx.xxx.xxx - - [04/May/2020:14:02:25 +0200] "GET /rest/sitemaps HTTP/1.1" 401 179 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/605.1.15 (KHTML, like Gecko)"
xxx.xxx.xxx.xxx - master [04/May/2020:14:02:26 +0200] "GET /rest/sitemaps HTTP/1.1" 502 157 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 13_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1"

for completeness, here is my nginx.conf file (domain masked):

removed to prevent duplicate code in this thread

Ok, I think I understand where this comes from. The difficult section is that one:

location = /rest/sitemaps {
	resolver 127.0.0.1 [::1];
	proxy_set_header X-Forwarded-Host $http_host;
	proxy_pass http://localhost:8080/cgi-bin/filter?user=$remote_user;
}

I indeed declare a local resolver (127.0.0.1 is your localhost, but you need to have a DNS resolver running on your server for that to work, something like dnsmasq for instance) to make sure that you don’t have any issue when starting nginx if your openhab server is not running (otherwise if I remember well nginx may just refuse to start).

I suspect you don’t have a DNS resolver running on your server, and this is what is causing all the errors in your error.log and oh_ssl.error.log.

To check this, you can change that section to:

location = /rest/sitemaps {
	proxy_set_header X-Forwarded-Host $http_host;
	proxy_pass http://127.0.0.1:8080/cgi-bin/filter?user=$remote_user;
}

The errors should be gone and you should now get the right output when on Safari (and the iOS app should now work as well). If this is the case, just try to stop openhab and restart nginx to see if this change is causing an issue or not. If not, then just leave everything like this and you are done :slight_smile:

1 Like

Excellent, works, also restart nginx service if openhab is down.
I already commented out this line once, but had not changed the localhost with the localhost IP address.
Thanks a lot for your support.

Cool, good news ! :+1:

I have updated my original post to fix that. Enjoy the set-up, really convenient for a multi-user access !

Hey all,

Just wanted to add my experience to get this working, exactly what i was looking for :slight_smile: so thanks!

But i had some issues with the filter.cgi script, it seemed the $HTTP_X_FORWARDED_HOST did not get filled with a value (and i am only using https). After lots of digging and learning… i changed the following:

curl --header "X-Forwarded-Proto: https" --header "X-Forwarded-Host: openhab.somedomein.xx" -s http://localhost:8080/rest/sitemaps | /usr/bin/jq -c "$exp"

So this changes it response to https with the correct domain. What makes it seem work for me!

Hope this helps someone, or I am doing things wrong :slight_smile: