etna 0.1.11

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/lib/etna/hmac.rb ADDED
@@ -0,0 +1,86 @@
1
+ module Etna
2
+ class Hmac
3
+ # These are the items that need to be signed
4
+ SIGN_ITEMS=[ :method, :host, :path, :expiration, :nonce, :id, :headers ]
5
+ SIGN_ITEMS.each { |item| attr_reader item }
6
+
7
+ def initialize application, params
8
+ @application = application
9
+
10
+ @test_signature = params.delete(:test_signature)
11
+
12
+ SIGN_ITEMS.each do |item|
13
+ raise ArgumentError, "Hmac requires param #{item}" unless params[item]
14
+ instance_variable_set("@#{item}", params[item])
15
+ end
16
+
17
+ @id = @id.to_sym
18
+
19
+ raise ArgumentError, "Headers must be a Hash" unless @headers.is_a?(Hash)
20
+ end
21
+
22
+ # this returns arguments for URI::HTTP.build
23
+ def url_params
24
+ params = {
25
+ signature: signature,
26
+ expiration: @expiration,
27
+ nonce: @nonce,
28
+ id: @id.to_s,
29
+ headers: @headers.keys.join(','),
30
+ }.merge(@headers).map do |name, value|
31
+ [
32
+ "X-Etna-#{ name.to_s.split(/_/).map(&:capitalize).join('-') }",
33
+ value
34
+ ]
35
+ end.to_h
36
+
37
+ return {
38
+ host: @host,
39
+ path: @path,
40
+ query: URI.encode_www_form(params)
41
+ }
42
+ end
43
+
44
+ def valid?
45
+ valid_id? && valid_signature? && valid_timestamp?
46
+ end
47
+
48
+ def signature
49
+ @application.sign.hmac(text_to_sign, @application.config(:hmac_keys)[@id])
50
+ end
51
+
52
+ private
53
+
54
+ def valid_signature?
55
+ signature == @test_signature
56
+ end
57
+
58
+ def valid_timestamp?
59
+ DateTime.parse(@expiration) >= DateTime.now
60
+ end
61
+
62
+ def valid_id?
63
+ @application.config(:hmac_keys).key?(@id)
64
+ end
65
+
66
+ # This scheme is adapted from the Hawk spec
67
+ # (github:hueniverse/hawk) and the Acquia HTTP
68
+ # Hmac spec (github:acquia/http-hmac-spec)
69
+
70
+
71
+ def text_to_sign
72
+ [
73
+ # these come from the route
74
+ @method,
75
+ @host,
76
+ @path,
77
+
78
+ # these are set as headers or params
79
+ @nonce,
80
+ @id,
81
+ @headers.map{|l| l.join('=')}.join(';'),
82
+ @expiration,
83
+ ].join("\n")
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,29 @@
1
+ require 'logger'
2
+
3
+ module Etna
4
+ class Logger < ::Logger
5
+ def initialize(log_dev, age, size)
6
+ super
7
+
8
+ self.formatter = proc do |severity, datetime, progname, msg|
9
+ format(severity, datetime, progname, msg)
10
+ end
11
+ end
12
+
13
+ def format(severity, datetime, progname, msg)
14
+ "#{severity}:#{datetime.iso8601} #{msg}\n"
15
+ end
16
+
17
+ def log_error(e)
18
+ error(e.message)
19
+ e.backtrace.each do |trace|
20
+ error(trace)
21
+ end
22
+ end
23
+
24
+ def log_request(request)
25
+ request.env['etna.logger'] = self
26
+ request.env['etna.request_id'] = (rand*36**6).to_i.to_s(36)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,41 @@
1
+ module Etna
2
+ class ParseBody
3
+ def initialize(app)
4
+ @app = app
5
+ end
6
+
7
+ def call(env)
8
+ params = env['rack.request.params'] || {}
9
+
10
+ case env['CONTENT_TYPE']
11
+ when %r{application/json}i
12
+ body = env['rack.input'].read
13
+ if body =~ %r/^\s*\{/
14
+ params.update(
15
+ JSON.parse(body)
16
+ )
17
+ end
18
+ when %r{application/x-www-form-urlencoded}i
19
+ params.update(
20
+ Rack::Utils.parse_nested_query(
21
+ env['rack.input'].read
22
+ )
23
+ )
24
+ when %r{multipart/form-data}i
25
+ params.update(
26
+ Rack::Multipart.parse_multipart(env)
27
+ )
28
+ end
29
+ # Always parse the params that are url-encoded.
30
+ params.update(
31
+ Rack::Utils.parse_nested_query(
32
+ env['QUERY_STRING'], '&'
33
+ )
34
+ )
35
+ env.update(
36
+ 'rack.request.params' => params
37
+ )
38
+ @app.call(env)
39
+ end
40
+ end
41
+ end
data/lib/etna/route.rb ADDED
@@ -0,0 +1,165 @@
1
+ module Etna
2
+ class Route
3
+ attr_reader :name
4
+
5
+ def initialize(method, route, options, &block)
6
+ @method = method
7
+ @action = options[:action]
8
+ @auth = options[:auth]
9
+ @name = route_name(options)
10
+ @route = route.gsub(/\A(?=[^\/])/, '/')
11
+ @block = block
12
+ end
13
+
14
+ def to_hash
15
+ {
16
+ method: @method,
17
+ route: @route,
18
+ name: @name.to_s,
19
+ params: parts
20
+ }.compact
21
+ end
22
+
23
+ def matches?(request)
24
+ @method == request.request_method && request.path.match(route_regexp)
25
+ end
26
+
27
+ NAMED_PARAM=/:([\w]+)/
28
+ GLOB_PARAM=/\*([\w]+)$/
29
+
30
+ PARAM_TYPES=[ NAMED_PARAM, GLOB_PARAM ]
31
+
32
+ UNSAFE=/[^\-_.!~*'()a-zA-Z\d;\/?:@&=+$,]/
33
+
34
+ def self.path(route, params=nil)
35
+ if params
36
+ PARAM_TYPES.reduce(route) do |path,pat|
37
+ path.gsub(pat) do
38
+ URI.encode( params[$1.to_sym], UNSAFE)
39
+ end
40
+ end
41
+ else
42
+ route
43
+ end
44
+ end
45
+
46
+ def path(params=nil)
47
+ self.class.path(@route, params)
48
+ end
49
+
50
+ def parts
51
+ part_list = PARAM_TYPES.map do |pat|
52
+ "(?:#{pat.source})"
53
+ end
54
+ @route.scan(
55
+ /(?:#{part_list.join('|')})/
56
+ ).flatten.compact
57
+ end
58
+
59
+ def call(app, request)
60
+ update_params(request)
61
+
62
+ unless authorized?(request)
63
+ return [ 403, { 'Content-Type' => 'application/json' }, [ { error: 'You are forbidden from performing this action.' }.to_json ] ]
64
+ end
65
+
66
+ if @action
67
+ controller, action = @action.split('#')
68
+ controller_class = Kernel.const_get(
69
+ :"#{controller.camel_case}Controller"
70
+ )
71
+ logger = request.env['etna.logger']
72
+ user = request.env['etna.user']
73
+
74
+ params = request.env['rack.request.params'].map do |key,value|
75
+ value = value.to_s
76
+ value = value[0..500] + "..." + value[-100..-1] if value.length > 600
77
+ [ key, value ]
78
+ end.to_h
79
+
80
+ logger.warn("User #{user ? user.email : :unknown} calling #{controller}##{action} with params #{params}")
81
+ return controller_class.new(request, action).response
82
+ elsif @block
83
+ application = Etna::Application.find(app.class).class
84
+ controller_class = application.const_defined?(:Controller) ? application.const_get(:Controller) : Etna::Controller
85
+
86
+ controller_class.new(request).response(&@block)
87
+ end
88
+ end
89
+
90
+ # the route does not require authorization
91
+ def noauth?
92
+ @auth && @auth[:noauth]
93
+ end
94
+
95
+ private
96
+
97
+ def authorized?(request)
98
+ # If there is no @auth requirement, they are ok - this doesn't preclude
99
+ # them being rejected in the controller response
100
+ !@auth || @auth[:noauth] || (user_authorized?(request) && hmac_authorized?(request))
101
+ end
102
+
103
+ def user_authorized?(request)
104
+ # this is true if there are no user requirements
105
+ return true unless @auth[:user]
106
+
107
+ user = request.env['etna.user']
108
+
109
+ # if there is a user requirement, we must have a user
110
+ return false unless user
111
+
112
+ params = request.env['rack.request.params']
113
+
114
+ @auth[:user].all? do |constraint, param_name|
115
+ user.respond_to?(constraint) && user.send(constraint, params[param_name])
116
+ end
117
+ end
118
+
119
+ def hmac_authorized?(request)
120
+ # either there is no hmac requirement, or we have a valid hmac
121
+ !@auth[:hmac] || request.env['etna.hmac'].valid?
122
+ end
123
+
124
+ def route_name(options)
125
+ # use the given one if you can
126
+ return options[:as] if options[:as]
127
+
128
+ # otherwise formulate it from the action if possible
129
+ return options[:action].sub(/#/,'_').to_sym if options[:action]
130
+
131
+ # unnamed route
132
+ return nil
133
+ end
134
+
135
+ def update_params(request)
136
+ match = route_regexp.match(request.path)
137
+ request.env['rack.request.params'].update(
138
+ Hash[
139
+ match.names.map(&:to_sym).zip(
140
+ match.captures.map do |capture|
141
+ URI.decode(capture)
142
+ end
143
+ )
144
+ ]
145
+ )
146
+ end
147
+
148
+ def route_regexp
149
+ @route_regexp ||=
150
+ Regexp.new(
151
+ '\A' +
152
+ @route.
153
+ # any :params match separator-free strings
154
+ gsub(NAMED_PARAM, '(?<\1>[^\.\/\?]+)').
155
+ # any *params match arbitrary strings
156
+ gsub(GLOB_PARAM, '(?<\1>.+)').
157
+ # ignore any trailing slashes in the route
158
+ gsub(/\/\z/, '') +
159
+ # trailing slashes in the path can be ignored
160
+ '/?' +
161
+ '\z'
162
+ )
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,84 @@
1
+ # This class handles the http request and routing.
2
+ module Etna
3
+ class Server
4
+ class << self
5
+ def route(method, path, options={}, &block)
6
+ @routes ||= []
7
+
8
+ @routes << Etna::Route.new(
9
+ method,
10
+ path,
11
+ (@default_options || {}).merge(options),
12
+ &block
13
+ )
14
+ end
15
+
16
+ def find_route(request)
17
+ @routes.find do |route|
18
+ route.matches? request
19
+ end
20
+ end
21
+
22
+ def with(options={}, &block)
23
+ @default_options = options
24
+ instance_eval(&block)
25
+ @default_options = nil
26
+ end
27
+
28
+ def get(path, options={}, &block)
29
+ route('GET', path, options, &block)
30
+ end
31
+
32
+ def post(path, options={}, &block)
33
+ route('POST', path, options, &block)
34
+ end
35
+
36
+ def put(path, options={}, &block)
37
+ route('PUT', path, options, &block)
38
+ end
39
+
40
+ def delete(path, options={}, &block)
41
+ route('DELETE', path, options, &block)
42
+ end
43
+
44
+ def route_path(request,name,params={})
45
+ route = routes.find do |route|
46
+ route.name.to_s == name.to_s
47
+ end
48
+ return route ? route.path(params) : nil
49
+ end
50
+
51
+ attr_reader :routes
52
+ end
53
+
54
+ def call(env)
55
+ request = Rack::Request.new(env)
56
+
57
+ request.env['etna.server'] = self
58
+
59
+ application.logger.log_request(request)
60
+
61
+ route = self.class.find_route(request)
62
+
63
+ if route
64
+ @params = request.env['rack.request.params']
65
+ return route.call(self, request)
66
+ end
67
+
68
+ [404, {}, ["There is no such path '#{request.path}'"]]
69
+ end
70
+
71
+ def initialize
72
+ # Setup logging.
73
+ application.setup_logger
74
+ end
75
+
76
+ private
77
+
78
+ # The base application class is a singleton independent of this rack server,
79
+ # holding e.g. configuration.
80
+ def application
81
+ @application ||= Etna::Application.instance
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,67 @@
1
+ # General signing/hashing utilities.
2
+ require 'jwt'
3
+ require 'securerandom'
4
+
5
+ module Etna
6
+ class SignService
7
+ def initialize(application)
8
+ @application = application
9
+ end
10
+
11
+ def hash_password(password)
12
+ signature(
13
+ [ password, @application.config(:pass_salt) ],
14
+ @application.config(:pass_algo)
15
+ )
16
+ end
17
+
18
+ def hmac(message, key)
19
+ OpenSSL::HMAC.hexdigest(
20
+ 'SHA256',
21
+ key,
22
+ message
23
+ )
24
+ end
25
+
26
+ def jwt_token(payload)
27
+ return JWT.encode(
28
+ payload,
29
+ private_key,
30
+ @application.config(:token_algo)
31
+ )
32
+ end
33
+
34
+ def uid(size=nil)
35
+ SecureRandom.hex(size)
36
+ end
37
+
38
+ def jwt_decode(token)
39
+ return JWT.decode(
40
+ token,
41
+ public_key,
42
+ true,
43
+ algorithm: @application.config(:token_algo)
44
+ )
45
+ end
46
+
47
+ def private_key
48
+ @private_key ||= OpenSSL::PKey::RSA.new(@application.config(:rsa_private))
49
+ end
50
+
51
+ def public_key
52
+ @public_key ||= OpenSSL::PKey::RSA.new(@application.config(:rsa_public))
53
+ end
54
+
55
+ def generate_private_key(key_size)
56
+ OpenSSL::PKey::RSA.generate(key_size)
57
+ end
58
+
59
+ private
60
+
61
+ def signature(params, algo)
62
+ algo = algo.upcase.to_sym
63
+ raise "Unknown signature algorithm!" unless [ :MD5, :SHA256 ].include?(algo)
64
+ Digest.const_get(algo).hexdigest(params.join)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,28 @@
1
+ module Etna::Spec
2
+ module Auth
3
+ AUTH_USERS = {
4
+ superuser: {
5
+ email: 'zeus@olympus.org', first: 'Zeus', perm: 'A:administration'
6
+ },
7
+ admin: {
8
+ email: 'hera@olympus.org', first: 'Hera', perm: 'a:labors'
9
+ },
10
+ editor: {
11
+ email: 'eurystheus@twelve-labors.org', first: 'Eurystheus', perm: 'E:labors'
12
+ },
13
+ restricted_editor: {
14
+ email: 'copreus@twelve-labors.org', first: 'Copreus', perm: 'e:labors'
15
+ },
16
+ viewer: {
17
+ email: 'hercules@twelve-labors.org', first: 'Hercules', perm: 'v:labors'
18
+ },
19
+ non_user: {
20
+ email: 'nessus@centaurs.org', first: 'Nessus', perm: ''
21
+ }
22
+ }
23
+
24
+ def auth_header(user_type)
25
+ header(*Etna::TestAuth.token_header(AUTH_USERS[user_type]))
26
+ end
27
+ end
28
+ end
data/lib/etna/spec.rb ADDED
@@ -0,0 +1 @@
1
+ require_relative 'spec/auth'
@@ -0,0 +1,30 @@
1
+ module Etna
2
+ class SymbolizeParams
3
+ def initialize(server)
4
+ @server = server
5
+ end
6
+
7
+ def call(env)
8
+ env['rack.request.params'] = symbolize(env['rack.request.params']) || {}
9
+
10
+ @server.call(env)
11
+ end
12
+
13
+ private
14
+
15
+ def symbolize(obj)
16
+ if obj.is_a?(Hash)
17
+ return obj.reduce({}) do |memo,(k,v)|
18
+ memo[k.to_sym] = symbolize(v)
19
+ memo
20
+ end
21
+ elsif obj.is_a?(Array)
22
+ return obj.reduce([]) do |memo,v|
23
+ memo << symbolize(v)
24
+ memo
25
+ end
26
+ end
27
+ obj
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,80 @@
1
+ require_relative 'user'
2
+
3
+ # This is an authentication layer you can use in testing. It will make an
4
+ # Etna::User as usual that your controller can respond to; you can pass
5
+ # permissions for this user directly in the Authorization header
6
+ module Etna
7
+ class TestAuth < Auth
8
+ def self.token_header(params)
9
+ token = Base64.strict_encode64(params.to_json)
10
+ return [ 'Authorization', "Etna #{token}" ]
11
+ end
12
+
13
+ def self.token_param(params)
14
+ token = Base64.strict_encode64(params.to_json)
15
+ return [ Etna::Auth.etna_url_param(:authorization).to_s, "Etna #{token}" ]
16
+ end
17
+
18
+ def self.hmac_header(signature)
19
+ return [ Etna::Auth.etna_url_param(:signature).to_s, signature ]
20
+ end
21
+
22
+ def self.hmac_params(params)
23
+ return {
24
+ expiration: params.delete(:expiration) || DateTime.now.iso8601,
25
+ id: params.delete(:id) || 'etna',
26
+ nonce: 'nonce',
27
+ signature: params.delete(:signature) || 'invalid',
28
+ headers: params.keys.join(',')
29
+ }.merge(params).map do |item, value|
30
+ [ Etna::Auth.etna_url_param(item).to_s, value ]
31
+ end.to_h
32
+ end
33
+
34
+ def approve_user(request)
35
+ token = auth(request,:etna)
36
+
37
+ return false unless token
38
+
39
+ # here we simply base64-encode our user hash and pass it through
40
+ payload = JSON.parse(Base64.decode64(token))
41
+
42
+ request.env['etna.user'] = Etna::User.new(payload.map{|k,v| [k.to_sym, v]}.to_h, token)
43
+ end
44
+
45
+ def approve_hmac(request)
46
+ hmac_signature = etna_param(request, :signature) || 'invalid'
47
+
48
+ headers = (etna_param(request, :headers)&.split(/,/) || []).map do |header|
49
+ [ header.to_sym, etna_param(request, header) ]
50
+ end.to_h
51
+
52
+ hmac_params = {
53
+ method: request.request_method,
54
+ host: request.host,
55
+ path: request.path,
56
+
57
+ expiration: etna_param(request, :expiration) || DateTime.now.iso8601,
58
+ id: etna_param(request, :id) || 'etna',
59
+ nonce: etna_param(request, :nonce) || 'nonce',
60
+ headers: headers,
61
+ test_signature: hmac_signature
62
+ }
63
+
64
+ hmac = Etna::TestHmac.new(application, hmac_params)
65
+
66
+ request.env['etna.hmac'] = hmac
67
+
68
+ return nil unless hmac.valid?
69
+
70
+ params(request).update(headers)
71
+
72
+ return true
73
+ end
74
+ end
75
+ class TestHmac < Hmac
76
+ def valid?
77
+ @test_signature == 'valid'
78
+ end
79
+ end
80
+ end
data/lib/etna/user.rb ADDED
@@ -0,0 +1,79 @@
1
+ module Etna
2
+ class User
3
+ ROLE_NAMES = {
4
+ 'A' => :admin,
5
+ 'E' => :editor,
6
+ 'V' => :viewer
7
+ }
8
+
9
+ def initialize params, token=nil
10
+ @first, @last, @email, @encoded_permissions = params.values_at(:first, :last, :email, :perm)
11
+ @token = token unless !token
12
+ raise ArgumentError, "No email given!" unless @email
13
+ end
14
+
15
+ attr_reader :first, :last, :email, :token
16
+
17
+ def permissions
18
+ @permissions ||= @encoded_permissions.split(/\;/).map do |roles|
19
+ role, projects = roles.split(/:/)
20
+
21
+ projects.split(/\,/).reduce([]) do |perms,project_name|
22
+ perms.push([
23
+ project_name,
24
+ {
25
+ role: ROLE_NAMES[role.upcase],
26
+ restricted: role == role.upcase
27
+ }
28
+ ])
29
+ end
30
+ end.inject([],:+).to_h
31
+ end
32
+
33
+ def name
34
+ "#{first} #{last}"
35
+ end
36
+
37
+ def projects
38
+ permissions.keys
39
+ end
40
+
41
+ ROLE_MATCH = {
42
+ admin: /[Aa]/,
43
+ editor: /[Ee]/,
44
+ viewer: /[Vv]/,
45
+ restricted: /[AEV]/,
46
+ }
47
+ def has_roles(project, *roles)
48
+ perm = permissions[project.to_s]
49
+
50
+ return false unless perm
51
+
52
+ return roles.map(&:to_sym).include?(perm[:role])
53
+ end
54
+
55
+ def is_superuser? project=nil
56
+ has_roles(:administration, :admin)
57
+ end
58
+
59
+ def can_edit? project
60
+ is_superuser? || has_roles(project, :admin, :editor)
61
+ end
62
+
63
+ def can_view? project
64
+ is_superuser? || has_roles(project, :admin, :editor, :viewer)
65
+ end
66
+
67
+ # superusers - administrators of the Administration group - cannot
68
+ # automatically see restricted data, they should be granted
69
+ # project-specific access.
70
+ def can_see_restricted? project
71
+ perm = permissions[project.to_s]
72
+ perm && perm[:restricted]
73
+ end
74
+
75
+ def is_admin? project
76
+ is_superuser? || has_roles(project, :admin)
77
+ end
78
+ end
79
+ end