gemstack-auth 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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +28 -0
- data/lib/gemstack/auth/controller.rb +164 -0
- data/lib/gemstack/auth/password.rb +73 -0
- data/lib/gemstack/auth/rate_limit.rb +43 -0
- data/lib/gemstack/auth/sessions.rb +53 -0
- data/lib/gemstack/auth/testing.rb +24 -0
- data/lib/gemstack/auth/tokens.rb +80 -0
- data/lib/gemstack/auth/user.rb +73 -0
- data/lib/gemstack/auth.rb +92 -0
- data/lib/gemstack/policy.rb +92 -0
- metadata +140 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 07da1fb0891a46be619884f39979d2f3be08c87fbc629b5a001cdfc20bc20ab1
|
|
4
|
+
data.tar.gz: aadbd0585749e3e9ec04895cb720391bdf9187de4326e27fb91b83a94cb7e253
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2adc7daafeceafa6e4a8242b32f53941ee148a242529b53b83d6272371afa323b3cf3062896ee342b090b65fdc761829a63a6149bd342df280f0ad52ac31fb79
|
|
7
|
+
data.tar.gz: 808dd380d6312bcfdf198b326901b88c12220bf98cd4991a0682bee4b7f66a31b1635a5c36d03cb58c795d43eceff22b04100035b5e94c9e3d3abba201e58305
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shoaib Malik
|
|
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,28 @@
|
|
|
1
|
+
# gemstack-auth
|
|
2
|
+
|
|
3
|
+
GemStack auth: Argon2id passwords, cookie sessions, API tokens, policies.
|
|
4
|
+
|
|
5
|
+
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
|
|
6
|
+
applications. All GemStack gems are developed together in that repository and released with the same
|
|
7
|
+
version.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Optional module — `gemstack add auth`:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "gemstack-auth", "~> 0.1"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Documentation
|
|
18
|
+
|
|
19
|
+
- [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/authentication.md)
|
|
20
|
+
- [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
|
|
21
|
+
[Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
|
|
22
|
+
|
|
23
|
+
Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
|
|
24
|
+
(this gem lives in `gems/gemstack-auth`).
|
|
25
|
+
|
|
26
|
+
## License
|
|
27
|
+
|
|
28
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Auth
|
|
5
|
+
# Authentication for controllers:
|
|
6
|
+
#
|
|
7
|
+
# class ApplicationController < GemStack::Controller
|
|
8
|
+
# include GemStack::Auth::Controller
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# class OrdersController < ApplicationController
|
|
12
|
+
# before :require_login
|
|
13
|
+
# def index = render(current_user.orders_dataset)
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# A request is authenticated by the session cookie (browsers) or by
|
|
17
|
+
# `Authorization: Bearer <api token>` (scripts, other services).
|
|
18
|
+
#
|
|
19
|
+
# Cross-site request forgery: state-changing requests (POST, PUT, PATCH,
|
|
20
|
+
# DELETE) from other sites are refused with 403 unless the origin is in
|
|
21
|
+
# config.auth.trusted_origins. Browsers label every request with
|
|
22
|
+
# Sec-Fetch-Site / Origin; clients that send neither (curl, servers) are
|
|
23
|
+
# not browsers and can't carry a victim's cookie (DECISIONS D-051).
|
|
24
|
+
module Controller
|
|
25
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
|
|
26
|
+
|
|
27
|
+
def self.included(base)
|
|
28
|
+
base.include(RateLimit)
|
|
29
|
+
base.include(Policy::Authorization)
|
|
30
|
+
base.before :verify_request_origin
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The signed-in user, or nil.
|
|
34
|
+
def current_user
|
|
35
|
+
return @current_user if defined?(@current_user)
|
|
36
|
+
|
|
37
|
+
@current_user = authenticate_request
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def signed_in? = !current_user.nil?
|
|
41
|
+
|
|
42
|
+
# :session, :token, or nil.
|
|
43
|
+
def authenticated_by
|
|
44
|
+
current_user
|
|
45
|
+
@authenticated_by
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The current `sessions` row (cookie authentication only).
|
|
49
|
+
def current_session
|
|
50
|
+
current_user
|
|
51
|
+
@current_session
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# before :require_login — 401 for anonymous requests.
|
|
55
|
+
def require_login
|
|
56
|
+
raise Unauthorized.new("Sign in to continue.", code: "unauthenticated") unless current_user
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Starts a session: a new token (never reuses one the browser already
|
|
60
|
+
# had, which would allow session fixation) in an HttpOnly cookie.
|
|
61
|
+
def sign_in(user)
|
|
62
|
+
Sessions.revoke(session_cookie) if session_cookie
|
|
63
|
+
token = Sessions.create(user.id, ip: request.ip, user_agent: request.user_agent)
|
|
64
|
+
write_session_cookie(token)
|
|
65
|
+
@current_session = Sessions.find(token)
|
|
66
|
+
@authenticated_by = :session
|
|
67
|
+
@current_user = user
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sign_out
|
|
71
|
+
Sessions.revoke(session_cookie) if session_cookie
|
|
72
|
+
delete_session_cookie
|
|
73
|
+
@current_session = nil
|
|
74
|
+
@authenticated_by = nil
|
|
75
|
+
@current_user = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def authenticate_request
|
|
81
|
+
if (token = bearer_token)
|
|
82
|
+
row = Tokens.find(token, purpose: "api") or return nil
|
|
83
|
+
@authenticated_by = :token
|
|
84
|
+
Auth.user_class[row[:user_id]]
|
|
85
|
+
elsif (token = session_cookie)
|
|
86
|
+
session = Sessions.find(token)
|
|
87
|
+
unless session
|
|
88
|
+
delete_session_cookie # expired or revoked: stop sending it
|
|
89
|
+
return nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
write_session_cookie(token) if session[:touched] # slide the cookie's expiry too
|
|
93
|
+
@current_session = session
|
|
94
|
+
@authenticated_by = :session
|
|
95
|
+
Auth.user_class[session[:user_id]]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def bearer_token
|
|
100
|
+
header = request.get_header("HTTP_AUTHORIZATION").to_s
|
|
101
|
+
header[/\ABearer\s+(\S+)\z/i, 1]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def session_cookie
|
|
105
|
+
value = request.cookies[Auth.config.cookie_name]
|
|
106
|
+
value.nil? || value.empty? ? nil : value
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def write_session_cookie(token)
|
|
110
|
+
config = Auth.config
|
|
111
|
+
replace_cookie(Rack::Utils.set_cookie_header(
|
|
112
|
+
config.cookie_name,
|
|
113
|
+
value: token, path: "/", httponly: true, secure: config.cookie_secure,
|
|
114
|
+
same_site: config.cookie_same_site, max_age: config.session_ttl.to_s
|
|
115
|
+
))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def delete_session_cookie
|
|
119
|
+
config = Auth.config
|
|
120
|
+
replace_cookie(Rack::Utils.set_cookie_header(
|
|
121
|
+
config.cookie_name,
|
|
122
|
+
value: "", path: "/", httponly: true, secure: config.cookie_secure,
|
|
123
|
+
same_site: config.cookie_same_site, max_age: "0", expires: Time.at(0)
|
|
124
|
+
))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def replace_cookie(line)
|
|
128
|
+
existing = headers["set-cookie"]
|
|
129
|
+
name = line[/\A[^=]+/]
|
|
130
|
+
kept = Array(existing).reject { |cookie| cookie.start_with?("#{name}=") }
|
|
131
|
+
headers["set-cookie"] = kept.empty? ? line : kept + [line]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def verify_request_origin
|
|
135
|
+
return if SAFE_METHODS.include?(request.request_method)
|
|
136
|
+
return if bearer_token && !session_cookie # custom headers can't be sent cross-site without CORS
|
|
137
|
+
return if same_origin_request?
|
|
138
|
+
|
|
139
|
+
raise Forbidden.new("Cross-site request refused.", code: "cross_site_request")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def same_origin_request?
|
|
143
|
+
origin = request.get_header("HTTP_ORIGIN")
|
|
144
|
+
return true if origin && Array(Auth.config.trusted_origins).include?(origin)
|
|
145
|
+
|
|
146
|
+
site = request.get_header("HTTP_SEC_FETCH_SITE")
|
|
147
|
+
return %w[same-origin none].include?(site) if site
|
|
148
|
+
return true if origin.nil? || origin.empty?
|
|
149
|
+
|
|
150
|
+
hosts = [request.host_with_port, request.get_header("HTTP_X_FORWARDED_HOST")].compact
|
|
151
|
+
host = URI.parse(origin).then { |uri| [uri.host, uri.port].join(":") }
|
|
152
|
+
hosts.any? { |value| normalize_host(value, origin) == host }
|
|
153
|
+
rescue URI::InvalidURIError
|
|
154
|
+
false
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def normalize_host(value, origin)
|
|
158
|
+
host, port = value.split(",").first.strip.split(":", 2)
|
|
159
|
+
port ||= origin.start_with?("https:") ? "443" : "80"
|
|
160
|
+
"#{host}:#{port}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "argon2"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module Auth
|
|
7
|
+
# Password hashing with Argon2id (DECISIONS D-049). Hashes from other
|
|
8
|
+
# systems in bcrypt format still verify (add gem "bcrypt") and are
|
|
9
|
+
# upgraded to Argon2id on the next successful login.
|
|
10
|
+
module Password
|
|
11
|
+
class << self
|
|
12
|
+
def create(plain)
|
|
13
|
+
config = Auth.config
|
|
14
|
+
Argon2::Password.new(t_cost: config.argon2_t_cost, m_cost: config.argon2_m_cost, p_cost: 1).create(plain)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def verify(plain, digest)
|
|
18
|
+
return false unless plain.is_a?(String) && digest.is_a?(String)
|
|
19
|
+
return false if plain.bytesize > 1024 # never spend hashing time on megabyte "passwords"
|
|
20
|
+
|
|
21
|
+
if digest.start_with?("$argon2")
|
|
22
|
+
Argon2::Password.verify_password(plain, digest)
|
|
23
|
+
elsif digest.match?(/\A\$2[aby]\$/)
|
|
24
|
+
bcrypt.new(digest) == plain
|
|
25
|
+
else
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
rescue Argon2::ArgonHashFail
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# True for bcrypt hashes and Argon2 hashes weaker than the current settings.
|
|
33
|
+
def needs_rehash?(digest)
|
|
34
|
+
match = digest.to_s.match(/\A\$argon2id\$v=\d+\$m=(\d+),t=(\d+),p=\d+\$/) or return true
|
|
35
|
+
config = Auth.config
|
|
36
|
+
match[1].to_i < (1 << config.argon2_m_cost) || match[2].to_i < config.argon2_t_cost
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Spends the same time as a real verification, so "no such user" and
|
|
40
|
+
# "wrong password" can't be told apart by timing.
|
|
41
|
+
def verify_dummy(plain)
|
|
42
|
+
@dummy ||= create(SecureRandom.hex(16))
|
|
43
|
+
verify(plain.to_s[0, 1024], @dummy)
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Validation messages for a new password (length only, per NIST SP 800-63B).
|
|
48
|
+
def errors(plain)
|
|
49
|
+
config = Auth.config
|
|
50
|
+
length = plain.to_s.length
|
|
51
|
+
if plain.to_s.strip.empty? then ["is required"]
|
|
52
|
+
elsif length < config.password_min_length
|
|
53
|
+
["is too short (minimum #{config.password_min_length} characters)"]
|
|
54
|
+
elsif length > config.password_max_length
|
|
55
|
+
["is too long (maximum #{config.password_max_length} characters)"]
|
|
56
|
+
else []
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def reset_dummy! = @dummy = nil
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def bcrypt
|
|
65
|
+
require "bcrypt"
|
|
66
|
+
BCrypt::Password
|
|
67
|
+
rescue LoadError
|
|
68
|
+
raise ConfigurationError, "this password hash is bcrypt; add gem \"bcrypt\" to verify it"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module Auth
|
|
7
|
+
# Fixed-window rate limits for controller actions, counted in
|
|
8
|
+
# GemStack.cache (use a shared store — :redis — with several servers).
|
|
9
|
+
#
|
|
10
|
+
# rate_limit to: 10, within: 60, only: :create # per client IP
|
|
11
|
+
# rate_limit to: 3, within: 3600, only: :create, name: "forgot-email",
|
|
12
|
+
# by: -> { params[:email].to_s.downcase }
|
|
13
|
+
#
|
|
14
|
+
# Over the limit: 429 with Retry-After.
|
|
15
|
+
module RateLimit
|
|
16
|
+
def self.included(base) = base.extend(ClassMethods)
|
|
17
|
+
|
|
18
|
+
module ClassMethods
|
|
19
|
+
def rate_limit(to:, within:, by: -> { request.ip }, only: nil, except: nil, name: nil)
|
|
20
|
+
limit = Integer(to)
|
|
21
|
+
period = Integer(within)
|
|
22
|
+
before(only: only, except: except) { enforce_rate_limit(limit, period, by, name) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def enforce_rate_limit(limit, period, by, name)
|
|
29
|
+
subject = instance_exec(&by).to_s
|
|
30
|
+
return if subject.empty?
|
|
31
|
+
|
|
32
|
+
now = Time.now.to_i
|
|
33
|
+
key = ["rate-limit", name || "#{self.class.name}##{action_name}", Digest::SHA256.hexdigest(subject)[0, 32],
|
|
34
|
+
now / period]
|
|
35
|
+
return if GemStack.cache.increment(key, expires_in: period) <= limit
|
|
36
|
+
|
|
37
|
+
retry_after = period - (now % period)
|
|
38
|
+
raise TooManyRequests.new("Too many attempts. Try again in #{retry_after} seconds.",
|
|
39
|
+
code: "rate_limited", headers: { "retry-after" => retry_after.to_s })
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Auth
|
|
5
|
+
# Browser sessions, one row per signed-in device in the `sessions` table.
|
|
6
|
+
# Being rows (not signed cookies), they can be listed and revoked:
|
|
7
|
+
# signing out everywhere or resetting a password really ends them.
|
|
8
|
+
module Sessions
|
|
9
|
+
class << self
|
|
10
|
+
def dataset = Auth.db[:sessions]
|
|
11
|
+
|
|
12
|
+
# Returns the token to put in the cookie.
|
|
13
|
+
def create(user_id, ip: nil, user_agent: nil)
|
|
14
|
+
token = Token.generate
|
|
15
|
+
now = Time.now
|
|
16
|
+
dataset.insert(token_digest: Token.digest(token), user_id: user_id, ip: ip,
|
|
17
|
+
user_agent: user_agent&.slice(0, 255), created_at: now, last_seen_at: now,
|
|
18
|
+
expires_at: now + Auth.config.session_ttl)
|
|
19
|
+
token
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The live session for a token, or nil. Extends the expiry (at most
|
|
23
|
+
# once per session_touch_interval); row[:touched] tells the caller to
|
|
24
|
+
# re-send the cookie with the new expiry.
|
|
25
|
+
def find(token)
|
|
26
|
+
return nil unless Token.plausible?(token)
|
|
27
|
+
|
|
28
|
+
now = Time.now
|
|
29
|
+
row = dataset.where(token_digest: Token.digest(token)).where { expires_at > now }.first or return nil
|
|
30
|
+
if row[:last_seen_at] <= now - Auth.config.session_touch_interval
|
|
31
|
+
expires = now + Auth.config.session_ttl
|
|
32
|
+
dataset.where(id: row[:id]).update(last_seen_at: now, expires_at: expires)
|
|
33
|
+
row = row.merge(last_seen_at: now, expires_at: expires, touched: true)
|
|
34
|
+
end
|
|
35
|
+
row
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def revoke(token) = Token.plausible?(token) ? dataset.where(token_digest: Token.digest(token)).delete : 0
|
|
39
|
+
|
|
40
|
+
# Ends every session of a user (optionally keeping one, by id).
|
|
41
|
+
def revoke_all(user_id, except: nil)
|
|
42
|
+
scope = dataset.where(user_id: user_id)
|
|
43
|
+
scope = scope.exclude(id: except) if except
|
|
44
|
+
scope.delete
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def for_user(user_id)
|
|
48
|
+
dataset.where(user_id: user_id).where { expires_at > Time.now }.order(Sequel.desc(:last_seen_at)).all
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "gemstack/auth"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module Auth
|
|
7
|
+
# Test helpers (added to GemStack::TestCase by `gemstack add auth`):
|
|
8
|
+
#
|
|
9
|
+
# sign_in_as(user) # later requests carry the session cookie
|
|
10
|
+
# get_json "/api/auth/me"
|
|
11
|
+
# get_json "/api/orders", {}, bearer_headers(user)
|
|
12
|
+
module Testing
|
|
13
|
+
def sign_in_as(user)
|
|
14
|
+
token = Sessions.create(user.id, ip: "127.0.0.1", user_agent: "test")
|
|
15
|
+
set_cookie("#{Auth.config.cookie_name}=#{token}")
|
|
16
|
+
token
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def api_token_for(user, name: "test") = Tokens.issue(user.id, purpose: "api", name: name).first
|
|
20
|
+
|
|
21
|
+
def bearer_headers(user) = { "HTTP_AUTHORIZATION" => "Bearer #{api_token_for(user)}" }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Auth
|
|
5
|
+
# Tokens in the `auth_tokens` table:
|
|
6
|
+
# "api" long-lived bearer tokens (Authorization: Bearer gs_…)
|
|
7
|
+
# "password_reset" single use, short-lived, emailed
|
|
8
|
+
# "email_verification" single use, emailed, bound to the address it was sent to
|
|
9
|
+
module Tokens
|
|
10
|
+
PURPOSES = %w[api password_reset email_verification].freeze
|
|
11
|
+
PREFIX = { "api" => "gs_" }.freeze
|
|
12
|
+
# last_used_at is written at most once a minute per token.
|
|
13
|
+
TOUCH_INTERVAL = 60
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def dataset = Auth.db[:auth_tokens]
|
|
17
|
+
|
|
18
|
+
# Returns [token, row id]. The plain token is shown to the user once
|
|
19
|
+
# and never stored. Issuing a reset/verification token invalidates the
|
|
20
|
+
# user's earlier ones for the same purpose.
|
|
21
|
+
def issue(user_id, purpose:, name: nil, email: nil, expires_in: default_ttl(purpose))
|
|
22
|
+
check_purpose!(purpose)
|
|
23
|
+
dataset.where(user_id: user_id, purpose: purpose).delete unless purpose == "api"
|
|
24
|
+
token = Token.generate(PREFIX.fetch(purpose, ""))
|
|
25
|
+
now = Time.now
|
|
26
|
+
id = dataset.insert(user_id: user_id, purpose: purpose, token_digest: Token.digest(token), name: name,
|
|
27
|
+
email: email, created_at: now, expires_at: expires_in && (now + expires_in))
|
|
28
|
+
[token, id]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The live token row, or nil. API tokens record when they were last used.
|
|
32
|
+
def find(token, purpose:)
|
|
33
|
+
check_purpose!(purpose)
|
|
34
|
+
return nil unless Token.plausible?(token)
|
|
35
|
+
|
|
36
|
+
now = Time.now
|
|
37
|
+
row = live(token, purpose, now).first or return nil
|
|
38
|
+
if purpose == "api" && (row[:last_used_at].nil? || row[:last_used_at] <= now - TOUCH_INTERVAL)
|
|
39
|
+
dataset.where(id: row[:id]).update(last_used_at: now)
|
|
40
|
+
end
|
|
41
|
+
row
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Uses up a single-use token: deletes and returns its row, atomically,
|
|
45
|
+
# so two concurrent requests can't both use one reset link.
|
|
46
|
+
def consume(token, purpose:)
|
|
47
|
+
check_purpose!(purpose)
|
|
48
|
+
return nil unless Token.plausible?(token)
|
|
49
|
+
|
|
50
|
+
live(token, purpose, Time.now).returning.delete.first
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def revoke(id, user_id:) = dataset.where(id: id, user_id: user_id).delete.positive?
|
|
54
|
+
def revoke_all(user_id, purpose:) = dataset.where(user_id: user_id, purpose: purpose).delete
|
|
55
|
+
|
|
56
|
+
def for_user(user_id, purpose: "api")
|
|
57
|
+
dataset.where(user_id: user_id, purpose: purpose).order(Sequel.desc(:created_at))
|
|
58
|
+
.select(:id, :name, :created_at, :last_used_at, :expires_at).all
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def live(token, purpose, now)
|
|
64
|
+
dataset.where(token_digest: Token.digest(token), purpose: purpose)
|
|
65
|
+
.where(Sequel.|({ expires_at: nil }, Sequel[:expires_at] > now))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def default_ttl(purpose)
|
|
69
|
+
config = Auth.config
|
|
70
|
+
{ "api" => config.api_token_ttl, "password_reset" => config.password_reset_ttl,
|
|
71
|
+
"email_verification" => config.email_verification_ttl }.fetch(purpose)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def check_purpose!(purpose)
|
|
75
|
+
raise ArgumentError, "unknown token purpose #{purpose.inspect}" unless PURPOSES.include?(purpose)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Auth
|
|
5
|
+
# Password and email behaviour for the user model:
|
|
6
|
+
#
|
|
7
|
+
# class User < GemStack::Model
|
|
8
|
+
# include GemStack::Auth::User
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# User.create(email: "Ada@Example.com", password: "correct horse battery")
|
|
12
|
+
# User.authenticate_by(email: "ada@example.com", password: "…") # => user or nil
|
|
13
|
+
#
|
|
14
|
+
# Needs the columns email (unique) and password_digest; email_verified_at
|
|
15
|
+
# for verification.
|
|
16
|
+
module User
|
|
17
|
+
EMAIL_FORMAT = /\A[^@\s]+@[^@\s.]+(\.[^@\s.]+)+\z/
|
|
18
|
+
|
|
19
|
+
def self.included(model) = model.extend(ClassMethods)
|
|
20
|
+
|
|
21
|
+
module ClassMethods
|
|
22
|
+
def normalize_email(email) = email.to_s.unicode_normalize(:nfkc).strip.downcase
|
|
23
|
+
|
|
24
|
+
# The user when the password matches; nil otherwise, in about the same
|
|
25
|
+
# time whether or not the email exists.
|
|
26
|
+
def authenticate_by(email:, password:)
|
|
27
|
+
user = first(email: normalize_email(email))
|
|
28
|
+
return user if user&.authenticate(password)
|
|
29
|
+
|
|
30
|
+
Password.verify_dummy(password) unless user
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :password
|
|
36
|
+
|
|
37
|
+
def email=(value)
|
|
38
|
+
self[:email] = value.nil? ? nil : self.class.normalize_email(value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Hashed right away (when the length is acceptable); the plain text is
|
|
42
|
+
# kept in memory only for validation.
|
|
43
|
+
def password=(plain)
|
|
44
|
+
@password = plain
|
|
45
|
+
self[:password_digest] = Password.errors(plain).empty? ? Password.create(plain) : nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Verifies a password; upgrades the stored hash when the settings changed.
|
|
49
|
+
def authenticate(plain)
|
|
50
|
+
return false unless Password.verify(plain, self[:password_digest])
|
|
51
|
+
|
|
52
|
+
if Password.needs_rehash?(self[:password_digest])
|
|
53
|
+
digest = Password.create(plain)
|
|
54
|
+
this.update(password_digest: digest)
|
|
55
|
+
self[:password_digest] = digest
|
|
56
|
+
end
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def email_verified? = !self[:email_verified_at].nil?
|
|
61
|
+
|
|
62
|
+
def validate
|
|
63
|
+
super
|
|
64
|
+
errors.add(:email, "is invalid") unless self[:email].to_s.match?(EMAIL_FORMAT) || errors.on(:email)
|
|
65
|
+
Password.errors(@password).each { |message| errors.add(:password, message) } if new? || !@password.nil?
|
|
66
|
+
return unless self[:email] && (new? || changed_columns.include?(:email))
|
|
67
|
+
|
|
68
|
+
validates_unique(:email,
|
|
69
|
+
message: "is already taken")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "gemstack/core"
|
|
6
|
+
require "gemstack/cache"
|
|
7
|
+
require "gemstack/http"
|
|
8
|
+
require "gemstack/db"
|
|
9
|
+
require "gemstack/mail"
|
|
10
|
+
|
|
11
|
+
module GemStack
|
|
12
|
+
# Authentication (DECISIONS D-048..D-052): Argon2id passwords, database
|
|
13
|
+
# sessions in an HttpOnly cookie for the Next.js frontend, and bearer API
|
|
14
|
+
# tokens for scripts and other services. `gemstack add auth` generates the
|
|
15
|
+
# tables, the User model, the controllers, the emails and the Next.js pages
|
|
16
|
+
# that use this module — all of it application code you can change.
|
|
17
|
+
module Auth
|
|
18
|
+
class Config < Settings
|
|
19
|
+
# The model that includes GemStack::Auth::User.
|
|
20
|
+
setting :user_class, default: "User"
|
|
21
|
+
# Sessions expire after this many seconds without use (sliding).
|
|
22
|
+
setting :session_ttl, default: 30 * 24 * 3600
|
|
23
|
+
# last_seen_at / expiry are updated at most this often (one UPDATE per session per interval).
|
|
24
|
+
setting :session_touch_interval, default: 300
|
|
25
|
+
# Secure cookies are HTTPS-only; local development runs on http://localhost.
|
|
26
|
+
setting :cookie_secure, default: -> { !GemStack.env.local? }
|
|
27
|
+
# The __Host- prefix makes browsers refuse the cookie unless it is Secure,
|
|
28
|
+
# host-only and Path=/ — no subdomain can set or overwrite it.
|
|
29
|
+
setting :cookie_name, default: -> { cookie_secure ? "__Host-session" : "gemstack_session" }
|
|
30
|
+
setting :cookie_same_site, default: :lax
|
|
31
|
+
setting :password_reset_ttl, default: 3600
|
|
32
|
+
setting :email_verification_ttl, default: 3 * 24 * 3600
|
|
33
|
+
# nil: API tokens don't expire (they can be revoked); or seconds.
|
|
34
|
+
setting :api_token_ttl, default: nil
|
|
35
|
+
setting :password_min_length, default: 12
|
|
36
|
+
setting :password_max_length, default: 128
|
|
37
|
+
# Argon2id cost: t = iterations, m = log2(memory KiB). 2 / 2^15 (32 MiB)
|
|
38
|
+
# takes ~35 ms per hash on a 2024 laptop (OWASP's minimum is 19 MiB).
|
|
39
|
+
setting :argon2_t_cost, default: -> { GemStack.env.test? ? 1 : 2 }
|
|
40
|
+
setting :argon2_m_cost, default: -> { GemStack.env.test? ? 8 : 15 }
|
|
41
|
+
# Other origins allowed to make cookie-authenticated, state-changing
|
|
42
|
+
# requests (e.g. "https://admin.example.com"). Same-origin always works.
|
|
43
|
+
setting :trusted_origins, default: []
|
|
44
|
+
# Where the frontend lives, for links in emails.
|
|
45
|
+
setting :app_url, default: -> { ENV.fetch("APP_URL", "http://localhost:#{ENV.fetch("PORT", "3000")}") }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
def config = GemStack.config.auth
|
|
50
|
+
|
|
51
|
+
def user_class
|
|
52
|
+
name = config.user_class
|
|
53
|
+
name.is_a?(String) ? Object.const_get(name) : name
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def db = DB.connection
|
|
57
|
+
|
|
58
|
+
# Deletes expired sessions and tokens; run it daily from a job.
|
|
59
|
+
def cleanup!
|
|
60
|
+
now = Time.now
|
|
61
|
+
{ sessions: db[:sessions].where { expires_at <= now }.delete,
|
|
62
|
+
auth_tokens: db[:auth_tokens].exclude(expires_at: nil).where { expires_at <= now }.delete }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A link into the frontend: Auth.url("/reset-password", token: t)
|
|
66
|
+
def url(path, **query)
|
|
67
|
+
base = config.app_url.to_s.chomp("/")
|
|
68
|
+
query.empty? ? "#{base}#{path}" : "#{base}#{path}?#{URI.encode_www_form(query)}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Opaque random tokens; only their SHA-256 digests are stored, so a
|
|
73
|
+
# database leak doesn't hand out working sessions or reset links.
|
|
74
|
+
module Token
|
|
75
|
+
def self.generate(prefix = "") = "#{prefix}#{SecureRandom.urlsafe_base64(32)}"
|
|
76
|
+
def self.digest(token) = OpenSSL::Digest::SHA256.hexdigest(token.to_s)
|
|
77
|
+
|
|
78
|
+
# Tokens are 43–50 characters; anything else is not worth a query.
|
|
79
|
+
def self.plausible?(token) = token.is_a?(String) && token.bytesize.between?(20, 200)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
require_relative "auth/password"
|
|
85
|
+
require_relative "auth/sessions"
|
|
86
|
+
require_relative "auth/tokens"
|
|
87
|
+
require_relative "auth/user"
|
|
88
|
+
require_relative "auth/rate_limit"
|
|
89
|
+
require_relative "auth/controller"
|
|
90
|
+
require_relative "policy"
|
|
91
|
+
|
|
92
|
+
GemStack::Config.namespace(:auth, GemStack::Auth::Config)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# Authorization rules, one policy class per model (DECISIONS D-052):
|
|
5
|
+
#
|
|
6
|
+
# class OrderPolicy < GemStack::Policy
|
|
7
|
+
# def show? = owner? || user&.admin?
|
|
8
|
+
# def update? = owner?
|
|
9
|
+
#
|
|
10
|
+
# class Scope < Scope
|
|
11
|
+
# def resolve = user&.admin? ? scope : scope.where(user_id: user&.id)
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# private
|
|
15
|
+
#
|
|
16
|
+
# def owner? = user && record.user_id == user.id
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# class OrdersController < ApplicationController
|
|
20
|
+
# def index = render(paginate(policy_scope(Order.order(:id))))
|
|
21
|
+
# def show = render(authorize!(Order.find(params[:id]))) # OrderPolicy#show?
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# Everything is denied unless a policy method says otherwise.
|
|
25
|
+
class Policy
|
|
26
|
+
class NotDefined < Error; end
|
|
27
|
+
|
|
28
|
+
attr_reader :user, :record
|
|
29
|
+
|
|
30
|
+
def initialize(user, record)
|
|
31
|
+
@user = user
|
|
32
|
+
@record = record
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def index? = false
|
|
36
|
+
def show? = false
|
|
37
|
+
def create? = false
|
|
38
|
+
def update? = false
|
|
39
|
+
def destroy? = false
|
|
40
|
+
|
|
41
|
+
# Narrows a dataset to the records the user may see.
|
|
42
|
+
class Scope
|
|
43
|
+
attr_reader :user, :scope
|
|
44
|
+
|
|
45
|
+
def initialize(user, scope)
|
|
46
|
+
@user = user
|
|
47
|
+
@scope = scope
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve
|
|
51
|
+
raise NotDefined, "#{self.class.name}#resolve is not defined; it decides which records a user may list"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The policy class for a record, a model class or a dataset: Order,
|
|
56
|
+
# Order.where(...) and an order all use OrderPolicy.
|
|
57
|
+
def self.for(subject)
|
|
58
|
+
model = if subject.is_a?(Class) then subject
|
|
59
|
+
elsif subject.respond_to?(:model) && subject.model.is_a?(Class) then subject.model
|
|
60
|
+
else subject.class
|
|
61
|
+
end
|
|
62
|
+
name = "#{model.name}Policy"
|
|
63
|
+
Object.const_get(name)
|
|
64
|
+
rescue NameError => e
|
|
65
|
+
raise unless e.name.to_s == name.split("::").last || e.name.to_s == name
|
|
66
|
+
|
|
67
|
+
raise NotDefined, "#{name} is not defined (create app/policies/#{Inflector.underscore(name)}.rb " \
|
|
68
|
+
"or run `gemstack generate policy #{model.name}`)"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Controller helpers (included with GemStack::Auth::Controller by `gemstack add auth`).
|
|
72
|
+
module Authorization
|
|
73
|
+
# Returns the record when allowed; 403 otherwise. The rule defaults to
|
|
74
|
+
# "<action>?" (show → show?); a custom action maps to its own name.
|
|
75
|
+
def authorize!(record, rule = nil, policy: nil)
|
|
76
|
+
rule ||= :"#{action_name}?"
|
|
77
|
+
allowed = (policy || Policy.for(record)).new(policy_user, record).public_send(rule)
|
|
78
|
+
return record if allowed
|
|
79
|
+
|
|
80
|
+
raise Forbidden.new("You are not allowed to do that.", code: "forbidden")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def policy_scope(scope, policy: nil) = (policy || Policy.for(scope))::Scope.new(policy_user, scope).resolve
|
|
84
|
+
|
|
85
|
+
def policy(record) = Policy.for(record).new(policy_user, record)
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def policy_user = respond_to?(:current_user) ? current_user : nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: gemstack-auth
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Shoaib Malik
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: argon2
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.3'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.3'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: gemstack-cache
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - '='
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 0.1.0
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - '='
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 0.1.0
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: gemstack-core
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - '='
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 0.1.0
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - '='
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 0.1.0
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: gemstack-db
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - '='
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: 0.1.0
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - '='
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: 0.1.0
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: gemstack-http
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - '='
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: 0.1.0
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - '='
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: 0.1.0
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: gemstack-mail
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - '='
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: 0.1.0
|
|
89
|
+
type: :runtime
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - '='
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: 0.1.0
|
|
96
|
+
email:
|
|
97
|
+
- gemstack26@gmail.com
|
|
98
|
+
executables: []
|
|
99
|
+
extensions: []
|
|
100
|
+
extra_rdoc_files: []
|
|
101
|
+
files:
|
|
102
|
+
- CHANGELOG.md
|
|
103
|
+
- LICENSE.txt
|
|
104
|
+
- README.md
|
|
105
|
+
- lib/gemstack/auth.rb
|
|
106
|
+
- lib/gemstack/auth/controller.rb
|
|
107
|
+
- lib/gemstack/auth/password.rb
|
|
108
|
+
- lib/gemstack/auth/rate_limit.rb
|
|
109
|
+
- lib/gemstack/auth/sessions.rb
|
|
110
|
+
- lib/gemstack/auth/testing.rb
|
|
111
|
+
- lib/gemstack/auth/tokens.rb
|
|
112
|
+
- lib/gemstack/auth/user.rb
|
|
113
|
+
- lib/gemstack/policy.rb
|
|
114
|
+
homepage: https://github.com/gemstack-rb/gemstack
|
|
115
|
+
licenses:
|
|
116
|
+
- MIT
|
|
117
|
+
metadata:
|
|
118
|
+
rubygems_mfa_required: 'true'
|
|
119
|
+
source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-auth
|
|
120
|
+
changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-auth/CHANGELOG.md
|
|
121
|
+
bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
|
|
122
|
+
documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
|
|
123
|
+
rdoc_options: []
|
|
124
|
+
require_paths:
|
|
125
|
+
- lib
|
|
126
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '4.0'
|
|
131
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
132
|
+
requirements:
|
|
133
|
+
- - ">="
|
|
134
|
+
- !ruby/object:Gem::Version
|
|
135
|
+
version: '0'
|
|
136
|
+
requirements: []
|
|
137
|
+
rubygems_version: 4.0.20
|
|
138
|
+
specification_version: 4
|
|
139
|
+
summary: 'GemStack auth: Argon2id passwords, cookie sessions, API tokens, policies'
|
|
140
|
+
test_files: []
|