authonomy 1.0.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: 4eb4be2ddf04609cecbcc0529726a09b46ae722b3df4efe8874267f44031dd52
4
+ data.tar.gz: 9de066f4e9797ae65a60487abfdec9ee310bb76ef2f2e1b54e0ef1dbc23f48c1
5
+ SHA512:
6
+ metadata.gz: b3376fc1684ced643e417534f6b1c48d867800619521cdfdb20e8d4d146c502486a900ae45e3e9103a673a23ab2fdc4981eb6f7e4a73b8893c3d9fe1201aec4d
7
+ data.tar.gz: 8815a23de14bfd2646b1ff1c34650b436bf55c1c957b37b0cdd5ae0e605b8a85fd6fd9fb7ad4d3cebae01729b57dbb4e650a0919240c357010e32e8f70243dc2
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Ilya Konyukhov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # authonomy
2
+ The authentication library for Rails framework
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+
5
+ module Authonomy
6
+ class Authenticator
7
+ ALGO = 'HS256'
8
+ LEEWAY = 30.seconds
9
+
10
+ class << self
11
+ # Returns access_token and refresh_token for user authentication
12
+ def tokens(subject, refresh_exp = nil)
13
+ now = Time.now.utc.to_i
14
+
15
+ access_payload = {
16
+ sub: subject,
17
+ iat: now,
18
+ exp: now + Authonomy.access_token_ttl.to_i
19
+ }
20
+ refresh_payload = {
21
+ acc: payload_digest(access_payload),
22
+ iat: now
23
+ }
24
+ if refresh_exp
25
+ refresh_payload[:exp] = refresh_exp.is_a?(Integer) ? refresh_exp : now + Authonomy.refresh_token_ttl.to_i
26
+ end
27
+
28
+ [encode(access_payload), encode(refresh_payload)]
29
+ end
30
+
31
+ # Based on given access and refresh tokens authenticates user or not
32
+ def authenticate(klass, access_token, refresh_token)
33
+ now = Time.now.utc.to_i
34
+
35
+ generate_response_tokens = false
36
+ refresh_payload = nil
37
+ refresh_exp = nil
38
+
39
+ access_opts = {
40
+ verify_sub: true,
41
+ verify_iat: true,
42
+ verify_expiration: true
43
+ }
44
+
45
+ if refresh_token
46
+ refresh_opts = {
47
+ verify_iat: true,
48
+ verify_expiration: false
49
+ }
50
+
51
+ refresh_payload = decode(refresh_token, refresh_opts)
52
+ refresh_exp = refresh_payload['exp']
53
+
54
+ if refresh_exp && refresh_exp > now
55
+ # don't verify access token expiration if refresh token has an exp timestamp and not expired yet
56
+ access_opts[:verify_expiration] = false
57
+ else
58
+ # sliding expiration
59
+ refresh_exp = nil
60
+ end
61
+ end
62
+
63
+ access_payload = decode(access_token, access_opts)
64
+
65
+ if refresh_payload
66
+ if refresh_payload['acc'] && refresh_payload['acc'] != payload_digest(access_payload)
67
+ # puts 'Refresh token mismatch'
68
+ return nil
69
+ end
70
+
71
+ # to avoid tokens mismatch for multiple simultaneous calls
72
+ generate_response_tokens = true # if now - access_payload['iat'] > 60
73
+ end
74
+
75
+ user = klass.find_by(id: access_payload['sub'])
76
+
77
+ unless user
78
+ # puts 'User not found'
79
+ return nil
80
+ end
81
+
82
+ if user.respond_to?(:access_expired_at) && user.access_expired_at.to_i > access_payload['iat']
83
+ # puts 'User access expired'
84
+ return nil
85
+ end
86
+
87
+ if generate_response_tokens
88
+ [user] + tokens(user.id, refresh_exp)
89
+ else
90
+ user
91
+ end
92
+
93
+ rescue ::JWT::DecodeError
94
+ nil
95
+ end
96
+
97
+ private
98
+
99
+ def encode(payload)
100
+ ::JWT.encode(payload, hmac_secret, ALGO)
101
+ end
102
+
103
+ def decode(token, opts = {})
104
+ ::JWT.decode(token, hmac_secret, true, opts.merge(leeway: LEEWAY, algorithms: [ALGO])).first
105
+ # can throw JWT::IncorrectAlgorithm, JWT::VerificationError, JWT::ExpiredSignature, JWT::InvalidIatError, JWT::InvalidSubError
106
+ end
107
+
108
+ def payload_digest(payload)
109
+ Digest::MD5.hexdigest(payload.to_json)
110
+ end
111
+
112
+ def hmac_secret
113
+ Authonomy.jwt_secret_key
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bcrypt'
4
+
5
+ module Authonomy
6
+ class Encryptor
7
+ class << self
8
+ def digest(password)
9
+ password = "#{password}#{Authonomy.pepper.presence}"
10
+
11
+ ::BCrypt::Password.create(password, cost: Authonomy.stretches).to_s
12
+ end
13
+
14
+ def compare(hashed_password, password)
15
+ return false if hashed_password.blank?
16
+
17
+ bcrypt = ::BCrypt::Password.new(hashed_password)
18
+
19
+ password = "#{password}#{Authonomy.pepper.presence}"
20
+ password = ::BCrypt::Engine.hash_secret(password, bcrypt.salt)
21
+
22
+ secure_compare(password, hashed_password)
23
+ end
24
+
25
+ private
26
+
27
+ # Constant-time comparison algorithm to prevent timing attacks
28
+ def secure_compare(abc, xyz)
29
+ return false if abc.blank? || xyz.blank? || abc.bytesize != xyz.bytesize
30
+
31
+ l = abc.unpack "C#{abc.bytesize}"
32
+
33
+ res = 0
34
+ xyz.each_byte { |byte| res |= byte ^ l.shift }
35
+ res.zero?
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'securerandom'
5
+
6
+ module Authonomy
7
+ class TokenGenerator
8
+ ALGO = 'SHA256'
9
+
10
+ class << self
11
+ def generator
12
+ @generator ||= new(
13
+ ActiveSupport::CachingKeyGenerator.new(
14
+ ActiveSupport::KeyGenerator.new(Authonomy.secret_key)
15
+ )
16
+ )
17
+ end
18
+ end
19
+
20
+ def initialize(key_generator)
21
+ @key_generator = key_generator
22
+ end
23
+
24
+ def digest(column, token)
25
+ key = key_for(column)
26
+ token.present? && OpenSSL::HMAC.hexdigest(ALGO, key, token.to_s)
27
+ end
28
+
29
+ def generate(klass, column, length)
30
+ key = key_for(column)
31
+
32
+ loop do
33
+ token = friendly_token(length)
34
+ encoded_token = OpenSSL::HMAC.hexdigest(ALGO, key, token)
35
+ break [token, encoded_token] unless klass.find_by(column => encoded_token)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def key_for(column)
42
+ @key_generator.generate_key("Authonomy #{column}")
43
+ end
44
+
45
+ def friendly_token(length)
46
+ rlength = (length * 3) / 4
47
+ SecureRandom.urlsafe_base64(rlength).tr('lIO0', 'sxyz')
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authonomy
4
+ VERSION = '1.0.0'
5
+ end
data/lib/authonomy.rb ADDED
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/time'
5
+ require 'active_support/key_generator'
6
+
7
+ require_relative 'authonomy/encryptor'
8
+ require_relative 'authonomy/token_generator'
9
+ require_relative 'authonomy/authenticator'
10
+
11
+ require 'uri/mailto'
12
+
13
+ module Authonomy
14
+ class << self
15
+ WRITER_METHODS = %i[
16
+ pepper
17
+ stretches
18
+ password_length
19
+ phone_length
20
+ access_token_ttl
21
+ refresh_token_ttl
22
+ invite_token_ttl
23
+ invite_token_length
24
+ confirm_email_token_ttl
25
+ confirm_email_token_length
26
+ reset_password_token_ttl
27
+ reset_password_token_length
28
+ secret_key
29
+ jwt_secret_key
30
+ ].freeze
31
+ attr_writer(*WRITER_METHODS)
32
+
33
+ READER_METHODS = %i[
34
+ pepper
35
+ secret_key
36
+ jwt_secret_key
37
+ ].freeze
38
+ attr_reader(*READER_METHODS)
39
+
40
+ def configure
41
+ yield self if block_given?
42
+ end
43
+
44
+ def stretches
45
+ @stretches || 11
46
+ end
47
+
48
+ def password_regexp
49
+ %r{
50
+ \A
51
+ (?=.*\d) # Must contain a digit
52
+ (?=.*[a-z]) # Must contain a lowercase character
53
+ (?=.*[A-Z]) # Must contain an uppercase character
54
+ (?=.*[!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~]) # Must contain a special character
55
+ }x
56
+ end
57
+
58
+ def password_length
59
+ @password_length || (8..128)
60
+ end
61
+
62
+ def email_regexp
63
+ # /\A[^@\s]+@([^@\s]+\.)+[^@\W]+\z/
64
+ URI::MailTo::EMAIL_REGEXP
65
+ end
66
+
67
+ def phone_regexp
68
+ /\A[\d\s+\-#*@&.,()]+\z/
69
+ end
70
+
71
+ def phone_length
72
+ @phone_length || (3..40)
73
+ end
74
+
75
+ def money_regexp
76
+ /[+\-]?[\d.,]+/
77
+ end
78
+
79
+ def url_regexp
80
+ %r{
81
+ \A
82
+ (?:(?:https?|ftp)://) # scheme
83
+ (?:\S+(?::\S*)?@)? # user:pass authentication
84
+ (?:
85
+ (?!10(?:\.\d{1,3}){3}) # IP address exclusion
86
+ (?!127(?:\.\d{1,3}){3}) # private & local networks
87
+ (?!169\.254(?:\.\d{1,3}){2})
88
+ (?!192\.168(?:\.\d{1,3}){2})
89
+ (?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})
90
+ (?:[1-9]\d?|1\d\d|2[01]\d|22[0-3]) # IP address dotted notation octets
91
+ (?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2} # excludes loopback network 0.0.0.0, reserved space >= 224.0.0.0, network & broacast addresses
92
+ (?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4])) # (first & last IP address of each class)
93
+ |
94
+ (?:(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+) # host name
95
+ (?:\.(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)* # domain name
96
+ (?:\.(?:[a-z\u00a1-\uffff]{2,})) # TLD identifier
97
+ )
98
+ (?::\d{2,5})? # port number
99
+ (?:/\S*)? # resource path
100
+ \z
101
+ }xi
102
+ end
103
+
104
+ def access_token_ttl
105
+ @access_token_ttl || 30.minutes
106
+ end
107
+
108
+ def refresh_token_ttl
109
+ @refresh_token_ttl || 1.week
110
+ end
111
+
112
+ def invite_token_ttl
113
+ @invite_token_ttl || 24.hours
114
+ end
115
+
116
+ def invite_token_length
117
+ @invite_token_length || 48
118
+ end
119
+
120
+ def confirm_email_token_ttl
121
+ @confirm_email_token_ttl || 24.hours
122
+ end
123
+
124
+ def confirm_email_token_length
125
+ @confirm_email_token_length || 48
126
+ end
127
+
128
+ def reset_password_token_ttl
129
+ @reset_password_token_ttl || 60.minutes
130
+ end
131
+
132
+ def reset_password_token_length
133
+ @reset_password_token_length || 48
134
+ end
135
+ end
136
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: authonomy
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Ilya Konyukhov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-09-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bcrypt
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: jwt
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: Flexible authentication solution for Rails
56
+ email: ilya@konyukhov.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - LICENSE
62
+ - README.md
63
+ - lib/authonomy.rb
64
+ - lib/authonomy/authenticator.rb
65
+ - lib/authonomy/encryptor.rb
66
+ - lib/authonomy/token_generator.rb
67
+ - lib/authonomy/version.rb
68
+ homepage: https://github.com/ilkon/authonomy
69
+ licenses:
70
+ - MIT
71
+ metadata:
72
+ rubygems_mfa_required: 'true'
73
+ homepage_uri: https://github.com/ilkon/authonomy
74
+ documentation_uri: https://rubydoc.info/github/ilkon/authonomy
75
+ changelog_uri: https://github.com/ilkon/authonomy/blob/main/CHANGELOG.md
76
+ source_code_uri: https://github.com/ilkon/authonomy
77
+ bug_tracker_uri: https://github.com/ilkon/authonomy/issues
78
+ wiki_uri: https://github.com/ilkon/authonomy/wiki
79
+ post_install_message:
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 2.1.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.3.7
95
+ signing_key:
96
+ specification_version: 4
97
+ summary: Flexible authentication solution for Rails
98
+ test_files: []