diff --git a/CMakeLists.txt b/CMakeLists.txt index 5c339ba..74ea801 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.5) +cmake_minimum_required(VERSION 3.16) # https://github.com/Sarcasm/cmake-superbuild/ option (USE_SUPERBUILD "Whether or not a superbuild should be invoked" ON) @@ -14,7 +14,15 @@ endif() find_package(jwt-cpp) find_package(OpenSSL) -add_library(jwp-plugin SHARED src/jwp-plugin.cpp) +set(CMAKE_CXX_STANDARD 20) + +add_library(jwp-plugin SHARED src/jwp-plugin.cpp src/AuthList.cpp src/Authorizer.cpp) +target_include_directories(jwp-plugin PUBLIC + $ + $ + ${jwt-cpp_INCLUDE_DIR} +) +target_link_libraries(jwp-plugin OpenSSL::Crypto) add_executable(jwt-example src/jwt-example.cpp) target_include_directories(jwt-example PRIVATE ${jwt-cpp_INCLUDE_DIR}) diff --git a/include/jwp-plugin/AuthList.hpp b/include/jwp-plugin/AuthList.hpp new file mode 100644 index 0000000..f9460f2 --- /dev/null +++ b/include/jwp-plugin/AuthList.hpp @@ -0,0 +1,16 @@ +#pragma once +#include +#include + +class AuthList +{ +public: + AuthList(); + + void add(const std::string& username); + void remove(const std::string& username); + bool confirm(const std::string& username); + +private: + std::forward_list _allowedUsernames; +}; diff --git a/include/jwp-plugin/Authorizer.hpp b/include/jwp-plugin/Authorizer.hpp new file mode 100644 index 0000000..2a93ba7 --- /dev/null +++ b/include/jwp-plugin/Authorizer.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include +#include + +class Authorizer +{ +public: + Authorizer(const std::string& pub_key, const std::string& issuer); + static std::optional read_key(const std::string& key_file); + void add_unknown(const std::string& username); + bool is_unknown(const std::string& username); + bool add(const std::string& token, const std::string& username); + bool can_read(const std::string& username); + bool can_write(const std::string& username); + void logout(const std::string& username); +private: + AuthList _writeList; + AuthList _readList; + AuthList _unknownList; + + std::string _pub_key; + std::string _issuer; +}; diff --git a/priv.key b/priv.key new file mode 100644 index 0000000..a759af3 --- /dev/null +++ b/priv.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEID6d/A9UnVV5xXf9RAvXSNTk/a1QNUrzfvawzEAWDh3e +-----END PRIVATE KEY----- diff --git a/pub.key b/pub.key new file mode 100644 index 0000000..8234de3 --- /dev/null +++ b/pub.key @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEA+IYMWskcPLcC8IsUy6xsj3whqlzYwFWuAmVR7ue/LLw= +-----END PUBLIC KEY----- diff --git a/src/AuthList.cpp b/src/AuthList.cpp new file mode 100644 index 0000000..ee69d9d --- /dev/null +++ b/src/AuthList.cpp @@ -0,0 +1,37 @@ +#include +#include + +AuthList::AuthList(): + _allowedUsernames{} +{ +} + +void AuthList::add(const std::string& username) +{ + // Is the username already in the list? + // If not add it. + const auto found = std::find(std::begin(_allowedUsernames), std::end(_allowedUsernames), username); + if(found == std::end(_allowedUsernames)) + { + _allowedUsernames.emplace_front(username); + } +} + +void AuthList::remove(const std::string& username) +{ + const auto found = std::find(std::begin(_allowedUsernames), std::end(_allowedUsernames), username); + if(found != std::end(_allowedUsernames)) + { + _allowedUsernames.remove(username); + } +} + +bool AuthList::confirm(const std::string& username) +{ + const auto found = std::find(std::begin(_allowedUsernames), std::end(_allowedUsernames), username); + if(found != std::end(_allowedUsernames)) + { + return true; + } + return false; +} diff --git a/src/Authorizer.cpp b/src/Authorizer.cpp new file mode 100644 index 0000000..5935ba2 --- /dev/null +++ b/src/Authorizer.cpp @@ -0,0 +1,114 @@ +#include +#include +#include +#include +#include +#include +#include +#include + + +Authorizer::Authorizer(const std::string& pub_key, const std::string& issuer): + _pub_key{pub_key}, + _issuer{issuer} +{ +} + +std::optional Authorizer::read_key(const std::string& key_file) +{ + // Read key from file. + std::ifstream key_stream(key_file, std::ios::binary); + if(not key_stream) + { + return std::nullopt; + } + std::stringstream ss; + ss << key_stream.rdbuf(); + return ss.str(); +} + +void Authorizer::add_unknown(const std::string& username) +{ + _unknownList.add(username); +} + +bool Authorizer::is_unknown(const std::string& username) +{ + return _unknownList.confirm(username); +} + +bool Authorizer::add(const std::string& token, const std::string& username) +{ + const auto decoded_token = jwt::decode(token); + + // Is the token valid? + const auto verifier = jwt::verify() + .with_issuer(_issuer) + .allow_algorithm(jwt::algorithm::rs256(_pub_key)); + try + { + verifier.verify(decoded_token); + } + catch(jwt::error::token_verification_exception& exception) + { + std::cout << exception.what() << std::endl; + return false; + } + auto claims = decoded_token.get_payload_claims(); + + // Check username matches. + if(not claims.contains("upn")) + { + std::cout << "Missing upn." << std::endl; + return false; + } + if(claims["upn"].as_string() != username) + { + std::cout << "Wrong username." << std::endl; + return false; + } + + // Check for mqtt-write claim value. + if(not (claims.contains("mqtt-write") and claims.contains("mqtt-read"))) + { + std::cout << "Missing mqtt-write or mqtt-read." << std::endl; + return false; + } + + bool can_write = claims["mqtt-write"].as_bool(); + bool can_read = claims["mqtt-read"].as_bool(); + if(not (can_write or can_read)) + { + std::cout << "Can't write or can't read." << std::endl; + return false; + } + + if(can_write) + { + _writeList.add(username); + } + if(can_read) + { + _readList.add(username); + } + + return true; +} + +bool Authorizer::can_read(const std::string& username) +{ + return _readList.confirm(username); +} + +bool Authorizer::can_write(const std::string& username) +{ + return _writeList.confirm(username); +} + +void Authorizer::logout(const std::string& username) +{ + _writeList.remove(username); + _readList.remove(username); + _unknownList.remove(username); +} + diff --git a/src/jwp-plugin.cpp b/src/jwp-plugin.cpp index 62265b6..e2bac5a 100644 --- a/src/jwp-plugin.cpp +++ b/src/jwp-plugin.cpp @@ -4,6 +4,8 @@ extern "C" { #include "mosquitto_plugin.h" } #include +#include +#include // Stuff we're "exporting" for the dynamic loading. extern "C" { @@ -14,10 +16,12 @@ extern "C" { // My functions int jwp_auth_basic_auth_callback(int event, void *event_data, void *userdata); int jwp_acl_check_callback(int event, void *event_data, void *userdata); +int jwp_disconnect_callback(int event, void *event_data, void *userdata); // Mosquitto Globals static mosquitto_plugin_id_t *plugin_id = nullptr; +static std::unique_ptr authorizer = nullptr; int mosquitto_plugin_version(int supported_version_count, const int *supported_versions) @@ -36,8 +40,47 @@ int mosquitto_plugin_init(mosquitto_plugin_id_t *identifier, void **userdata, st { plugin_id = identifier; + + if(option_count != 2) + { + return MOSQ_ERR_INVAL; + } + + std::string public_key; + std::string issuer; + for(int index = 0; index < option_count; index++) + { + const auto key = std::string(options[index].key); + if(key == "public_key") + { + const auto key = Authorizer::read_key(std::string(options[index].value)); + if(key) + { + public_key = *key; + } + else + { + return MOSQ_ERR_INVAL; + } + } + else if(key == "issuer") + { + issuer = std::string(options[index].value); + } + } + + if(public_key.empty() or issuer.empty()) + { + mosquitto_log_printf(MOSQ_LOG_ERR, "public_key or issue not set."); + return MOSQ_ERR_INVAL; + } + + authorizer = std::make_unique(public_key, issuer); + + // Register the callbacks. mosquitto_callback_register(plugin_id, MOSQ_EVT_BASIC_AUTH, jwp_auth_basic_auth_callback, NULL, NULL); mosquitto_callback_register(plugin_id, MOSQ_EVT_ACL_CHECK, jwp_acl_check_callback, NULL, NULL); + mosquitto_callback_register(plugin_id, MOSQ_EVT_DISCONNECT, jwp_disconnect_callback, NULL, NULL); // May want MOSQ_EVT_RELOAD as well. return MOSQ_ERR_SUCCESS; @@ -49,6 +92,7 @@ int mosquitto_plugin_cleanup(void *userdata, struct mosquitto_opt *options, int { mosquitto_callback_unregister(plugin_id, MOSQ_EVT_BASIC_AUTH, jwp_auth_basic_auth_callback, NULL); mosquitto_callback_unregister(plugin_id, MOSQ_EVT_ACL_CHECK, jwp_acl_check_callback, NULL); + mosquitto_callback_unregister(plugin_id, MOSQ_EVT_DISCONNECT, jwp_disconnect_callback, NULL); } return MOSQ_ERR_SUCCESS; @@ -56,45 +100,69 @@ int mosquitto_plugin_cleanup(void *userdata, struct mosquitto_opt *options, int int jwp_auth_basic_auth_callback(int event, void *event_data, void *userdata) { + if(not authorizer) + { + // Not sure this is possible. + return MOSQ_ERR_AUTH; + } + struct mosquitto_evt_basic_auth *auth_data = static_cast(event_data); if(!auth_data->username or !auth_data->password) { - mosquitto_log_printf(MOSQ_LOG_ERR, "No username or password."); + authorizer->add_unknown(std::string(auth_data->username)); return MOSQ_ERR_PLUGIN_DEFER; } - mosquitto_log_printf(MOSQ_LOG_ERR, "Username: %s Password: %s", - auth_data->username, auth_data->password); + bool is_authed = authorizer->add(std::string(auth_data->password), std::string(auth_data->username)); - return MOSQ_ERR_SUCCESS; // MOSQ_ERR_AUTH; + if(is_authed) + { + return MOSQ_ERR_SUCCESS; + } + else + { + return MOSQ_ERR_AUTH; + } } int jwp_acl_check_callback(int event, void *event_data, void *userdata) { + if(not authorizer) + { + return MOSQ_ERR_ACL_DENIED; + } + struct mosquitto_evt_acl_check *acl_data = static_cast(event_data); - std::string event_name = "none"; + const std::string username = std::string(mosquitto_client_username(acl_data->client)); + + if(authorizer->is_unknown(username)) + { + return MOSQ_ERR_PLUGIN_DEFER; + } + switch(acl_data->access) { case MOSQ_ACL_SUBSCRIBE: - event_name = "subscribe"; - break; + return MOSQ_ERR_PLUGIN_DEFER; case MOSQ_ACL_UNSUBSCRIBE: - event_name = "unsubscribe"; - break; + return MOSQ_ERR_PLUGIN_DEFER; case MOSQ_ACL_WRITE: - event_name = "write"; - break; + return (authorizer->can_write(username) ? MOSQ_ERR_SUCCESS : MOSQ_ERR_ACL_DENIED); case MOSQ_ACL_READ: - event_name = "read"; - break; + return (authorizer->can_read(username) ? MOSQ_ERR_SUCCESS : MOSQ_ERR_ACL_DENIED); + default: + return MOSQ_ERR_ACL_DENIED; } +} - mosquitto_log_printf(MOSQ_LOG_ERR, "Topic: %s Event: %s", - acl_data->topic, event_name.c_str()); - - return MOSQ_ERR_SUCCESS; // MOSQ_ERR_ACL_DENIED; +int jwp_disconnect_callback(int event, void *event_data, void *userdata) +{ + struct mosquitto_evt_disconnect *disconnect_data = static_cast(event_data); + const std::string username = std::string(mosquitto_client_username(disconnect_data->client)); + authorizer->logout(username); + return MOSQ_ERR_SUCCESS; } diff --git a/test/key.pem b/test/key.pem new file mode 100644 index 0000000..f1cfb72 --- /dev/null +++ b/test/key.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlIKdtC04YbRMO0L4ID4YOWLr2AxYpQZYZ3g9BNpVm+IjDdn4H5HaYwYvOcbdjKyRdmwm+rsrIbWxCGYQCD5TtaCnq1IGwOueoprgCTDNSpTxsKQ+JuEUIhKc4rygVhX7JKIvVikfWimKVuNJBVhut/O+/N0AarasszAyinc3gjwtu2SyLBdZtIe3Krs1MIvYb786J2RhK3GfLzrXVzmKjA2/ThB9D6sS7dtZCe//37kYZzGUv5+xFkjkKwZr2aULMlmpUosFd/S2w3zsZkGRELLTvdRf5PVKeGpk40EneETJAHwiMjX6+jO/vlFQIj/Ye66ypVhCCI+NizE/hWbdawIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/test/mosquitto.conf b/test/mosquitto.conf index 3ae57db..c303ccb 100644 --- a/test/mosquitto.conf +++ b/test/mosquitto.conf @@ -9,6 +9,8 @@ log_type all allow_anonymous true auth_plugin /home/jimmy/Develop/mosquitto-plugin/build/libjwp-plugin.so +auth_opt_issuer https://auth.jpace121.net/realms/jpace121-main +auth_opt_public_key /home/jimmy/Develop/mosquitto-plugin/test/key.pem # ----------------------------------------------------------------- # External authentication and topic access plugin options diff --git a/test/python-client.py b/test/python-client.py new file mode 100644 index 0000000..128db6e --- /dev/null +++ b/test/python-client.py @@ -0,0 +1,39 @@ +import requests +import json +import time +import paho.mqtt.client +import time + + +# Get urls. +well_known = requests.get("https://auth.jpace121.net/realms/jpace121-main/.well-known/openid-configuration").json() +auth_url = well_known['device_authorization_endpoint'] +token_url = well_known['token_endpoint'] + +header = {"Content-Type":"application/x-www-form-urlencoded"} +data = {"client_id" : "jpace-mqtt"} + +# Request login. +r = requests.post(auth_url, headers=header, data=data) +r_json = r.json() +print("Go to: {} to login.".format(r_json['verification_uri_complete'])) + +# Wait for token. +data['grant_type'] = "urn:ietf:params:oauth:grant-type:device_code" +data['device_code'] = r_json['device_code'] +for index in range(1, 20): + r = requests.post(token_url, headers=header, data=data) + if 'access_token' in r.json(): + break + time.sleep(r_json['interval']) + +token = r.json()['access_token'] +print(token) + +client = paho.mqtt.client.Client(protocol=paho.mqtt.client.MQTTv5, + transport="tcp") +client.username_pw_set("jimmy", password=token) +client.connect("localhost", port=8081) + +print("Waiting on connection.") +time.sleep(20) diff --git a/test/python-ex.py b/test/python-ex.py new file mode 100644 index 0000000..affefd6 --- /dev/null +++ b/test/python-ex.py @@ -0,0 +1,33 @@ +import requests +import json +import time +import jwt + +# Get urls. +well_known = requests.get("https://auth.jpace121.net/realms/jpace121-main/.well-known/openid-configuration") +well_known = well_known.json() +auth_url = well_known['device_authorization_endpoint'] +token_url = well_known['token_endpoint'] + +header = {"Content-Type":"application/x-www-form-urlencoded"} +data = {"client_id" : "jpace-mqtt"} + +r = requests.post(auth_url, headers=header, data=data) +r_json = r.json() + +print("Go to: {} to login.".format(r_json['verification_uri_complete'])) + +data['grant_type'] = "urn:ietf:params:oauth:grant-type:device_code" +data['device_code'] = r_json['device_code'] + +for index in range(1, 20): + r = requests.post(token_url, headers=header, data=data) + if 'access_token' in r.json(): + break + time.sleep(r_json['interval']) + +public_key = b"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlIKdtC04YbRMO0L4ID4YOWLr2AxYpQZYZ3g9BNpVm+IjDdn4H5HaYwYvOcbdjKyRdmwm+rsrIbWxCGYQCD5TtaCnq1IGwOueoprgCTDNSpTxsKQ+JuEUIhKc4rygVhX7JKIvVikfWimKVuNJBVhut/O+/N0AarasszAyinc3gjwtu2SyLBdZtIe3Krs1MIvYb786J2RhK3GfLzrXVzmKjA2/ThB9D6sS7dtZCe//37kYZzGUv5+xFkjkKwZr2aULMlmpUosFd/S2w3zsZkGRELLTvdRf5PVKeGpk40EneETJAHwiMjX6+jO/vlFQIj/Ye66ypVhCCI+NizE/hWbdawIDAQAB\n-----END PUBLIC KEY-----" + +#decoded = jwt.decode(r.json()['access_token'], audience=None, options={"verify_signature": False, 'verify_aud': False}) +decoded = jwt.decode(r.json()['access_token'], public_key, algorithms=['RS256']) +print('Decoded: {}'.format(decoded))