vines 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/Rakefile CHANGED
@@ -33,10 +33,10 @@ is mandatory on all client and server connections."
33
33
  s.executables = %w[vines]
34
34
  s.require_path = "lib"
35
35
 
36
- s.add_dependency "activerecord", "~> 3.2.1"
36
+ s.add_dependency "activerecord", "~> 3.2.3"
37
37
  s.add_dependency "bcrypt-ruby", "~> 3.0.1"
38
- s.add_dependency "em-http-request", "~> 1.0.1"
39
- s.add_dependency "em-hiredis", "~> 0.1.0"
38
+ s.add_dependency "em-http-request", "~> 1.0.2"
39
+ s.add_dependency "em-hiredis", "~> 0.1.1"
40
40
  s.add_dependency "eventmachine", ">= 0.12.10"
41
41
  s.add_dependency "http_parser.rb", "~> 0.5.3"
42
42
  s.add_dependency "mongo", "~> 1.5.2"
@@ -44,10 +44,10 @@ is mandatory on all client and server connections."
44
44
  s.add_dependency "net-ldap", "~> 0.2.2"
45
45
  s.add_dependency "nokogiri", "~> 1.4.7"
46
46
 
47
- s.add_development_dependency "minitest", "~> 2.11.2"
47
+ s.add_development_dependency "minitest", "~> 2.12.1"
48
48
  s.add_development_dependency "coffee-script", "~> 2.2.0"
49
49
  s.add_development_dependency "coffee-script-source", "~> 1.2.0"
50
- s.add_development_dependency "uglifier", "~> 1.2.3"
50
+ s.add_development_dependency "uglifier", "~> 1.2.4"
51
51
  s.add_development_dependency "rake"
52
52
  s.add_development_dependency "sqlite3"
53
53
 
@@ -142,6 +142,7 @@ end
142
142
  vines/cluster/subscriber
143
143
 
144
144
  vines/stream
145
+ vines/stream/sasl
145
146
  vines/stream/state
146
147
  vines/stream/parser
147
148
 
@@ -41,6 +41,9 @@ module Vines
41
41
  end
42
42
  end
43
43
 
44
+ # Advance the state machine into the +Closed+ state so any remaining queued
45
+ # nodes are not processed while we're waiting for EM to actually close the
46
+ # connection.
44
47
  def close_connection(after_writing=false)
45
48
  super
46
49
  @closed = true
@@ -189,9 +192,9 @@ module Vines
189
192
  node.name == ERROR && ns == NAMESPACES[:stream]
190
193
  end
191
194
 
192
- # Schedule a queue pop on the EM thread to handle the next element.
193
- # This provides the in-order stanza processing guarantee required by
194
- # RFC 6120 section 10.1.
195
+ # Schedule a queue pop on the EM thread to handle the next element. This
196
+ # guarantees all stanzas received on this stream are processed in order.
197
+ # http://tools.ietf.org/html/rfc6120#section-10.1
195
198
  def process_node_queue
196
199
  @nodes.pop do |node|
197
200
  Fiber.new do
@@ -229,13 +232,13 @@ module Vines
229
232
  ["#{label} stanza:".ljust(PAD), from, to, node])
230
233
  end
231
234
 
232
- # Returns the current state of the stream's state machine. Provided as a
235
+ # Returns the current +State+ of the stream's state machine. Provided as a
233
236
  # method so subclasses can override the behavior.
234
237
  def state
235
238
  @state
236
239
  end
237
240
 
238
- # Return true if this is a valid domain-only JID that can be used in
241
+ # Return +true+ if this is a valid domain-only JID that can be used in
239
242
  # stream initiation stanza headers.
240
243
  def valid_address?(jid)
241
244
  JID.new(jid).domain? rescue false
@@ -2,10 +2,11 @@
2
2
 
3
3
  module Vines
4
4
  class Stream
5
-
6
5
  # Implements the XMPP protocol for client-to-server (c2s) streams. This
7
6
  # serves connected streams using the jabber:client namespace.
8
7
  class Client < Stream
8
+ MECHANISMS = %w[PLAIN].freeze
9
+
9
10
  def initialize(config)
10
11
  super
11
12
  @session = Client::Session.new(self)
@@ -28,6 +29,12 @@ module Vines
28
29
  end
29
30
  end
30
31
 
32
+ # Return an array of allowed authentication mechanisms advertised as
33
+ # client stream features.
34
+ def authentication_mechanisms
35
+ MECHANISMS
36
+ end
37
+
31
38
  def ssl_handshake_completed
32
39
  if get_peer_cert
33
40
  close_connection unless cert_domain_matches?(@session.domain)
@@ -41,8 +48,9 @@ module Vines
41
48
 
42
49
  def start(node)
43
50
  to, from = %w[to from].map {|a| node[a] }
44
- @session.domain = to
51
+ @session.domain = to unless @session.domain
45
52
  send_stream_header(from)
53
+ raise StreamErrors::NotAuthorized if domain_change?(to)
46
54
  raise StreamErrors::UnsupportedVersion unless node['version'] == '1.0'
47
55
  raise StreamErrors::ImproperAddressing unless valid_address?(@session.domain)
48
56
  raise StreamErrors::HostUnknown unless config.vhost?(@session.domain)
@@ -52,6 +60,13 @@ module Vines
52
60
 
53
61
  private
54
62
 
63
+ # The +to+ domain address set on the initial stream header must not change
64
+ # during stream restarts. This prevents a user from authenticating in one
65
+ # domain, then using a stream in a different domain.
66
+ def domain_change?(to)
67
+ to != @session.domain
68
+ end
69
+
55
70
  def send_stream_header(to)
56
71
  attrs = {
57
72
  'xmlns' => NAMESPACES[:client],
@@ -4,75 +4,54 @@ module Vines
4
4
  class Stream
5
5
  class Client
6
6
  class Auth < State
7
- NS = NAMESPACES[:sasl]
8
- AUTH = 'auth'.freeze
9
- SUCCESS = %Q{<success xmlns="#{NS}"/>}.freeze
7
+ NS = NAMESPACES[:sasl]
8
+ MECHANISM = 'mechanism'.freeze
9
+ AUTH = 'auth'.freeze
10
+ PLAIN = 'PLAIN'.freeze
11
+ EXTERNAL = 'EXTERNAL'.freeze
12
+ SUCCESS = %Q{<success xmlns="#{NS}"/>}.freeze
10
13
  MAX_AUTH_ATTEMPTS = 3
11
- AUTH_MECHANISMS = {'PLAIN' => :plain_auth, 'EXTERNAL' => :external_auth}.freeze
12
14
 
13
15
  def initialize(stream, success=BindRestart)
14
16
  super
15
- @attempts, @outstanding = 0, false
17
+ @attempts = 0
18
+ @sasl = SASL.new(stream)
16
19
  end
17
20
 
18
21
  def node(node)
19
22
  raise StreamErrors::NotAuthorized unless auth?(node)
20
- unless node.text.empty?
21
- (name = AUTH_MECHANISMS[node['mechanism']]) ?
22
- method(name).call(node) :
23
- send_auth_fail(SaslErrors::InvalidMechanism.new)
24
- else
23
+ if node.text.empty?
25
24
  send_auth_fail(SaslErrors::MalformedRequest.new)
25
+ elsif stream.authentication_mechanisms.include?(node[MECHANISM])
26
+ case node[MECHANISM]
27
+ when PLAIN then plain_auth(node)
28
+ when EXTERNAL then external_auth(node)
29
+ end
30
+ else
31
+ send_auth_fail(SaslErrors::InvalidMechanism.new)
26
32
  end
27
33
  end
28
34
 
29
35
  private
30
36
 
31
37
  def auth?(node)
32
- node.name == AUTH && namespace(node) == NS && !@outstanding
38
+ node.name == AUTH && namespace(node) == NS
33
39
  end
34
40
 
35
- # Authenticate s2s streams by comparing their domain to
36
- # their SSL certificate.
37
- def external_auth(stanza)
38
- domain = Base64.decode64(stanza.text)
39
- cert = OpenSSL::X509::Certificate.new(stream.get_peer_cert) rescue nil
40
- if (!OpenSSL::SSL.verify_certificate_identity(cert, domain) rescue false)
41
- send_auth_fail(SaslErrors::NotAuthorized.new)
42
- stream.write('</stream:stream>')
43
- stream.close_connection_after_writing
44
- else
45
- stream.remote_domain = domain
46
- send_auth_success
47
- end
41
+ def plain_auth(node)
42
+ stream.user = @sasl.plain_auth(node.text)
43
+ send_auth_success
44
+ rescue => e
45
+ send_auth_fail(e)
48
46
  end
49
47
 
50
- # Authenticate c2s streams using a username and password. Call the
51
- # authentication module in a separate thread to avoid blocking stanza
52
- # processing for other users.
53
- def plain_auth(stanza)
54
- jid, node, password = Base64.decode64(stanza.text).split("\000")
55
- jid = [node, stream.domain].join('@') if jid.nil? || jid.empty?
56
- log.info("Authenticating user: %s" % jid)
57
- @outstanding = true
58
- begin
59
- user = stream.storage.authenticate(jid, password)
60
- finish(user || SaslErrors::NotAuthorized.new)
61
- rescue => e
62
- log.error("Failed to authenticate: #{e.to_s}")
63
- finish(SaslErrors::TemporaryAuthFailure.new)
64
- end
65
- end
66
-
67
- def finish(result)
68
- @outstanding = false
69
- if result.kind_of?(Exception)
70
- send_auth_fail(result)
71
- else
72
- stream.user = result
73
- log.info("Authentication succeeded: %s" % stream.user.jid)
74
- send_auth_success
75
- end
48
+ def external_auth(node)
49
+ @sasl.external_auth(node.text)
50
+ send_auth_success
51
+ rescue => e
52
+ send_auth_fail(e)
53
+ stream.write('</stream:stream>')
54
+ stream.close_connection_after_writing
76
55
  end
77
56
 
78
57
  def send_auth_success
@@ -15,18 +15,14 @@ module Vines
15
15
  features = doc.create_element('stream:features') do |el|
16
16
  el << doc.create_element('mechanisms') do |parent|
17
17
  parent.default_namespace = NAMESPACES[:sasl]
18
- mechanisms.each {|name| parent << doc.create_element('mechanism', name) }
18
+ stream.authentication_mechanisms.each do |name|
19
+ parent << doc.create_element('mechanism', name)
20
+ end
19
21
  end
20
22
  end
21
23
  stream.write(features)
22
24
  advance
23
25
  end
24
-
25
- private
26
-
27
- def mechanisms
28
- ['EXTERNAL', 'PLAIN']
29
- end
30
26
  end
31
27
  end
32
28
  end
@@ -0,0 +1,74 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ class Stream
5
+ # Provides plain (username/password) and external (TLS certificate) SASL
6
+ # authentication to client and server streams.
7
+ class SASL
8
+ include Vines::Log
9
+ EMPTY = '='.freeze
10
+
11
+ def initialize(stream)
12
+ @stream = stream
13
+ end
14
+
15
+ # Authenticate s2s streams, comparing their domain to their SSL certificate.
16
+ # Return +true+ if the base64 encoded domain matches the TLS certificate
17
+ # presented earlier in stream negotiation. Raise a +SaslError+ if
18
+ # authentication failed.
19
+ # http://xmpp.org/extensions/xep-0178.html#s2s
20
+ def external_auth(encoded)
21
+ unless encoded == EMPTY
22
+ authzid = decode64(encoded)
23
+ matches_from = (authzid == @stream.remote_domain)
24
+ raise SaslErrors::InvalidAuthzid unless matches_from
25
+ end
26
+ matches_from = @stream.cert_domain_matches?(@stream.remote_domain)
27
+ matches_from or raise SaslErrors::NotAuthorized
28
+ end
29
+
30
+ # Authenticate c2s streams using a username and password. Return the
31
+ # authenticated +User+ or raise a +SaslError+ if authentication failed.
32
+ def plain_auth(encoded)
33
+ jid, password = decode_credentials(encoded)
34
+ user = authenticate(jid, password)
35
+ user or raise SaslErrors::NotAuthorized
36
+ end
37
+
38
+ private
39
+
40
+ # Storage backends should not raise errors, but if an unexpected error
41
+ # occurs during authentication, convert it to a temporary-auth-failure.
42
+ # Return the authenticated +User+ or +nil+ if authentication failed.
43
+ def authenticate(jid, password)
44
+ log.info("Authenticating user: %s" % jid)
45
+ @stream.storage.authenticate(jid, password).tap do |user|
46
+ log.info("Authentication succeeded: %s" % user.jid) if user
47
+ end
48
+ rescue => e
49
+ log.error("Failed to authenticate: #{e.to_s}")
50
+ raise SaslErrors::TemporaryAuthFailure
51
+ end
52
+
53
+ # Return the +JID+ and password decoded from the base64 encoded SASL PLAIN
54
+ # credentials formatted as authzid\0authcid\0password.
55
+ # http://tools.ietf.org/html/rfc6120#section-6.3.8
56
+ # http://tools.ietf.org/html/rfc4616
57
+ def decode_credentials(encoded)
58
+ authzid, node, password = decode64(encoded).split("\x00")
59
+ raise SaslErrors::NotAuthorized if node.nil? || node.empty? || password.nil? || password.empty?
60
+ jid = JID.new(node, @stream.domain) rescue (raise SaslErrors::NotAuthorized)
61
+ raise SaslErrors::InvalidAuthzid unless authzid.nil? || authzid.empty? || authzid.downcase == jid.to_s
62
+ [jid, password]
63
+ end
64
+
65
+ # Decode the base64 encoded string, raising an error for invalid data.
66
+ # http://tools.ietf.org/html/rfc6120#section-13.9.1
67
+ def decode64(encoded)
68
+ Base64.strict_decode64(encoded)
69
+ rescue
70
+ raise SaslErrors::IncorrectEncoding
71
+ end
72
+ end
73
+ end
74
+ end
@@ -2,12 +2,12 @@
2
2
 
3
3
  module Vines
4
4
  class Stream
5
-
6
5
  # Implements the XMPP protocol for server-to-server (s2s) streams. This
7
6
  # serves connected streams using the jabber:server namespace. This handles
8
7
  # both accepting incoming s2s streams and initiating outbound s2s streams
9
8
  # to other servers.
10
9
  class Server < Stream
10
+ MECHANISMS = %w[EXTERNAL].freeze
11
11
 
12
12
  # Starts the connection to the remote server. When the stream is
13
13
  # connected and ready to send stanzas it will yield to the callback
@@ -76,6 +76,12 @@ module Vines
76
76
  close_connection unless cert_domain_matches?(@remote_domain)
77
77
  end
78
78
 
79
+ # Return an array of allowed authentication mechanisms advertised as
80
+ # server stream features.
81
+ def authentication_mechanisms
82
+ MECHANISMS
83
+ end
84
+
79
85
  def stream_type
80
86
  :server
81
87
  end
@@ -105,8 +111,10 @@ module Vines
105
111
 
106
112
  def start(node)
107
113
  if @outbound then send_stream_header; return end
108
- @domain, @remote_domain = %w[to from].map {|a| node[a] }
114
+ to, from = %w[to from].map {|a| node[a] }
115
+ @domain, @remote_domain = to, from unless @domain
109
116
  send_stream_header
117
+ raise StreamErrors::NotAuthorized if domain_change?(to, from)
110
118
  raise StreamErrors::UnsupportedVersion unless node['version'] == '1.0'
111
119
  raise StreamErrors::ImproperAddressing unless valid_address?(@domain) && valid_address?(@remote_domain)
112
120
  raise StreamErrors::HostUnknown unless config.vhost?(@domain) || config.pubsub?(@domain) || config.component?(@domain)
@@ -117,6 +125,14 @@ module Vines
117
125
 
118
126
  private
119
127
 
128
+ # The +to+ and +from+ domain addresses set on the initial stream header
129
+ # must not change during stream restarts. This prevents a server from
130
+ # authenticating as one domain, then sending stanzas from users in a
131
+ # different domain.
132
+ def domain_change?(to, from)
133
+ to != @domain || from != @remote_domain
134
+ end
135
+
120
136
  def send_stream_header
121
137
  attrs = {
122
138
  'xmlns' => NAMESPACES[:server],
@@ -7,12 +7,6 @@ module Vines
7
7
  def initialize(stream, success=Auth)
8
8
  super
9
9
  end
10
-
11
- private
12
-
13
- def mechanisms
14
- ['EXTERNAL']
15
- end
16
10
  end
17
11
  end
18
12
  end
@@ -13,8 +13,8 @@ module Vines
13
13
 
14
14
  def node(node)
15
15
  raise StreamErrors::NotAuthorized unless external?(node)
16
- authz = Base64.encode64(stream.domain).chomp
17
- stream.write(%Q{<auth xmlns="#{NS}" mechanism="EXTERNAL">#{authz}</auth>})
16
+ authzid = Base64.strict_encode64(stream.domain)
17
+ stream.write(%Q{<auth xmlns="#{NS}" mechanism="EXTERNAL">#{authzid}</auth>})
18
18
  advance
19
19
  end
20
20
 
@@ -5,6 +5,9 @@ module Vines
5
5
  class Server
6
6
  class Outbound
7
7
  class AuthResult < State
8
+ SUCCESS = 'success'.freeze
9
+ FAILURE = 'failure'.freeze
10
+
8
11
  def initialize(stream, success=FinalRestart)
9
12
  super
10
13
  end
@@ -12,14 +15,14 @@ module Vines
12
15
  def node(node)
13
16
  raise StreamErrors::NotAuthorized unless namespace(node) == NAMESPACES[:sasl]
14
17
  case node.name
15
- when 'success'
16
- stream.start(node)
17
- stream.reset
18
- advance
19
- when 'failure'
20
- stream.close_connection
21
- else
22
- raise StreamErrors::NotAuthorized
18
+ when SUCCESS
19
+ stream.start(node)
20
+ stream.reset
21
+ advance
22
+ when FAILURE
23
+ stream.close_connection
24
+ else
25
+ raise StreamErrors::NotAuthorized
23
26
  end
24
27
  end
25
28
  end
@@ -5,7 +5,9 @@ module Vines
5
5
  class Server
6
6
  class Outbound
7
7
  class TLSResult < State
8
- NS = NAMESPACES[:tls]
8
+ NS = NAMESPACES[:tls]
9
+ PROCEED = 'proceed'.freeze
10
+ FAILURE = 'failure'.freeze
9
11
 
10
12
  def initialize(stream, success=AuthRestart)
11
13
  super
@@ -14,15 +16,15 @@ module Vines
14
16
  def node(node)
15
17
  raise StreamErrors::NotAuthorized unless namespace(node) == NS
16
18
  case node.name
17
- when 'proceed'
18
- stream.encrypt
19
- stream.start(node)
20
- stream.reset
21
- advance
22
- when 'failure'
23
- stream.close_connection
24
- else
25
- raise StreamErrors::NotAuthorized
19
+ when PROCEED
20
+ stream.encrypt
21
+ stream.start(node)
22
+ stream.reset
23
+ advance
24
+ when FAILURE
25
+ stream.close_connection
26
+ else
27
+ raise StreamErrors::NotAuthorized
26
28
  end
27
29
  end
28
30
  end