standard_id 0.1.5 → 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 +529 -20
- data/app/controllers/concerns/standard_id/inertia_rendering.rb +49 -0
- data/app/controllers/concerns/standard_id/inertia_support.rb +31 -0
- 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 +50 -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 +12 -21
- data/app/controllers/standard_id/web/signup_controller.rb +11 -8
- 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 +19 -8
- data/lib/standard_config/config.rb +13 -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 +13 -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 +20 -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
|
@@ -37,6 +37,11 @@ A comprehensive authentication engine for Rails applications, built on the secur
|
|
|
37
37
|
- **Remember Me**: Extended session support
|
|
38
38
|
- **Account Lockout**: Protection against brute force attacks
|
|
39
39
|
|
|
40
|
+
### ⚡ Frontend Framework Support
|
|
41
|
+
- **Inertia.js Integration**: Optional support for React, Vue, or Svelte frontends
|
|
42
|
+
- **Conditional Rendering**: Automatically switches between ERB and Inertia based on configuration
|
|
43
|
+
- **External Redirects**: Proper handling of OAuth redirects in SPA contexts
|
|
44
|
+
|
|
40
45
|
## Installation
|
|
41
46
|
|
|
42
47
|
Add this line to your application's Gemfile:
|
|
@@ -114,9 +119,15 @@ StandardId.configure do |config|
|
|
|
114
119
|
# Custom layout for web views
|
|
115
120
|
config.web_layout = "application"
|
|
116
121
|
|
|
117
|
-
#
|
|
118
|
-
# config.
|
|
119
|
-
# config.
|
|
122
|
+
# Inertia.js support (see Inertia.js Integration section below)
|
|
123
|
+
# config.use_inertia = true
|
|
124
|
+
# config.inertia_component_namespace = "auth"
|
|
125
|
+
|
|
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)
|
|
120
131
|
|
|
121
132
|
# Subset configuration
|
|
122
133
|
# config.password.minimum_length = 12
|
|
@@ -177,37 +188,535 @@ StandardId.configure do |config|
|
|
|
177
188
|
name: social_info[:name] || social_info[:given_name]
|
|
178
189
|
}
|
|
179
190
|
}
|
|
180
|
-
|
|
181
|
-
# Optional: run a callback whenever a social login completes
|
|
182
|
-
config.social.social_callback = ->(social_info:, provider:, tokens:, account:) {
|
|
183
|
-
AuditLog.social_login(
|
|
184
|
-
provider: provider,
|
|
185
|
-
email: social_info[:email],
|
|
186
|
-
tokens: tokens,
|
|
187
|
-
account_id: account.id,
|
|
188
|
-
)
|
|
189
|
-
}
|
|
190
191
|
end
|
|
191
192
|
```
|
|
192
193
|
|
|
193
194
|
`social_info` is an indifferent-access hash containing at least `email`, `name`, and `provider_id`.
|
|
194
195
|
|
|
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
|
+
|
|
209
|
+
### Inertia.js Integration
|
|
210
|
+
|
|
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.
|
|
212
|
+
|
|
213
|
+
#### Setup
|
|
214
|
+
|
|
215
|
+
1. Add the `inertia_rails` gem to your Gemfile:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
gem "inertia_rails"
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
2. Enable Inertia in your StandardId configuration:
|
|
196
222
|
|
|
197
223
|
```ruby
|
|
198
224
|
StandardId.configure do |config|
|
|
199
|
-
|
|
200
|
-
config.
|
|
201
|
-
|
|
225
|
+
config.use_inertia = true
|
|
226
|
+
config.inertia_component_namespace = "auth" # Optional, defaults to "standard_id"
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
3. Create the corresponding frontend components. The component path follows the pattern:
|
|
231
|
+
`{namespace}/{ControllerName}/{action}`
|
|
232
|
+
|
|
233
|
+
For example, with `inertia_component_namespace = "auth"`:
|
|
234
|
+
- Login page: `pages/auth/login/show.tsx`
|
|
235
|
+
- Signup page: `pages/auth/signup/show.tsx`
|
|
236
|
+
|
|
237
|
+
#### Example Component (React)
|
|
238
|
+
|
|
239
|
+
```tsx
|
|
240
|
+
// frontend/pages/auth/login/show.tsx
|
|
241
|
+
import { useForm } from '@inertiajs/react'
|
|
242
|
+
|
|
243
|
+
interface Props {
|
|
244
|
+
redirect_uri: string
|
|
245
|
+
connection: string | null
|
|
246
|
+
flash: { notice?: string; alert?: string }
|
|
247
|
+
social_providers: { google_enabled: boolean; apple_enabled: boolean }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export default function LoginShow({ redirect_uri, flash, social_providers }: Props) {
|
|
251
|
+
const { data, setData, post, processing } = useForm({
|
|
252
|
+
'login[email]': '',
|
|
253
|
+
'login[password]': '',
|
|
254
|
+
'login[remember_me]': false,
|
|
255
|
+
redirect_uri,
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
259
|
+
e.preventDefault()
|
|
260
|
+
post('/login')
|
|
202
261
|
}
|
|
203
262
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
SmsService.send_code(phone, code)
|
|
263
|
+
const handleSocialLogin = (connection: string) => {
|
|
264
|
+
post('/login', { data: { connection, redirect_uri } })
|
|
207
265
|
}
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<div className="login-container">
|
|
269
|
+
{flash.alert && <div className="alert alert-error">{flash.alert}</div>}
|
|
270
|
+
{flash.notice && <div className="alert alert-success">{flash.notice}</div>}
|
|
271
|
+
|
|
272
|
+
<form onSubmit={handleSubmit}>
|
|
273
|
+
<div>
|
|
274
|
+
<label htmlFor="email">Email</label>
|
|
275
|
+
<input
|
|
276
|
+
id="email"
|
|
277
|
+
type="email"
|
|
278
|
+
value={data['login[email]']}
|
|
279
|
+
onChange={e => setData('login[email]', e.target.value)}
|
|
280
|
+
required
|
|
281
|
+
/>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<div>
|
|
285
|
+
<label htmlFor="password">Password</label>
|
|
286
|
+
<input
|
|
287
|
+
id="password"
|
|
288
|
+
type="password"
|
|
289
|
+
value={data['login[password]']}
|
|
290
|
+
onChange={e => setData('login[password]', e.target.value)}
|
|
291
|
+
required
|
|
292
|
+
/>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<div>
|
|
296
|
+
<label>
|
|
297
|
+
<input
|
|
298
|
+
type="checkbox"
|
|
299
|
+
checked={data['login[remember_me]'] as boolean}
|
|
300
|
+
onChange={e => setData('login[remember_me]', e.target.checked)}
|
|
301
|
+
/>
|
|
302
|
+
Remember me
|
|
303
|
+
</label>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<button type="submit" disabled={processing}>
|
|
307
|
+
{processing ? 'Signing in...' : 'Sign In'}
|
|
308
|
+
</button>
|
|
309
|
+
</form>
|
|
310
|
+
|
|
311
|
+
{(social_providers.google_enabled || social_providers.apple_enabled) && (
|
|
312
|
+
<div className="social-login">
|
|
313
|
+
<p>Or continue with</p>
|
|
314
|
+
{social_providers.google_enabled && (
|
|
315
|
+
<button type="button" onClick={() => handleSocialLogin('google')}>
|
|
316
|
+
Sign in with Google
|
|
317
|
+
</button>
|
|
318
|
+
)}
|
|
319
|
+
{social_providers.apple_enabled && (
|
|
320
|
+
<button type="button" onClick={() => handleSocialLogin('apple')}>
|
|
321
|
+
Sign in with Apple
|
|
322
|
+
</button>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
> **Note:** The `useForm` hook from `@inertiajs/react` automatically handles CSRF tokens. When you call `post()`, `put()`, `patch()`, or `delete()`, Inertia reads the CSRF token from the `<meta name="csrf-token">` tag in your layout and includes it in the request headers.
|
|
332
|
+
|
|
333
|
+
#### Props Passed to Components
|
|
334
|
+
|
|
335
|
+
Authentication pages receive the following props:
|
|
336
|
+
|
|
337
|
+
| Prop | Type | Description |
|
|
338
|
+
|------|------|-------------|
|
|
339
|
+
| `redirect_uri` | `string` | URL to redirect to after authentication |
|
|
340
|
+
| `connection` | `string \| null` | Social provider connection (if any) |
|
|
341
|
+
| `flash` | `{ notice?: string, alert?: string }` | Flash messages |
|
|
342
|
+
| `social_providers` | `{ google_enabled: boolean, apple_enabled: boolean }` | Available social providers |
|
|
343
|
+
| `errors` | `object` | Validation errors (on form submission failures) |
|
|
344
|
+
|
|
345
|
+
#### Using Authentication in Host App Controllers
|
|
346
|
+
|
|
347
|
+
You can use the `authenticate_account!` method in your own controllers to require authentication with Inertia-compatible redirects:
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
class DashboardController < ApplicationController
|
|
351
|
+
include StandardId::WebAuthentication
|
|
352
|
+
|
|
353
|
+
before_action :authenticate_account!
|
|
354
|
+
|
|
355
|
+
def show
|
|
356
|
+
# Only authenticated users can access this
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
This will redirect unauthenticated users to the login page using `inertia_location` for Inertia requests, ensuring proper SPA navigation.
|
|
362
|
+
|
|
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:
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
StandardId.configure do |config|
|
|
397
|
+
config.events.enable_logging = true
|
|
398
|
+
end
|
|
399
|
+
```
|
|
400
|
+
|
|
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
|
+
# ...
|
|
529
|
+
end
|
|
530
|
+
```
|
|
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
|
|
208
691
|
end
|
|
209
692
|
```
|
|
210
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
|
+
|
|
211
720
|
## Usage Examples
|
|
212
721
|
|
|
213
722
|
### Web Authentication
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module InertiaRendering
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
include StandardId::InertiaSupport
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Render with Inertia if enabled, otherwise use standard Rails rendering
|
|
12
|
+
def render_with_inertia(action: nil, props: {}, component: nil, status: :ok, **options)
|
|
13
|
+
if use_inertia?
|
|
14
|
+
component_name = component || inertia_component_name(action)
|
|
15
|
+
render inertia: component_name, props: props, status: status, **options
|
|
16
|
+
else
|
|
17
|
+
render_options = { status: status }
|
|
18
|
+
render_options[:action] = action if action.present?
|
|
19
|
+
render_options.merge!(options.except(:inertia, :props))
|
|
20
|
+
render(**render_options)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Generate the Inertia component name based on controller and action
|
|
25
|
+
def inertia_component_name(action = nil)
|
|
26
|
+
namespace = StandardId.config.inertia_component_namespace.presence || "standard_id"
|
|
27
|
+
controller_name = self.class.name.demodulize.delete_suffix("Controller")
|
|
28
|
+
action_str = (action || self.action_name).to_s
|
|
29
|
+
|
|
30
|
+
"#{namespace}/#{controller_name}/#{action_str}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Build common props for authentication pages
|
|
34
|
+
def auth_page_props(additional_props = {})
|
|
35
|
+
{
|
|
36
|
+
redirect_uri: @redirect_uri,
|
|
37
|
+
connection: @connection,
|
|
38
|
+
flash: {
|
|
39
|
+
notice: flash[:notice],
|
|
40
|
+
alert: flash[:alert]
|
|
41
|
+
}.compact,
|
|
42
|
+
social_providers: {
|
|
43
|
+
google_enabled: StandardId.config.google_client_id.present?,
|
|
44
|
+
apple_enabled: StandardId.config.apple_client_id.present?
|
|
45
|
+
}
|
|
46
|
+
}.deep_merge(additional_props)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module InertiaSupport
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
helper_method :use_inertia?
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Check if Inertia rendering should be used
|
|
12
|
+
def use_inertia?
|
|
13
|
+
StandardId.config.use_inertia && inertia_available?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Check if inertia_rails gem is available in the host application
|
|
17
|
+
def inertia_available?
|
|
18
|
+
defined?(::InertiaRails)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Redirect to an external URL or non-Inertia endpoint
|
|
22
|
+
# Uses inertia_location for Inertia requests, otherwise standard redirect_to
|
|
23
|
+
def redirect_with_inertia(url, **options)
|
|
24
|
+
if use_inertia? && request.inertia?
|
|
25
|
+
inertia_location url
|
|
26
|
+
else
|
|
27
|
+
redirect_to url, **options
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -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
|