From 06f731d58304887081748be1892c6f858a9b3f1c Mon Sep 17 00:00:00 2001 From: Christina Date: Tue, 26 Nov 2024 16:06:41 +0100 Subject: [PATCH 01/14] multiple auth backend wip --- src/lavinmq/amqp/connection_factory.cr | 22 +++++++--------------- src/lavinmq/auth/auth_handler.cr | 13 +++++++++++++ src/lavinmq/auth/basic_auth.cr | 22 ++++++++++++++++++++++ src/lavinmq/auth/oauth2.cr | 15 +++++++++++++++ 4 files changed, 57 insertions(+), 15 deletions(-) create mode 100644 src/lavinmq/auth/auth_handler.cr create mode 100644 src/lavinmq/auth/basic_auth.cr create mode 100644 src/lavinmq/auth/oauth2.cr diff --git a/src/lavinmq/amqp/connection_factory.cr b/src/lavinmq/amqp/connection_factory.cr index a9774da4ae..e7ae17cd52 100644 --- a/src/lavinmq/amqp/connection_factory.cr +++ b/src/lavinmq/amqp/connection_factory.cr @@ -1,7 +1,11 @@ require "../version" require "../logger" require "./client" +require "../user_store" +require "../vhost_store" require "../client/connection_factory" +require "../auth/basic_auth" +require "../auth/oauth2" module LavinMQ module AMQP @@ -102,15 +106,9 @@ module LavinMQ def authenticate(socket, remote_address, users, start_ok, log) username, password = credentials(start_ok) - user = users[username]? - return user if user && user.password && user.password.not_nil!.verify(password) && - guest_only_loopback?(remote_address, user) - - if user.nil? - log.warn { "User \"#{username}\" not found" } - else - log.warn { "Authentication failure for user \"#{username}\"" } - end + # TODO: only initialize handler for the configured auth methods + auth_handler = LavinMQ::BasicAuthHandler.new(LavinMQ::OAuth2Handler.new) + return auth_handler.authenticate(username, password, users, remote_address) props = start_ok.client_properties if capabilities = props["capabilities"]?.try &.as?(AMQP::Table) if capabilities["authentication_failure_close"]?.try &.as?(Bool) @@ -181,12 +179,6 @@ module LavinMQ end nil end - - private def guest_only_loopback?(remote_address, user) : Bool - return true unless user.name == "guest" - return true unless Config.instance.guest_only_loopback? - remote_address.loopback? - end end end end diff --git a/src/lavinmq/auth/auth_handler.cr b/src/lavinmq/auth/auth_handler.cr new file mode 100644 index 0000000000..58fa5c43a2 --- /dev/null +++ b/src/lavinmq/auth/auth_handler.cr @@ -0,0 +1,13 @@ +module LavinMQ + abstract class AuthHandler + + @successor : AuthHandler? + + def initialize(successor : AuthHandler? = nil) + @successor = successor + end + + def authenticate(username : String, password : String) + end + end +end diff --git a/src/lavinmq/auth/basic_auth.cr b/src/lavinmq/auth/basic_auth.cr new file mode 100644 index 0000000000..d1f7b9306f --- /dev/null +++ b/src/lavinmq/auth/basic_auth.cr @@ -0,0 +1,22 @@ +require "./auth_handler" +require "../server" + +module LavinMQ + class BasicAuthHandler < LavinMQ::AuthHandler + + def authenticate(username : String, password : String, users : UserStore, remote_address : Socket::Address) + user = users[username] + # TODO: do not do authentication check if the user is not in the userstore, instead pass directly to the next handler + return user if user && user.password && user.password.not_nil!.verify(password) && + guest_only_loopback?(remote_address, user) + puts "Basic authentication failed" + @successor ? @successor.try &.authenticate(username, password) : nil + end + + private def guest_only_loopback?(remote_address, user) : Bool + return true unless user.name == "guest" + return true unless Config.instance.guest_only_loopback? + remote_address.loopback? + end + end +end diff --git a/src/lavinmq/auth/oauth2.cr b/src/lavinmq/auth/oauth2.cr new file mode 100644 index 0000000000..09d486052f --- /dev/null +++ b/src/lavinmq/auth/oauth2.cr @@ -0,0 +1,15 @@ +module LavinMQ + class OAuth2Handler < LavinMQ::AuthHandler + def authenticate(username : String, password : String) + + # TODO: implement the OAuth2 authentication logic and permissions parser here + if password.starts_with?("oauth") + puts "OAuth2 authentication successful" + return nil + else + puts "OAuth2 authentication failed" + return @successor ? @successor.try &.authenticate(username, password) : nil + end + end + end +end From b04927083d540c2761532b8900d4488de936eff8 Mon Sep 17 00:00:00 2001 From: Christina Date: Tue, 26 Nov 2024 16:35:32 +0100 Subject: [PATCH 02/14] format --- src/lavinmq/auth/auth_handler.cr | 1 - src/lavinmq/auth/basic_auth.cr | 9 ++++----- src/lavinmq/auth/oauth2.cr | 1 - 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/lavinmq/auth/auth_handler.cr b/src/lavinmq/auth/auth_handler.cr index 58fa5c43a2..6cf82f2e3d 100644 --- a/src/lavinmq/auth/auth_handler.cr +++ b/src/lavinmq/auth/auth_handler.cr @@ -1,6 +1,5 @@ module LavinMQ abstract class AuthHandler - @successor : AuthHandler? def initialize(successor : AuthHandler? = nil) diff --git a/src/lavinmq/auth/basic_auth.cr b/src/lavinmq/auth/basic_auth.cr index d1f7b9306f..f5d193c997 100644 --- a/src/lavinmq/auth/basic_auth.cr +++ b/src/lavinmq/auth/basic_auth.cr @@ -3,20 +3,19 @@ require "../server" module LavinMQ class BasicAuthHandler < LavinMQ::AuthHandler - def authenticate(username : String, password : String, users : UserStore, remote_address : Socket::Address) user = users[username] # TODO: do not do authentication check if the user is not in the userstore, instead pass directly to the next handler return user if user && user.password && user.password.not_nil!.verify(password) && - guest_only_loopback?(remote_address, user) + guest_only_loopback?(remote_address, user) puts "Basic authentication failed" @successor ? @successor.try &.authenticate(username, password) : nil end private def guest_only_loopback?(remote_address, user) : Bool - return true unless user.name == "guest" - return true unless Config.instance.guest_only_loopback? - remote_address.loopback? + return true unless user.name == "guest" + return true unless Config.instance.guest_only_loopback? + remote_address.loopback? end end end diff --git a/src/lavinmq/auth/oauth2.cr b/src/lavinmq/auth/oauth2.cr index 09d486052f..85da614710 100644 --- a/src/lavinmq/auth/oauth2.cr +++ b/src/lavinmq/auth/oauth2.cr @@ -1,7 +1,6 @@ module LavinMQ class OAuth2Handler < LavinMQ::AuthHandler def authenticate(username : String, password : String) - # TODO: implement the OAuth2 authentication logic and permissions parser here if password.starts_with?("oauth") puts "OAuth2 authentication successful" From da20f1fb2fd851febc9d9c945ecd3412caf8ec76 Mon Sep 17 00:00:00 2001 From: Christina Date: Tue, 26 Nov 2024 16:39:12 +0100 Subject: [PATCH 03/14] fixup --- src/lavinmq/amqp/connection_factory.cr | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lavinmq/amqp/connection_factory.cr b/src/lavinmq/amqp/connection_factory.cr index e7ae17cd52..2d7bc219f6 100644 --- a/src/lavinmq/amqp/connection_factory.cr +++ b/src/lavinmq/amqp/connection_factory.cr @@ -1,8 +1,6 @@ require "../version" require "../logger" require "./client" -require "../user_store" -require "../vhost_store" require "../client/connection_factory" require "../auth/basic_auth" require "../auth/oauth2" From d466199a8f54765f73a20357e7d82685d6b954a4 Mon Sep 17 00:00:00 2001 From: "Jade D. Kharats" Date: Mon, 25 Nov 2024 22:34:42 +0100 Subject: [PATCH 04/14] add auth chain --- spec/auth_spec.cr | 62 +++++++++++++++++++ src/lavinmq/auth/auth_chain.cr | 32 ++++++++++ src/lavinmq/auth/services/auth_service.cr | 18 ++++++ src/lavinmq/auth/services/http_service.cr | 28 +++++++++ .../auth/services/local_auth_service.cr | 20 ++++++ src/lavinmq/server.cr | 2 + 6 files changed, 162 insertions(+) create mode 100644 spec/auth_spec.cr create mode 100644 src/lavinmq/auth/auth_chain.cr create mode 100644 src/lavinmq/auth/services/auth_service.cr create mode 100644 src/lavinmq/auth/services/http_service.cr create mode 100644 src/lavinmq/auth/services/local_auth_service.cr diff --git a/spec/auth_spec.cr b/spec/auth_spec.cr new file mode 100644 index 0000000000..25efe4334a --- /dev/null +++ b/spec/auth_spec.cr @@ -0,0 +1,62 @@ +require "./spec_helper" + +class MockAuthService < LavinMQ::AuthenticationService + property? should_succeed : Bool + property last_username : String? + property last_password : String? + + def initialize(@should_succeed = false) + end + + def authorize?(username : String, password : String) + @last_username = username + @last_password = password + + if @should_succeed + "allow" + else + try_next(username, password) + end + end +end + +describe LavinMQ::AuthenticationChain do + describe "#authorize?" do + it "returns nil when no services are configured" do + chain = LavinMQ::AuthenticationChain.new + chain.authorize?("user", "pass").should be_nil + end + + it "tries services in order until success" do + # Arrange + chain = LavinMQ::AuthenticationChain.new + service1 = MockAuthService.new(should_succeed: false) + service2 = MockAuthService.new(should_succeed: true) + service3 = MockAuthService.new(should_succeed: true) + + chain.add_service(service1) + chain.add_service(service2) + chain.add_service(service3) + + # Act + user = chain.authorize?("test_user", "test_pass") + + # Assert + user.should_not be_nil + service1.last_username.should eq("test_user") + service2.last_username.should eq("test_user") + service3.last_username.should be_nil # Ne devrait pas être appelé + end + + it "returns nil if all services fail" do + chain = LavinMQ::AuthenticationChain.new + service1 = MockAuthService.new(should_succeed: false) + service2 = MockAuthService.new(should_succeed: false) + + chain.add_service(service1) + chain.add_service(service2) + + chain.authorize?("user", "pass").should be_nil + end + end +end diff --git a/src/lavinmq/auth/auth_chain.cr b/src/lavinmq/auth/auth_chain.cr new file mode 100644 index 0000000000..db5d4ad7c0 --- /dev/null +++ b/src/lavinmq/auth/auth_chain.cr @@ -0,0 +1,32 @@ +require "./services/auth_service" +require "./services/local_auth_service" +require "./services/http_service" + +module LavinMQ + class AuthenticationChain + @first_service : AuthenticationService? + + def initialize + @first_service = nil + end + + def add_service(service : AuthenticationService) + if first = @first_service + current = first + while next_service = current.next_service + current = next_service + end + current.then(service) + else + @first_service = service + end + self + end + + def authorize?(username : String, password : String) + if service = @first_service + service.authorize?(username, password) + end + end + end +end diff --git a/src/lavinmq/auth/services/auth_service.cr b/src/lavinmq/auth/services/auth_service.cr new file mode 100644 index 0000000000..802da72731 --- /dev/null +++ b/src/lavinmq/auth/services/auth_service.cr @@ -0,0 +1,18 @@ +module LavinMQ + abstract class AuthenticationService + property next_service : AuthenticationService? + + abstract def authorize?(username : String, password : String) + + def then(service : AuthenticationService) : AuthenticationService + @next_service = service + service + end + + protected def try_next(username : String, password : String) + if next_service = @next_service + next_service.authorize?(username, password) + end + end + end +end diff --git a/src/lavinmq/auth/services/http_service.cr b/src/lavinmq/auth/services/http_service.cr new file mode 100644 index 0000000000..a1e9ad7323 --- /dev/null +++ b/src/lavinmq/auth/services/http_service.cr @@ -0,0 +1,28 @@ +require "http/client" +require "json" +require "./auth_service" + +module LavinMQ + class HttpAuthService < AuthenticationService + def initialize(@method : String, @user_path : String, @whost_path : String, @resource_path : String, @topic_path : String) + end + + def authorize?(username : String, password : String) + + payload = { + "username" => username, + "password" => password + }.to_json + + success = ::HTTP::Client.post(@user_path, + headers: ::HTTP::Headers{"Content-Type" => "application/json"}, + body: payload).success? + + if success + "allow" + else + try_next(username, password) + end + end + end +end diff --git a/src/lavinmq/auth/services/local_auth_service.cr b/src/lavinmq/auth/services/local_auth_service.cr new file mode 100644 index 0000000000..4fa5c910b7 --- /dev/null +++ b/src/lavinmq/auth/services/local_auth_service.cr @@ -0,0 +1,20 @@ +require "./auth_service" + +module LavinMQ + class LocalAuthService < AuthenticationService + def initialize(@users_store : UserStore) + end + + def authorize?(username : String, password : String) + if user = @users_store[username]? + if user.password && user.password.not_nil!.verify(password) + "allow" + else + try_next(username, password) + end + else + try_next(username, password) + end + end + end +end diff --git a/src/lavinmq/server.cr b/src/lavinmq/server.cr index 4d9e39cfb4..ae6d1ef019 100644 --- a/src/lavinmq/server.cr +++ b/src/lavinmq/server.cr @@ -10,6 +10,8 @@ require "./exchange" require "./amqp/queue" require "./parameter" require "./config" +require "./cache" +require "./auth/auth_chain" require "./connection_info" require "./proxy_protocol" require "./client/client" From e233c71caae632929880be16d5eb9051ff0d73d8 Mon Sep 17 00:00:00 2001 From: Christina Date: Wed, 27 Nov 2024 11:03:03 +0100 Subject: [PATCH 05/14] adapt http_auth --- spec/auth_spec.cr | 62 ------------------- src/lavinmq/auth/auth_chain.cr | 32 ---------- .../http_service.cr => http_auth.cr} | 11 +--- src/lavinmq/auth/services/auth_service.cr | 18 ------ .../auth/services/local_auth_service.cr | 20 ------ src/lavinmq/server.cr | 2 - 6 files changed, 3 insertions(+), 142 deletions(-) delete mode 100644 spec/auth_spec.cr delete mode 100644 src/lavinmq/auth/auth_chain.cr rename src/lavinmq/auth/{services/http_service.cr => http_auth.cr} (62%) delete mode 100644 src/lavinmq/auth/services/auth_service.cr delete mode 100644 src/lavinmq/auth/services/local_auth_service.cr diff --git a/spec/auth_spec.cr b/spec/auth_spec.cr deleted file mode 100644 index 25efe4334a..0000000000 --- a/spec/auth_spec.cr +++ /dev/null @@ -1,62 +0,0 @@ -require "./spec_helper" - -class MockAuthService < LavinMQ::AuthenticationService - property? should_succeed : Bool - property last_username : String? - property last_password : String? - - def initialize(@should_succeed = false) - end - - def authorize?(username : String, password : String) - @last_username = username - @last_password = password - - if @should_succeed - "allow" - else - try_next(username, password) - end - end -end - -describe LavinMQ::AuthenticationChain do - describe "#authorize?" do - it "returns nil when no services are configured" do - chain = LavinMQ::AuthenticationChain.new - chain.authorize?("user", "pass").should be_nil - end - - it "tries services in order until success" do - # Arrange - chain = LavinMQ::AuthenticationChain.new - service1 = MockAuthService.new(should_succeed: false) - service2 = MockAuthService.new(should_succeed: true) - service3 = MockAuthService.new(should_succeed: true) - - chain.add_service(service1) - chain.add_service(service2) - chain.add_service(service3) - - # Act - user = chain.authorize?("test_user", "test_pass") - - # Assert - user.should_not be_nil - service1.last_username.should eq("test_user") - service2.last_username.should eq("test_user") - service3.last_username.should be_nil # Ne devrait pas être appelé - end - - it "returns nil if all services fail" do - chain = LavinMQ::AuthenticationChain.new - service1 = MockAuthService.new(should_succeed: false) - service2 = MockAuthService.new(should_succeed: false) - - chain.add_service(service1) - chain.add_service(service2) - - chain.authorize?("user", "pass").should be_nil - end - end -end diff --git a/src/lavinmq/auth/auth_chain.cr b/src/lavinmq/auth/auth_chain.cr deleted file mode 100644 index db5d4ad7c0..0000000000 --- a/src/lavinmq/auth/auth_chain.cr +++ /dev/null @@ -1,32 +0,0 @@ -require "./services/auth_service" -require "./services/local_auth_service" -require "./services/http_service" - -module LavinMQ - class AuthenticationChain - @first_service : AuthenticationService? - - def initialize - @first_service = nil - end - - def add_service(service : AuthenticationService) - if first = @first_service - current = first - while next_service = current.next_service - current = next_service - end - current.then(service) - else - @first_service = service - end - self - end - - def authorize?(username : String, password : String) - if service = @first_service - service.authorize?(username, password) - end - end - end -end diff --git a/src/lavinmq/auth/services/http_service.cr b/src/lavinmq/auth/http_auth.cr similarity index 62% rename from src/lavinmq/auth/services/http_service.cr rename to src/lavinmq/auth/http_auth.cr index a1e9ad7323..848f763e91 100644 --- a/src/lavinmq/auth/services/http_service.cr +++ b/src/lavinmq/auth/http_auth.cr @@ -3,21 +3,16 @@ require "json" require "./auth_service" module LavinMQ - class HttpAuthService < AuthenticationService - def initialize(@method : String, @user_path : String, @whost_path : String, @resource_path : String, @topic_path : String) - end - - def authorize?(username : String, password : String) - + class HttpAuthHandler < AuthHandler + def authenticate(username : String, password : String) payload = { "username" => username, "password" => password }.to_json success = ::HTTP::Client.post(@user_path, - headers: ::HTTP::Headers{"Content-Type" => "application/json"}, + headers: ::HTTP::Headers{"Content-Type" => "application/json"}, body: payload).success? - if success "allow" else diff --git a/src/lavinmq/auth/services/auth_service.cr b/src/lavinmq/auth/services/auth_service.cr deleted file mode 100644 index 802da72731..0000000000 --- a/src/lavinmq/auth/services/auth_service.cr +++ /dev/null @@ -1,18 +0,0 @@ -module LavinMQ - abstract class AuthenticationService - property next_service : AuthenticationService? - - abstract def authorize?(username : String, password : String) - - def then(service : AuthenticationService) : AuthenticationService - @next_service = service - service - end - - protected def try_next(username : String, password : String) - if next_service = @next_service - next_service.authorize?(username, password) - end - end - end -end diff --git a/src/lavinmq/auth/services/local_auth_service.cr b/src/lavinmq/auth/services/local_auth_service.cr deleted file mode 100644 index 4fa5c910b7..0000000000 --- a/src/lavinmq/auth/services/local_auth_service.cr +++ /dev/null @@ -1,20 +0,0 @@ -require "./auth_service" - -module LavinMQ - class LocalAuthService < AuthenticationService - def initialize(@users_store : UserStore) - end - - def authorize?(username : String, password : String) - if user = @users_store[username]? - if user.password && user.password.not_nil!.verify(password) - "allow" - else - try_next(username, password) - end - else - try_next(username, password) - end - end - end -end diff --git a/src/lavinmq/server.cr b/src/lavinmq/server.cr index ae6d1ef019..4d9e39cfb4 100644 --- a/src/lavinmq/server.cr +++ b/src/lavinmq/server.cr @@ -10,8 +10,6 @@ require "./exchange" require "./amqp/queue" require "./parameter" require "./config" -require "./cache" -require "./auth/auth_chain" require "./connection_info" require "./proxy_protocol" require "./client/client" From 606988d12e8ee97b1787d8a604edb3e2a193d7b6 Mon Sep 17 00:00:00 2001 From: "Jade D. Kharats" Date: Mon, 25 Nov 2024 21:07:06 +0100 Subject: [PATCH 06/14] add generic cache --- spec/cache_spec.cr | 36 +++++++++++++++++++++++ src/lavinmq/cache.cr | 66 +++++++++++++++++++++++++++++++++++++++++++ src/lavinmq/server.cr | 1 + 3 files changed, 103 insertions(+) create mode 100644 spec/cache_spec.cr create mode 100644 src/lavinmq/cache.cr diff --git a/spec/cache_spec.cr b/spec/cache_spec.cr new file mode 100644 index 0000000000..273a73a2ab --- /dev/null +++ b/spec/cache_spec.cr @@ -0,0 +1,36 @@ +require "./spec_helper" + +describe LavinMQ::Cache do + cache = LavinMQ::Cache(String, String).new(1.seconds) + + it "set key" do + cache.set("key1", "allow").should eq "allow" + end + + it "get key" do + cache.set("keyget", "deny") + cache.get?("keyget").should eq "deny" + end + + it "invalid cache after 10 second" do + cache.set("keyinvalid", "expired") + sleep(2.seconds) + cache.get?("keyinvalid").should be_nil + end + + it "delete key" do + cache.set("keydelete", "deleted") + cache.delete("keydelete") + cache.get?("keydelete").should be_nil + end + + it "cleanup expired entry" do + cache.set("clean1", "expired1") + cache.set("clean2", "expired2") + cache.set("clean3", "valid", 10.seconds) + sleep(2.seconds) + cache.get?("clean1").should be_nil + cache.get?("clean2").should be_nil + cache.get?("clean3").should eq "valid" + end +end diff --git a/src/lavinmq/cache.cr b/src/lavinmq/cache.cr new file mode 100644 index 0000000000..bab53447cf --- /dev/null +++ b/src/lavinmq/cache.cr @@ -0,0 +1,66 @@ +module LavinMQ + class CacheEntry(T) + getter value : T + getter expires_at : Time + + def initialize(@value : T, ttl : Time::Span) + @expires_at = Time.utc + ttl + end + + def expired? : Bool + Time.utc > @expires_at + end + end + + class Cache(K, V) + def initialize(@default_ttl : Time::Span = 1.hour) + @mutex = Mutex.new + @data = Hash(K, CacheEntry(V)).new + end + + def set(key : K, value : V, ttl : Time::Span = @default_ttl) : V + @mutex.synchronize do + @data[key] = CacheEntry.new(value, ttl) + value + end + end + + def get?(key : K) : V? + @mutex.synchronize do + entry = @data[key]? + return nil unless entry + + if entry.expired? + @data.delete(key) + nil + else + entry.value + end + end + end + + def delete(key : K) : Bool + @mutex.synchronize do + @data.delete(key) ? true : false + end + end + + def cleanup + @mutex.synchronize do + @data.reject! { |_, entry| entry.expired? } + end + end + + def clear + @mutex.synchronize do + @data.clear + end + end + + def size : Int32 + @mutex.synchronize do + @data.size + end + end + end +end diff --git a/src/lavinmq/server.cr b/src/lavinmq/server.cr index 4d9e39cfb4..a06df7a26d 100644 --- a/src/lavinmq/server.cr +++ b/src/lavinmq/server.cr @@ -10,6 +10,7 @@ require "./exchange" require "./amqp/queue" require "./parameter" require "./config" +require "./cache" require "./connection_info" require "./proxy_protocol" require "./client/client" From fea68a60c47adc10918712939dea95d658b6bd03 Mon Sep 17 00:00:00 2001 From: Christina Date: Wed, 27 Nov 2024 11:18:54 +0100 Subject: [PATCH 07/14] adapt cache and handlers --- src/lavinmq/amqp/connection_factory.cr | 4 ++-- src/lavinmq/{cache.cr => auth/auth_cache.cr} | 0 src/lavinmq/auth/{ => handlers}/basic_auth.cr | 4 ++-- src/lavinmq/auth/{ => handlers}/http_auth.cr | 0 src/lavinmq/auth/{ => handlers}/oauth2.cr | 2 ++ src/lavinmq/server.cr | 1 - 6 files changed, 6 insertions(+), 5 deletions(-) rename src/lavinmq/{cache.cr => auth/auth_cache.cr} (100%) rename src/lavinmq/auth/{ => handlers}/basic_auth.cr (94%) rename src/lavinmq/auth/{ => handlers}/http_auth.cr (100%) rename src/lavinmq/auth/{ => handlers}/oauth2.cr (94%) diff --git a/src/lavinmq/amqp/connection_factory.cr b/src/lavinmq/amqp/connection_factory.cr index 2d7bc219f6..256c6848ab 100644 --- a/src/lavinmq/amqp/connection_factory.cr +++ b/src/lavinmq/amqp/connection_factory.cr @@ -2,8 +2,8 @@ require "../version" require "../logger" require "./client" require "../client/connection_factory" -require "../auth/basic_auth" -require "../auth/oauth2" +require "../auth/handlers/basic_auth" +require "../auth/handlers/oauth2" module LavinMQ module AMQP diff --git a/src/lavinmq/cache.cr b/src/lavinmq/auth/auth_cache.cr similarity index 100% rename from src/lavinmq/cache.cr rename to src/lavinmq/auth/auth_cache.cr diff --git a/src/lavinmq/auth/basic_auth.cr b/src/lavinmq/auth/handlers/basic_auth.cr similarity index 94% rename from src/lavinmq/auth/basic_auth.cr rename to src/lavinmq/auth/handlers/basic_auth.cr index f5d193c997..e6cacad5aa 100644 --- a/src/lavinmq/auth/basic_auth.cr +++ b/src/lavinmq/auth/handlers/basic_auth.cr @@ -1,5 +1,5 @@ -require "./auth_handler" -require "../server" +require "../auth_handler" +require "../../server" module LavinMQ class BasicAuthHandler < LavinMQ::AuthHandler diff --git a/src/lavinmq/auth/http_auth.cr b/src/lavinmq/auth/handlers/http_auth.cr similarity index 100% rename from src/lavinmq/auth/http_auth.cr rename to src/lavinmq/auth/handlers/http_auth.cr diff --git a/src/lavinmq/auth/oauth2.cr b/src/lavinmq/auth/handlers/oauth2.cr similarity index 94% rename from src/lavinmq/auth/oauth2.cr rename to src/lavinmq/auth/handlers/oauth2.cr index 85da614710..d9d00e0570 100644 --- a/src/lavinmq/auth/oauth2.cr +++ b/src/lavinmq/auth/handlers/oauth2.cr @@ -1,3 +1,5 @@ +require "../auth_handler" + module LavinMQ class OAuth2Handler < LavinMQ::AuthHandler def authenticate(username : String, password : String) diff --git a/src/lavinmq/server.cr b/src/lavinmq/server.cr index a06df7a26d..4d9e39cfb4 100644 --- a/src/lavinmq/server.cr +++ b/src/lavinmq/server.cr @@ -10,7 +10,6 @@ require "./exchange" require "./amqp/queue" require "./parameter" require "./config" -require "./cache" require "./connection_info" require "./proxy_protocol" require "./client/client" From 354d8f09556402aac969e8369180c91daf65ed6b Mon Sep 17 00:00:00 2001 From: Christina Date: Tue, 14 Jan 2025 16:35:53 +0100 Subject: [PATCH 08/14] temp/wip auth chain --- src/lavinmq/amqp/connection_factory.cr | 12 +-- src/lavinmq/auth/auth_cache.cr | 112 ++++++++++++------------ src/lavinmq/auth/auth_chain.cr | 47 ++++++++++ src/lavinmq/auth/auth_handler.cr | 5 ++ src/lavinmq/auth/handlers/basic_auth.cr | 1 + src/lavinmq/auth/handlers/http_auth.cr | 11 +-- src/lavinmq/config.cr | 21 +++++ src/lavinmq/server.cr | 4 +- 8 files changed, 145 insertions(+), 68 deletions(-) create mode 100644 src/lavinmq/auth/auth_chain.cr diff --git a/src/lavinmq/amqp/connection_factory.cr b/src/lavinmq/amqp/connection_factory.cr index 256c6848ab..48cf8f9eaa 100644 --- a/src/lavinmq/amqp/connection_factory.cr +++ b/src/lavinmq/amqp/connection_factory.cr @@ -4,20 +4,21 @@ require "./client" require "../client/connection_factory" require "../auth/handlers/basic_auth" require "../auth/handlers/oauth2" +require "../auth/handlers/http_auth" module LavinMQ module AMQP class ConnectionFactory < LavinMQ::ConnectionFactory Log = LavinMQ::Log.for "amqp.connection_factory" - def start(socket, connection_info, vhosts, users) : Client? + def start(socket, connection_info, vhosts, users, auth_chain) : Client? remote_address = connection_info.src socket.read_timeout = 15.seconds metadata = ::Log::Metadata.build({address: remote_address.to_s}) logger = Logger.new(Log, metadata) if confirm_header(socket, logger) if start_ok = start(socket, logger) - if user = authenticate(socket, remote_address, users, start_ok, logger) + if user = authenticate(socket, remote_address, users, start_ok, logger, auth_chain) if tune_ok = tune(socket, logger) if vhost = open(socket, vhosts, user, logger) socket.read_timeout = heartbeat_timeout(tune_ok) @@ -49,7 +50,7 @@ module LavinMQ elsif proto != AMQP::PROTOCOL_START_0_9_1 && proto != AMQP::PROTOCOL_START_0_9 socket.write AMQP::PROTOCOL_START_0_9_1.to_slice socket.flush - log.warn { "Unexpected protocol #{String.new(proto.to_unsafe, count).inspect}, closing socket" } + log.warn { "Unexpected protocol '#{String.new(proto.to_slice)}', closing socket" } false else true @@ -102,11 +103,10 @@ module LavinMQ end end - def authenticate(socket, remote_address, users, start_ok, log) + def authenticate(socket, remote_address, users, start_ok, log, auth_chain) username, password = credentials(start_ok) # TODO: only initialize handler for the configured auth methods - auth_handler = LavinMQ::BasicAuthHandler.new(LavinMQ::OAuth2Handler.new) - return auth_handler.authenticate(username, password, users, remote_address) + return auth_chain.authenticate(username, password) #(username, password, users, remote_address) props = start_ok.client_properties if capabilities = props["capabilities"]?.try &.as?(AMQP::Table) if capabilities["authentication_failure_close"]?.try &.as?(Bool) diff --git a/src/lavinmq/auth/auth_cache.cr b/src/lavinmq/auth/auth_cache.cr index bab53447cf..0a71690a7f 100644 --- a/src/lavinmq/auth/auth_cache.cr +++ b/src/lavinmq/auth/auth_cache.cr @@ -1,66 +1,66 @@ -module LavinMQ - class CacheEntry(T) - getter value : T - getter expires_at : Time +# module LavinMQ +# class CacheEntry(T) +# getter value : T +# getter expires_at : Time - def initialize(@value : T, ttl : Time::Span) - @expires_at = Time.utc + ttl - end +# def initialize(@value : T, ttl : Time::Span) +# @expires_at = Time.utc + ttl +# end - def expired? : Bool - Time.utc > @expires_at - end - end +# def expired? : Bool +# Time.utc > @expires_at +# end +# end - class Cache(K, V) - def initialize(@default_ttl : Time::Span = 1.hour) - @mutex = Mutex.new - @data = Hash(K, CacheEntry(V)).new - end +# class Cache(K, V) +# def initialize(@default_ttl : Time::Span = 1.hour) +# @mutex = Mutex.new +# @data = Hash(K, CacheEntry(V)).new +# end - def set(key : K, value : V, ttl : Time::Span = @default_ttl) : V - @mutex.synchronize do - @data[key] = CacheEntry.new(value, ttl) - value - end - end +# def set(key : K, value : V, ttl : Time::Span = @default_ttl) : V +# @mutex.synchronize do +# @data[key] = CacheEntry.new(value, ttl) +# value +# end +# end - def get?(key : K) : V? - @mutex.synchronize do - entry = @data[key]? - return nil unless entry +# def get?(key : K) : V? +# @mutex.synchronize do +# entry = @data[key]? +# return nil unless entry - if entry.expired? - @data.delete(key) - nil - else - entry.value - end - end - end +# if entry.expired? +# @data.delete(key) +# nil +# else +# entry.value +# end +# end +# end - def delete(key : K) : Bool - @mutex.synchronize do - @data.delete(key) ? true : false - end - end +# def delete(key : K) : Bool +# @mutex.synchronize do +# @data.delete(key) ? true : false +# end +# end - def cleanup - @mutex.synchronize do - @data.reject! { |_, entry| entry.expired? } - end - end +# def cleanup +# @mutex.synchronize do +# @data.reject! { |_, entry| entry.expired? } +# end +# end - def clear - @mutex.synchronize do - @data.clear - end - end +# def clear +# @mutex.synchronize do +# @data.clear +# end +# end - def size : Int32 - @mutex.synchronize do - @data.size - end - end - end -end +# def size : Int32 +# @mutex.synchronize do +# @data.size +# end +# end +# end +# end diff --git a/src/lavinmq/auth/auth_chain.cr b/src/lavinmq/auth/auth_chain.cr new file mode 100644 index 0000000000..c18136be87 --- /dev/null +++ b/src/lavinmq/auth/auth_chain.cr @@ -0,0 +1,47 @@ +module LavinMQ + class AuthChain + @first_handler : AuthHandler? + + def initialize + backends = Config.instance.auth_backends + if backends.empty? + add_handler(BasicAuthHandler.new) + else + # need to add initializers to these in order to only send in username & password in auth + backends.each do |backend| + case backend + when "oauth" + add_handler(OAuth2Handler.new) + when "http" + add_handler(HTTPAuthHandler.new) + when "basic" + add_handler(BasicAuthHandler.new) + else + raise "Unsupported authentication backend: #{backend}" + end + end + end + end + + def add_handler(handler : AuthHandler) + pp "Adding handler #{handler}" + if first = @first_handler + current = first + while next_handler = current.@successor + current = next_handler + end + current.then(handler) + else + @first_handler = handler + end + self + end + + def authenticate(username : String, password : String) + pp "hello #{username} #{password}" + pp @first_handler + + @first_handler.try &.authenticate(username, password) + end + end +end diff --git a/src/lavinmq/auth/auth_handler.cr b/src/lavinmq/auth/auth_handler.cr index 6cf82f2e3d..1ec037d2aa 100644 --- a/src/lavinmq/auth/auth_handler.cr +++ b/src/lavinmq/auth/auth_handler.cr @@ -3,9 +3,14 @@ module LavinMQ @successor : AuthHandler? def initialize(successor : AuthHandler? = nil) + #don't init this here, it is set in then and can be a property. @successor = successor end + def then(handler : AuthHandler) : AuthHandler + @successor = handler + end + def authenticate(username : String, password : String) end end diff --git a/src/lavinmq/auth/handlers/basic_auth.cr b/src/lavinmq/auth/handlers/basic_auth.cr index e6cacad5aa..bc2b635a7c 100644 --- a/src/lavinmq/auth/handlers/basic_auth.cr +++ b/src/lavinmq/auth/handlers/basic_auth.cr @@ -5,6 +5,7 @@ module LavinMQ class BasicAuthHandler < LavinMQ::AuthHandler def authenticate(username : String, password : String, users : UserStore, remote_address : Socket::Address) user = users[username] + pp "USER: #{user}" # TODO: do not do authentication check if the user is not in the userstore, instead pass directly to the next handler return user if user && user.password && user.password.not_nil!.verify(password) && guest_only_loopback?(remote_address, user) diff --git a/src/lavinmq/auth/handlers/http_auth.cr b/src/lavinmq/auth/handlers/http_auth.cr index 848f763e91..b3303e38d8 100644 --- a/src/lavinmq/auth/handlers/http_auth.cr +++ b/src/lavinmq/auth/handlers/http_auth.cr @@ -1,21 +1,22 @@ require "http/client" require "json" -require "./auth_service" +require "../auth_handler" module LavinMQ - class HttpAuthHandler < AuthHandler + class HTTPAuthHandler < AuthHandler def authenticate(username : String, password : String) payload = { "username" => username, "password" => password }.to_json - - success = ::HTTP::Client.post(@user_path, + user_path = Config.instance.http_auth_url || "" + success = ::HTTP::Client.post(user_path, headers: ::HTTP::Headers{"Content-Type" => "application/json"}, body: payload).success? if success - "allow" + puts "HTTP authentication successful" else + return @successor ? @successor.try &.authenticate(username, password, ) : nil try_next(username, password) end end diff --git a/src/lavinmq/config.cr b/src/lavinmq/config.cr index 0f2a347d63..41b995c8a3 100644 --- a/src/lavinmq/config.cr +++ b/src/lavinmq/config.cr @@ -62,6 +62,9 @@ module LavinMQ property default_consumer_prefetch = UInt16::MAX property yield_each_received_bytes = 131_072 # max number of bytes to read from a client connection without letting other tasks in the server do any work property yield_each_delivered_bytes = 1_048_576 # max number of bytes sent to a client without tending to other tasks in the server + property http_auth_url : String? = "" + property oauth_url : String? = "" + property auth_backends = [] of String @@instance : Config = self.new def self.instance : LavinMQ::Config @@ -145,6 +148,12 @@ module LavinMQ p.on("--default-consumer-prefetch=NUMBER", "Default consumer prefetch (default 65535)") do |v| @default_consumer_prefetch = v.to_u16 end + # p.on("--http_auth_url=URL", "URL to authenticate HTTP clients") do |v| + # @http_auth_url = v + # end + # p.on("--oauth_url=URL", "URL to authenticate OAuth2 clients") do |v| + # @oauth_url = v + # end p.invalid_option { |arg| abort "Invalid argument: #{arg}" } end parser.parse(ARGV.dup) # only parse args to get config_file @@ -295,6 +304,18 @@ module LavinMQ end end + private def parse_auth(settings) + settings.each do |config, v| + case config + when "http" then @http_auth_url = v + when "oauth" then @oauth_url = v + when "auth_backends" then @auth_backends = v.split(",") + else + STDERR.puts "WARNING: Unrecognized configuration 'auth/#{config}'" + end + end + end + private def parse_experimental(settings) settings.each do |config, v| case config diff --git a/src/lavinmq/server.cr b/src/lavinmq/server.cr index 4d9e39cfb4..a902d7c4e1 100644 --- a/src/lavinmq/server.cr +++ b/src/lavinmq/server.cr @@ -15,6 +15,7 @@ require "./proxy_protocol" require "./client/client" require "./client/connection_factory" require "./amqp/connection_factory" +require "./auth/auth_chain" require "./stats" module LavinMQ @@ -37,6 +38,7 @@ module LavinMQ @users = UserStore.new(@data_dir, @replicator) @vhosts = VHostStore.new(@data_dir, @users, @replicator) @parameters = ParameterStore(Parameter).new(@data_dir, "parameters.json", @replicator) + @auth_chain = LavinMQ::AuthChain.new @amqp_connection_factory = LavinMQ::AMQP::ConnectionFactory.new apply_parameter spawn stats_loop, name: "Server#stats_loop" @@ -245,7 +247,7 @@ module LavinMQ end def handle_connection(socket, connection_info) - client = @amqp_connection_factory.start(socket, connection_info, @vhosts, @users) + client = @amqp_connection_factory.start(socket, connection_info, @vhosts, @users, @auth_chain) ensure socket.close if client.nil? end From 2d6cfd9bc08516a6d9f8f11f8c47677f7bb40b08 Mon Sep 17 00:00:00 2001 From: Christina Date: Wed, 15 Jan 2025 10:48:00 +0100 Subject: [PATCH 09/14] refactoring auth chain --- src/lavinmq/amqp/connection_factory.cr | 2 +- src/lavinmq/auth/auth_chain.cr | 8 ++++---- src/lavinmq/auth/auth_handler.cr | 20 +++++++++++-------- src/lavinmq/auth/handlers/basic_auth.cr | 11 ++++++---- .../auth/handlers/{http_auth.cr => http.cr} | 5 +++++ src/lavinmq/auth/handlers/oauth2.cr | 5 +++++ src/lavinmq/server.cr | 2 +- 7 files changed, 35 insertions(+), 18 deletions(-) rename src/lavinmq/auth/handlers/{http_auth.cr => http.cr} (88%) diff --git a/src/lavinmq/amqp/connection_factory.cr b/src/lavinmq/amqp/connection_factory.cr index 48cf8f9eaa..d6abe9b1b0 100644 --- a/src/lavinmq/amqp/connection_factory.cr +++ b/src/lavinmq/amqp/connection_factory.cr @@ -4,7 +4,7 @@ require "./client" require "../client/connection_factory" require "../auth/handlers/basic_auth" require "../auth/handlers/oauth2" -require "../auth/handlers/http_auth" +require "../auth/handlers/http" module LavinMQ module AMQP diff --git a/src/lavinmq/auth/auth_chain.cr b/src/lavinmq/auth/auth_chain.cr index c18136be87..eb1b1f81e7 100644 --- a/src/lavinmq/auth/auth_chain.cr +++ b/src/lavinmq/auth/auth_chain.cr @@ -2,10 +2,10 @@ module LavinMQ class AuthChain @first_handler : AuthHandler? - def initialize + def initialize(users : UserStore) backends = Config.instance.auth_backends if backends.empty? - add_handler(BasicAuthHandler.new) + add_handler(BasicAuthHandler.new(users)) else # need to add initializers to these in order to only send in username & password in auth backends.each do |backend| @@ -15,7 +15,7 @@ module LavinMQ when "http" add_handler(HTTPAuthHandler.new) when "basic" - add_handler(BasicAuthHandler.new) + add_handler(BasicAuthHandler.new(users)) else raise "Unsupported authentication backend: #{backend}" end @@ -30,7 +30,7 @@ module LavinMQ while next_handler = current.@successor current = next_handler end - current.then(handler) + current.set_next(handler) else @first_handler = handler end diff --git a/src/lavinmq/auth/auth_handler.cr b/src/lavinmq/auth/auth_handler.cr index 1ec037d2aa..d63b629b17 100644 --- a/src/lavinmq/auth/auth_handler.cr +++ b/src/lavinmq/auth/auth_handler.cr @@ -1,17 +1,21 @@ module LavinMQ abstract class AuthHandler - @successor : AuthHandler? + property successor : AuthHandler? - def initialize(successor : AuthHandler? = nil) - #don't init this here, it is set in then and can be a property. - @successor = successor - end - def then(handler : AuthHandler) : AuthHandler - @successor = handler + # abstract def authenticate?(username : String, password : String) : Bool + + def set_next(service : AuthHandler) : AuthHandler + @successor = service + service end - def authenticate(username : String, password : String) + protected def try_next(username : String, password : String) + if next_service = @next_service + next_service.authenticate(username, password) + else + false + end end end end diff --git a/src/lavinmq/auth/handlers/basic_auth.cr b/src/lavinmq/auth/handlers/basic_auth.cr index bc2b635a7c..79597f931f 100644 --- a/src/lavinmq/auth/handlers/basic_auth.cr +++ b/src/lavinmq/auth/handlers/basic_auth.cr @@ -3,12 +3,15 @@ require "../../server" module LavinMQ class BasicAuthHandler < LavinMQ::AuthHandler - def authenticate(username : String, password : String, users : UserStore, remote_address : Socket::Address) - user = users[username] + + def initialize(@users : UserStore) + end + + def authenticate(username : String, password : String) + user = @users[username] pp "USER: #{user}" # TODO: do not do authentication check if the user is not in the userstore, instead pass directly to the next handler - return user if user && user.password && user.password.not_nil!.verify(password) && - guest_only_loopback?(remote_address, user) + return user if user && user.password && user.password.not_nil!.verify(password) puts "Basic authentication failed" @successor ? @successor.try &.authenticate(username, password) : nil end diff --git a/src/lavinmq/auth/handlers/http_auth.cr b/src/lavinmq/auth/handlers/http.cr similarity index 88% rename from src/lavinmq/auth/handlers/http_auth.cr rename to src/lavinmq/auth/handlers/http.cr index b3303e38d8..ebf6cc8b65 100644 --- a/src/lavinmq/auth/handlers/http_auth.cr +++ b/src/lavinmq/auth/handlers/http.cr @@ -4,6 +4,11 @@ require "../auth_handler" module LavinMQ class HTTPAuthHandler < AuthHandler + + def initialize(successor : AuthHandler? = nil) + @successor = successor + end + def authenticate(username : String, password : String) payload = { "username" => username, diff --git a/src/lavinmq/auth/handlers/oauth2.cr b/src/lavinmq/auth/handlers/oauth2.cr index d9d00e0570..9775ee123d 100644 --- a/src/lavinmq/auth/handlers/oauth2.cr +++ b/src/lavinmq/auth/handlers/oauth2.cr @@ -2,6 +2,11 @@ require "../auth_handler" module LavinMQ class OAuth2Handler < LavinMQ::AuthHandler + + def initialize(successor : AuthHandler? = nil) + @successor = successor + end + def authenticate(username : String, password : String) # TODO: implement the OAuth2 authentication logic and permissions parser here if password.starts_with?("oauth") diff --git a/src/lavinmq/server.cr b/src/lavinmq/server.cr index a902d7c4e1..675fce321e 100644 --- a/src/lavinmq/server.cr +++ b/src/lavinmq/server.cr @@ -38,7 +38,7 @@ module LavinMQ @users = UserStore.new(@data_dir, @replicator) @vhosts = VHostStore.new(@data_dir, @users, @replicator) @parameters = ParameterStore(Parameter).new(@data_dir, "parameters.json", @replicator) - @auth_chain = LavinMQ::AuthChain.new + @auth_chain = LavinMQ::AuthChain.new(@users) @amqp_connection_factory = LavinMQ::AMQP::ConnectionFactory.new apply_parameter spawn stats_loop, name: "Server#stats_loop" From 18484e344959e78ab5776215ea3a7256fc061b25 Mon Sep 17 00:00:00 2001 From: Christina Date: Wed, 15 Jan 2025 14:58:54 +0100 Subject: [PATCH 10/14] authhandler chaining works --- src/lavinmq/amqp/connection_factory.cr | 10 ++++++++-- src/lavinmq/auth/auth_chain.cr | 13 +++++-------- src/lavinmq/auth/auth_handler.cr | 13 ++++++------- src/lavinmq/auth/handlers/basic_auth.cr | 9 +-------- src/lavinmq/auth/handlers/http.cr | 21 +++++++-------------- src/lavinmq/auth/handlers/oauth2.cr | 7 +++---- src/lavinmq/config.cr | 2 +- 7 files changed, 31 insertions(+), 44 deletions(-) diff --git a/src/lavinmq/amqp/connection_factory.cr b/src/lavinmq/amqp/connection_factory.cr index d6abe9b1b0..b7300507ab 100644 --- a/src/lavinmq/amqp/connection_factory.cr +++ b/src/lavinmq/amqp/connection_factory.cr @@ -105,8 +105,8 @@ module LavinMQ def authenticate(socket, remote_address, users, start_ok, log, auth_chain) username, password = credentials(start_ok) - # TODO: only initialize handler for the configured auth methods - return auth_chain.authenticate(username, password) #(username, password, users, remote_address) + user = users[username]? + return user if user && auth_chain.authenticate(username, password) && guest_only_loopback?(remote_address, user) props = start_ok.client_properties if capabilities = props["capabilities"]?.try &.as?(AMQP::Table) if capabilities["authentication_failure_close"]?.try &.as?(Bool) @@ -177,6 +177,12 @@ module LavinMQ end nil end + + private def guest_only_loopback?(remote_address, user) : Bool + return true unless user.name == "guest" + return true unless Config.instance.guest_only_loopback? + remote_address.loopback? + end end end end diff --git a/src/lavinmq/auth/auth_chain.cr b/src/lavinmq/auth/auth_chain.cr index eb1b1f81e7..f1986a6e2e 100644 --- a/src/lavinmq/auth/auth_chain.cr +++ b/src/lavinmq/auth/auth_chain.cr @@ -7,13 +7,13 @@ module LavinMQ if backends.empty? add_handler(BasicAuthHandler.new(users)) else - # need to add initializers to these in order to only send in username & password in auth + # TODO: gather config for http and oauth and send into handlers backends.each do |backend| case backend when "oauth" - add_handler(OAuth2Handler.new) + add_handler(OAuth2Handler.new(users)) when "http" - add_handler(HTTPAuthHandler.new) + add_handler(HTTPAuthHandler.new(users)) when "basic" add_handler(BasicAuthHandler.new(users)) else @@ -24,13 +24,12 @@ module LavinMQ end def add_handler(handler : AuthHandler) - pp "Adding handler #{handler}" if first = @first_handler current = first while next_handler = current.@successor current = next_handler end - current.set_next(handler) + current.set_successor(handler) else @first_handler = handler end @@ -38,9 +37,7 @@ module LavinMQ end def authenticate(username : String, password : String) - pp "hello #{username} #{password}" - pp @first_handler - + # TODO: Cache the authorized users, and call authenticate from cache class @first_handler.try &.authenticate(username, password) end end diff --git a/src/lavinmq/auth/auth_handler.cr b/src/lavinmq/auth/auth_handler.cr index d63b629b17..df7adc9fcc 100644 --- a/src/lavinmq/auth/auth_handler.cr +++ b/src/lavinmq/auth/auth_handler.cr @@ -2,19 +2,18 @@ module LavinMQ abstract class AuthHandler property successor : AuthHandler? + abstract def authenticate(username : String, password : String) - # abstract def authenticate?(username : String, password : String) : Bool - - def set_next(service : AuthHandler) : AuthHandler + def set_successor(service : AuthHandler) : AuthHandler @successor = service service end - protected def try_next(username : String, password : String) - if next_service = @next_service - next_service.authenticate(username, password) + def try_next(username : String, password : String) + if successor = @successor + successor.authenticate(username, password) else - false + nil end end end diff --git a/src/lavinmq/auth/handlers/basic_auth.cr b/src/lavinmq/auth/handlers/basic_auth.cr index 79597f931f..63353feb4e 100644 --- a/src/lavinmq/auth/handlers/basic_auth.cr +++ b/src/lavinmq/auth/handlers/basic_auth.cr @@ -9,17 +9,10 @@ module LavinMQ def authenticate(username : String, password : String) user = @users[username] - pp "USER: #{user}" # TODO: do not do authentication check if the user is not in the userstore, instead pass directly to the next handler return user if user && user.password && user.password.not_nil!.verify(password) puts "Basic authentication failed" - @successor ? @successor.try &.authenticate(username, password) : nil - end - - private def guest_only_loopback?(remote_address, user) : Bool - return true unless user.name == "guest" - return true unless Config.instance.guest_only_loopback? - remote_address.loopback? + try_next(username, password) end end end diff --git a/src/lavinmq/auth/handlers/http.cr b/src/lavinmq/auth/handlers/http.cr index ebf6cc8b65..55a1de0480 100644 --- a/src/lavinmq/auth/handlers/http.cr +++ b/src/lavinmq/auth/handlers/http.cr @@ -5,24 +5,17 @@ require "../auth_handler" module LavinMQ class HTTPAuthHandler < AuthHandler - def initialize(successor : AuthHandler? = nil) - @successor = successor + def initialize(@users : UserStore) end def authenticate(username : String, password : String) - payload = { - "username" => username, - "password" => password - }.to_json - user_path = Config.instance.http_auth_url || "" - success = ::HTTP::Client.post(user_path, - headers: ::HTTP::Headers{"Content-Type" => "application/json"}, - body: payload).success? - if success - puts "HTTP authentication successful" + # TODO: implement the HTTP authentication logic and permissions parser here + if password.starts_with?("http") + puts "http authentication successful" + return @users[username] else - return @successor ? @successor.try &.authenticate(username, password, ) : nil - try_next(username, password) + puts "OAuth2 authentication failed" + return try_next(username, password) end end end diff --git a/src/lavinmq/auth/handlers/oauth2.cr b/src/lavinmq/auth/handlers/oauth2.cr index 9775ee123d..566e8c49ba 100644 --- a/src/lavinmq/auth/handlers/oauth2.cr +++ b/src/lavinmq/auth/handlers/oauth2.cr @@ -3,18 +3,17 @@ require "../auth_handler" module LavinMQ class OAuth2Handler < LavinMQ::AuthHandler - def initialize(successor : AuthHandler? = nil) - @successor = successor + def initialize(@users : UserStore) end def authenticate(username : String, password : String) # TODO: implement the OAuth2 authentication logic and permissions parser here if password.starts_with?("oauth") puts "OAuth2 authentication successful" - return nil + @users[username] else puts "OAuth2 authentication failed" - return @successor ? @successor.try &.authenticate(username, password) : nil + try_next(username, password) end end end diff --git a/src/lavinmq/config.cr b/src/lavinmq/config.cr index 41b995c8a3..69c2aaaafb 100644 --- a/src/lavinmq/config.cr +++ b/src/lavinmq/config.cr @@ -64,7 +64,7 @@ module LavinMQ property yield_each_delivered_bytes = 1_048_576 # max number of bytes sent to a client without tending to other tasks in the server property http_auth_url : String? = "" property oauth_url : String? = "" - property auth_backends = [] of String + property auth_backends = ["basic", "oauth", "http"] @@instance : Config = self.new def self.instance : LavinMQ::Config From d3bf5c1e4041488355911abff0e7105526283db6 Mon Sep 17 00:00:00 2001 From: Christina Date: Wed, 15 Jan 2025 15:01:21 +0100 Subject: [PATCH 11/14] typo --- src/lavinmq/auth/handlers/http.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lavinmq/auth/handlers/http.cr b/src/lavinmq/auth/handlers/http.cr index 55a1de0480..d0ee330d9d 100644 --- a/src/lavinmq/auth/handlers/http.cr +++ b/src/lavinmq/auth/handlers/http.cr @@ -11,10 +11,10 @@ module LavinMQ def authenticate(username : String, password : String) # TODO: implement the HTTP authentication logic and permissions parser here if password.starts_with?("http") - puts "http authentication successful" + puts "HTTP authentication successful" return @users[username] else - puts "OAuth2 authentication failed" + puts "HTTP authentication failed" return try_next(username, password) end end From 53cd6de3fad6ec4cab31708b5b8fc939800abfd1 Mon Sep 17 00:00:00 2001 From: Christina Date: Wed, 15 Jan 2025 15:02:30 +0100 Subject: [PATCH 12/14] format --- src/lavinmq/auth/handlers/basic_auth.cr | 1 - src/lavinmq/auth/handlers/http.cr | 1 - src/lavinmq/auth/handlers/oauth2.cr | 1 - src/lavinmq/config.cr | 4 ++-- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/lavinmq/auth/handlers/basic_auth.cr b/src/lavinmq/auth/handlers/basic_auth.cr index 63353feb4e..aec63dcb4c 100644 --- a/src/lavinmq/auth/handlers/basic_auth.cr +++ b/src/lavinmq/auth/handlers/basic_auth.cr @@ -3,7 +3,6 @@ require "../../server" module LavinMQ class BasicAuthHandler < LavinMQ::AuthHandler - def initialize(@users : UserStore) end diff --git a/src/lavinmq/auth/handlers/http.cr b/src/lavinmq/auth/handlers/http.cr index d0ee330d9d..fcafa03ad7 100644 --- a/src/lavinmq/auth/handlers/http.cr +++ b/src/lavinmq/auth/handlers/http.cr @@ -4,7 +4,6 @@ require "../auth_handler" module LavinMQ class HTTPAuthHandler < AuthHandler - def initialize(@users : UserStore) end diff --git a/src/lavinmq/auth/handlers/oauth2.cr b/src/lavinmq/auth/handlers/oauth2.cr index 566e8c49ba..737f717c26 100644 --- a/src/lavinmq/auth/handlers/oauth2.cr +++ b/src/lavinmq/auth/handlers/oauth2.cr @@ -2,7 +2,6 @@ require "../auth_handler" module LavinMQ class OAuth2Handler < LavinMQ::AuthHandler - def initialize(@users : UserStore) end diff --git a/src/lavinmq/config.cr b/src/lavinmq/config.cr index 69c2aaaafb..7623bad6a0 100644 --- a/src/lavinmq/config.cr +++ b/src/lavinmq/config.cr @@ -307,8 +307,8 @@ module LavinMQ private def parse_auth(settings) settings.each do |config, v| case config - when "http" then @http_auth_url = v - when "oauth" then @oauth_url = v + when "http" then @http_auth_url = v + when "oauth" then @oauth_url = v when "auth_backends" then @auth_backends = v.split(",") else STDERR.puts "WARNING: Unrecognized configuration 'auth/#{config}'" From 7621056f86ec67387a4a2427484567f773cc5449 Mon Sep 17 00:00:00 2001 From: Christina Date: Fri, 17 Jan 2025 13:07:06 +0100 Subject: [PATCH 13/14] playing around with jwt lib --- shard.lock | 12 ++++++++++ shard.yml | 2 ++ src/lavinmq/auth/auth_handler.cr | 2 ++ src/lavinmq/auth/handlers/basic_auth.cr | 3 +-- src/lavinmq/auth/handlers/http.cr | 4 ++-- src/lavinmq/auth/handlers/oauth2.cr | 29 ++++++++++++++++++++----- src/lavinmq/config.cr | 5 +++++ 7 files changed, 47 insertions(+), 10 deletions(-) diff --git a/shard.lock b/shard.lock index 182e0db6a9..5f5a121e91 100644 --- a/shard.lock +++ b/shard.lock @@ -12,10 +12,22 @@ shards: git: https://github.com/cloudamqp/amqp-client.cr.git version: 1.2.8 + bindata: + git: https://github.com/spider-gazelle/bindata.git + version: 2.1.0 + + jwt: + git: https://github.com/crystal-community/jwt.git + version: 1.6.1 + lz4: git: https://github.com/84codes/lz4.cr.git version: 1.0.0+git.commit.96d714f7593c66ca7425872fd26c7b1286806d3d + openssl_ext: + git: https://github.com/spider-gazelle/openssl_ext.git + version: 2.4.4 + systemd: git: https://github.com/84codes/systemd.cr.git version: 2.0.0 diff --git a/shard.yml b/shard.yml index 6e2eb48079..a0c00f52f7 100644 --- a/shard.yml +++ b/shard.yml @@ -32,6 +32,8 @@ dependencies: github: 84codes/systemd.cr lz4: github: 84codes/lz4.cr + jwt: + github: crystal-community/jwt development_dependencies: ameba: diff --git a/src/lavinmq/auth/auth_handler.cr b/src/lavinmq/auth/auth_handler.cr index df7adc9fcc..66a2d590d6 100644 --- a/src/lavinmq/auth/auth_handler.cr +++ b/src/lavinmq/auth/auth_handler.cr @@ -1,6 +1,8 @@ module LavinMQ abstract class AuthHandler + Log = LavinMQ::Log.for "auth.handler" property successor : AuthHandler? + @log = Logger.new(Log) abstract def authenticate(username : String, password : String) diff --git a/src/lavinmq/auth/handlers/basic_auth.cr b/src/lavinmq/auth/handlers/basic_auth.cr index aec63dcb4c..cd4cdecc6b 100644 --- a/src/lavinmq/auth/handlers/basic_auth.cr +++ b/src/lavinmq/auth/handlers/basic_auth.cr @@ -8,9 +8,8 @@ module LavinMQ def authenticate(username : String, password : String) user = @users[username] - # TODO: do not do authentication check if the user is not in the userstore, instead pass directly to the next handler return user if user && user.password && user.password.not_nil!.verify(password) - puts "Basic authentication failed" + @log.warn { "Basic authentication failed" } try_next(username, password) end end diff --git a/src/lavinmq/auth/handlers/http.cr b/src/lavinmq/auth/handlers/http.cr index fcafa03ad7..50ae2490c9 100644 --- a/src/lavinmq/auth/handlers/http.cr +++ b/src/lavinmq/auth/handlers/http.cr @@ -10,10 +10,10 @@ module LavinMQ def authenticate(username : String, password : String) # TODO: implement the HTTP authentication logic and permissions parser here if password.starts_with?("http") - puts "HTTP authentication successful" + @log.warn { "HTTP authentication successful" } return @users[username] else - puts "HTTP authentication failed" + @log.warn { "HTTP authentication failed" } return try_next(username, password) end end diff --git a/src/lavinmq/auth/handlers/oauth2.cr b/src/lavinmq/auth/handlers/oauth2.cr index 737f717c26..ce28fd9869 100644 --- a/src/lavinmq/auth/handlers/oauth2.cr +++ b/src/lavinmq/auth/handlers/oauth2.cr @@ -1,19 +1,36 @@ require "../auth_handler" +require "jwt" +require "../../config" module LavinMQ class OAuth2Handler < LavinMQ::AuthHandler def initialize(@users : UserStore) end + # Temporary for tests + @token : String = LavinMQ::Config.instance.token + @public_key : String = LavinMQ::Config.instance.public_key + def authenticate(username : String, password : String) - # TODO: implement the OAuth2 authentication logic and permissions parser here - if password.starts_with?("oauth") - puts "OAuth2 authentication successful" - @users[username] - else - puts "OAuth2 authentication failed" + begin + payload, header = JWT.decode(@token, key: @public_key, algorithm: JWT::Algorithm::RS256, verify: true, validate: true) + oauth_user + rescue ex : JWT::DecodeError + @log.warn { "OAuth2 authentication failed, could not decode token: #{ex}" } + try_next(username, password) + rescue ex : JWT::UnsupportedAlgorithmError + @log.warn { "OAuth2 authentication failed, unsupported algortihm: #{ex}" } + try_next(username, password) + rescue ex + @log.warn { "OAuth2 authentication failed: #{ex}" } try_next(username, password) end end + + def oauth_user + # TODO: Create a uset that will be deleted when it disconnects, but also cannot be authorised with basic auth. + # introduce the needed configs for validation, and parse the payload to get the user details + user = @users.create("oauth_user", "password") + end end end diff --git a/src/lavinmq/config.cr b/src/lavinmq/config.cr index 7623bad6a0..b69ddd26fd 100644 --- a/src/lavinmq/config.cr +++ b/src/lavinmq/config.cr @@ -65,6 +65,11 @@ module LavinMQ property http_auth_url : String? = "" property oauth_url : String? = "" property auth_backends = ["basic", "oauth", "http"] + #this will be fetched from an jwks endpoint + property public_key = "" + # this will come from the connect packet + property token = "" + @@instance : Config = self.new def self.instance : LavinMQ::Config From 42eaf190b333494e0845166f8f24a0bf2cf63381 Mon Sep 17 00:00:00 2001 From: Christina Date: Tue, 21 Jan 2025 13:08:47 +0100 Subject: [PATCH 14/14] temp --- spec/auth_sepc.cr | 6 +++ spec/cache_spec.cr | 60 ++++++++++++++--------------- src/lavinmq/auth/auth_chain.cr | 7 +++- src/lavinmq/auth/handlers/oauth2.cr | 13 ++++++- src/lavinmq/config.cr | 29 +++++++++++--- src/lavinmq/user.cr | 2 + 6 files changed, 79 insertions(+), 38 deletions(-) create mode 100644 spec/auth_sepc.cr diff --git a/spec/auth_sepc.cr b/spec/auth_sepc.cr new file mode 100644 index 0000000000..0de0c5ab97 --- /dev/null +++ b/spec/auth_sepc.cr @@ -0,0 +1,6 @@ +require "./spec_helper" + +describe LavinMQ::AuthHandler do + + +end diff --git a/spec/cache_spec.cr b/spec/cache_spec.cr index 273a73a2ab..42385caeb4 100644 --- a/spec/cache_spec.cr +++ b/spec/cache_spec.cr @@ -1,36 +1,36 @@ -require "./spec_helper" +# require "./spec_helper" -describe LavinMQ::Cache do - cache = LavinMQ::Cache(String, String).new(1.seconds) +# describe LavinMQ::Cache do +# cache = LavinMQ::Cache(String, String).new(1.seconds) - it "set key" do - cache.set("key1", "allow").should eq "allow" - end +# it "set key" do +# cache.set("key1", "allow").should eq "allow" +# end - it "get key" do - cache.set("keyget", "deny") - cache.get?("keyget").should eq "deny" - end +# it "get key" do +# cache.set("keyget", "deny") +# cache.get?("keyget").should eq "deny" +# end - it "invalid cache after 10 second" do - cache.set("keyinvalid", "expired") - sleep(2.seconds) - cache.get?("keyinvalid").should be_nil - end +# it "invalid cache after 10 second" do +# cache.set("keyinvalid", "expired") +# sleep(2.seconds) +# cache.get?("keyinvalid").should be_nil +# end - it "delete key" do - cache.set("keydelete", "deleted") - cache.delete("keydelete") - cache.get?("keydelete").should be_nil - end +# it "delete key" do +# cache.set("keydelete", "deleted") +# cache.delete("keydelete") +# cache.get?("keydelete").should be_nil +# end - it "cleanup expired entry" do - cache.set("clean1", "expired1") - cache.set("clean2", "expired2") - cache.set("clean3", "valid", 10.seconds) - sleep(2.seconds) - cache.get?("clean1").should be_nil - cache.get?("clean2").should be_nil - cache.get?("clean3").should eq "valid" - end -end +# it "cleanup expired entry" do +# cache.set("clean1", "expired1") +# cache.set("clean2", "expired2") +# cache.set("clean3", "valid", 10.seconds) +# sleep(2.seconds) +# cache.get?("clean1").should be_nil +# cache.get?("clean2").should be_nil +# cache.get?("clean3").should eq "valid" +# end +# end diff --git a/src/lavinmq/auth/auth_chain.cr b/src/lavinmq/auth/auth_chain.cr index f1986a6e2e..fa72f3bc41 100644 --- a/src/lavinmq/auth/auth_chain.cr +++ b/src/lavinmq/auth/auth_chain.cr @@ -1,10 +1,12 @@ +require "./auth_cache" + module LavinMQ class AuthChain @first_handler : AuthHandler? def initialize(users : UserStore) backends = Config.instance.auth_backends - if backends.empty? + if backends.nil? || backends.size == 0 add_handler(BasicAuthHandler.new(users)) else # TODO: gather config for http and oauth and send into handlers @@ -38,6 +40,9 @@ module LavinMQ def authenticate(username : String, password : String) # TODO: Cache the authorized users, and call authenticate from cache class + # if authorized = @auth_cache.get?(username) + # return authorized + # end @first_handler.try &.authenticate(username, password) end end diff --git a/src/lavinmq/auth/handlers/oauth2.cr b/src/lavinmq/auth/handlers/oauth2.cr index ce28fd9869..bcfc9f9321 100644 --- a/src/lavinmq/auth/handlers/oauth2.cr +++ b/src/lavinmq/auth/handlers/oauth2.cr @@ -1,6 +1,8 @@ require "../auth_handler" require "jwt" require "../../config" +require "http/client" + module LavinMQ class OAuth2Handler < LavinMQ::AuthHandler @@ -11,9 +13,14 @@ module LavinMQ @token : String = LavinMQ::Config.instance.token @public_key : String = LavinMQ::Config.instance.public_key + def authenticate(username : String, password : String) begin - payload, header = JWT.decode(@token, key: @public_key, algorithm: JWT::Algorithm::RS256, verify: true, validate: true) + fetch_jwks_token + payload, header = JWT.decode(@token, key: @public_key, algorithm: JWT::Algorithm::RS256, verify: true) + + pp payload + pp header oauth_user rescue ex : JWT::DecodeError @log.warn { "OAuth2 authentication failed, could not decode token: #{ex}" } @@ -27,7 +34,11 @@ module LavinMQ end end + private def fetch_jwks_token + end + def oauth_user + # Discuss ow to do this? # TODO: Create a uset that will be deleted when it disconnects, but also cannot be authorised with basic auth. # introduce the needed configs for validation, and parse the payload to get the user details user = @users.create("oauth_user", "password") diff --git a/src/lavinmq/config.cr b/src/lavinmq/config.cr index b69ddd26fd..fb8b0a622f 100644 --- a/src/lavinmq/config.cr +++ b/src/lavinmq/config.cr @@ -62,14 +62,31 @@ module LavinMQ property default_consumer_prefetch = UInt16::MAX property yield_each_received_bytes = 131_072 # max number of bytes to read from a client connection without letting other tasks in the server do any work property yield_each_delivered_bytes = 1_048_576 # max number of bytes sent to a client without tending to other tasks in the server - property http_auth_url : String? = "" - property oauth_url : String? = "" - property auth_backends = ["basic", "oauth", "http"] + property auth_cache_ttl = 1.hour + property jwks_uri : String = "https://demos-test.criipto.id/.well-known/jwks" + property iss : String? = "" + property aud : String? = "" + property sub : String? = "" + property algorithm : JWT::Algorithm? = JWT::Algorithm::RS256 + property token_expiration_tolerance : Int32 = 60 + property token_cache_duration : Int32 = 60 + property auth_backends : Array(String)? = ["basic", "oauth", "http"] + + # ---- FOR TESTING PURPOSES ONLY ---- #this will be fetched from an jwks endpoint - property public_key = "" + property public_key = "-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAi63Nh2tY0KZOvy1wEknC +1iUC75g+kuAueaph4TH4BXOdIspCmM6z47G5aCEMY6esTdq/skR9LwgwF6jHkwsj +PPE0wBv8AFprD8ib2u4VIdm4Sy94wruZnDVzE0YcIadptp9MD2sFLHmwF3wJ5rmw +CSWRBWqcpFCYha40C2qHokudzMusHV2AMQHzuAnk0WxgO+OCtyHzPBRq4DbuGSBM +9vqP0mvPCtM3pWnTO0LIJzUwbhNd3bWSKe3ItlhfLu9GXaZqYYwhw9hjvlkmEZsR +aB+LOn//FBtJhDmrrA/zmHwA39oALdynhU6BCXzEG/z/4JdA4gC7Ad64dVuN+bHQ +uQIDAQAB +-----END PUBLIC KEY----- +" # this will come from the connect packet - property token = "" - + property token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Im9hdXRoIiwicGVybWlzc2lvbnMiOnsidXNlciI6InUxIiwidmhvc3QiOiIvIiwiY29uZmlndXJlIjoiYyIsIndyaXRlIjoidyIsInJlYWQiOiJyIn0sImlhdCI6MTczNzExNjI1NSwiZXhwIjoxNzM3MTE5ODU1fQ.POlh6o99cDQgfliDpOWAS-BNvTXtWI1myp7sVA9Y25lqlUCx4M5LkA1wuPSlENTU43bi2aAGOaSyH_dHRF2XVsMIn9t2UX735PMCgWSciv0pypAH56ake1kLkbM-JnzcHyAtbAo3sK5rzDtvI2Gj23jtSn8LkiSASa1Xs3DLQVGwDYeBgQfdYr5fjhBHTK8wv8KYW1cHH0A_s-oUeCDI0ps-rNNGTOyBqn55WDAfs_eOCJ3TeLymntndBf6ySdFumi2y04N7MVBAAngtKo6c7ej-J_MoOdwwm9UXNyIgsQogiVtr9QuM4a1fNPaj5T2PC-bqg9Jlce7T2EW7JWNs6Q" + # ---- FOR TESTING PURPOSES ONLY ---- @@instance : Config = self.new def self.instance : LavinMQ::Config diff --git a/src/lavinmq/user.cr b/src/lavinmq/user.cr index 115e39d9a6..a74235f2ca 100644 --- a/src/lavinmq/user.cr +++ b/src/lavinmq/user.cr @@ -3,6 +3,7 @@ require "./password" require "./sortable_json" module LavinMQ + # needs to be extracted to own file (steg 1) enum Tag Administrator Monitoring @@ -15,6 +16,7 @@ module LavinMQ end end + # maybe have multiple types of users, let User be absracts and inherit class User include SortableJSON getter name, password, permissions