actn-api 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.env +2 -0
  3. data/.gitignore +17 -0
  4. data/.travis.yml +3 -0
  5. data/Gemfile +4 -0
  6. data/LICENSE.txt +22 -0
  7. data/Procfile +11 -0
  8. data/README.md +130 -0
  9. data/Rakefile +16 -0
  10. data/actn-api.gemspec +33 -0
  11. data/apis/core/backend.rb +103 -0
  12. data/apis/core/frontend.rb +46 -0
  13. data/apis/public/connect.rb +40 -0
  14. data/apis/public/delete.rb +15 -0
  15. data/apis/public/query.rb +26 -0
  16. data/apis/public/upsert.rb +16 -0
  17. data/bin/actn-api +36 -0
  18. data/config/common.rb +16 -0
  19. data/config/core.rb +1 -0
  20. data/config/haproxy.cfg +73 -0
  21. data/config/public.rb +5 -0
  22. data/db/1_api.sql +9 -0
  23. data/db/schemas/client.json +41 -0
  24. data/db/schemas/user.json +23 -0
  25. data/lib/actn/api/client.rb +92 -0
  26. data/lib/actn/api/core.rb +35 -0
  27. data/lib/actn/api/goliath/params.rb +70 -0
  28. data/lib/actn/api/goliath/validator.rb +54 -0
  29. data/lib/actn/api/mw/auth.rb +122 -0
  30. data/lib/actn/api/mw/cors.rb +58 -0
  31. data/lib/actn/api/mw/no_xss.rb +28 -0
  32. data/lib/actn/api/public.rb +89 -0
  33. data/lib/actn/api/user.rb +59 -0
  34. data/lib/actn/api/version.rb +5 -0
  35. data/lib/actn/api.rb +16 -0
  36. data/test/actn/test_backend.rb +81 -0
  37. data/test/actn/test_client.rb +31 -0
  38. data/test/actn/test_connect.rb +42 -0
  39. data/test/actn/test_delete.rb +53 -0
  40. data/test/actn/test_frontend.rb +32 -0
  41. data/test/actn/test_query.rb +60 -0
  42. data/test/actn/test_upsert.rb +77 -0
  43. data/test/actn/test_user.rb +37 -0
  44. data/test/minitest_helper.rb +27 -0
  45. data/test/support/test.html +7 -0
  46. data/views/core/app.erb +1 -0
  47. data/views/public/connect.erb +123 -0
  48. metadata +258 -0
@@ -0,0 +1,41 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-04/schema#",
3
+ "type": "object",
4
+ "properties": {
5
+ "apikey": {
6
+ "description": "Autogenerated API key for basic auth",
7
+ "type": "string"
8
+ },
9
+ "secret_hash": {
10
+ "description": "hashed version of api secret",
11
+ "type": "string"
12
+ },
13
+ "domain": {
14
+ "description": "Allowed domain for CORS calls",
15
+ "type": "string"
16
+ },
17
+ "acl": {
18
+ "description": "Access control list",
19
+ "type": "object",
20
+ "patternProperties": {
21
+ "[allow|disallow]": {
22
+ "type": "array",
23
+ "pattern": "/(\\*|GET|POST|PUT|PATCH|DELETE)(\\:\/.*)?/g"
24
+ }
25
+ },
26
+ "required": ["allow","disallow"]
27
+ },
28
+ "sessions": {
29
+ "description": "Active sessions belongs to client",
30
+ "type": "object",
31
+ "patternProperties": {
32
+ "/.*/": {
33
+ "type": "array",
34
+ "minItems": 2,
35
+ "maxItems": 2
36
+ }
37
+ }
38
+ }
39
+ },
40
+ "required": ["apikey","secret_hash","domain","acl"]
41
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-04/schema#",
3
+ "type": "object",
4
+ "properties": {
5
+ "first_name": {
6
+ "type": "string"
7
+ },
8
+ "last_name": {
9
+ "type": "string"
10
+ },
11
+ "email": {
12
+ "type": "string",
13
+ "format": "email"
14
+ },
15
+ "hash": {
16
+ "type": "string"
17
+ },
18
+ "phone": {
19
+ "type": "string"
20
+ }
21
+ },
22
+ "required": ["first_name","last_name","email"]
23
+ }
@@ -0,0 +1,92 @@
1
+ require 'actn/db/mod'
2
+ require 'actn/core_ext/string'
3
+ require 'bcrypt'
4
+
5
+ module Actn
6
+ module Api
7
+ class Client < DB::Mod
8
+
9
+ self.table = "clients"
10
+ self.schema = "core"
11
+
12
+ BCrypt::Engine.cost = 4
13
+
14
+ DEFAULT_ACL = {allow: ['*'], disallow: []}
15
+ TTL = 360
16
+
17
+ attr_accessor :secret
18
+
19
+ data_attr_accessor :apikey, :secret_hash, :domain, :sessions, :acl
20
+
21
+ validates_presence_of :apikey, :domain, :acl, :secret_hash
22
+
23
+ before_validation :set_defaults
24
+
25
+ def self.find_for_auth domain, apikey
26
+ client = self.find_by(domain: domain, apikey: apikey)
27
+ client
28
+ end
29
+
30
+ def auth_by_secret secret
31
+ self.secret == secret
32
+ end
33
+
34
+ def auth_by_session session_id
35
+ return unless client_session = self.sessions[session_id]
36
+ if BCrypt::Password.new(client_session[0]) == session_id
37
+ if Time.now.to_f - client_session[1] > TTL
38
+ invalidated = self.update(sessions: self.sessions.tap{|s| s.delete session_id })
39
+ return false
40
+ else
41
+ return true
42
+ end
43
+ end
44
+ end
45
+
46
+ def set_session session_id
47
+ self.update( { sessions: {session_id => [BCrypt::Password.create(session_id), Time.now.to_f] }} )
48
+ end
49
+
50
+ def secret
51
+ @hash ||= BCrypt::Password.new(self.secret_hash) if self.secret_hash
52
+ end
53
+
54
+ def credentials
55
+ {'apikey' => self.apikey, 'secret' => @secret}
56
+ end
57
+
58
+ def reset_credentials!
59
+ reset_credentials
60
+ _update
61
+ self
62
+ end
63
+
64
+ def can? resource
65
+ return if self.acl['disallow'].include?("*") || self.acl['disallow'].include?(resource)
66
+ self.acl['allow'].include?("*") || self.acl['allow'].include?(resource)
67
+ end
68
+
69
+ def to_json options = {}
70
+ super(options.merge(methods: [:credentials], exclude: [:sessions, :secret_hash]))
71
+ end
72
+
73
+ private
74
+
75
+ def set_defaults
76
+ self.domain = self.domain.to_domain
77
+ self.sessions ||= {}
78
+ self.acl ||= DEFAULT_ACL
79
+ reset_credentials unless self.persisted?
80
+ end
81
+
82
+ def reset_credentials
83
+ self.apikey = SecureRandom.hex
84
+ @secret = SecureRandom.hex
85
+ @hash = BCrypt::Password.create(@secret)
86
+ self.secret_hash = @hash
87
+ end
88
+
89
+ end
90
+
91
+ end
92
+ end
@@ -0,0 +1,35 @@
1
+ require 'helmet'
2
+ require 'rack/csrf'
3
+ require 'actn/db'
4
+ require 'actn/api/user'
5
+ require 'actn/api/mw/auth'
6
+ require 'actn/api/mw/no_xss'
7
+ require 'actn/api/goliath/validator'
8
+ require 'actn/api/goliath/params'
9
+
10
+ module Actn
11
+ module Api
12
+ class Core < Helmet::API
13
+
14
+ OK = '{"success": true}'
15
+
16
+ def self.inherited base
17
+
18
+ base.init
19
+
20
+ super
21
+
22
+ base.use Goliath::Rack::Params
23
+ base.use Goliath::Rack::Heartbeat
24
+
25
+ base.use Mw::NoXSS
26
+ base.use Rack::Session::Cookie, secret: ENV['SECRET']
27
+ base.use Rack::Csrf, skip_if: proc { |request|
28
+ request.env.key?('HTTP_X_APIKEY') && request.env.key?('HTTP_X_SECRET')
29
+ }
30
+
31
+ end
32
+
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,70 @@
1
+ require 'oj'
2
+ require 'rack/utils'
3
+
4
+ module Goliath
5
+ module Rack
6
+ # URL_ENCODED = %r{^application/x-www-form-urlencoded}
7
+ # JSON_ENCODED = %r{^application/json}
8
+
9
+ # A middle ware to parse params. This will parse both the
10
+ # query string parameters and the body and place them into
11
+ # the _params_ hash of the Goliath::Env for the request.
12
+ #
13
+ # @example
14
+ # use Goliath::Rack::Params
15
+ #
16
+ class Params
17
+ module Parser
18
+ def retrieve_params(env)
19
+ params = env['params'] || {}
20
+ begin
21
+ params.merge!(::Rack::Utils.parse_nested_query(env['QUERY_STRING']))
22
+ if env['rack.input']
23
+ post_params = ::Rack::Utils::Multipart.parse_multipart(env)
24
+ unless post_params
25
+ body = env['rack.input'].read
26
+ env['rack.input'].rewind
27
+
28
+ unless body.empty?
29
+ post_params = case(env['CONTENT_TYPE'])
30
+ when URL_ENCODED then
31
+ ::Rack::Utils.parse_nested_query(body)
32
+ when JSON_ENCODED then
33
+ json = Oj.load(body)
34
+ if json.is_a?(Hash)
35
+ json
36
+ else
37
+ {'_json' => json}
38
+ end
39
+ else
40
+ {}
41
+ end
42
+ else
43
+ post_params = {}
44
+ end
45
+ end
46
+ params = { 'query' => params, 'data' => post_params }
47
+ end
48
+ rescue StandardError => e
49
+ raise Goliath::Validation::BadRequestError, "Invalid parameters: #{e.class.to_s}"
50
+ end
51
+ params
52
+ end
53
+ end
54
+
55
+ include Goliath::Rack::Validator
56
+ include Parser
57
+
58
+ def initialize(app)
59
+ @app = app
60
+ end
61
+
62
+ def call(env)
63
+ Goliath::Rack::Validator.safely(env) do
64
+ env['params'] = retrieve_params(env)
65
+ @app.call(env)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,54 @@
1
+ require 'oj'
2
+
3
+ module Goliath
4
+ module Rack
5
+ module Validator
6
+ module_function
7
+ ERROR = 'error'
8
+
9
+ # @param status_code [Integer] HTTP status code for this error.
10
+ # @param msg [String] message to inject into the response body.
11
+ # @param headers [Hash] Response headers to preserve in an error response;
12
+ # (the Content-Length header, if any, is removed)
13
+ def validation_error(status_code, msg, headers={})
14
+ headers.delete('Content-Length')
15
+ [status_code, headers, Oj.dump({ERROR => msg})]
16
+ end
17
+
18
+ # Execute a block of code safely.
19
+ #
20
+ # If the block raises any exception that derives from
21
+ # Goliath::Validation::Error (see specifically those in
22
+ # goliath/validation/standard_http_errors.rb), it will be turned into the
23
+ # corresponding 4xx response with a corresponding message.
24
+ #
25
+ # If the block raises any other kind of error, we log it and return a
26
+ # less-communicative 500 response.
27
+ #
28
+ # @example
29
+ # # will convert the ForbiddenError exception into a 403 response
30
+ # # and an uncaught error in do_something_risky! into a 500 response
31
+ # safely(env, headers) do
32
+ # raise ForbiddenError unless account_info['valid'] == true
33
+ # do_something_risky!
34
+ # [status, headers, body]
35
+ # end
36
+ #
37
+ #
38
+ # @param env [Goliath::Env] The current request env
39
+ # @param headers [Hash] Response headers to preserve in an error response
40
+ #
41
+ def safely(env, headers={})
42
+ begin
43
+ yield
44
+ rescue Goliath::Validation::Error => e
45
+ validation_error(e.status_code, e.message, headers)
46
+ rescue Exception => e
47
+ env.logger.error(e.message)
48
+ env.logger.error(e.backtrace.join("\n"))
49
+ validation_error(500, e.message, headers)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,122 @@
1
+ require 'goliath/rack'
2
+ require 'goliath/validation'
3
+ require 'bcrypt'
4
+
5
+ ##
6
+ # Bync.rb
7
+ # Bouncer of our midningt api club
8
+
9
+ module Actn
10
+ module Api
11
+ module Mw
12
+ class Auth
13
+
14
+ include Goliath::Rack::BarrierAroundware
15
+ include Goliath::Validation
16
+
17
+ class MissingApikeyError < BadRequestError ; end
18
+ class InvalidCredentialsError < UnauthorizedError ; end
19
+
20
+ attr_accessor :client, :opts
21
+
22
+ def initialize(env, opts = {})
23
+ self.opts = opts
24
+ super(env)
25
+ end
26
+
27
+
28
+ def pre_process
29
+
30
+ unless excluded?
31
+
32
+ validate_apikey!
33
+
34
+ # On non-GET non-HEAD requests, we have to check auth now.
35
+ unless lazy_authorization?
36
+ perform # yield execution until user_info has arrived
37
+ authorize_client!
38
+ end
39
+
40
+ end
41
+
42
+ return Goliath::Connection::AsyncResponse
43
+ end
44
+
45
+ def post_process
46
+
47
+ unless excluded?
48
+
49
+ # We have to check auth now, we skipped it before
50
+ if lazy_authorization?
51
+ validate_client!
52
+ end
53
+
54
+ end
55
+
56
+ [status, headers, body]
57
+ end
58
+
59
+ def lazy_authorization?
60
+ (env['REQUEST_METHOD'] == 'GET') || (env['REQUEST_METHOD'] == 'HEAD')
61
+ end
62
+
63
+ def validate_apikey!
64
+ return true if with_session? && current_user_uuid
65
+ raise MissingApikeyError.new("Missing Api Key") if apikey.to_s.empty?
66
+ end
67
+
68
+ def validate_client!
69
+ return true if with_session? && current_user_uuid
70
+ raise Goliath::Validation::UnauthorizedError unless client_valid?
71
+ end
72
+
73
+ def authorize_client!
74
+ return true if with_session? && current_user_uuid
75
+ unless client_valid? && client_authorized?
76
+ raise InvalidCredentialsError.new("Invalid Credentials")
77
+ end
78
+ env['rack.session'][:user_uuid] = self.client.uuid
79
+ end
80
+
81
+ def apikey
82
+ env['HTTP_X_APIKEY']
83
+ end
84
+
85
+ def secret
86
+ env['HTTP_X_SECRET']
87
+ end
88
+
89
+ def client_valid?
90
+ self.client = Client.find_for_auth(host, apikey)
91
+ end
92
+
93
+ def client_authorized?
94
+ return unless self.client
95
+ (
96
+ self.secret.nil? ?
97
+ self.client.auth_by_session(env['rack.session'].id) :
98
+ self.client.auth_by_secret(self.secret)
99
+ ) && self.client.can?("#{env['REQUEST_METHOD']}:#{env['REQUEST_PATH']}")
100
+ end
101
+
102
+ def host
103
+ (env['HTTP_ORIGIN'] || env['HTTP_HOST']).to_domain
104
+ end
105
+
106
+ def excluded?
107
+ opts[:exclude].nil? ? false : (env['REQUEST_PATH'] =~ opts[:exclude])
108
+ end
109
+
110
+ def with_session?
111
+ opts[:with_session]
112
+ end
113
+
114
+ def current_user_uuid
115
+ env['rack.session'][:user_uuid]
116
+ end
117
+
118
+
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,58 @@
1
+ require 'goliath/rack'
2
+ require 'rack/csrf'
3
+
4
+ ##
5
+ # Cors.rb
6
+ # Cross domain middle ware
7
+
8
+
9
+ module Actn
10
+ module Api
11
+ module Mw
12
+ class Cors
13
+
14
+ include Goliath::Rack::AsyncMiddleware
15
+
16
+ def initialize(app)
17
+ @app = app
18
+ end
19
+
20
+ def call(env)
21
+ if env['REQUEST_METHOD'] == 'OPTIONS'
22
+ [200, cors_headers(env,false), []]
23
+ else
24
+ super(env)
25
+ end
26
+ end
27
+
28
+ def post_process(env, status, headers, body)
29
+ headers = cors_headers(env).merge(headers)
30
+ [status, headers, body]
31
+ end
32
+
33
+ private
34
+
35
+ attr_accessor :options
36
+
37
+ def cors_headers env, csrf = true
38
+ headers = {}
39
+ headers['Access-Control-Allow-Credentials'] = 'true'
40
+ headers['Access-Control-Allow-Origin'] = env['HTTP_ORIGIN']
41
+ headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'
42
+ headers['Access-Control-Allow-Headers'] = '*, X-Requested-With, X-Prototype-Version, X-CSRF-Token, Authorization, Origin, Accept, Content-Type, Referer'
43
+ headers['Access-Control-Expose-Headers'] = 'X_CSRF_TOKEN, X_APIKEY'
44
+ headers['Access-Control-Max-Age'] = "#{Client::TTL}"
45
+ headers['P3P'] = 'CP="IDC DSP COR CURa ADMa OUR IND PHY ONL COM STA"'
46
+
47
+ headers['X_CSRF_TOKEN'] = Rack::Csrf.token(env) if csrf
48
+
49
+ client_headers_to_approve = env['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'].to_s.gsub(/[^\w\-\,]+/,'')
50
+ headers['Access-Control-Allow-Headers'] += ",#{client_headers_to_approve}" if not client_headers_to_approve.empty?
51
+
52
+ headers
53
+ end
54
+
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,28 @@
1
+ require 'goliath/rack'
2
+
3
+ ##
4
+ # Sec.rb
5
+ # Security comes first
6
+
7
+ module Actn
8
+ module Api
9
+ module Mw
10
+ class NoXSS
11
+
12
+ include Goliath::Rack::AsyncMiddleware
13
+
14
+ HEADERS = {
15
+ 'X-Frame-Options' => 'SAMEORIGIN',
16
+ 'X-XSS-Protection' => '1; mode=block',
17
+ 'X-Content-Type-Options' => 'nosniff'
18
+ }
19
+
20
+ def post_process(env, status, headers, body)
21
+ headers.update HEADERS
22
+ [status, headers, body]
23
+ end
24
+
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,89 @@
1
+ require 'goliath'
2
+ require 'actn/db/set'
3
+ require 'actn/api/client'
4
+ require 'actn/api/mw/auth'
5
+ require 'actn/api/mw/cors'
6
+ require 'actn/api/mw/no_xss'
7
+ require 'actn/api/goliath/validator'
8
+ require 'actn/api/goliath/params'
9
+
10
+ module Actn
11
+ module Api
12
+ class Public < Goliath::API
13
+
14
+ CT_JS = { 'Content-Type' => 'application/javascript' }
15
+ CT_JSON = {'Content-Type' => 'application/json'}
16
+
17
+
18
+ def self.inherited base
19
+
20
+ super
21
+
22
+ base.use Goliath::Rack::Params
23
+ base.use Goliath::Rack::Heartbeat
24
+
25
+ base.use Mw::NoXSS
26
+ base.use Rack::Session::Cookie, secret: ENV['SECRET']
27
+ base.use Rack::Csrf, skip: ['OPTIONS:/.*'], skip_if: proc { |r| ENV['RACK_ENV'] == "test" }
28
+ base.use Mw::Cors
29
+ base.use Goliath::Rack::BarrierAroundwareFactory, Mw::Auth, exclude: /^\/connect$/
30
+
31
+ super
32
+ end
33
+
34
+
35
+ def process table, path
36
+ raise NotImplementedError
37
+ end
38
+
39
+
40
+
41
+ def response env
42
+
43
+ path = env['REQUEST_PATH'] || "/"
44
+
45
+ unless table = path[1..-1].split("/").first
46
+ raise Goliath::Validation::Error.new(400, "model identifier missing")
47
+ end
48
+
49
+ begin
50
+ json = process(table,path)
51
+ rescue PG::InternalError => e
52
+ if e.message =~ /does not exist/
53
+ raise Goliath::Validation::NotFoundError.new("resource not found")
54
+ else
55
+ raise e
56
+ end
57
+ end
58
+
59
+ status = json =~ /errors/ ? 406 : 200
60
+
61
+ [status, CT_JSON, json]
62
+
63
+ end
64
+
65
+ private
66
+
67
+ def limit
68
+ query['limit'] || 50
69
+ end
70
+
71
+ def page
72
+ ((query['page'] || 1).to_i - 1)
73
+ end
74
+
75
+ def offset
76
+ limit * page
77
+ end
78
+
79
+ def query
80
+ params['query']
81
+ end
82
+
83
+ def data
84
+ params['data']
85
+ end
86
+
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,59 @@
1
+ require 'actn/db/mod'
2
+ require 'actn/core_ext/string'
3
+ require 'bcrypt'
4
+
5
+ module Actn
6
+
7
+ module Api
8
+
9
+ class User < DB::Mod
10
+
11
+ BCrypt::Engine.cost = 4
12
+
13
+ self.schema = "core"
14
+ self.table = "users"
15
+
16
+ def self.find_for_auth params
17
+ return unless user = self.find_by('email' => params['email'])
18
+ return unless user.password == params['password']
19
+ user.uuid
20
+ end
21
+
22
+ attr_accessor :password, :password_confirmation
23
+
24
+ data_attr_accessor :first_name, :last_name, :email, :hash, :phone
25
+
26
+ validates_presence_of :first_name, :last_name, :email
27
+ validates_presence_of :password, :password_confirmation, unless: 'persisted?'
28
+ validates_confirmation_of :password, unless: 'persisted?'
29
+ validate :validate_unique_email, unless: 'persisted?'
30
+
31
+ before_create :set_password
32
+
33
+ def password
34
+ if self.hash
35
+ @password ||= BCrypt::Password.new(self.hash)
36
+ else
37
+ @password
38
+ end
39
+ end
40
+
41
+ def to_json options = {}
42
+ super(options.merge(:exclude [:hash]))
43
+ end
44
+
45
+ private
46
+
47
+ def set_password
48
+ @password = BCrypt::Password.create(self.password)
49
+ self.hash = @password
50
+ end
51
+
52
+
53
+ def validate_unique_email
54
+ errors.add(:email, "has already been taken") if self.class.find_by('email' => self.email)
55
+ end
56
+
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,5 @@
1
+ module Actn
2
+ module Api
3
+ VERSION = "0.0.1"
4
+ end
5
+ end
data/lib/actn/api.rb ADDED
@@ -0,0 +1,16 @@
1
+ require "actn/paths"
2
+ require "actn/db"
3
+ require "actn/api/version"
4
+
5
+ module Actn
6
+ module Api
7
+ include Paths
8
+
9
+ def self.gem_root
10
+ @@gem_root ||= File.expand_path('../../../', __FILE__)
11
+ end
12
+
13
+ end
14
+ end
15
+
16
+ Actn::DB.paths << Actn::Api.gem_root