standard_id 0.1.6 → 0.1.7
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 +368 -22
- data/app/controllers/concerns/standard_id/set_current_request_details.rb +19 -0
- data/app/controllers/concerns/standard_id/social_authentication.rb +86 -37
- data/app/controllers/concerns/standard_id/web_authentication.rb +29 -1
- data/app/controllers/standard_id/api/base_controller.rb +1 -0
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +7 -18
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +33 -37
- data/app/controllers/standard_id/web/base_controller.rb +1 -0
- data/app/controllers/standard_id/web/login_controller.rb +6 -19
- data/app/controllers/standard_id/web/signup_controller.rb +3 -6
- data/app/forms/standard_id/web/signup_form.rb +32 -1
- data/app/models/standard_id/browser_session.rb +8 -0
- data/app/models/standard_id/client_secret_credential.rb +11 -0
- data/app/models/standard_id/device_session.rb +4 -0
- data/app/models/standard_id/identifier.rb +28 -0
- data/app/models/standard_id/service_session.rb +1 -1
- data/app/models/standard_id/session.rb +16 -2
- data/app/views/standard_id/web/auth/callback/providers/{apple_mobile.html.erb → mobile_callback.html.erb} +1 -1
- data/config/routes/api.rb +1 -2
- data/config/routes/web.rb +4 -3
- data/lib/generators/standard_id/install/templates/standard_id.rb +11 -8
- data/lib/standard_config/config.rb +3 -12
- data/lib/standard_config/config_provider.rb +6 -6
- data/lib/standard_config/schema.rb +2 -2
- data/lib/standard_id/account_locking.rb +86 -0
- data/lib/standard_id/account_status.rb +45 -0
- data/lib/standard_id/api/authentication_guard.rb +40 -1
- data/lib/standard_id/api/token_manager.rb +1 -1
- data/lib/standard_id/config/schema.rb +11 -9
- data/lib/standard_id/current_attributes.rb +9 -0
- data/lib/standard_id/engine.rb +9 -0
- data/lib/standard_id/errors.rb +12 -0
- data/lib/standard_id/events/definitions.rb +157 -0
- data/lib/standard_id/events/event.rb +123 -0
- data/lib/standard_id/events/subscribers/account_locking_subscriber.rb +17 -0
- data/lib/standard_id/events/subscribers/account_status_subscriber.rb +17 -0
- data/lib/standard_id/events/subscribers/base.rb +165 -0
- data/lib/standard_id/events/subscribers/logging_subscriber.rb +122 -0
- data/lib/standard_id/events.rb +137 -0
- data/lib/standard_id/oauth/authorization_code_flow.rb +10 -0
- data/lib/standard_id/oauth/client_credentials_flow.rb +31 -0
- data/lib/standard_id/oauth/password_flow.rb +36 -4
- data/lib/standard_id/oauth/passwordless_otp_flow.rb +38 -2
- data/lib/standard_id/oauth/subflows/social_login_grant.rb +11 -22
- data/lib/standard_id/oauth/token_grant_flow.rb +22 -1
- data/lib/standard_id/passwordless/base_strategy.rb +32 -0
- data/lib/standard_id/provider_registry.rb +73 -0
- data/lib/standard_id/{social_providers → providers}/apple.rb +46 -7
- data/lib/standard_id/providers/base.rb +242 -0
- data/lib/standard_id/{social_providers → providers}/google.rb +26 -7
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/authentication_guard.rb +29 -0
- data/lib/standard_id/web/session_manager.rb +39 -1
- data/lib/standard_id/web/token_manager.rb +2 -2
- data/lib/standard_id.rb +13 -2
- metadata +18 -6
- data/lib/standard_id/social_providers/response_builder.rb +0 -18
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2878f305d6dfe83c5a1c0851cde68602cbd17899b1a676981afd8166f56995e0
|
|
4
|
+
data.tar.gz: 4c1802cc0bb54045165eb42289d75dda85db9a8838d61a6c1b4e7a7ff50e7828
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8073e2e1f0208261525be8218960ef6481e8a5558f281a56baf2316b4750f9557b37365831b9aa530706e2d52d6c088e3cc3e9c23c4a4ef722d641f2041ea357
|
|
7
|
+
data.tar.gz: a803c19a19f5fbcc3acae0511f629c6d4b1520d67a54cfa16d062fcc60333083d28cd5149a6658bfb16e37ae3a2ff1d7cdf06d657d16130010a08c67b8c851ff
|
data/README.md
CHANGED
|
@@ -123,9 +123,11 @@ StandardId.configure do |config|
|
|
|
123
123
|
# config.use_inertia = true
|
|
124
124
|
# config.inertia_component_namespace = "auth"
|
|
125
125
|
|
|
126
|
-
#
|
|
127
|
-
# config.
|
|
128
|
-
# config.
|
|
126
|
+
# Session lifetimes
|
|
127
|
+
# config.session.browser_session_lifetime = 86400 # 24 hours (web sessions)
|
|
128
|
+
# config.session.browser_session_remember_me_lifetime = 2_592_000 # 30 days (remember me cookies)
|
|
129
|
+
# config.session.device_session_lifetime = 2_592_000 # 30 days (API device sessions)
|
|
130
|
+
# config.session.service_session_lifetime = 7_776_000 # 90 days (service-to-service sessions)
|
|
129
131
|
|
|
130
132
|
# Subset configuration
|
|
131
133
|
# config.password.minimum_length = 12
|
|
@@ -186,21 +188,24 @@ StandardId.configure do |config|
|
|
|
186
188
|
name: social_info[:name] || social_info[:given_name]
|
|
187
189
|
}
|
|
188
190
|
}
|
|
189
|
-
|
|
190
|
-
# Optional: run a callback whenever a social login completes
|
|
191
|
-
config.social.social_callback = ->(social_info:, provider:, tokens:, account:) {
|
|
192
|
-
AuditLog.social_login(
|
|
193
|
-
provider: provider,
|
|
194
|
-
email: social_info[:email],
|
|
195
|
-
tokens: tokens,
|
|
196
|
-
account_id: account.id,
|
|
197
|
-
)
|
|
198
|
-
}
|
|
199
191
|
end
|
|
200
192
|
```
|
|
201
193
|
|
|
202
194
|
`social_info` is an indifferent-access hash containing at least `email`, `name`, and `provider_id`.
|
|
203
195
|
|
|
196
|
+
To handle social login completion (e.g., for analytics or audit logging), subscribe to the `SOCIAL_AUTH_COMPLETED` event:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
# config/initializers/standard_id_events.rb
|
|
200
|
+
StandardId::Events.subscribe(StandardId::Events::SOCIAL_AUTH_COMPLETED) do |event|
|
|
201
|
+
Analytics.track_social_login(
|
|
202
|
+
provider: event[:provider],
|
|
203
|
+
account_id: event[:account].id,
|
|
204
|
+
tokens: event[:tokens]
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
204
209
|
### Inertia.js Integration
|
|
205
210
|
|
|
206
211
|
StandardId supports [Inertia.js](https://inertiajs.com/) for modern React, Vue, or Svelte frontends. When enabled, web controllers render Inertia components instead of ERB views.
|
|
@@ -355,22 +360,363 @@ end
|
|
|
355
360
|
|
|
356
361
|
This will redirect unauthenticated users to the login page using `inertia_location` for Inertia requests, ensuring proper SPA navigation.
|
|
357
362
|
|
|
358
|
-
### Passwordless
|
|
363
|
+
### Passwordless Code Delivery
|
|
364
|
+
|
|
365
|
+
Subscribe to the `PASSWORDLESS_CODE_GENERATED` event to deliver OTP codes:
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
# config/initializers/standard_id_events.rb
|
|
369
|
+
StandardId::Events.subscribe(StandardId::Events::PASSWORDLESS_CODE_GENERATED) do |event|
|
|
370
|
+
case event[:channel]
|
|
371
|
+
when "email"
|
|
372
|
+
UserMailer.send_code(event[:identifier], event[:code_challenge].code).deliver_now
|
|
373
|
+
when "sms"
|
|
374
|
+
SmsService.send_code(event[:identifier], event[:code_challenge].code)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Event payload includes:
|
|
380
|
+
- `channel` - `"email"` or `"sms"`
|
|
381
|
+
- `identifier` - The email address or phone number
|
|
382
|
+
- `code_challenge` - The code challenge object with `.code` method
|
|
383
|
+
- `expires_at` - When the code expires
|
|
384
|
+
|
|
385
|
+
> **Note**: If you're using the deprecated `passwordless_email_sender` or `passwordless_sms_sender` callbacks, see the [Migration Guide](docs/MIGRATION_GUIDE.md) for upgrade instructions.
|
|
386
|
+
|
|
387
|
+
## Event System
|
|
388
|
+
|
|
389
|
+
StandardId emits events throughout the authentication lifecycle using `ActiveSupport::Notifications`. This enables decoupled handling of cross-cutting concerns like logging, analytics, audit trails, and webhooks.
|
|
390
|
+
|
|
391
|
+
### Enabling Event Logging
|
|
392
|
+
|
|
393
|
+
Enable the built-in structured logging subscriber:
|
|
359
394
|
|
|
360
395
|
```ruby
|
|
361
396
|
StandardId.configure do |config|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
397
|
+
config.events.enable_logging = true
|
|
398
|
+
end
|
|
399
|
+
```
|
|
366
400
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
401
|
+
This outputs JSON-structured logs for all authentication events:
|
|
402
|
+
|
|
403
|
+
```json
|
|
404
|
+
{
|
|
405
|
+
"subject": "standard_id.authentication.attempt.succeeded",
|
|
406
|
+
"severity": "info",
|
|
407
|
+
"duration": 50.25,
|
|
408
|
+
"account_id": 123,
|
|
409
|
+
"auth_method": "password",
|
|
410
|
+
"ip_address": "192.168.1.1"
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### Available Events
|
|
415
|
+
|
|
416
|
+
| Category | Events |
|
|
417
|
+
|----------|--------|
|
|
418
|
+
| **Authentication** | `authentication.attempt.started`, `authentication.attempt.succeeded`, `authentication.attempt.failed`, `authentication.password.validated`, `authentication.password.failed`, `authentication.otp.validated`, `authentication.otp.failed` |
|
|
419
|
+
| **Session** | `session.creating`, `session.created`, `session.validating`, `session.validated`, `session.expired`, `session.revoked`, `session.refreshed` |
|
|
420
|
+
| **Account** | `account.creating`, `account.created`, `account.verified`, `account.status_changed`, `account.activated`, `account.deactivated`, `account.locked`, `account.unlocked` |
|
|
421
|
+
| **Identifier** | `identifier.created`, `identifier.verification.started`, `identifier.verification.succeeded`, `identifier.verification.failed`, `identifier.linked` |
|
|
422
|
+
| **OAuth** | `oauth.authorization.requested`, `oauth.authorization.granted`, `oauth.authorization.denied`, `oauth.token.issuing`, `oauth.token.issued`, `oauth.token.refreshed`, `oauth.code.consumed` |
|
|
423
|
+
| **Passwordless** | `passwordless.code.requested`, `passwordless.code.generated`, `passwordless.code.sent`, `passwordless.code.verified`, `passwordless.code.failed`, `passwordless.account.created` |
|
|
424
|
+
| **Social** | `social.auth.started`, `social.auth.callback_received`, `social.user_info.fetched`, `social.account.created`, `social.account.linked`, `social.auth.completed` |
|
|
425
|
+
| **Credential** | `credential.password.created`, `credential.password.reset_initiated`, `credential.password.reset_completed`, `credential.password.changed`, `credential.client_secret.created`, `credential.client_secret.rotated` |
|
|
426
|
+
|
|
427
|
+
### Subscribing to Events
|
|
428
|
+
|
|
429
|
+
#### Block-based (simple)
|
|
430
|
+
|
|
431
|
+
```ruby
|
|
432
|
+
# config/initializers/standard_id_events.rb
|
|
433
|
+
StandardId::Events.subscribe(StandardId::Events::AUTHENTICATION_SUCCEEDED) do |event|
|
|
434
|
+
Analytics.track_login(
|
|
435
|
+
account_id: event[:account].id,
|
|
436
|
+
method: event[:auth_method],
|
|
437
|
+
ip: event[:ip_address]
|
|
438
|
+
)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Subscribe to multiple events at once
|
|
442
|
+
StandardId::Events.subscribe(
|
|
443
|
+
StandardId::Events::SESSION_CREATING,
|
|
444
|
+
StandardId::Events::SESSION_VALIDATING,
|
|
445
|
+
StandardId::Events::OAUTH_TOKEN_ISSUING
|
|
446
|
+
) do |event|
|
|
447
|
+
# Handle all three events with the same block
|
|
448
|
+
check_rate_limit(event[:account], event[:ip_address])
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Subscribe to events with pattern matching
|
|
452
|
+
StandardId::Events.subscribe(/social/) do |event|
|
|
453
|
+
Rails.logger.info("Social event: #{event.name}")
|
|
454
|
+
end
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
#### Class-based (complex logic)
|
|
458
|
+
|
|
459
|
+
```ruby
|
|
460
|
+
# app/subscribers/audit_subscriber.rb
|
|
461
|
+
class AuditSubscriber < StandardId::Events::Subscribers::Base
|
|
462
|
+
subscribe_to StandardId::Events::AUTHENTICATION_SUCCEEDED
|
|
463
|
+
subscribe_to StandardId::Events::AUTHENTICATION_FAILED
|
|
464
|
+
subscribe_to StandardId::Events::SESSION_REVOKED
|
|
465
|
+
|
|
466
|
+
def call(event)
|
|
467
|
+
AuditLog.create!(
|
|
468
|
+
event_type: event.short_name,
|
|
469
|
+
account_id: event[:account]&.id,
|
|
470
|
+
ip_address: event[:ip_address],
|
|
471
|
+
metadata: event.payload
|
|
472
|
+
)
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# config/initializers/standard_id_events.rb
|
|
477
|
+
AuditSubscriber.attach
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
## Account Status (Activation/Deactivation)
|
|
481
|
+
|
|
482
|
+
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.
|
|
483
|
+
|
|
484
|
+
### Setup
|
|
485
|
+
|
|
486
|
+
1. Add a migration for the status column. For PostgreSQL (recommended), use a native enum type:
|
|
487
|
+
|
|
488
|
+
```ruby
|
|
489
|
+
# PostgreSQL with native enum (recommended)
|
|
490
|
+
class AddStatusToUsers < ActiveRecord::Migration[8.0]
|
|
491
|
+
def up
|
|
492
|
+
create_enum :account_status, %w[active inactive]
|
|
493
|
+
|
|
494
|
+
add_column :users, :status, :enum, enum_type: :account_status, default: "active", null: false
|
|
495
|
+
add_column :users, :activated_at, :datetime
|
|
496
|
+
add_column :users, :deactivated_at, :datetime
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def down
|
|
500
|
+
remove_column :users, :status
|
|
501
|
+
remove_column :users, :activated_at
|
|
502
|
+
remove_column :users, :deactivated_at
|
|
503
|
+
|
|
504
|
+
drop_enum :account_status
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
For other databases (MySQL, SQLite), use a string column:
|
|
510
|
+
|
|
511
|
+
```ruby
|
|
512
|
+
# String column (MySQL, SQLite)
|
|
513
|
+
class AddStatusToUsers < ActiveRecord::Migration[8.0]
|
|
514
|
+
def change
|
|
515
|
+
add_column :users, :status, :string, default: "active", null: false
|
|
516
|
+
add_column :users, :activated_at, :datetime
|
|
517
|
+
add_column :users, :deactivated_at, :datetime
|
|
518
|
+
add_index :users, :status
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
2. Include the concern in your account model:
|
|
524
|
+
|
|
525
|
+
```ruby
|
|
526
|
+
class User < ApplicationRecord
|
|
527
|
+
include StandardId::AccountStatus
|
|
528
|
+
# ...
|
|
371
529
|
end
|
|
372
530
|
```
|
|
373
531
|
|
|
532
|
+
The concern works with both PostgreSQL enum and string columns - Rails enum handles both transparently.
|
|
533
|
+
|
|
534
|
+
### Usage
|
|
535
|
+
|
|
536
|
+
```ruby
|
|
537
|
+
# Deactivate an account
|
|
538
|
+
user.deactivate!
|
|
539
|
+
# => Emits ACCOUNT_DEACTIVATED event
|
|
540
|
+
# => All active sessions are automatically revoked
|
|
541
|
+
|
|
542
|
+
# Reactivate an account
|
|
543
|
+
user.activate!
|
|
544
|
+
# => Emits ACCOUNT_ACTIVATED event
|
|
545
|
+
# => User can log in again
|
|
546
|
+
|
|
547
|
+
# Check status
|
|
548
|
+
user.active? # => true/false
|
|
549
|
+
user.inactive? # => true/false
|
|
550
|
+
|
|
551
|
+
# Query scopes
|
|
552
|
+
User.active # => Users with status 'active'
|
|
553
|
+
User.inactive # => Users with status 'inactive'
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### Handling AccountDeactivatedError
|
|
557
|
+
|
|
558
|
+
When an inactive account attempts to authenticate, `StandardId::AccountDeactivatedError` is raised. You need to handle this error in your application controller:
|
|
559
|
+
|
|
560
|
+
```ruby
|
|
561
|
+
# app/controllers/application_controller.rb
|
|
562
|
+
class ApplicationController < ActionController::Base
|
|
563
|
+
include StandardId::WebAuthentication
|
|
564
|
+
|
|
565
|
+
rescue_from StandardId::AccountDeactivatedError, with: :handle_account_deactivated
|
|
566
|
+
|
|
567
|
+
private
|
|
568
|
+
|
|
569
|
+
def handle_account_deactivated
|
|
570
|
+
# For web requests, redirect with a message
|
|
571
|
+
redirect_to login_path, alert: "Your account has been deactivated. Please contact support."
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
For API controllers:
|
|
577
|
+
|
|
578
|
+
```ruby
|
|
579
|
+
# app/controllers/api/base_controller.rb
|
|
580
|
+
class Api::BaseController < ActionController::API
|
|
581
|
+
include StandardId::ApiAuthentication
|
|
582
|
+
|
|
583
|
+
rescue_from StandardId::AccountDeactivatedError, with: :handle_account_deactivated
|
|
584
|
+
|
|
585
|
+
private
|
|
586
|
+
|
|
587
|
+
def handle_account_deactivated
|
|
588
|
+
render json: {
|
|
589
|
+
error: "account_deactivated",
|
|
590
|
+
message: "Your account has been deactivated"
|
|
591
|
+
}, status: :forbidden
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
## Account Locking (Administrative Security)
|
|
597
|
+
|
|
598
|
+
StandardId provides an optional `AccountLocking` concern for administrative account locking. This is distinct from account deactivation - locking is for security enforcement by administrators, while deactivation is for lifecycle management.
|
|
599
|
+
|
|
600
|
+
### Key Differences from Account Deactivation
|
|
601
|
+
|
|
602
|
+
| Feature | Account Status | Account Locking |
|
|
603
|
+
|---------|---------------|-----------------|
|
|
604
|
+
| **Purpose** | Lifecycle management | Security enforcement |
|
|
605
|
+
| **Who Controls** | System/User | Admin/Staff only |
|
|
606
|
+
| **User Reversible** | Yes (future) | No |
|
|
607
|
+
| **Use Cases** | Inactivity, user choice | Policy violation, security incident, fraud |
|
|
608
|
+
|
|
609
|
+
An account can be in any combination:
|
|
610
|
+
- Active + Unlocked ✅ (normal operation)
|
|
611
|
+
- Active + Locked ⚠️ (admin locked for security)
|
|
612
|
+
- Inactive + Unlocked ⚠️ (deactivated but not locked)
|
|
613
|
+
- Inactive + Locked 🚫 (both restrictions apply)
|
|
614
|
+
|
|
615
|
+
### Setup
|
|
616
|
+
|
|
617
|
+
1. Add a migration for the locking columns:
|
|
618
|
+
|
|
619
|
+
```ruby
|
|
620
|
+
class AddLockingToUsers < ActiveRecord::Migration[8.0]
|
|
621
|
+
def change
|
|
622
|
+
add_column :users, :locked, :boolean, default: false, null: false
|
|
623
|
+
add_column :users, :locked_at, :datetime
|
|
624
|
+
add_column :users, :lock_reason, :string
|
|
625
|
+
add_column :users, :locked_by_id, :integer
|
|
626
|
+
add_column :users, :locked_by_type, :string
|
|
627
|
+
add_column :users, :unlocked_at, :datetime
|
|
628
|
+
add_column :users, :unlocked_by_id, :integer
|
|
629
|
+
add_column :users, :unlocked_by_type, :string
|
|
630
|
+
|
|
631
|
+
add_index :users, :locked
|
|
632
|
+
add_index :users, [:locked_by_type, :locked_by_id]
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
2. Include the concern in your account model:
|
|
638
|
+
|
|
639
|
+
```ruby
|
|
640
|
+
class User < ApplicationRecord
|
|
641
|
+
include StandardId::AccountLocking # For admin locking
|
|
642
|
+
include StandardId::AccountStatus # Optional: for activation/deactivation
|
|
643
|
+
# ...
|
|
644
|
+
end
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
### Usage
|
|
648
|
+
|
|
649
|
+
```ruby
|
|
650
|
+
# Lock an account (revokes all active sessions immediately)
|
|
651
|
+
user.lock!(reason: "Suspicious activity detected", locked_by: current_admin)
|
|
652
|
+
# => Emits ACCOUNT_LOCKED event
|
|
653
|
+
# => All active sessions (browser, device, service) are revoked
|
|
654
|
+
|
|
655
|
+
# Unlock an account (user must log in again)
|
|
656
|
+
user.unlock!(unlocked_by: current_admin)
|
|
657
|
+
# => Emits ACCOUNT_UNLOCKED event
|
|
658
|
+
# => User can log in again
|
|
659
|
+
|
|
660
|
+
# Check lock status
|
|
661
|
+
user.locked? # => true/false
|
|
662
|
+
user.unlocked? # => true/false
|
|
663
|
+
|
|
664
|
+
# Query scopes
|
|
665
|
+
User.locked # => Users with locked = true
|
|
666
|
+
User.unlocked # => Users with locked = false
|
|
667
|
+
|
|
668
|
+
# Combine with AccountStatus scopes
|
|
669
|
+
User.unlocked.active # => Users who can log in
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### Handling AccountLockedError
|
|
673
|
+
|
|
674
|
+
When a locked account attempts to authenticate, `StandardId::AccountLockedError` is raised. The error includes metadata about the lock:
|
|
675
|
+
|
|
676
|
+
```ruby
|
|
677
|
+
# app/controllers/application_controller.rb
|
|
678
|
+
class ApplicationController < ActionController::Base
|
|
679
|
+
include StandardId::WebAuthentication
|
|
680
|
+
|
|
681
|
+
rescue_from StandardId::AccountLockedError, with: :handle_account_locked
|
|
682
|
+
|
|
683
|
+
private
|
|
684
|
+
|
|
685
|
+
def handle_account_locked(error)
|
|
686
|
+
# error.account - The locked account
|
|
687
|
+
# error.lock_reason - Why the account was locked
|
|
688
|
+
# error.locked_at - When the account was locked
|
|
689
|
+
redirect_to login_path, alert: "Your account has been locked. Please contact support."
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
For API controllers:
|
|
695
|
+
|
|
696
|
+
```ruby
|
|
697
|
+
# app/controllers/api/base_controller.rb
|
|
698
|
+
class Api::BaseController < ActionController::API
|
|
699
|
+
include StandardId::ApiAuthentication
|
|
700
|
+
|
|
701
|
+
rescue_from StandardId::AccountLockedError, with: :handle_account_locked
|
|
702
|
+
|
|
703
|
+
private
|
|
704
|
+
|
|
705
|
+
def handle_account_locked(error)
|
|
706
|
+
render json: {
|
|
707
|
+
error: "account_locked",
|
|
708
|
+
message: "Your account has been locked. Please contact support.",
|
|
709
|
+
locked_at: error.locked_at&.iso8601
|
|
710
|
+
# Note: Consider not exposing lock_reason to end users for security
|
|
711
|
+
}, status: :forbidden
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
### Event Subscriptions
|
|
717
|
+
|
|
718
|
+
Both `AccountStatus` and `AccountLocking` subscribe to the same events (`OAUTH_TOKEN_ISSUING`, `SESSION_CREATING`, `SESSION_VALIDATING`). The lock check runs alongside the status check - authentication fails if either condition prevents access.
|
|
719
|
+
|
|
374
720
|
## Usage Examples
|
|
375
721
|
|
|
376
722
|
### Web Authentication
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module SetCurrentRequestDetails
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
before_action :set_current_request_details
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def set_current_request_details
|
|
12
|
+
return unless defined?(::Current)
|
|
13
|
+
|
|
14
|
+
::Current.request_id = request.request_id if ::Current.respond_to?(:request_id=)
|
|
15
|
+
::Current.ip_address = request.remote_ip if ::Current.respond_to?(:ip_address=)
|
|
16
|
+
::Current.user_agent = request.user_agent if ::Current.respond_to?(:user_agent=)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -2,64 +2,70 @@ module StandardId
|
|
|
2
2
|
module SocialAuthentication
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
|
|
5
|
+
included do
|
|
6
|
+
prepend_before_action :prepare_provider
|
|
7
|
+
end
|
|
8
|
+
|
|
5
9
|
private
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
access_token: params[:access_token],
|
|
14
|
-
redirect_uri: redirect_uri
|
|
15
|
-
)
|
|
16
|
-
when "apple"
|
|
17
|
-
StandardId::SocialProviders::Apple.get_user_info(
|
|
18
|
-
code: params[:code],
|
|
19
|
-
id_token: params[:id_token],
|
|
20
|
-
redirect_uri: redirect_uri,
|
|
21
|
-
client_id: apple_client_id_for_flow(flow)
|
|
22
|
-
)
|
|
23
|
-
else
|
|
24
|
-
raise StandardId::InvalidRequestError, "Unsupported provider: #{connection}"
|
|
25
|
-
end
|
|
11
|
+
attr_reader :provider
|
|
12
|
+
|
|
13
|
+
def prepare_provider
|
|
14
|
+
@provider = StandardId::ProviderRegistry.get(params[:provider])
|
|
15
|
+
rescue StandardId::ProviderRegistry::ProviderNotFoundError => e
|
|
16
|
+
raise StandardId::InvalidRequestError, e.message
|
|
26
17
|
end
|
|
27
18
|
|
|
28
|
-
def
|
|
29
|
-
|
|
19
|
+
def get_user_info_from_provider(redirect_uri: nil, flow: :web)
|
|
20
|
+
provider_params = {
|
|
21
|
+
code: params[:code],
|
|
22
|
+
id_token: params[:id_token],
|
|
23
|
+
access_token: params[:access_token],
|
|
24
|
+
redirect_uri: redirect_uri
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
resolved_params = provider.resolve_params(provider_params, context: { flow: flow })
|
|
28
|
+
provider.get_user_info(**resolved_params.compact)
|
|
30
29
|
end
|
|
31
30
|
|
|
32
|
-
def find_or_create_account_from_social(raw_social_info
|
|
31
|
+
def find_or_create_account_from_social(raw_social_info)
|
|
33
32
|
social_info = raw_social_info.to_h.with_indifferent_access
|
|
34
33
|
email = social_info[:email]
|
|
35
|
-
raise StandardId::InvalidRequestError, "No email provided by #{provider}" if email.blank?
|
|
34
|
+
raise StandardId::InvalidRequestError, "No email provided by #{provider.provider_name}" if email.blank?
|
|
35
|
+
|
|
36
|
+
emit_social_user_info_fetched(provider, social_info, email)
|
|
36
37
|
|
|
37
38
|
identifier = StandardId::EmailIdentifier.find_by(value: email)
|
|
38
39
|
|
|
39
40
|
if identifier.present?
|
|
41
|
+
emit_social_account_linked(identifier.account, provider, identifier)
|
|
40
42
|
identifier.account
|
|
41
43
|
else
|
|
42
|
-
account = build_account_from_social(social_info
|
|
44
|
+
account = build_account_from_social(social_info)
|
|
43
45
|
identifier = StandardId::EmailIdentifier.create!(
|
|
44
46
|
account: account,
|
|
45
47
|
value: email
|
|
46
48
|
)
|
|
47
49
|
identifier.verify! if identifier.respond_to?(:verify!)
|
|
50
|
+
emit_social_account_created(account, provider, social_info)
|
|
48
51
|
account
|
|
49
52
|
end
|
|
50
53
|
end
|
|
51
54
|
|
|
52
|
-
def build_account_from_social(social_info
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
def build_account_from_social(social_info)
|
|
56
|
+
emit_account_creating_from_social(social_info)
|
|
57
|
+
attrs = resolve_account_attributes(social_info)
|
|
58
|
+
account = StandardId.account_class.create!(attrs)
|
|
59
|
+
emit_account_created_from_social(account)
|
|
60
|
+
account
|
|
55
61
|
end
|
|
56
62
|
|
|
57
|
-
def resolve_account_attributes(social_info
|
|
63
|
+
def resolve_account_attributes(social_info)
|
|
58
64
|
resolver = StandardId.config.social_account_attributes
|
|
59
65
|
attrs = if resolver.respond_to?(:call)
|
|
60
66
|
payload = {
|
|
61
67
|
social_info: social_info,
|
|
62
|
-
provider: provider
|
|
68
|
+
provider: provider.provider_name
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
filtered_payload = StandardId::Utils::CallableParameterFilter.filter(resolver, payload)
|
|
@@ -95,18 +101,61 @@ module StandardId
|
|
|
95
101
|
end
|
|
96
102
|
|
|
97
103
|
def run_social_callback(provider:, social_info:, provider_tokens:, account:)
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
emit_social_auth_completed(provider, social_info, provider_tokens, account)
|
|
105
|
+
end
|
|
100
106
|
|
|
101
|
-
|
|
107
|
+
def emit_social_user_info_fetched(provider, social_info, email)
|
|
108
|
+
StandardId::Events.publish(
|
|
109
|
+
StandardId::Events::SOCIAL_USER_INFO_FETCHED,
|
|
102
110
|
provider: provider,
|
|
103
111
|
social_info: social_info,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
email: email
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def emit_social_account_created(account, provider, social_info)
|
|
117
|
+
StandardId::Events.publish(
|
|
118
|
+
StandardId::Events::SOCIAL_ACCOUNT_CREATED,
|
|
119
|
+
account: account,
|
|
120
|
+
provider: provider,
|
|
121
|
+
social_info: social_info
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def emit_social_account_linked(account, provider, identifier)
|
|
126
|
+
StandardId::Events.publish(
|
|
127
|
+
StandardId::Events::SOCIAL_ACCOUNT_LINKED,
|
|
128
|
+
account: account,
|
|
129
|
+
provider: provider,
|
|
130
|
+
identifier: identifier
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def emit_social_auth_completed(provider, social_info, provider_tokens, account)
|
|
135
|
+
StandardId::Events.publish(
|
|
136
|
+
StandardId::Events::SOCIAL_AUTH_COMPLETED,
|
|
137
|
+
account: account,
|
|
138
|
+
provider: provider,
|
|
139
|
+
social_info: social_info,
|
|
140
|
+
tokens: provider_tokens
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def emit_account_creating_from_social(social_info)
|
|
145
|
+
StandardId::Events.publish(
|
|
146
|
+
StandardId::Events::ACCOUNT_CREATING,
|
|
147
|
+
account_params: resolve_account_attributes(social_info),
|
|
148
|
+
auth_method: "social:#{provider.provider_name}"
|
|
149
|
+
)
|
|
150
|
+
end
|
|
107
151
|
|
|
108
|
-
|
|
109
|
-
|
|
152
|
+
def emit_account_created_from_social(account)
|
|
153
|
+
StandardId::Events.publish(
|
|
154
|
+
StandardId::Events::ACCOUNT_CREATED,
|
|
155
|
+
account: account,
|
|
156
|
+
auth_method: "social:#{provider.provider_name}",
|
|
157
|
+
source: "social"
|
|
158
|
+
)
|
|
110
159
|
end
|
|
111
160
|
end
|
|
112
161
|
end
|
|
@@ -49,11 +49,39 @@ module StandardId
|
|
|
49
49
|
password = login_params[:password]
|
|
50
50
|
remember_me = ActiveModel::Type::Boolean.new.cast(login_params[:remember_me])
|
|
51
51
|
|
|
52
|
+
StandardId::Events.publish(
|
|
53
|
+
StandardId::Events::AUTHENTICATION_ATTEMPT_STARTED,
|
|
54
|
+
account_lookup: login,
|
|
55
|
+
auth_method: "password"
|
|
56
|
+
)
|
|
57
|
+
|
|
52
58
|
StandardId::PasswordCredential.find_by(login:).tap do |password_credential|
|
|
53
|
-
|
|
59
|
+
unless password_credential&.authenticate(password)
|
|
60
|
+
StandardId::Events.publish(
|
|
61
|
+
StandardId::Events::AUTHENTICATION_FAILED,
|
|
62
|
+
account_lookup: login,
|
|
63
|
+
auth_method: "password",
|
|
64
|
+
error_code: "invalid_credentials",
|
|
65
|
+
error_message: "Invalid login or password"
|
|
66
|
+
)
|
|
67
|
+
return nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
StandardId::Events.publish(
|
|
71
|
+
StandardId::Events::PASSWORD_VALIDATED,
|
|
72
|
+
account: password_credential.account,
|
|
73
|
+
credential_id: password_credential.id
|
|
74
|
+
)
|
|
54
75
|
|
|
55
76
|
session_manager.sign_in_account(password_credential.account)
|
|
56
77
|
session_manager.set_remember_cookie(password_credential) if remember_me
|
|
78
|
+
|
|
79
|
+
StandardId::Events.publish(
|
|
80
|
+
StandardId::Events::AUTHENTICATION_SUCCEEDED,
|
|
81
|
+
account: password_credential.account,
|
|
82
|
+
auth_method: "password",
|
|
83
|
+
session_type: "browser"
|
|
84
|
+
)
|
|
57
85
|
end
|
|
58
86
|
end
|
|
59
87
|
|