rack-bacoo 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3fdf5ceccc9b9ea971009174fc466efd4c6ce6591e5874e07be5de1a0790c1af
4
+ data.tar.gz: fb3aa483a0bd4bb5dfede0b42222a2e057be8e06e592890ae750208e95575aba
5
+ SHA512:
6
+ metadata.gz: 61b3d57dd281538ffd9c55096a934d8428a08b006aa24cf913a5107116d088b7726283ff0d276301e4bc2d8fdd7dec59c99b03480aaed3285129e4e375d899d6
7
+ data.tar.gz: ccb197ead61d53a9ebeb0947e757cd40c4514ecdf5da4dbfb1546754c5df30909d72323ca9370ab3dbdce65583a59de26be7727c4105667b019d115b585b61dc
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "basic_auth_request"
4
+ require_relative "session_cookie"
5
+
6
+ module Rack
7
+ module Bacoo
8
+ class Authenticator
9
+ def initialize(cookie_attributes:, cookie_encryptor:, password_cost:, users:)
10
+ @cookie_attributes = cookie_attributes
11
+ @cookie_encryptor = cookie_encryptor
12
+ @password_cost = password_cost
13
+ @users = users
14
+ end
15
+
16
+ def with_env(env)
17
+ @env = env
18
+ @basic_auth_request = BasicAuthRequest.new(env)
19
+ @session_cookie = SessionCookie.new(@cookie_attributes, @cookie_encryptor, env)
20
+ @credentials = Credentials.new(@password_cost, @users)
21
+ yield
22
+ end
23
+
24
+ def basic_auth_provided?
25
+ @basic_auth_request.provided?
26
+ end
27
+
28
+ def valid_basic_auth?
29
+ @basic_auth_request.valid_basic_auth?
30
+ end
31
+
32
+ def basic_authenticated
33
+ return unless @credentials.basic_authenticated?(@basic_auth_request)
34
+
35
+ yield @credentials
36
+ end
37
+
38
+ def cookie_authenticated
39
+ return unless @credentials.cookie_authenticated?(@session_cookie)
40
+
41
+ yield @credentials
42
+ end
43
+
44
+ def persist_session!(credentials, headers)
45
+ @session_cookie.set(headers, credentials.username, credentials.password)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module Bacoo
5
+ class BasicAuthRequest < Rack::Auth::AbstractRequest
6
+ def valid_basic_auth?
7
+ scheme == "basic" && credentials.length == 2
8
+ end
9
+
10
+ def username
11
+ credentials.first
12
+ end
13
+
14
+ def password
15
+ credentials.last
16
+ end
17
+
18
+ private
19
+
20
+ def credentials
21
+ @credentials ||= params.unpack1("m").split(":", 2)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module Bacoo
5
+ class CookieAttributesParser
6
+ DEFAULTS = {
7
+ cookie_name: "rack-bacoo",
8
+ path: "/",
9
+ max_age: 24 * 60 * 60, # 24 hours
10
+ secure: true,
11
+ http_only: true,
12
+ same_site: "Lax"
13
+ }.freeze
14
+
15
+ def self.call(cookie_attributes)
16
+ cookie_attributes.each do |pair|
17
+ case pair
18
+ in [:secure, nil] | [:secure, false]
19
+ raise ArgumentError, "secure must be true"
20
+ in [:http_only, nil] | [:http_only, false]
21
+ raise ArgumentError, "http_only must be true"
22
+ in [:httponly, nil] | [:httponly, false]
23
+ raise ArgumentError, "httponly must be true"
24
+ in [:same_site, nil] | [:same_site, false]
25
+ raise ArgumentError, "same_site must be either lax or strict"
26
+ in [:same_site, val] if val.to_s.downcase == "none"
27
+ raise ArgumentError, "same_site must be either lax or strict"
28
+ else
29
+ end
30
+ end
31
+
32
+ DEFAULTS.merge(cookie_attributes)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bcrypt"
4
+ require "securerandom"
5
+
6
+ module Rack
7
+ module Bacoo
8
+ class Credentials
9
+ WRONG_USER = [SecureRandom.hex, SecureRandom.hex].freeze
10
+
11
+ attr_reader :username, :password
12
+
13
+ def initialize(password_cost, users)
14
+ @password_cost = password_cost
15
+ @users = users
16
+ end
17
+
18
+ def basic_authenticated?(session)
19
+ @username = session.username
20
+ @password = BCrypt::Password.create(session.password, cost: @password_cost)
21
+ authenticated?(@username, @password)
22
+ end
23
+
24
+ def cookie_authenticated?(session)
25
+ @username = session.username
26
+ @password = session.password
27
+ authenticated?(@username, BCrypt::Password.new(@password))
28
+ end
29
+
30
+ private
31
+
32
+ def authenticated?(username, encrypted_password)
33
+ valid_password = WRONG_USER[1]
34
+
35
+ @users.each do |u, p|
36
+ valid_password = p if Rack::Utils.secure_compare(u, username)
37
+ end
38
+
39
+ encrypted_password == valid_password
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "openssl"
5
+
6
+ module Rack
7
+ module Bacoo
8
+ class Encryptor
9
+ DecryptionError = Class.new(StandardError)
10
+
11
+ AUTH_TAG_LENGTH = 16
12
+ CIPHER = "aes-256-gcm"
13
+ SALT = "entropy comes from the password"
14
+ SEPARATOR = "--"
15
+
16
+ def initialize(password)
17
+ key_len = new_cipher.key_len
18
+ digest = OpenSSL::Digest.new("SHA256")
19
+ # Rationale on how the key is generated: https://github.com/rails/rails/pull/6952
20
+ @key = OpenSSL::PKCS5.pbkdf2_hmac(password, SALT, 1000, key_len, digest)
21
+ end
22
+
23
+ def encrypt(data)
24
+ cipher = new_cipher
25
+ cipher.encrypt
26
+ cipher.key = @key
27
+ iv = cipher.random_iv
28
+ cipher.auth_data = ""
29
+ encrypted_data = cipher.update(data) + cipher.final
30
+ parts = [encrypted_data, iv, cipher.auth_tag(AUTH_TAG_LENGTH)]
31
+ parts.map { ::Base64.strict_encode64(_1) }.join(SEPARATOR)
32
+ end
33
+
34
+ def decrypt(encrypted_message)
35
+ cipher = new_cipher
36
+ encrypted_data, iv, auth_tag = extract_parts(encrypted_message)
37
+
38
+ # Currently the OpenSSL bindings do not raise an error if auth_tag is
39
+ # truncated, which would allow an attacker to easily forge it:
40
+ # https://github.com/ruby/openssl/issues/63
41
+ raise DecryptionError, "truncated auth_tag" if auth_tag.bytesize != AUTH_TAG_LENGTH
42
+
43
+ cipher.decrypt
44
+ cipher.key = @key
45
+ cipher.iv = iv
46
+ cipher.auth_tag = auth_tag
47
+ cipher.auth_data = ""
48
+ cipher.update(encrypted_data) + cipher.final
49
+ end
50
+
51
+ private
52
+
53
+ # Base64 encodes with a 6-bit alphabet plus padding:
54
+ # https://en.wikipedia.org/wiki/Base64
55
+ def length_after_base64(length_before_base64)
56
+ 4 * (length_before_base64 / 3.0).ceil
57
+ end
58
+
59
+ def length_of_encoded_iv
60
+ @length_of_encoded_iv ||= length_after_base64(new_cipher.iv_len)
61
+ end
62
+
63
+ def length_of_encoded_auth_tag
64
+ @length_of_encoded_auth_tag ||= length_after_base64(AUTH_TAG_LENGTH)
65
+ end
66
+
67
+ def extract_parts(encrypted_message)
68
+ parts = []
69
+ rindex = encrypted_message.length
70
+
71
+ parts << extract_part(encrypted_message, rindex, length_of_encoded_auth_tag)
72
+ rindex -= SEPARATOR.length + length_of_encoded_auth_tag
73
+
74
+ parts << extract_part(encrypted_message, rindex, length_of_encoded_iv)
75
+ rindex -= SEPARATOR.length + length_of_encoded_iv
76
+
77
+ parts << encrypted_message[0, rindex]
78
+
79
+ parts.reverse!.map! { ::Base64.strict_decode64(_1) }
80
+ end
81
+
82
+ def extract_part(encrypted_message, rindex, length)
83
+ index = rindex - length
84
+
85
+ unless encrypted_message[index - SEPARATOR.length, SEPARATOR.length] == SEPARATOR
86
+ raise DecryptionError, "missing separator"
87
+ end
88
+
89
+ encrypted_message[index, length]
90
+ end
91
+
92
+ def new_cipher
93
+ OpenSSL::Cipher.new(CIPHER)
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bcrypt"
4
+ require "securerandom"
5
+
6
+ module Rack
7
+ module Bacoo
8
+ class SessionCookie
9
+ WRONG_USERNAME = SecureRandom.hex
10
+ WRONG_PASSWORD = BCrypt::Password.create(SecureRandom.hex, cost: 1)
11
+
12
+ def initialize(cookie_attributes, encryptor, env)
13
+ @cookie_attributes = cookie_attributes.dup
14
+ @cookie_name = @cookie_attributes.delete(:cookie_name)
15
+ @encryptor = encryptor
16
+ @env = env
17
+ end
18
+
19
+ def username
20
+ get.nil? ? WRONG_USERNAME : get.first
21
+ end
22
+
23
+ def password
24
+ get.nil? ? WRONG_PASSWORD : get.last
25
+ end
26
+
27
+ def set(headers, username, encrypted_password)
28
+ value = @encryptor.encrypt("#{username}:#{encrypted_password}:#{expires_at}")
29
+ value = @cookie_attributes.merge(value: value)
30
+ Utils.set_cookie_header!(headers, @cookie_name, value)
31
+ end
32
+
33
+ private
34
+
35
+ def get
36
+ value = Utils.parse_cookies(@env).fetch(@cookie_name, nil)
37
+ return nil if value.nil?
38
+
39
+ username, password, expires_at = @encryptor.decrypt(value).split(":")
40
+ return nil if expires_at.to_i < Time.now.to_i
41
+
42
+ [username, password]
43
+ end
44
+
45
+ # If a cookie has both the Max-Age and the Expires attribute, the
46
+ # Max-Age attribute has precedence and controls the expiration date
47
+ # of the cookie.
48
+ # https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.2
49
+ def expires_at
50
+ case @cookie_attributes
51
+ in { max_age: max_age }
52
+ Time.now.to_i + max_age.to_i
53
+ in { expires: expires }
54
+ expires.to_i
55
+ else
56
+ Time.now.to_i
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module Bacoo
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/rack-bacoo.rb ADDED
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ require_relative "rack/bacoo/version"
6
+ require_relative "rack/bacoo/credentials"
7
+ require_relative "rack/bacoo/authenticator"
8
+ require_relative "rack/bacoo/cookie_attributes_parser"
9
+ require_relative "rack/bacoo/encryptor"
10
+
11
+ module Rack
12
+ module Bacoo
13
+ class Middleware
14
+ DEFAULTS = {
15
+ cookie_attributes: {},
16
+ password_cost: BCrypt::Engine.cost,
17
+ paths: [Regexp.new(".*")],
18
+ realm: nil
19
+ }.freeze
20
+
21
+ attr_accessor :realm
22
+
23
+ def initialize(app, config)
24
+ config = DEFAULTS.merge(config)
25
+
26
+ @app = app
27
+ @paths, @realm = config.fetch_values(:paths, :realm)
28
+ @authenticator = Authenticator.new(
29
+ cookie_attributes: CookieAttributesParser.call(config.fetch(:cookie_attributes)),
30
+ cookie_encryptor: Encryptor.new(config.fetch(:encrypt_cookie_with)),
31
+ password_cost: config.fetch(:password_cost),
32
+ users: config.fetch(:users)
33
+ )
34
+ end
35
+
36
+ def call(env)
37
+ @authenticator.with_env(env) do
38
+ actual_path = Utils.clean_path_info(Utils.unescape_path(env["PATH_INFO"]))
39
+ return @app.call(env) if @paths.none? { _1.match? actual_path }
40
+ @authenticator.cookie_authenticated { |credentials| return call_app(credentials, env) }
41
+ return unauthorized unless @authenticator.basic_auth_provided?
42
+ return bad_request unless @authenticator.valid_basic_auth?
43
+ @authenticator.basic_authenticated { |credentials| return call_app(credentials, env) }
44
+
45
+ unauthorized
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def call_app(credentials, env)
52
+ env["REMOTE_USER"] = credentials.username
53
+ @app.call(env).tap do |_, headers, _|
54
+ @authenticator.persist_session!(credentials, headers)
55
+ end
56
+ end
57
+
58
+ def unauthorized
59
+ [
60
+ 401,
61
+ {
62
+ CONTENT_TYPE => "text/plain",
63
+ CONTENT_LENGTH => "0",
64
+ "www-authenticate" => "Basic realm=\"#{realm}\""
65
+ },
66
+ []
67
+ ]
68
+ end
69
+
70
+ def bad_request
71
+ [
72
+ 400,
73
+ {
74
+ CONTENT_TYPE => "text/plain",
75
+ CONTENT_LENGTH => "0"
76
+ },
77
+ []
78
+ ]
79
+ end
80
+ end
81
+ end
82
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rack-bacoo
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - 3v0k4
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: bcrypt
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rack
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.2'
54
+ description: Rack::Bacoo combines HTTP Basic Authentication with a session cookie
55
+ so that you don't have to input username and password on each visit. The session
56
+ cookie is encrypted (aes-256-gcm), and the password inside is hashed (bcrypt).
57
+ email:
58
+ - riccardo.odone@gmail.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - lib/rack-bacoo.rb
64
+ - lib/rack/bacoo/authenticator.rb
65
+ - lib/rack/bacoo/basic_auth_request.rb
66
+ - lib/rack/bacoo/cookie_attributes_parser.rb
67
+ - lib/rack/bacoo/credentials.rb
68
+ - lib/rack/bacoo/encryptor.rb
69
+ - lib/rack/bacoo/session_cookie.rb
70
+ - lib/rack/bacoo/version.rb
71
+ homepage: https://github.com/3v0k4/rack-bacoo
72
+ licenses:
73
+ - MIT
74
+ metadata:
75
+ homepage_uri: https://github.com/3v0k4/rack-bacoo
76
+ source_code_uri: https://github.com/3v0k4/rack-bacoo
77
+ changelog_uri: https://github.com/3v0k4/rack-bacoo/blob/main/CHANGELOG.md
78
+ rubygems_mfa_required: 'true'
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: 3.1.0
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 3.6.9
94
+ specification_version: 4
95
+ summary: Combine HTTP Basic Authentication with a session cookie
96
+ test_files: []