Skip to content

Commit

Permalink
Update SASL authentication for changes in XEP-0178.
Browse files Browse the repository at this point in the history
  • Loading branch information
dgraham committed Apr 15, 2012
1 parent 62f8a96 commit 3c6d2a6
Show file tree
Hide file tree
Showing 14 changed files with 416 additions and 153 deletions.
10 changes: 5 additions & 5 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,21 @@ is mandatory on all client and server connections."
s.executables = %w[vines]
s.require_path = "lib"

s.add_dependency "activerecord", "~> 3.2.1"
s.add_dependency "activerecord", "~> 3.2.3"
s.add_dependency "bcrypt-ruby", "~> 3.0.1"
s.add_dependency "em-http-request", "~> 1.0.1"
s.add_dependency "em-hiredis", "~> 0.1.0"
s.add_dependency "em-http-request", "~> 1.0.2"
s.add_dependency "em-hiredis", "~> 0.1.1"
s.add_dependency "eventmachine", ">= 0.12.10"
s.add_dependency "http_parser.rb", "~> 0.5.3"
s.add_dependency "mongo", "~> 1.5.2"
s.add_dependency "bson_ext", "~> 1.5.2"
s.add_dependency "net-ldap", "~> 0.2.2"
s.add_dependency "nokogiri", "~> 1.4.7"

s.add_development_dependency "minitest", "~> 2.11.2"
s.add_development_dependency "minitest", "~> 2.12.1"
s.add_development_dependency "coffee-script", "~> 2.2.0"
s.add_development_dependency "coffee-script-source", "~> 1.2.0"
s.add_development_dependency "uglifier", "~> 1.2.3"
s.add_development_dependency "uglifier", "~> 1.2.4"
s.add_development_dependency "rake"
s.add_development_dependency "sqlite3"

Expand Down
1 change: 1 addition & 0 deletions lib/vines.rb
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ def call(severity, time, program, msg)
vines/cluster/subscriber

vines/stream
vines/stream/sasl
vines/stream/state
vines/stream/parser

Expand Down
13 changes: 8 additions & 5 deletions lib/vines/stream.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ def create_parser
end
end

# Advance the state machine into the +Closed+ state so any remaining queued
# nodes are not processed while we're waiting for EM to actually close the
# connection.
def close_connection(after_writing=false)
super
@closed = true
Expand Down Expand Up @@ -189,9 +192,9 @@ def error?(node)
node.name == ERROR && ns == NAMESPACES[:stream]
end

# Schedule a queue pop on the EM thread to handle the next element.
# This provides the in-order stanza processing guarantee required by
# RFC 6120 section 10.1.
# Schedule a queue pop on the EM thread to handle the next element. This
# guarantees all stanzas received on this stream are processed in order.
# http://tools.ietf.org/html/rfc6120#section-10.1
def process_node_queue
@nodes.pop do |node|
Fiber.new do
Expand Down Expand Up @@ -229,13 +232,13 @@ def log_node(node, direction)
["#{label} stanza:".ljust(PAD), from, to, node])
end

# Returns the current state of the stream's state machine. Provided as a
# Returns the current +State+ of the stream's state machine. Provided as a
# method so subclasses can override the behavior.
def state
@state
end

# Return true if this is a valid domain-only JID that can be used in
# Return +true+ if this is a valid domain-only JID that can be used in
# stream initiation stanza headers.
def valid_address?(jid)
JID.new(jid).domain? rescue false
Expand Down
9 changes: 8 additions & 1 deletion lib/vines/stream/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

module Vines
class Stream

# Implements the XMPP protocol for client-to-server (c2s) streams. This
# serves connected streams using the jabber:client namespace.
class Client < Stream
MECHANISMS = %w[PLAIN].freeze

def initialize(config)
super
@session = Client::Session.new(self)
Expand All @@ -28,6 +29,12 @@ def method_missing(name, *args)
end
end

# Return an array of allowed authentication mechanisms advertised as
# client stream features.
def authentication_mechanisms
MECHANISMS
end

def ssl_handshake_completed
if get_peer_cert
close_connection unless cert_domain_matches?(@session.domain)
Expand Down
79 changes: 29 additions & 50 deletions lib/vines/stream/client/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,75 +4,54 @@ module Vines
class Stream
class Client
class Auth < State
NS = NAMESPACES[:sasl]
AUTH = 'auth'.freeze
SUCCESS = %Q{<success xmlns="#{NS}"/>}.freeze
NS = NAMESPACES[:sasl]
MECHANISM = 'mechanism'.freeze
AUTH = 'auth'.freeze
PLAIN = 'PLAIN'.freeze
EXTERNAL = 'EXTERNAL'.freeze
SUCCESS = %Q{<success xmlns="#{NS}"/>}.freeze
MAX_AUTH_ATTEMPTS = 3
AUTH_MECHANISMS = {'PLAIN' => :plain_auth, 'EXTERNAL' => :external_auth}.freeze

def initialize(stream, success=BindRestart)
super
@attempts, @outstanding = 0, false
@attempts = 0
@sasl = SASL.new(stream)
end

def node(node)
raise StreamErrors::NotAuthorized unless auth?(node)
unless node.text.empty?
(name = AUTH_MECHANISMS[node['mechanism']]) ?
method(name).call(node) :
send_auth_fail(SaslErrors::InvalidMechanism.new)
else
if node.text.empty?
send_auth_fail(SaslErrors::MalformedRequest.new)
elsif stream.authentication_mechanisms.include?(node[MECHANISM])
case node[MECHANISM]
when PLAIN then plain_auth(node)
when EXTERNAL then external_auth(node)
end
else
send_auth_fail(SaslErrors::InvalidMechanism.new)
end
end

private

def auth?(node)
node.name == AUTH && namespace(node) == NS && !@outstanding
node.name == AUTH && namespace(node) == NS
end

# Authenticate s2s streams by comparing their domain to
# their SSL certificate.
def external_auth(stanza)
domain = Base64.decode64(stanza.text)
cert = OpenSSL::X509::Certificate.new(stream.get_peer_cert) rescue nil
if (!OpenSSL::SSL.verify_certificate_identity(cert, domain) rescue false)
send_auth_fail(SaslErrors::NotAuthorized.new)
stream.write('</stream:stream>')
stream.close_connection_after_writing
else
stream.remote_domain = domain
send_auth_success
end
def plain_auth(node)
stream.user = @sasl.plain_auth(node.text)
send_auth_success
rescue => e
send_auth_fail(e)
end

# Authenticate c2s streams using a username and password. Call the
# authentication module in a separate thread to avoid blocking stanza
# processing for other users.
def plain_auth(stanza)
jid, node, password = Base64.decode64(stanza.text).split("\000")
jid = [node, stream.domain].join('@') if jid.nil? || jid.empty?
log.info("Authenticating user: %s" % jid)
@outstanding = true
begin
user = stream.storage.authenticate(jid, password)
finish(user || SaslErrors::NotAuthorized.new)
rescue => e
log.error("Failed to authenticate: #{e.to_s}")
finish(SaslErrors::TemporaryAuthFailure.new)
end
end

def finish(result)
@outstanding = false
if result.kind_of?(Exception)
send_auth_fail(result)
else
stream.user = result
log.info("Authentication succeeded: %s" % stream.user.jid)
send_auth_success
end
def external_auth(node)
@sasl.external_auth(node.text)
send_auth_success
rescue => e
send_auth_fail(e)
stream.write('</stream:stream>')
stream.close_connection_after_writing
end

def send_auth_success
Expand Down
10 changes: 3 additions & 7 deletions lib/vines/stream/client/auth_restart.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,14 @@ def node(node)
features = doc.create_element('stream:features') do |el|
el << doc.create_element('mechanisms') do |parent|
parent.default_namespace = NAMESPACES[:sasl]
mechanisms.each {|name| parent << doc.create_element('mechanism', name) }
stream.authentication_mechanisms.each do |name|
parent << doc.create_element('mechanism', name)
end
end
end
stream.write(features)
advance
end

private

def mechanisms
['EXTERNAL', 'PLAIN']
end
end
end
end
Expand Down
74 changes: 74 additions & 0 deletions lib/vines/stream/sasl.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# encoding: UTF-8

module Vines
class Stream
# Provides plain (username/password) and external (TLS certificate) SASL
# authentication to client and server streams.
class SASL
include Vines::Log
EMPTY = '='.freeze

def initialize(stream)
@stream = stream
end

# Authenticate s2s streams, comparing their domain to their SSL certificate.
# Return +true+ if the base64 encoded domain matches the TLS certificate
# presented earlier in stream negotiation. Raise a +SaslError+ if
# authentication failed.
# http://xmpp.org/extensions/xep-0178.html#s2s
def external_auth(encoded)
unless encoded == EMPTY
authzid = decode64(encoded)
matches_from = (authzid == @stream.remote_domain)
raise SaslErrors::InvalidAuthzid unless matches_from
end
matches_from = @stream.cert_domain_matches?(@stream.remote_domain)
matches_from or raise SaslErrors::NotAuthorized
end

# Authenticate c2s streams using a username and password. Return the
# authenticated +User+ or raise a +SaslError+ if authentication failed.
def plain_auth(encoded)
jid, password = decode_credentials(encoded)
user = authenticate(jid, password)
user or raise SaslErrors::NotAuthorized
end

private

# Storage backends should not raise errors, but if an unexpected error
# occurs during authentication, convert it to a temporary-auth-failure.
# Return the authenticated +User+ or +nil+ if authentication failed.
def authenticate(jid, password)
log.info("Authenticating user: %s" % jid)
@stream.storage.authenticate(jid, password).tap do |user|
log.info("Authentication succeeded: %s" % user.jid) if user
end
rescue => e
log.error("Failed to authenticate: #{e.to_s}")
raise SaslErrors::TemporaryAuthFailure
end

# Return the +JID+ and password decoded from the base64 encoded SASL PLAIN
# credentials formatted as authzid\0authcid\0password.
# http://tools.ietf.org/html/rfc6120#section-6.3.8
# http://tools.ietf.org/html/rfc4616
def decode_credentials(encoded)
authzid, node, password = decode64(encoded).split("\x00")
raise SaslErrors::InvalidAuthzid unless authzid.nil? || authzid.empty?
raise SaslErrors::NotAuthorized if node.nil? || node.empty? || password.nil? || password.empty?
jid = JID.new(node, @stream.domain) rescue (raise SaslErrors::NotAuthorized)
[jid, password]
end

# Decode the base64 encoded string, raising an error for invalid data.
# http://tools.ietf.org/html/rfc6120#section-13.9.1
def decode64(encoded)
Base64.strict_decode64(encoded)
rescue
raise SaslErrors::IncorrectEncoding
end
end
end
end
8 changes: 7 additions & 1 deletion lib/vines/stream/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

module Vines
class Stream

# Implements the XMPP protocol for server-to-server (s2s) streams. This
# serves connected streams using the jabber:server namespace. This handles
# both accepting incoming s2s streams and initiating outbound s2s streams
# to other servers.
class Server < Stream
MECHANISMS = %w[EXTERNAL].freeze

# Starts the connection to the remote server. When the stream is
# connected and ready to send stanzas it will yield to the callback
Expand Down Expand Up @@ -76,6 +76,12 @@ def ssl_handshake_completed
close_connection unless cert_domain_matches?(@remote_domain)
end

# Return an array of allowed authentication mechanisms advertised as
# server stream features.
def authentication_mechanisms
MECHANISMS
end

def stream_type
:server
end
Expand Down
6 changes: 0 additions & 6 deletions lib/vines/stream/server/auth_restart.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,6 @@ class AuthRestart < Client::AuthRestart
def initialize(stream, success=Auth)
super
end

private

def mechanisms
['EXTERNAL']
end
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/vines/stream/server/outbound/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ def initialize(stream, success=AuthResult)

def node(node)
raise StreamErrors::NotAuthorized unless external?(node)
authz = Base64.encode64(stream.domain).chomp
stream.write(%Q{<auth xmlns="#{NS}" mechanism="EXTERNAL">#{authz}</auth>})
authzid = Base64.strict_encode64(stream.domain)
stream.write(%Q{<auth xmlns="#{NS}" mechanism="EXTERNAL">#{authzid}</auth>})
advance
end

Expand Down
2 changes: 1 addition & 1 deletion test/rake_test_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Use the latest MiniTest gem instead of the buggy
# version included with Ruby 1.9.2.
gem 'minitest', '~> 2.11.2'
gem 'minitest', '~> 2.12.1'

# Load the test files from the command line.

Expand Down

0 comments on commit 3c6d2a6

Please sign in to comment.