standard_id 0.2.9 → 0.3.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 +4 -4
- data/README.md +58 -14
- data/app/controllers/concerns/standard_id/passwordless_strategy.rb +18 -0
- data/app/controllers/standard_id/api/passwordless_controller.rb +1 -10
- data/app/controllers/standard_id/web/login_controller.rb +47 -4
- data/app/controllers/standard_id/web/login_verify_controller.rb +170 -0
- data/app/views/standard_id/web/login_verify/show.html.erb +35 -0
- data/config/brakeman.ignore +30 -0
- data/config/routes/web.rb +1 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +2 -0
- data/lib/standard_id/api/session_manager.rb +3 -1
- data/lib/standard_id/config/schema.rb +2 -0
- data/lib/standard_id/passwordless/base_strategy.rb +1 -1
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/session_manager.rb +1 -1
- metadata +20 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9878cd708e3ea39bc8cad97946dd6509c2423a815ade09c726d80c591f72cf7f
|
|
4
|
+
data.tar.gz: 4ee3cf785da092a4efd199dee423d214c0b99936928cd381e8167650474c9c7b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 68cdd5a479a507d7647055b06534744f458546ac81a0a1bf71b0464739316ade506a50303a811fefac56142f223d1d913af4a41fd53243a1678ec64a50f48d5e
|
|
7
|
+
data.tar.gz: bb8137fb52314263901a3ed6640ef9b327f1397424b68776af4edd4b37be9d0da26e7974d5728252981823b9dd48fc4aec4825f960aec3a3769a20a7cef3ead4
|
data/README.md
CHANGED
|
@@ -516,27 +516,71 @@ StandardId::Events.subscribe(/social/) do |event|
|
|
|
516
516
|
end
|
|
517
517
|
```
|
|
518
518
|
|
|
519
|
-
|
|
519
|
+
### Audit Logging
|
|
520
|
+
|
|
521
|
+
For production audit trails, use the [standard_audit](https://github.com/rarebit-one/standard_audit) gem. StandardId and StandardAudit have zero direct references to each other — the host application wires them together.
|
|
522
|
+
|
|
523
|
+
#### Setup
|
|
524
|
+
|
|
525
|
+
Add both gems to your Gemfile:
|
|
520
526
|
|
|
521
527
|
```ruby
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
528
|
+
gem "standard_id"
|
|
529
|
+
gem "standard_audit"
|
|
530
|
+
```
|
|
525
531
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
532
|
+
Run the StandardAudit install generator:
|
|
533
|
+
|
|
534
|
+
```bash
|
|
535
|
+
rails generate standard_audit:install
|
|
536
|
+
rails db:migrate
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
#### Wiring StandardId events to StandardAudit
|
|
540
|
+
|
|
541
|
+
Configure StandardAudit to subscribe to StandardId's event namespace and map its payload conventions:
|
|
542
|
+
|
|
543
|
+
```ruby
|
|
544
|
+
# config/initializers/standard_audit.rb
|
|
545
|
+
StandardAudit.configure do |config|
|
|
546
|
+
config.subscribe_to /\Astandard_id\./
|
|
547
|
+
|
|
548
|
+
# StandardId uses :account and :current_account rather than :actor/:target.
|
|
549
|
+
# Map them so StandardAudit extracts the right records.
|
|
550
|
+
config.actor_extractor = ->(payload) {
|
|
551
|
+
payload[:current_account] || payload[:account]
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
config.target_extractor = ->(payload) {
|
|
555
|
+
# Only set a target when the actor (current_account) differs from the
|
|
556
|
+
# account being acted upon — e.g. an admin locking another user.
|
|
557
|
+
if payload[:current_account]
|
|
558
|
+
target = payload[:account] || payload[:client_application]
|
|
559
|
+
target unless target == payload[:current_account]
|
|
560
|
+
end
|
|
561
|
+
}
|
|
534
562
|
end
|
|
563
|
+
```
|
|
535
564
|
|
|
536
|
-
|
|
537
|
-
|
|
565
|
+
That's it. Every StandardId authentication event will now be persisted as an audit log entry. No changes are needed inside StandardId itself.
|
|
566
|
+
|
|
567
|
+
#### Querying audit logs
|
|
568
|
+
|
|
569
|
+
```ruby
|
|
570
|
+
# All auth events for a user
|
|
571
|
+
StandardAudit::AuditLog.for_actor(user).reverse_chronological
|
|
572
|
+
|
|
573
|
+
# Failed logins this week
|
|
574
|
+
StandardAudit::AuditLog
|
|
575
|
+
.by_event_type("standard_id.authentication.attempt.failed")
|
|
576
|
+
.this_week
|
|
577
|
+
|
|
578
|
+
# All activity from an IP address
|
|
579
|
+
StandardAudit::AuditLog.from_ip("192.168.1.1")
|
|
538
580
|
```
|
|
539
581
|
|
|
582
|
+
See the [StandardAudit README](https://github.com/rarebit-one/standard_audit) for the full query interface, async processing, GDPR compliance, and multi-tenancy support.
|
|
583
|
+
|
|
540
584
|
## Account Status (Activation/Deactivation)
|
|
541
585
|
|
|
542
586
|
StandardId provides an optional `AccountStatus` concern for managing account activation and deactivation. This uses Rails enum with the event system to enforce status checks and handle side effects without modifying core authentication logic.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module PasswordlessStrategy
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
STRATEGY_MAP = {
|
|
6
|
+
"email" => StandardId::Passwordless::EmailStrategy,
|
|
7
|
+
"sms" => StandardId::Passwordless::SmsStrategy
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def strategy_for(connection)
|
|
13
|
+
klass = STRATEGY_MAP[connection]
|
|
14
|
+
raise StandardId::InvalidRequestError, "Unsupported connection type: #{connection}" unless klass
|
|
15
|
+
klass.new(request)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
module StandardId
|
|
2
2
|
module Api
|
|
3
3
|
class PasswordlessController < BaseController
|
|
4
|
-
|
|
5
|
-
"email" => StandardId::Passwordless::EmailStrategy,
|
|
6
|
-
"sms" => StandardId::Passwordless::SmsStrategy
|
|
7
|
-
}.freeze
|
|
4
|
+
include StandardId::PasswordlessStrategy
|
|
8
5
|
|
|
9
6
|
def start
|
|
10
7
|
raise StandardId::InvalidRequestError, "username, email, or phone_number parameter is required" if start_params[:username].blank?
|
|
@@ -16,12 +13,6 @@ module StandardId
|
|
|
16
13
|
|
|
17
14
|
private
|
|
18
15
|
|
|
19
|
-
def strategy_for(connection)
|
|
20
|
-
klass = STRATEGY_MAP[connection]
|
|
21
|
-
raise StandardId::InvalidRequestError, "Unsupported connection type: #{connection}" unless klass
|
|
22
|
-
klass.new(request)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
16
|
def start_params
|
|
26
17
|
return @start_params if @start_params.present?
|
|
27
18
|
|
|
@@ -3,7 +3,7 @@ module StandardId
|
|
|
3
3
|
class LoginController < BaseController
|
|
4
4
|
include StandardId::InertiaRendering
|
|
5
5
|
include StandardId::Web::SocialLoginParams
|
|
6
|
-
|
|
6
|
+
include StandardId::PasswordlessStrategy
|
|
7
7
|
|
|
8
8
|
layout "public"
|
|
9
9
|
|
|
@@ -16,19 +16,62 @@ module StandardId
|
|
|
16
16
|
@redirect_uri = params[:redirect_uri] || after_authentication_url
|
|
17
17
|
@connection = params[:connection]
|
|
18
18
|
|
|
19
|
-
render_with_inertia props: auth_page_props
|
|
19
|
+
render_with_inertia props: auth_page_props(passwordless_enabled: passwordless_enabled?)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def create
|
|
23
|
+
if passwordless_enabled?
|
|
24
|
+
handle_passwordless_login
|
|
25
|
+
else
|
|
26
|
+
handle_password_login
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def passwordless_enabled?
|
|
33
|
+
StandardId.config.passwordless.enabled
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def handle_password_login
|
|
23
37
|
if sign_in_account(login_params)
|
|
24
38
|
redirect_to params[:redirect_uri] || after_authentication_url, status: :see_other, notice: "Successfully signed in"
|
|
25
39
|
else
|
|
26
40
|
flash.now[:alert] = "Invalid email or password"
|
|
27
|
-
render_with_inertia action: :show, props: auth_page_props, status: :unprocessable_content
|
|
41
|
+
render_with_inertia action: :show, props: auth_page_props(passwordless_enabled: passwordless_enabled?), status: :unprocessable_content
|
|
28
42
|
end
|
|
29
43
|
end
|
|
30
44
|
|
|
31
|
-
|
|
45
|
+
def handle_passwordless_login
|
|
46
|
+
email = login_params[:email].to_s.strip.downcase
|
|
47
|
+
connection = StandardId.config.passwordless.connection
|
|
48
|
+
|
|
49
|
+
if email.blank?
|
|
50
|
+
flash.now[:alert] = "Please enter your email address"
|
|
51
|
+
render_with_inertia action: :show, props: auth_page_props(passwordless_enabled: passwordless_enabled?), status: :unprocessable_content
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
strategy = strategy_for(connection)
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
strategy.start!(username: email, connection: connection)
|
|
59
|
+
rescue StandardId::InvalidRequestError => e
|
|
60
|
+
flash.now[:alert] = e.message
|
|
61
|
+
render_with_inertia action: :show, props: auth_page_props(passwordless_enabled: passwordless_enabled?), status: :unprocessable_content
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
code_ttl = StandardId.config.passwordless.code_ttl
|
|
66
|
+
signed_payload = Rails.application.message_verifier(:otp).generate(
|
|
67
|
+
{ username: email, connection: connection },
|
|
68
|
+
expires_in: code_ttl.seconds
|
|
69
|
+
)
|
|
70
|
+
session[:standard_id_otp_payload] = signed_payload
|
|
71
|
+
session[:return_to_after_authenticating] = params[:redirect_uri] if params[:redirect_uri].present?
|
|
72
|
+
|
|
73
|
+
redirect_to login_verify_path, status: :see_other
|
|
74
|
+
end
|
|
32
75
|
|
|
33
76
|
def redirect_if_authenticated
|
|
34
77
|
redirect_to after_authentication_url, status: :see_other, notice: "You are already signed in" if authenticated?
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Web
|
|
3
|
+
class LoginVerifyController < BaseController
|
|
4
|
+
include StandardId::InertiaRendering
|
|
5
|
+
include StandardId::PasswordlessStrategy
|
|
6
|
+
|
|
7
|
+
class OtpVerificationFailed < StandardError; end
|
|
8
|
+
|
|
9
|
+
layout "public"
|
|
10
|
+
|
|
11
|
+
skip_before_action :require_browser_session!, only: [:show, :update]
|
|
12
|
+
|
|
13
|
+
before_action :ensure_passwordless_enabled!
|
|
14
|
+
before_action :redirect_if_authenticated, only: [:show]
|
|
15
|
+
before_action :require_otp_payload!
|
|
16
|
+
|
|
17
|
+
def show
|
|
18
|
+
render_with_inertia props: verify_page_props
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def update
|
|
22
|
+
code = params[:code].to_s.strip
|
|
23
|
+
|
|
24
|
+
if code.blank?
|
|
25
|
+
flash.now[:alert] = "Please enter the verification code"
|
|
26
|
+
render_with_inertia action: :show, props: verify_page_props, status: :unprocessable_content
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Record failed attempts outside the main transaction so they persist
|
|
31
|
+
challenge = find_active_challenge
|
|
32
|
+
attempts = record_attempt(challenge, code)
|
|
33
|
+
|
|
34
|
+
if challenge.blank? || !ActiveSupport::SecurityUtils.secure_compare(challenge.code, code)
|
|
35
|
+
emit_otp_validation_failed(attempts) if challenge.present?
|
|
36
|
+
|
|
37
|
+
flash.now[:alert] = "Invalid or expired verification code"
|
|
38
|
+
render_with_inertia action: :show, props: verify_page_props, status: :unprocessable_content
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
strategy = strategy_for(@otp_data[:connection])
|
|
43
|
+
|
|
44
|
+
begin
|
|
45
|
+
ActiveRecord::Base.transaction do
|
|
46
|
+
# Re-fetch with lock inside transaction to prevent concurrent use
|
|
47
|
+
locked_challenge = StandardId::CodeChallenge.lock.find(challenge.id)
|
|
48
|
+
raise OtpVerificationFailed unless locked_challenge.active?
|
|
49
|
+
|
|
50
|
+
account = strategy.find_or_create_account(@otp_data[:username])
|
|
51
|
+
locked_challenge.use!
|
|
52
|
+
|
|
53
|
+
emit_otp_validated(account, locked_challenge)
|
|
54
|
+
session_manager.sign_in_account(account)
|
|
55
|
+
emit_authentication_succeeded(account)
|
|
56
|
+
end
|
|
57
|
+
rescue OtpVerificationFailed
|
|
58
|
+
flash.now[:alert] = "Invalid or expired verification code"
|
|
59
|
+
render_with_inertia action: :show, props: verify_page_props, status: :unprocessable_content
|
|
60
|
+
return
|
|
61
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
62
|
+
flash.now[:alert] = "Unable to complete sign in: #{e.record.errors.full_messages.to_sentence}"
|
|
63
|
+
render_with_inertia action: :show, props: verify_page_props, status: :unprocessable_content
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
session.delete(:standard_id_otp_payload)
|
|
68
|
+
|
|
69
|
+
redirect_to after_authentication_url, status: :see_other, notice: "Successfully signed in"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def ensure_passwordless_enabled!
|
|
75
|
+
return if StandardId.config.passwordless.enabled
|
|
76
|
+
|
|
77
|
+
session.delete(:standard_id_otp_payload)
|
|
78
|
+
redirect_to login_path, alert: "Passwordless login is not available"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def redirect_if_authenticated
|
|
82
|
+
redirect_to after_authentication_url, status: :see_other if authenticated?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def require_otp_payload!
|
|
86
|
+
signed_payload = session[:standard_id_otp_payload]
|
|
87
|
+
|
|
88
|
+
if signed_payload.blank?
|
|
89
|
+
redirect_to login_path, alert: "Please start the login process"
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
@otp_data = Rails.application.message_verifier(:otp).verify(signed_payload).symbolize_keys
|
|
95
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
96
|
+
session.delete(:standard_id_otp_payload)
|
|
97
|
+
redirect_to login_path, alert: "Your verification session has expired. Please try again."
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def find_active_challenge
|
|
102
|
+
StandardId::CodeChallenge.active.find_by(
|
|
103
|
+
realm: "authentication",
|
|
104
|
+
channel: @otp_data[:connection],
|
|
105
|
+
target: @otp_data[:username]
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def record_attempt(challenge, code)
|
|
110
|
+
return 0 if challenge.blank?
|
|
111
|
+
return 0 if ActiveSupport::SecurityUtils.secure_compare(challenge.code, code)
|
|
112
|
+
|
|
113
|
+
attempts = (challenge.metadata["attempts"] || 0) + 1
|
|
114
|
+
challenge.update!(metadata: challenge.metadata.merge("attempts" => attempts))
|
|
115
|
+
|
|
116
|
+
max_attempts = StandardId.config.passwordless.max_attempts
|
|
117
|
+
challenge.use! if attempts >= max_attempts
|
|
118
|
+
|
|
119
|
+
attempts
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def emit_otp_validated(account, challenge)
|
|
123
|
+
StandardId::Events.publish(
|
|
124
|
+
StandardId::Events::OTP_VALIDATED,
|
|
125
|
+
account: account,
|
|
126
|
+
channel: @otp_data[:connection]
|
|
127
|
+
)
|
|
128
|
+
StandardId::Events.publish(
|
|
129
|
+
StandardId::Events::PASSWORDLESS_CODE_VERIFIED,
|
|
130
|
+
code_challenge: challenge,
|
|
131
|
+
account: account,
|
|
132
|
+
channel: @otp_data[:connection]
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def emit_otp_validation_failed(attempts)
|
|
137
|
+
StandardId::Events.publish(
|
|
138
|
+
StandardId::Events::OTP_VALIDATION_FAILED,
|
|
139
|
+
identifier: @otp_data[:username],
|
|
140
|
+
channel: @otp_data[:connection],
|
|
141
|
+
attempts: attempts
|
|
142
|
+
)
|
|
143
|
+
StandardId::Events.publish(
|
|
144
|
+
StandardId::Events::PASSWORDLESS_CODE_FAILED,
|
|
145
|
+
identifier: @otp_data[:username],
|
|
146
|
+
channel: @otp_data[:connection],
|
|
147
|
+
attempts: attempts
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def emit_authentication_succeeded(account)
|
|
152
|
+
StandardId::Events.publish(
|
|
153
|
+
StandardId::Events::AUTHENTICATION_SUCCEEDED,
|
|
154
|
+
account: account,
|
|
155
|
+
auth_method: "passwordless_otp",
|
|
156
|
+
session_type: "browser"
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def verify_page_props
|
|
161
|
+
{
|
|
162
|
+
flash: {
|
|
163
|
+
notice: flash[:notice],
|
|
164
|
+
alert: flash[:alert]
|
|
165
|
+
}.compact
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<%# ERB fallback for non-Inertia rendering. Flash is read directly here;
|
|
2
|
+
Inertia clients receive structured flash via verify_page_props instead. %>
|
|
3
|
+
<% content_for :title, "Verify Code" %>
|
|
4
|
+
|
|
5
|
+
<div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
|
|
6
|
+
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
|
7
|
+
<h2 class="mt-6 text-center text-2xl/9 font-bold tracking-tight text-gray-900 dark:text-white">Enter verification code</h2>
|
|
8
|
+
<p class="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">We sent you a verification code.</p>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px]">
|
|
12
|
+
<div class="bg-white px-6 py-12 shadow sm:rounded-lg sm:px-12 dark:bg-gray-800/50 dark:shadow-none dark:outline dark:outline-1 dark:-outline-offset-1 dark:outline-white/10">
|
|
13
|
+
|
|
14
|
+
<% if flash[:alert].present? %>
|
|
15
|
+
<div class="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-300"><%= flash[:alert] %></div>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% if flash[:notice].present? %>
|
|
18
|
+
<div class="mb-4 rounded-md bg-green-50 p-4 text-sm text-green-700 dark:bg-green-900/20 dark:text-green-300"><%= flash[:notice] %></div>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<%= form_with url: login_verify_path, method: :patch, local: true, html: { class: "space-y-6" } do |form| %>
|
|
22
|
+
<div>
|
|
23
|
+
<%= form.label :code, "Verification code", class: "block text-sm/6 font-medium text-gray-900 dark:text-white" %>
|
|
24
|
+
<div class="mt-2">
|
|
25
|
+
<%= form.text_field :code, required: true, autofocus: true, autocomplete: "one-time-code", inputmode: "numeric", maxlength: 6, class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500" %>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div>
|
|
30
|
+
<%= form.submit "Verify", class: "flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500" %>
|
|
31
|
+
</div>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ignored_warnings": [
|
|
3
|
+
{
|
|
4
|
+
"fingerprint": "24fc02735a2ad863d6bf1171a4a329b208e9e7c41841fa0149d8e6878d4ce299",
|
|
5
|
+
"note": "Auth engine intentionally redirects to params[:redirect_uri] after signup for OAuth/post-auth flow"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"fingerprint": "6b35e9906d62a9b9cd0dff9cf53924d40e74bc4f96cfccf27e67e93551113243",
|
|
9
|
+
"note": "Auth engine intentionally redirects to params[:redirect_uri] after logout for OAuth/post-auth flow"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"fingerprint": "8fddb7968577ac46fdda8e799f476c3eced60cc585da9c30fa61117f91e04ab9",
|
|
13
|
+
"note": "HEAD vs GET distinction is inconsequential here; redirect_to_login adding redirect_uri param on GET-only is safe"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"fingerprint": "bdbc72619da2ba771b1185ccf16acce801066689bf51adf116eab8c8714b39af",
|
|
17
|
+
"note": "HEAD vs GET distinction is inconsequential here; storing return URL on GET-only is safe"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"fingerprint": "16bd6ec7c3fa130eb80c15fc90c87f9859d89b37258807bfaffe4101366611a6",
|
|
21
|
+
"note": "Auth engine intentionally redirects to params[:redirect_uri] after login for OAuth/post-auth flow"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"fingerprint": "e4f96cb212c73c3165c3db6eaa6368c29d362b61264f034e80c9fa6705d72e5b",
|
|
25
|
+
"note": "Auth engine intentionally redirects to params[:redirect_uri] when user is not authenticated"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"updated": "2026-02-27",
|
|
29
|
+
"brakeman_version": "8.0.2"
|
|
30
|
+
}
|
data/config/routes/web.rb
CHANGED
|
@@ -2,6 +2,7 @@ StandardId::WebEngine.routes.draw do
|
|
|
2
2
|
scope module: :web do
|
|
3
3
|
# Authentication flows
|
|
4
4
|
resource :login, only: [:show, :create], controller: :login
|
|
5
|
+
resource :login_verify, only: [:show, :update], controller: :login_verify
|
|
5
6
|
resource :logout, only: [:create], controller: :logout
|
|
6
7
|
resource :signup, only: [:show, :create], controller: :signup
|
|
7
8
|
|
|
@@ -101,6 +101,8 @@ StandardId.configure do |c|
|
|
|
101
101
|
# Events
|
|
102
102
|
# Enable or disable logging emitted via the internal event system
|
|
103
103
|
# c.events.enable_logging = false
|
|
104
|
+
#
|
|
105
|
+
# For audit logging, use the standard_audit gem. See the README for wiring instructions.
|
|
104
106
|
|
|
105
107
|
# Social login credentials (if enabled in your app)
|
|
106
108
|
# c.social.google_client_id = ENV["GOOGLE_CLIENT_ID"]
|
|
@@ -12,7 +12,9 @@ module StandardId
|
|
|
12
12
|
|
|
13
13
|
def current_account
|
|
14
14
|
return unless current_session
|
|
15
|
-
@current_account ||= StandardId.account_class
|
|
15
|
+
@current_account ||= StandardId.account_class
|
|
16
|
+
.find_by(id: current_session.account_id)
|
|
17
|
+
&.tap { |a| a.strict_loading!(false) }
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def revoke_current_session!
|
|
@@ -23,6 +23,8 @@ StandardConfig.schema.draw do
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
scope :passwordless do
|
|
26
|
+
field :enabled, type: :boolean, default: false
|
|
27
|
+
field :connection, type: :string, default: "email"
|
|
26
28
|
field :code_ttl, type: :integer, default: 600 # 10 minutes in seconds
|
|
27
29
|
field :max_attempts, type: :integer, default: 3
|
|
28
30
|
field :retry_delay, type: :integer, default: 30 # 30 seconds
|
|
@@ -34,7 +34,7 @@ module StandardId
|
|
|
34
34
|
channel: connection_type,
|
|
35
35
|
target: username,
|
|
36
36
|
code: code,
|
|
37
|
-
expires_at:
|
|
37
|
+
expires_at: StandardId.config.passwordless.code_ttl.seconds.from_now,
|
|
38
38
|
ip_address: request.remote_ip,
|
|
39
39
|
user_agent: request.user_agent
|
|
40
40
|
)
|
data/lib/standard_id/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: standard_id
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -51,6 +51,20 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '2.7'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: ostruct
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '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'
|
|
54
68
|
description: StandardId is an authentication engine that provides a complete, secure-by-default
|
|
55
69
|
solution for identity management, reducing boilerplate and eliminating common security
|
|
56
70
|
pitfalls.
|
|
@@ -68,6 +82,7 @@ files:
|
|
|
68
82
|
- app/controllers/concerns/standard_id/api_authentication.rb
|
|
69
83
|
- app/controllers/concerns/standard_id/inertia_rendering.rb
|
|
70
84
|
- app/controllers/concerns/standard_id/inertia_support.rb
|
|
85
|
+
- app/controllers/concerns/standard_id/passwordless_strategy.rb
|
|
71
86
|
- app/controllers/concerns/standard_id/set_current_request_details.rb
|
|
72
87
|
- app/controllers/concerns/standard_id/social_authentication.rb
|
|
73
88
|
- app/controllers/concerns/standard_id/web/social_login_params.rb
|
|
@@ -85,6 +100,7 @@ files:
|
|
|
85
100
|
- app/controllers/standard_id/web/auth/callback/providers_controller.rb
|
|
86
101
|
- app/controllers/standard_id/web/base_controller.rb
|
|
87
102
|
- app/controllers/standard_id/web/login_controller.rb
|
|
103
|
+
- app/controllers/standard_id/web/login_verify_controller.rb
|
|
88
104
|
- app/controllers/standard_id/web/logout_controller.rb
|
|
89
105
|
- app/controllers/standard_id/web/reset_password/confirm_controller.rb
|
|
90
106
|
- app/controllers/standard_id/web/reset_password/start_controller.rb
|
|
@@ -123,10 +139,12 @@ files:
|
|
|
123
139
|
- app/views/standard_id/web/account/show.html.erb
|
|
124
140
|
- app/views/standard_id/web/auth/callback/providers/mobile_callback.html.erb
|
|
125
141
|
- app/views/standard_id/web/login/show.html.erb
|
|
142
|
+
- app/views/standard_id/web/login_verify/show.html.erb
|
|
126
143
|
- app/views/standard_id/web/reset_password/confirm/show.html.erb
|
|
127
144
|
- app/views/standard_id/web/reset_password/start/show.html.erb
|
|
128
145
|
- app/views/standard_id/web/sessions/index.html.erb
|
|
129
146
|
- app/views/standard_id/web/signup/show.html.erb
|
|
147
|
+
- config/brakeman.ignore
|
|
130
148
|
- config/initializers/generators.rb
|
|
131
149
|
- config/initializers/migration_helpers.rb
|
|
132
150
|
- config/routes/api.rb
|
|
@@ -214,7 +232,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
214
232
|
- !ruby/object:Gem::Version
|
|
215
233
|
version: '0'
|
|
216
234
|
requirements: []
|
|
217
|
-
rubygems_version:
|
|
235
|
+
rubygems_version: 4.0.3
|
|
218
236
|
specification_version: 4
|
|
219
237
|
summary: A comprehensive authentication engine for Rails, built on the security primitives
|
|
220
238
|
introduced in Rails 7/8.
|