From c7ceb337d86e454630f1e073a051a99d934ef9e9 Mon Sep 17 00:00:00 2001 From: Ali Naqvi Date: Thu, 12 Sep 2019 13:13:19 +0800 Subject: [PATCH] Added SASL Auth mechanisms --- README.md | 7 +- src/xmpp/auth.cr | 110 ++++++++++++++++++++-------- src/xmpp/auth/anonymous.cr | 9 +++ src/xmpp/auth/digest-md5.cr | 93 ++++++++++++++++++++++++ src/xmpp/auth/plain.cr | 13 ++++ src/xmpp/auth/scram-sha.cr | 134 +++++++++++++++++++++++++++++++++++ src/xmpp/config.cr | 13 ++-- src/xmpp/router.cr | 6 -- src/xmpp/session.cr | 33 ++++++--- src/xmpp/stanza/node.cr | 25 +++++++ src/xmpp/stanza/parser.cr | 6 +- src/xmpp/stanza/sasl_auth.cr | 86 +++++++++++++++++++--- src/xmpp/stanza/stream.cr | 9 ++- src/xmpp/stream_manager.cr | 3 + 14 files changed, 482 insertions(+), 65 deletions(-) create mode 100644 src/xmpp/auth/anonymous.cr create mode 100644 src/xmpp/auth/digest-md5.cr create mode 100644 src/xmpp/auth/plain.cr create mode 100644 src/xmpp/auth/scram-sha.cr diff --git a/README.md b/README.md index 9cf5c16..b32cffd 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,12 @@ config = XMPP::Config.new( host: "localhost", jid: "test@localhost", password: "test", - log_file: STDOUT # Capture all out-going and in-coming messages + log_file: STDOUT, # Capture all out-going and in-coming messages + # Order of SASL Authentication Mechanism, first matched method supported by server will be used + # for authentication. Below is default order that will be used if `sasl_auth_order` param is not set. + sasl_auth_order: [XMPP::AuthMechanism::SCRAM_SHA_512, XMPP::AuthMechanism::SCRAM_SHA_256, + XMPP::AuthMechanism::SCRAM_SHA_1, XMPP::AuthMechanism::DIGEST_MD5, + XMPP::AuthMechanism::PLAIN, XMPP::AuthMechanism::ANONYMOUS] router = XMPP::Router.new diff --git a/src/xmpp/auth.cr b/src/xmpp/auth.cr index 17b657e..31601fe 100644 --- a/src/xmpp/auth.cr +++ b/src/xmpp/auth.cr @@ -1,45 +1,65 @@ -require "base64" +require "./auth/*" module XMPP - private module Auth - extend self - - def auth_sasl(io, features, user, password) - # TODO: implement other type of SASL Authentication - have_plain = false - features.mechanisms.try &.mechanism.each do |m| - if m == "PLAIN" - have_plain = true - break + class AuthenticationError < Exception; end + + enum AuthMechanism + SCRAM_SHA_512 + SCRAM_SHA_256 + SCRAM_SHA_1 + DIGEST_MD5 + PLAIN + ANONYMOUS + + def to_s + s = AuthMechanism.names[self.value] + s.gsub("_", "-") + end + end + + SASL_AUTH_ORDER = [AuthMechanism::SCRAM_SHA_512, AuthMechanism::SCRAM_SHA_256, AuthMechanism::SCRAM_SHA_1, + AuthMechanism::DIGEST_MD5, AuthMechanism::PLAIN, + AuthMechanism::ANONYMOUS] + + private class AuthHandler + @io : IO + @password : String + @jid : JID + @features : Stanza::StreamFeatures + + def initialize(@io, @features, @password, @jid) + raise AuthenticationError.new "Server returned empty list of Authentication mechanisms" unless (@features.mechanisms.try &.mechanism.size || 0) > 0 + end + + def authenticate(methods : Array(AuthMechanism)) + if (mechanisms = @features.mechanisms.try &.mechanism) + methods.each do |method| + if mechanisms.includes? method.to_s + return do_auth(method) + end end + raise AuthenticationError.new "None of the preferred Auth mechanism '[#{methods.join(",")}]' supported by server. Server supported mechanisms are [#{mechanisms.join(",")}]" + else + raise AuthenticationError.new "Server returned empty list of Authentication mechanisms" end - raise "PLAIN authentication is not supported by server: [#{features.mechanisms.try &.mechanism.join}]" unless have_plain - auth_plain io, user, password end - # Plain authentication: send base64-encoded \x00 user \x00 password - def auth_plain(io, user, password) - raw = "\x00#{user}\x00#{password}" - enc = Base64.encode(raw) - xml = sprintf "%s", Stanza::NS_SASL, enc - io.write xml.to_slice - - # Next message should be either success or failure - val = Stanza::Parser.next_packet read_resp(io) - if val.is_a?(Stanza::SASLSuccess) - # we are good - elsif val.is_a?(Stanza::SASLFailure) - # v.Any is type of sub-element in failure, which gives a description of what failed - v = val.as(Stanza::SASLFailure) - raise "auth failure: #{v.any.try &.to_xml}" + private def do_auth(method : AuthMechanism) + case method + when AuthMechanism::DIGEST_MD5 then auth_digest_md5 + when AuthMechanism::PLAIN then auth_plain + when AuthMechanism::ANONYMOUS then auth_anonymous + when AuthMechanism::SCRAM_SHA_1 then auth_scram("sha1") + when AuthMechanism::SCRAM_SHA_256 then auth_scram("sha256") + when AuthMechanism::SCRAM_SHA_512 then auth_scram("sha512") else - raise "expected SASL success or failure, got #{val.name}" + raise AuthenticationError.new "Auth mechanism '#{method.to_s}' not implemented. Currently implemented mechanisms are [#{SASL_AUTH_ORDER.join(",")}]" end end - private def read_resp(io) + private def read_resp b = Bytes.new(1024) - io.read(b) + @io.read(b) xml = String.new(b) document = XML.parse(xml) if (r = document.first_element_child) @@ -48,5 +68,33 @@ module XMPP raise "Invalid response from server: #{document.to_xml}" end end + + private def send(xml : String) + @io.write xml.to_slice + end + + private def send(packet : Stanza::Packet) + send(packet.to_xml) + end + + private def handle_resp(tag) + # Next message should be either success or failure + val = Stanza::Parser.next_packet read_resp + if val.is_a?(Stanza::SASLSuccess) + # we are good + elsif val.is_a?(Stanza::SASLFailure) + # v.Any is type of sub-element in failure, which gives a description of what failed + v = val.as(Stanza::SASLFailure) + raise AuthenticationError.new "#{tag} - auth failure: #{v.any.try &.to_xml}" + else + raise AuthenticationError.new "#{tag} - expected SASL success or failure, got #{val.name}" + end + end + + private def nonce(n : Int32) + b = Bytes.new(n) + Random.new.random_bytes(b) + Base64.strict_encode(b) + end end end diff --git a/src/xmpp/auth/anonymous.cr b/src/xmpp/auth/anonymous.cr new file mode 100644 index 0000000..c74872a --- /dev/null +++ b/src/xmpp/auth/anonymous.cr @@ -0,0 +1,9 @@ +module XMPP + private class AuthHandler + # Anonymous Auth + def auth_anonymous + send Stanza::SASLAuth.new(mechanism: "ANONYMOUS") + handle_resp("anonymous") + end + end +end diff --git a/src/xmpp/auth/digest-md5.cr b/src/xmpp/auth/digest-md5.cr new file mode 100644 index 0000000..6a51fa7 --- /dev/null +++ b/src/xmpp/auth/digest-md5.cr @@ -0,0 +1,93 @@ +require "base64" +require "openssl/md5.cr" +require "digest/md5.cr" + +module XMPP + private class AuthHandler + # DIGEST-MD5 Auth - https://wiki.xmpp.org/web/SASL_and_DIGEST-MD5 + def auth_digest_md5 + send Stanza::SASLAuth.new(mechanism: "DIGEST-MD5") + val = Stanza::Parser.next_packet read_resp + if val.is_a?(Stanza::SASLChallenge) + challenge = val.as(Stanza::SASLChallenge).body + body = do_response(parse_digest_challenge(challenge)) + send Stanza::SASLResponse.new(body) + val = Stanza::Parser.next_packet read_resp + if val.is_a?(Stanza::SASLChallenge) + # we are good + challenge = val.as(Stanza::SASLChallenge).body + Logger.info("digest-md5 - Auth successful: #{Base64.decode_string(challenge)}") + handle_success + elsif val.is_a?(Stanza::SASLFailure) + v = val.as(Stanza::SASLFailure) + raise "digest-md5 - auth failure: #{v.any.try &.to_xml}" + else + raise "digest-md5 - expected SASL success or failure, got #{val.name}" + end + else + raise "digest-md5 - Expecting challenge, got : #{val.to_xml}" + end + end + + private def parse_digest_challenge(challenge : String) + val = Base64.decode_string(challenge) + res = Hash(String, String).new + val.split(",").each do |v| + pair = v.split("=") + key, val = pair[0], pair[1].strip('"') + next if key == "qop" && val != "auth" + raise "Invalid challenge. algorithm provided multiple times" if key == "algorithm" && res.has_key?("algorithm") + raise "Invalid challenge. charset provided multiple times" if key == "charset" && res.has_key?("charset") + res[key] = val + end + res["realm"] = @jid.domain unless res.has_key?("realm") + raise "Invalid challenge. nonce not found" unless res.has_key?("nonce") + raise "Invalid challenge. qop not found" unless res.has_key?("qop") + raise "Invalid challenge. algorithm not found" unless res.has_key?("algorithm") + res + end + + private def do_response(challenge) + res = Hash{ + "nonce" => challenge["nonce"], + "charset" => challenge["charset"], + "username" => @jid.node, + "realm" => challenge["realm"], + "cnonce" => nonce(16), + "nc" => "00000001", + "qop" => challenge["qop"], + "digest-uri" => "xmpp/#{@jid.domain}", + } + res["response"] = make_response(res) + vals = %w(nc qop response charset) + sb = String.build do |str| + res.each do |k, v| + str << k << "=" + if !vals.includes?(k) + str << "\"" << v << "\"" + else + str << v + end + str << "," + end + end + Base64.strict_encode(sb.rstrip(",")) + end + + private def make_response(res) + x = "#{res["username"]}:#{res["realm"]}:#{@password}" + y = String.new(OpenSSL::MD5.hash(x).to_slice) + a1 = "#{y}:#{res["nonce"]}:#{res["cnonce"]}" + a2 = "AUTHENTICATE:#{res["digest-uri"]}" + ha1 = Digest::MD5.hexdigest(a1) + ha2 = Digest::MD5.hexdigest(a2) + kd = "#{ha1}:#{res["nonce"]}:#{res["nc"]}:#{res["cnonce"]}:#{res["qop"]}:#{ha2}" + Digest::MD5.hexdigest(kd) + end + + private def handle_success + send Stanza::SASLResponse.new + handle_resp("digest-md5") + end + end +end diff --git a/src/xmpp/auth/plain.cr b/src/xmpp/auth/plain.cr new file mode 100644 index 0000000..e804dde --- /dev/null +++ b/src/xmpp/auth/plain.cr @@ -0,0 +1,13 @@ +require "base64" + +module XMPP + private class AuthHandler + # Plain authentication: send base64-encoded \x00 user \x00 password + def auth_plain + raw = "\x00#{@jid.node}\x00#{@password}" + enc = Base64.encode(raw) + send Stanza::SASLAuth.new(mechanism: "PLAIN", body: enc) + handle_resp("plain") + end + end +end diff --git a/src/xmpp/auth/scram-sha.cr b/src/xmpp/auth/scram-sha.cr new file mode 100644 index 0000000..eb2fc60 --- /dev/null +++ b/src/xmpp/auth/scram-sha.cr @@ -0,0 +1,134 @@ +require "openssl/pkcs5" +require "openssl/hmac" +require "openssl/sha1" + +module XMPP + private class AuthHandler + def auth_scram(method : String) + case method + when "sha256" + auth_scram_sha("SCRAM-SHA-256", OpenSSL::Algorithm::SHA256) + when "sha512" + auth_scram_sha("SCRAM-SHA-512", OpenSSL::Algorithm::SHA512) + else + auth_scram_sha("SCRAM-SHA-1", OpenSSL::Algorithm::SHA1) + end + end + + # X-SCRAM_SHA_X Auth - https://wiki.xmpp.org/web/SASL_and_SCRAM-SHA-1 + def auth_scram_sha(name, algorithm) + nonce = nonce(16) + msg = "n=#{escape(@jid.node || "")},r=#{nonce}" + raw = "n,,#{msg}" + enc = Base64.strict_encode(raw) + send Stanza::SASLAuth.new(mechanism: name, body: enc) + val = Stanza::Parser.next_packet read_resp + if val.is_a?(Stanza::SASLChallenge) + body = val.as(Stanza::SASLChallenge).body + server_resp = Base64.decode_string(body) + challenge = parse_scram_challenge(body, nonce) + resp, server_sig = scram_response(msg, server_resp, challenge, algorithm) + + send Stanza::SASLResponse.new(resp) + val = Stanza::Parser.next_packet read_resp + if val.is_a?(Stanza::SASLSuccess) + # we are good + body = val.as(Stanza::SASLSuccess).body + sig = Base64.decode_string(body) + raise AuthenticationError.new "Server returned invalid signature on success" unless sig.starts_with?("v=") + raise AuthenticationError.new "Server returned signature mismatch." unless sig[2..] == server_sig + Logger.info("#{name} - Auth successful: #{sig}") + elsif val.is_a?(Stanza::SASLFailure) + v = val.as(Stanza::SASLFailure) + raise AuthenticationError.new "#{name} - auth failure: #{v.any.try &.to_xml}" + else + raise AuthenticationError.new "#{name} - expected SASL success or failure, got #{val.name}" + end + else + if val.is_a?(Stanza::SASLFailure) + v = val.as(Stanza::SASLFailure) + raise AuthenticationError.new "Selected mechanism [#{name}] is not supported by server" if v.type == "invalid-mechanism" + end + raise AuthenticationError.new "#{name} - Expecting challenge, got : #{val.to_xml}" + end + end + + private def hash_func(algorithm) + if algorithm.sha256? + f = "SHA256" + else + f = "SHA1" + end + OpenSSL::Digest.new(f) + end + + private def scram_response(initial_msg, server_resp, challenge, algorithm) + bare_msg = "c=biws,r=#{challenge["r"]}" + server_salt = Base64.decode(challenge["s"]) + hasher = hash_func(algorithm) + salted_pwd = OpenSSL::PKCS5.pbkdf2_hmac(secret: @password, salt: server_salt, iterations: challenge["i"].to_i32, + algorithm: algorithm, key_size: hasher.digest_size) + client_key = OpenSSL::HMAC.digest(algorithm: algorithm, key: salted_pwd, data: "Client Key") + + # stored_key = OpenSSL::SHA1.hash(client_key.to_unsafe, LibC::SizeT.new(client_key.bytesize)) + hasher.update(client_key) + stored_key = hasher.digest + + auth_msg = "#{initial_msg},#{server_resp},#{bare_msg}" + client_sig = OpenSSL::HMAC.digest(algorithm: algorithm, key: stored_key, data: auth_msg) + client_proof = xor(client_key, client_sig) + + server_key = OpenSSL::HMAC.digest(algorithm: algorithm, key: salted_pwd, data: "Server Key") + server_sig = OpenSSL::HMAC.digest(algorithm: algorithm, key: server_key, data: auth_msg) + + final_msg = "#{bare_msg},p=#{Base64.strict_encode(client_proof)}" + {Base64.strict_encode(final_msg), Base64.strict_encode(server_sig)} + end + + private def parse_scram_challenge(challenge, nonce) + value = Base64.decode_string(challenge) + res = Hash(String, String).new + value.split(",").each do |v| + pair = v.split("=") + key, val = pair[0], v[2..] + res[key] = val + end + # RFC 5802: + # m: This attribute is reserved for future extensibility. In this + # version of SCRAM, its presence in a client or a server message + # MUST cause authentication failure when the attribute is parsed by + # the other end. + raise "Server sent reserved attribute 'm'" if res.has_key?("m") + if (i = res["i"]?) + raise "Server sent invalid iteration count" if i.to_i?.nil? + else + raise "Server didn't sent iteration count" + end + if (salt = res["s"]?) + raise "Server sent empty salt" if salt.blank? + # res["s"] = Base64.decode_string(salt) + else + raise "Server didn't sent salt" + end + if (r = res["r"]?) + raise "Server sent nonce didn't match" unless r.starts_with?(nonce) + else + raise "Server didn't sent nonce" + end + res + end + + private def xor(a : Bytes, b : Bytes) + if a.bytesize > b.bytesize + b.map_with_index { |v, i| v ^ a[i] } + else + a.map_with_index { |v, i| v ^ b[i] } + end + end + + private def escape(str : String) + # Escape "=" and "," + str.gsub("=", "=3D").gsub(",", "=2C") + end + end +end diff --git a/src/xmpp/config.cr b/src/xmpp/config.cr index 8dd855f..85f64ed 100644 --- a/src/xmpp/config.cr +++ b/src/xmpp/config.cr @@ -1,4 +1,5 @@ require "./jid" +require "./auth" module XMPP struct Config @@ -9,14 +10,16 @@ module XMPP getter lang : String getter connect_timeout : Int32 getter tls : Bool # TLS Support - # allow_insecure can be set to true to allow to open a session without TLS. If TLS - # is supported on the server, we will still try to use it. - getter allow_insecure : Bool + # skip_cert_verify can be set to true to allow to open a TLS session and skip + # verification of SSL certs + getter skip_cert_verify : Bool getter log_file : IO? getter parsed_jid : JID + getter sasl_auth_order : Array(AuthMechanism) - def initialize(@jid, @password, @host, @port = 5222, @lang = "en", @tls = false, - @allow_insecure = true, time_out = 15, @log_file = nil) + def initialize(@jid, @password, @host, @port = 5222, @lang = "en", @tls = true, + @skip_cert_verify = true, time_out = 15, @log_file = nil, + @sasl_auth_order = SASL_AUTH_ORDER) raise "missing password" if @password.blank? @connect_timeout = time_out @parsed_jid = JID.new @jid diff --git a/src/xmpp/router.cr b/src/xmpp/router.cr index 19961f2..52812ef 100644 --- a/src/xmpp/router.cr +++ b/src/xmpp/router.cr @@ -92,12 +92,6 @@ module XMPP iq.make_error err s.send iq end - - # HandleFunc registers a new route with a matcher for for a given packet name (iq, message, presence) - # See Route.Path() and Route.Callback(). - # def handler_func(name : String, &handler : Callback) - # route.packet(name).handler_func(handler) - # end end class Route diff --git a/src/xmpp/session.cr b/src/xmpp/session.cr index 381b620..1d1fa20 100644 --- a/src/xmpp/session.cr +++ b/src/xmpp/session.cr @@ -42,12 +42,27 @@ module XMPP end @stream_logger = StreamLogger.new(io, config.log_file) @features = open config.parsed_jid.domain + + ok = @features.tls_required + if ok && !config.tls + raise AuthenticationError.new "Server requires TLS session. Ensure you either 'tls' attribute of config to 'true'" + end + + _, ok = @features.does_start_tls + if config.tls && !ok + raise AuthenticationError.new "You requested TLS session, but Server doesn't support TLS" + end + # starttls - tls_conn = start_tls_if_supported io, config - if tls_conn.is_a?(IO::Buffered) - tls_conn.sync = false + if ok && config.tls + tls_conn = start_tls_if_supported io, config + if tls_conn.is_a?(IO::Buffered) + tls_conn.sync = false + end + raise AuthenticationError.new "Failed to negotiate TLS session" unless @tls_enabled + else + tls_conn = io end - raise "failed to negotiate TLS session" unless @tls_enabled && config.allow_insecure reset(io, tls_conn, config) if @tls_enabled # auth @@ -128,12 +143,12 @@ module XMPP begin Stanza::TLSProceed.new read_resp rescue ex - raise "expecting starttls proceed: #{ex.message}" + raise AuthenticationError.new "expecting starttls proceed: #{ex.message}" end # Conert existing connection to TLS context = OpenSSL::SSL::Context::Client.new - context.verify_mode = OpenSSL::SSL::VerifyMode::None if o.allow_insecure + context.verify_mode = OpenSSL::SSL::VerifyMode::None if o.skip_cert_verify begin tls_conn = OpenSSL::SSL::Socket::Client.new(socket, context) tls_conn.sync = true @@ -147,14 +162,16 @@ module XMPP return tls_conn end # If we do not allow cleartext connections, make it explicit that server do not support starttls - raise "XMPP server does not advertise support for starttls" unless o.allow_insecure + raise AuthenticationError.new "XMPP server does not advertise support for starttls" if o.tls # starttls is not supported => we do not upgrade the connection socket end private def auth(o) - Auth.auth_sasl @stream_logger, @features, o.parsed_jid.node, o.password + auth = AuthHandler.new(@stream_logger, @features, o.password, o.parsed_jid) + auth.authenticate o.sasl_auth_order + # Auth.auth_sasl @stream_logger, @features, o.password, o.parsed_jid end private def resume(o) diff --git a/src/xmpp/stanza/node.cr b/src/xmpp/stanza/node.cr index 8a008b7..33c6970 100644 --- a/src/xmpp/stanza/node.cr +++ b/src/xmpp/stanza/node.cr @@ -3,6 +3,31 @@ require "../stanza" module XMPP::Stanza # Generic / unknown content + class Nodes + getter nodes : Array(Node) = Array(Node).new + + def self.new(node : XML::NodeSet) + pr = new() + node.select(&.element?).each do |child| + pr.nodes << Node.new child + end + pr + end + + def to_xml + val = XML.build(quote_char: '\'') do |xml| + to_xml xml + end + val.sub(%(), "").lstrip("\n") + end + + def to_xml(elem : XML::Builder) + elem.element("xml") do + @nodes.each { |n| n.to_xml elem } + end + end + end + # Node is a generic structure to represent XML data. It is used to parse # unreferenced or custom stanza payload. diff --git a/src/xmpp/stanza/parser.cr b/src/xmpp/stanza/parser.cr index a4e5cae..0ad9ba2 100644 --- a/src/xmpp/stanza/parser.cr +++ b/src/xmpp/stanza/parser.cr @@ -57,8 +57,10 @@ module XMPP::Stanza # decode_sasl decodes a packet related to SASL authentication def decode_sasl(node : XML::Node) case node.name - when "success" then SASLSuccess.new node - when "failure" then SASLFailure.new node + when "challenge" then SASLChallenge.new node + when "response" then SASLResponse.new node + when "success" then SASLSuccess.new node + when "failure" then SASLFailure.new node else raise "unexpected XMPP packet #{node.namespace.try &.href} <#{node.name}>" end diff --git a/src/xmpp/stanza/sasl_auth.cr b/src/xmpp/stanza/sasl_auth.cr index 7e52952..82d9c45 100644 --- a/src/xmpp/stanza/sasl_auth.cr +++ b/src/xmpp/stanza/sasl_auth.cr @@ -5,9 +5,10 @@ module XMPP::Stanza # SASLAuth implements SASL Authentication initiation. # Reference: https://tools.ietf.org/html/rfc6120#section-6.4.2 class SASLAuth + include Packet class_getter xml_name : XMLName = XMLName.new("urn:ietf:params:xml:ns:xmpp-sasl auth") property mechanism : String = "" - property value : Node? = nil + property body : String = "" def self.new(node : XML::Node) raise "Invalid node(#{node.name}), expecting #{@@xml_name.to_s}" unless (node.namespace.try &.href == @@xml_name.space) && (node.name == @@xml_name.local) @@ -17,22 +18,26 @@ module XMPP::Stanza when "mechanism" then cls.mechanism = attr.children[0].content end end - node.children.select(&.element?).each do |child| - cls.value = Node.new child - break - end + cls.body = node.text cls end + def initialize(@mechanism = "", @body = "") + end + def to_xml(elem : XML::Builder) dict = Hash(String, String).new dict["xmlns"] = @@xml_name.space unless @@xml_name.space.blank? dict["mechanism"] = mechanism unless mechanism.blank? elem.element(@@xml_name.local, dict) do - value.try &.to_xml elem + elem.text body unless body.blank? end end + + def name + "sasl:auth" + end end # SASLSuccess implements SASL Success nonza, sent by server as a result of the @@ -41,15 +46,18 @@ module XMPP::Stanza class SASLSuccess include Packet class_getter xml_name : XMLName = XMLName.new("urn:ietf:params:xml:ns:xmpp-sasl", "success") + property body : String = "" def self.new(node : XML::Node) raise "Invalid node(#{node.name}, expecting #{@@xml_name.to_s}" unless (node.namespace.try &.href == @@xml_name.space) && (node.name == @@xml_name.local) - new() + cls = new() + cls.body = node.text + cls end def to_xml(elem : XML::Builder) - elem.element(@@xml_name.local, xmlns: @@xml_name.space) + elem.element(@@xml_name.local, xmlns: @@xml_name.space) { elem.text body } end def name @@ -61,15 +69,22 @@ module XMPP::Stanza class SASLFailure include Packet class_getter xml_name : XMLName = XMLName.new("urn:ietf:params:xml:ns:xmpp-sasl failure") - property any : Node? = nil # error reason is a subelement + property type : String = "" + property reason : String = "" + property any : Nodes? = nil # error reason is a subelement def self.new(node : XML::Node) raise "Invalid node(#{node.name}), expecting #{@@xml_name.to_s}" unless (node.namespace.try &.href == @@xml_name.space) && (node.name == @@xml_name.local) cls = new() node.children.select(&.element?).each do |child| - cls.any = Node.new child - break + case child.name + when "text" + cls.reason = child.content + else + cls.type = child.name + end end + cls.any = Nodes.new node.children cls end @@ -84,6 +99,55 @@ module XMPP::Stanza end end + # SASL Challenge + class SASLChallenge + include Packet + class_getter xml_name : XMLName = XMLName.new("urn:ietf:params:xml:ns:xmpp-sasl", "challenge") + property body : String = "" + + def self.new(node : XML::Node) + raise "Invalid node(#{node.name}, expecting #{@@xml_name.to_s}" unless (node.namespace.try &.href == @@xml_name.space) && + (node.name == @@xml_name.local) + cls = new() + cls.body = node.text + cls + end + + def to_xml(elem : XML::Builder) + elem.element(@@xml_name.local, xmlns: @@xml_name.space) { elem.text body } + end + + def name + "sasl:challenge" + end + end + + # SASL Response + class SASLResponse + include Packet + class_getter xml_name : XMLName = XMLName.new("urn:ietf:params:xml:ns:xmpp-sasl", "response") + property body : String = "" + + def self.new(node : XML::Node) + raise "Invalid node(#{node.name}, expecting #{@@xml_name.to_s}" unless (node.namespace.try &.href == @@xml_name.space) && + (node.name == @@xml_name.local) + new(node.text) + end + + def initialize(@body = "") + end + + def to_xml(elem : XML::Builder) + elem.element(@@xml_name.local, xmlns: @@xml_name.space) do + elem.text body unless body.blank? + end + end + + def name + "sasl:response" + end + end + # Resource binding # Bind is an IQ payload used during session negotiation to bind user resource # to the current XMPP stream. diff --git a/src/xmpp/stanza/stream.cr b/src/xmpp/stanza/stream.cr index 39a6c66..543bf03 100644 --- a/src/xmpp/stanza/stream.cr +++ b/src/xmpp/stanza/stream.cr @@ -77,11 +77,18 @@ module XMPP::Stanza def does_start_tls if (t = start_tls) - return {t, true} if t.required + return {t, true} # if t.required end {TLSStartTLS.new, false} end + def tls_required + if (t = start_tls) + return t.required + end + false + end + def does_stream_management !stream_management.nil? end diff --git a/src/xmpp/stream_manager.cr b/src/xmpp/stream_manager.cr index 0b6c92e..6fad16c 100644 --- a/src/xmpp/stream_manager.cr +++ b/src/xmpp/stream_manager.cr @@ -66,6 +66,9 @@ module XMPP @metrics.reset begin @client.resume(state) + rescue ex : AuthenticationError + Logger.error ex.message + raise ex rescue ex # Add some delay to avoid hammering server attemps += 1