Add authentication to http-api

This commit is contained in:
Roger A. Light 2025-10-14 00:18:37 +01:00
parent 4d296b2f78
commit dd83c8e97a
8 changed files with 124 additions and 17 deletions

View file

@ -340,6 +340,21 @@ static enum MHD_Result http_api__process_api(struct MHD_Connection *connection,
}
static int check_basic_auth(struct mosquitto__listener *listener, struct MHD_Connection *connection)
{
struct mosquitto context = {0};
int rc;
context.listener = listener;
context.username = MHD_basic_auth_get_username_password (connection, &context.password);
rc = mosquitto_basic_auth(&context);
MHD_free(context.username);
MHD_free(context.password);
return rc;
}
static enum MHD_Result http_api_handler(void *cls, struct MHD_Connection
*connection, const char *url, const char *method, const char *version,
const char *upload_data, size_t *upload_data_size, void **con_cls)
@ -358,6 +373,13 @@ static enum MHD_Result http_api_handler(void *cls, struct MHD_Connection
MHD_destroy_response(response);
return ret;
}
if(check_basic_auth(listener, connection) != MOSQ_ERR_SUCCESS){
char *buf = "Not authorised\n";
struct MHD_Response *response = MHD_create_response_from_buffer(strlen(buf), (void *)buf, MHD_RESPMEM_MUST_COPY);
return MHD_queue_basic_auth_fail_response(connection, "Mosquitto API", response);
}
if(!strncasecmp(url, "/api/", strlen("/api/"))){
return http_api__process_api(connection, url);
}else{
@ -406,7 +428,6 @@ int http_api__start(struct mosquitto__listener *listener)
return MOSQ_ERR_NOMEM;
}
}
listener->security_options->allow_anonymous = true;
listener->protocol = mp_http_api;
bind_address = listener->host;

View file

@ -7,6 +7,7 @@ import re
def write_config(filename, mqtt_port, ws_port, http_port):
with open(filename, 'w') as f:
f.write(f"allow_anonymous true\n")
f.write(f"listener {mqtt_port}\n")
f.write(f"listener 0 {mqtt_port}.sock\n")
@ -138,10 +139,10 @@ try:
http_conn.request("GET", "/api/v1/version")
response = http_conn.getresponse()
if response.status != 200:
raise ValueError(f"/api/v1/version {response.status}")
raise ValueError(f"Error: /api/v1/version {response.status}")
payload = response.read().decode('utf-8')
if not re.match(r'^\d+\.\d+\.\d+$', payload):
raise ValueError(f"/api/v1/version\n{payload}")
raise ValueError(f"Error: /api/v1/version\n{payload}")
rc = 0

View file

@ -0,0 +1 @@
user:$6$Ut1cUS9PG8+gC3vn$tOjCfSJJDe1Alu9HktxxyyzwN4+6mAMSWGRAF9gmMN8pzcGTPVEYYMAZpCEp96Oz2ZRRz5YKM6lPMf1tUbb6zA==

80
test/broker/22-http-api-auth.py Executable file
View file

@ -0,0 +1,80 @@
#!/usr/bin/env python3
from mosq_test_helper import *
import base64
import http.client
import json
import re
def write_config(filename, mqtt_port, http_port):
with open(filename, 'w') as f:
f.write(f"listener {mqtt_port}\n")
f.write(f"listener {http_port}\n")
f.write("protocol http_api\n")
f.write(f"plugin {mosq_test.get_build_root()}/plugins/password-file/mosquitto_password_file.so\n")
f.write("plugin_opt_password_file %s/%s\n" % (Path(__file__).resolve().parent, filename.replace('.conf', '.pwfile')))
mqtt_port, http_port = mosq_test.get_port(2)
conf_file = os.path.basename(__file__).replace('.py', '.conf')
write_config(conf_file, mqtt_port, http_port)
broker = mosq_test.start_broker(filename=os.path.basename(__file__), use_conf=True, port=mqtt_port)
rc = 1
try:
http_conn = http.client.HTTPConnection(f"localhost:{http_port}")
# No auth
http_conn.request("GET", "/api/v1/version")
response = http_conn.getresponse()
if response.status != 401:
raise ValueError(f"Error: /api/v1/version {response.status}")
payload = response.read().decode('utf-8')
if payload != "Not authorised\n":
raise ValueError(f"Error: {payload}")
# Bad auth
credentials = "user:invalid"
encoded_credentials = base64.b64encode(credentials.encode()).decode()
headers = {
"Authorization": f"Basic {encoded_credentials}"
}
http_conn.request("GET", "/api/v1/version", headers=headers)
response = http_conn.getresponse()
if response.status != 401:
raise ValueError(f"Error: /api/v1/version {response.status}")
payload = response.read().decode('utf-8')
if payload != "Not authorised\n":
raise ValueError(f"Error: {payload}")
# Good auth
credentials = "user:password"
encoded_credentials = base64.b64encode(credentials.encode()).decode()
headers = {
"Authorization": f"Basic {encoded_credentials}"
}
http_conn.request("GET", "/api/v1/version", headers=headers)
response = http_conn.getresponse()
if response.status != 200:
raise ValueError(f"Error: /api/v1/version {response.status}")
rc = 0
except mosq_test.TestError:
pass
except Exception as e:
print(e)
finally:
os.remove(conf_file)
broker.terminate()
if mosq_test.wait_for_subprocess(broker):
print("broker not terminated")
if rc == 0: rc=1
(stdo, stde) = broker.communicate()
if rc != 0:
print(stde.decode('utf-8'))
rc = 1
exit(rc)

View file

@ -6,6 +6,7 @@ import json
def write_config(filename, mqtt_port, http_port):
with open(filename, 'w') as f:
f.write("allow_anonymous true\n")
f.write(f"listener {mqtt_port}\n")
f.write(f"listener {http_port} 127.0.0.1\n")
f.write("protocol http_api\n")
@ -31,40 +32,40 @@ try:
http_conn.request("POST", "/post")
response = http_conn.getresponse()
if response.status != 405:
raise ValueError(f"/post {response.status}")
raise ValueError(f"Error: /post {response.status}")
# Bad request
http_conn.request("PUT", "/put")
response = http_conn.getresponse()
if response.status != 405:
raise ValueError(f"/put {response.status}")
raise ValueError(f"Error: /put {response.status}")
# Missing file
http_conn.request("GET", "/missing")
response = http_conn.getresponse()
if response.status != 404:
raise ValueError(f"/api/missing {response.status}")
raise ValueError(f"Error: /api/missing {response.status}")
# File not in dir
http_conn.request("GET", "../../../../../../../../etc/passwd")
response = http_conn.getresponse()
if response.status != 404:
raise ValueError(f"../../../../../../../../etc/passwd {response.status}")
raise ValueError(f"Error: ../../../../../../../../etc/passwd {response.status}")
# Present file
http_conn.request("GET", "/index.html")
response = http_conn.getresponse()
if response.status != 200:
raise ValueError(f"/index.html {response.status}")
raise ValueError(f"Error: /index.html {response.status}")
# Root
http_conn.request("GET", "/")
response = http_conn.getresponse()
if response.status != 200:
raise ValueError(f"/ {response.status}")
raise ValueError(f"Error: / {response.status}")
payload = response.read().decode('utf-8')
if payload != "<html></html>":
raise ValueError(f"/ {payload}")
raise ValueError(f"Error: / {payload}")
rc = 0

View file

@ -7,6 +7,7 @@ import ssl
def write_config(filename, mqtt_port, http_port):
with open(filename, 'w') as f:
f.write("allow_anonymous true\n")
f.write(f"listener {mqtt_port}\n")
f.write(f"listener 0 {mqtt_port}.sock\n")
f.write(f"certfile {ssl_dir}/server.crt\n")
@ -35,19 +36,19 @@ try:
http_conn.request("POST", "/api/badrequest")
response = http_conn.getresponse()
if response.status != 405:
raise ValueError(f"/api/badrequest {response.status}")
raise ValueError(f"Error: /api/badrequest {response.status}")
# Missing API
http_conn.request("GET", "/api/missing")
response = http_conn.getresponse()
if response.status != 404:
raise ValueError(f"/api/missing {response.status}")
raise ValueError(f"Error: /api/missing {response.status}")
# Listeners API
http_conn.request("GET", "/api/v1/listeners")
response = http_conn.getresponse()
if response.status != 200:
raise ValueError(f"/api/v1/listeners {response.status}")
raise ValueError(f"Error: /api/v1/listeners {response.status}")
payload = json.loads(response.read().decode('utf-8'))
expected_payload = {
"listeners": [{
@ -71,13 +72,13 @@ try:
}]
}
if payload != expected_payload:
raise ValueError(f"/api/v1/listeners payload {payload}")
raise ValueError(f"Error: /api/v1/listeners payload {payload}")
# systree API
http_conn.request("GET", "/api/v1/systree")
response = http_conn.getresponse()
if response.status != 200:
raise ValueError(f"/api/v1/systree {response.status}")
raise ValueError(f"Error: /api/v1/systree {response.status}")
payload = json.loads(response.read().decode('utf-8'))
for topic in [
@ -127,7 +128,7 @@ try:
'$SYS/broker/uptime': -1
}
if payload != expected_payload:
raise ValueError(f"/api/v1/systree payload\n{payload}\n{expected_payload}")
raise ValueError(f"Error: /api/v1/systree payload\n{payload}\n{expected_payload}")
rc = 0
except mosq_test.TestError:

View file

@ -352,6 +352,7 @@ endif
22:
./22-http-api-api.py
./22-http-api-auth.py
./22-http-api-file.py
./22-http-api-tls.py

View file

@ -294,7 +294,8 @@ tests = [
(1, './21-proxy-v2-ssl-require-tls-failure.py'),
(1, './21-proxy-v2-ssl-require-tls-success.py'),
(2, './22-http-api-api.py'),
(3, './22-http-api-api.py'),
(2, './22-http-api-auth.py'),
(2, './22-http-api-file.py'),
(2, './22-http-api-tls.py'),