standard_id 0.1.0 → 0.1.1
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 +340 -7
- data/app/controllers/standard_id/api/providers_controller.rb +15 -16
- data/app/controllers/standard_id/web/verify_email/base_controller.rb +9 -0
- data/app/controllers/standard_id/web/verify_email/confirm_controller.rb +41 -0
- data/app/controllers/standard_id/web/verify_email/start_controller.rb +39 -0
- data/app/controllers/standard_id/web/verify_phone/base_controller.rb +9 -0
- data/app/controllers/standard_id/web/verify_phone/confirm_controller.rb +41 -0
- data/app/controllers/standard_id/web/verify_phone/start_controller.rb +39 -0
- data/app/forms/standard_id/web/signup_form.rb +3 -14
- data/app/models/standard_id/{passwordless_challenge.rb → code_challenge.rb} +9 -5
- data/app/models/standard_id/email_identifier.rb +2 -0
- data/app/models/standard_id/identifier.rb +17 -0
- data/app/models/standard_id/phone_number_identifier.rb +2 -0
- data/app/models/standard_id/username_identifier.rb +2 -0
- data/config/routes/web.rb +12 -0
- data/db/migrate/20250830000000_create_standard_id_client_applications.rb +3 -3
- data/db/migrate/20250830232800_create_standard_id_identifiers.rb +1 -1
- data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -2
- data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -2
- data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -2
- data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +29 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +31 -3
- data/lib/{standard_id → standard_config}/config.rb +1 -1
- data/lib/standard_config/config_provider.rb +82 -0
- data/lib/standard_config/manager.rb +86 -0
- data/lib/standard_config/schema.rb +137 -0
- data/lib/standard_config.rb +38 -0
- data/lib/standard_id/api/session_manager.rb +1 -1
- data/lib/standard_id/config/schema.rb +48 -0
- data/lib/standard_id/oauth/passwordless_otp_flow.rb +7 -6
- data/lib/standard_id/passwordless/base_strategy.rb +8 -4
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +12 -4
- metadata +15 -4
- data/db/migrate/20250903135906_create_standard_id_passwordless_challenges.rb +0 -22
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c7150dd77b059e80767d262bc8a8b507399491977208404f3bd2d04209f9f45d
|
|
4
|
+
data.tar.gz: e5157110e538a9e6cf625cee184fb423747235eac74c65821fb1b62ba0201452
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6c06a909ce1cca77763f8a9c23a01e2703a84c4b4b99435c5f63dc0a9f3ccf54c3f55c98ea9147b51a9b8ce538be6be57003a4f7c744f608c2a4fe40bf04df46
|
|
7
|
+
data.tar.gz: b6dc47dc00160c2487c8611b73c21330cd6cbec0865036e235628fb744151c4f946afd0db17f02fa0ca31f3df7261cf94adad09d8c9b2963ed32806dcaf19e63
|
data/README.md
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
# StandardId
|
|
2
|
-
Short description and motivation.
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
A comprehensive authentication engine for Rails applications, built on the security primitives introduced in Rails 7/8. StandardId provides a complete, secure-by-default solution for identity management, reducing boilerplate and eliminating common security pitfalls.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
### 🔐 Complete Authentication System
|
|
8
|
+
- **Web Authentication**: Cookie-based sessions with CSRF protection
|
|
9
|
+
- **API Authentication**: JWT-based tokens for API access
|
|
10
|
+
- **Dual Engine Architecture**: Separate web (`/`) and API (`/api`) endpoints
|
|
11
|
+
- **Session Management**: Browser sessions, device sessions, and service sessions with STI
|
|
12
|
+
|
|
13
|
+
### 🚀 OAuth 2.0 & OpenID Connect
|
|
14
|
+
- **Authorization Code Flow**: Standard OAuth flow with PKCE support
|
|
15
|
+
- **Implicit Flow**: For single-page applications
|
|
16
|
+
- **Client Credentials Flow**: For service-to-service authentication
|
|
17
|
+
- **Password Flow**: Direct username/password authentication
|
|
18
|
+
- **Refresh Token Flow**: Automatic token renewal
|
|
19
|
+
- **Social Login**: Google OAuth and Apple Sign In integration
|
|
20
|
+
|
|
21
|
+
### 📱 Passwordless Authentication
|
|
22
|
+
- **Email OTP**: Send one-time passwords via email
|
|
23
|
+
- **SMS OTP**: Send one-time passwords via SMS
|
|
24
|
+
- **Configurable Delivery**: Host app controls message delivery
|
|
25
|
+
- **10-minute Expiry**: Secure time-limited codes
|
|
26
|
+
|
|
27
|
+
### 🏢 Multi-Tenant Support
|
|
28
|
+
- **Client Management**: OAuth clients with secret rotation
|
|
29
|
+
- **Polymorphic Ownership**: Clients can belong to accounts, organizations, etc.
|
|
30
|
+
- **Scope Management**: Fine-grained permission control
|
|
31
|
+
- **Redirect URI Validation**: Secure callback handling
|
|
32
|
+
|
|
33
|
+
### 🔑 Advanced Security
|
|
34
|
+
- **PKCE Support**: Proof Key for Code Exchange
|
|
35
|
+
- **JWT Tokens**: Stateless authentication with configurable expiry
|
|
36
|
+
- **Secret Rotation**: Client secret management with audit trail
|
|
37
|
+
- **Remember Me**: Extended session support
|
|
38
|
+
- **Account Lockout**: Protection against brute force attacks
|
|
6
39
|
|
|
7
40
|
## Installation
|
|
41
|
+
|
|
8
42
|
Add this line to your application's Gemfile:
|
|
9
43
|
|
|
10
44
|
```ruby
|
|
@@ -13,16 +47,315 @@ gem "standard_id"
|
|
|
13
47
|
|
|
14
48
|
And then execute:
|
|
15
49
|
```bash
|
|
16
|
-
$ bundle
|
|
50
|
+
$ bundle install
|
|
17
51
|
```
|
|
18
52
|
|
|
19
|
-
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
### 1. Generate Configuration
|
|
56
|
+
|
|
20
57
|
```bash
|
|
21
|
-
|
|
58
|
+
rails generate standard_id:install
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 2. Configure Your Account Model
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# config/initializers/standard_id.rb
|
|
65
|
+
StandardId.configure do |config|
|
|
66
|
+
config.account_class_name = "User" # or "Account"
|
|
67
|
+
config.issuer = "https://your-app.com"
|
|
68
|
+
config.login_url = "/login"
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 3. Mount the Engines
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# config/routes.rb
|
|
76
|
+
Rails.application.routes.draw do
|
|
77
|
+
mount StandardId::WebEngine, at: "/", as: :standard_id_web
|
|
78
|
+
|
|
79
|
+
namespace :api do
|
|
80
|
+
mount StandardId::ApiEngine, at: "/", as: :standard_id_api
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 4. Include Authentication in Controllers
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
# For web controllers
|
|
89
|
+
class ApplicationController < ActionController::Base
|
|
90
|
+
include StandardId::Web::WebAuthentication
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# For API controllers
|
|
94
|
+
class ApiController < ActionController::API
|
|
95
|
+
include StandardId::Api::ApiAuthentication
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
### Basic Configuration
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
StandardId.configure do |config|
|
|
105
|
+
# Required: Your account model
|
|
106
|
+
config.account_class_name = "User"
|
|
107
|
+
|
|
108
|
+
# OAuth issuer for ID tokens
|
|
109
|
+
config.issuer = "https://your-app.com"
|
|
110
|
+
|
|
111
|
+
# Login URL for redirects
|
|
112
|
+
config.login_url = "/login"
|
|
113
|
+
|
|
114
|
+
# Custom layout for web views
|
|
115
|
+
config.web_layout = "application"
|
|
116
|
+
|
|
117
|
+
# Passwordless delivery callbacks
|
|
118
|
+
# config.passwordless_email_sender = ->(email, code) { UserMailer.send_code(email, code).deliver_now }
|
|
119
|
+
# config.passwordless_sms_sender = ->(phone, code) { SmsService.send_code(phone, code) }
|
|
120
|
+
|
|
121
|
+
# Subset configuration
|
|
122
|
+
# config.password.minimum_length = 12
|
|
123
|
+
# config.password.require_special_chars = true
|
|
124
|
+
# config.passwordless.code_ttl = 600
|
|
125
|
+
# config.oauth.default_token_lifetime = 3600
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Social Login Setup
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
StandardId.configure do |config|
|
|
133
|
+
# Google OAuth
|
|
134
|
+
config.social.google_client_id = ENV["GOOGLE_CLIENT_ID"]
|
|
135
|
+
config.social.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
|
|
136
|
+
|
|
137
|
+
# Apple Sign In
|
|
138
|
+
config.social.apple_client_id = ENV["APPLE_CLIENT_ID"]
|
|
139
|
+
config.social.apple_private_key = ENV["APPLE_PRIVATE_KEY"]
|
|
140
|
+
config.social.apple_key_id = ENV["APPLE_KEY_ID"]
|
|
141
|
+
config.social.apple_team_id = ENV["APPLE_TEAM_ID"]
|
|
142
|
+
end
|
|
22
143
|
```
|
|
23
144
|
|
|
145
|
+
### Passwordless Authentication
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
StandardId.configure do |config|
|
|
149
|
+
# Email delivery
|
|
150
|
+
config.passwordless_email_sender = ->(email, code) {
|
|
151
|
+
UserMailer.send_code(email, code).deliver_now
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# SMS delivery
|
|
155
|
+
config.passwordless_sms_sender = ->(phone, code) {
|
|
156
|
+
SmsService.send_code(phone, code)
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Usage Examples
|
|
162
|
+
|
|
163
|
+
### Web Authentication
|
|
164
|
+
|
|
165
|
+
```erb
|
|
166
|
+
<!-- Login form -->
|
|
167
|
+
<%= form_with url: login_path, local: true do |f| %>
|
|
168
|
+
<%= f.email_field :email, placeholder: "Email" %>
|
|
169
|
+
<%= f.password_field :password, placeholder: "Password" %>
|
|
170
|
+
<%= f.check_box :remember_me %>
|
|
171
|
+
<%= f.label :remember_me, "Remember me" %>
|
|
172
|
+
<%= f.submit "Sign In" %>
|
|
173
|
+
<% end %>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### OAuth Authorization
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
# Redirect to authorization endpoint
|
|
180
|
+
redirect_to "/api/authorize?" + {
|
|
181
|
+
response_type: "code",
|
|
182
|
+
client_id: "your_client_id",
|
|
183
|
+
redirect_uri: "https://your-app.com/callback",
|
|
184
|
+
scope: "openid profile email",
|
|
185
|
+
state: "random_state_value"
|
|
186
|
+
}.to_query
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Social Login
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# Google login
|
|
193
|
+
redirect_to "/api/authorize?" + {
|
|
194
|
+
response_type: "code",
|
|
195
|
+
client_id: "your_client_id",
|
|
196
|
+
redirect_uri: "https://your-app.com/callback",
|
|
197
|
+
connection: "google-oauth2"
|
|
198
|
+
}.to_query
|
|
199
|
+
|
|
200
|
+
# Apple login
|
|
201
|
+
redirect_to "/api/authorize?" + {
|
|
202
|
+
response_type: "code",
|
|
203
|
+
client_id: "your_client_id",
|
|
204
|
+
redirect_uri: "https://your-app.com/callback",
|
|
205
|
+
connection: "apple"
|
|
206
|
+
}.to_query
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Passwordless Authentication
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
# Start passwordless flow
|
|
213
|
+
POST /api/passwordless/start
|
|
214
|
+
{
|
|
215
|
+
"connection": "email",
|
|
216
|
+
"username": "user@example.com"
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
# Verify code
|
|
220
|
+
POST /api/passwordless/verify
|
|
221
|
+
{
|
|
222
|
+
"connection": "email",
|
|
223
|
+
"username": "user@example.com",
|
|
224
|
+
"otp": "123456"
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### API Authentication
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
# In your API controllers
|
|
232
|
+
class Api::UsersController < ApiController
|
|
233
|
+
before_action :authenticate_account!
|
|
234
|
+
|
|
235
|
+
def show
|
|
236
|
+
render json: current_account
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Database Schema
|
|
242
|
+
|
|
243
|
+
StandardId creates the following tables:
|
|
244
|
+
|
|
245
|
+
- `standard_id_accounts` - User accounts
|
|
246
|
+
- `standard_id_identifiers` - Email/phone identifiers (STI)
|
|
247
|
+
- `standard_id_sessions` - Authentication sessions (STI)
|
|
248
|
+
- `standard_id_clients` - OAuth clients
|
|
249
|
+
- `standard_id_client_secret_credentials` - Client secrets
|
|
250
|
+
- `standard_id_password_credentials` - Password storage
|
|
251
|
+
- `standard_id_code_challenges` - OTP codes for authentication and verification
|
|
252
|
+
|
|
253
|
+
## API Endpoints
|
|
254
|
+
|
|
255
|
+
### Web Routes (mounted at `/`)
|
|
256
|
+
- `GET /login` - Login form
|
|
257
|
+
- `POST /login` - Process login
|
|
258
|
+
- `POST /logout` - Logout
|
|
259
|
+
- `GET /signup` - Signup form
|
|
260
|
+
- `POST /signup` - Process signup
|
|
261
|
+
- `GET /account` - Account management
|
|
262
|
+
- `GET /sessions` - Active sessions
|
|
263
|
+
|
|
264
|
+
### API Routes (mounted at `/api`)
|
|
265
|
+
- `GET /authorize` - OAuth authorization endpoint
|
|
266
|
+
- `POST /oauth/token` - Token exchange endpoint
|
|
267
|
+
- `GET /userinfo` - OpenID Connect userinfo
|
|
268
|
+
- `POST /passwordless/start` - Start passwordless flow
|
|
269
|
+
- `POST /passwordless/verify` - Verify OTP code
|
|
270
|
+
- `GET /oauth/callback/google` - Google OAuth callback
|
|
271
|
+
- `POST /oauth/callback/apple` - Apple Sign In callback
|
|
272
|
+
|
|
273
|
+
## Client Management
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
# Create OAuth client
|
|
277
|
+
client = StandardId::Client.create!(
|
|
278
|
+
owner: current_account,
|
|
279
|
+
name: "My Application",
|
|
280
|
+
redirect_uris: "https://app.com/callback",
|
|
281
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
282
|
+
response_types: ["code"],
|
|
283
|
+
scopes: ["openid", "profile", "email"]
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Generate client secret
|
|
287
|
+
secret = client.create_client_secret!(name: "Production Secret")
|
|
288
|
+
|
|
289
|
+
# Rotate client secret
|
|
290
|
+
new_secret = client.rotate_client_secret!
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Schema DSL
|
|
294
|
+
|
|
295
|
+
Schema is declared using a routes-like DSL and can be extended by provider gems:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
# core gem (already provided)
|
|
299
|
+
require "standard_id/config/schema"
|
|
300
|
+
|
|
301
|
+
StandardConfig.schema.draw do
|
|
302
|
+
scope :base do
|
|
303
|
+
field :account_class_name, type: :string, default: "User"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
scope :social do
|
|
307
|
+
field :google_client_id, type: :string, default: nil
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# provider gem
|
|
312
|
+
require "standard_id/config/schema"
|
|
313
|
+
|
|
314
|
+
StandardConfig.schema.draw do
|
|
315
|
+
scope :social do
|
|
316
|
+
field :my_provider_client_id, type: :string, default: nil
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Notes:
|
|
322
|
+
|
|
323
|
+
- Multiple `schema.draw` calls are additive; the same scope can be extended in multiple files/gems.
|
|
324
|
+
- Redefining an existing field will emit a warning; last definition wins.
|
|
325
|
+
|
|
326
|
+
## Testing
|
|
327
|
+
|
|
328
|
+
StandardId includes comprehensive test coverage:
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
# Run all tests
|
|
332
|
+
bundle exec rspec
|
|
333
|
+
|
|
334
|
+
# Run specific test suites
|
|
335
|
+
bundle exec rspec spec/models/
|
|
336
|
+
bundle exec rspec spec/controllers/
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
## Security Considerations
|
|
340
|
+
|
|
341
|
+
- All passwords are hashed using bcrypt
|
|
342
|
+
- JWT tokens are signed and verified
|
|
343
|
+
- CSRF protection enabled for web requests
|
|
344
|
+
- Secure session management with proper expiry
|
|
345
|
+
- Client secrets are rotatable with audit trail
|
|
346
|
+
- PKCE support for public clients
|
|
347
|
+
- Rate limiting on authentication endpoints
|
|
348
|
+
|
|
24
349
|
## Contributing
|
|
25
|
-
|
|
350
|
+
|
|
351
|
+
1. Fork the repository
|
|
352
|
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
|
353
|
+
3. Write tests for your changes
|
|
354
|
+
4. Ensure all tests pass (`bin/rspec`)
|
|
355
|
+
5. Commit your changes (`git commit -am 'Add amazing feature'`)
|
|
356
|
+
6. Push to the branch (`git push origin feature/amazing-feature`)
|
|
357
|
+
7. Open a Pull Request
|
|
26
358
|
|
|
27
359
|
## License
|
|
360
|
+
|
|
28
361
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -123,22 +123,21 @@ module StandardId
|
|
|
123
123
|
|
|
124
124
|
identifier = StandardId::EmailIdentifier.find_by(value: email)
|
|
125
125
|
|
|
126
|
-
if identifier
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
end
|
|
126
|
+
return identifier.account if identifier.present?
|
|
127
|
+
|
|
128
|
+
account = Account.create!(
|
|
129
|
+
name: (user_info["name"] || user_info["given_name"] || email),
|
|
130
|
+
email:
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
identifier = StandardId::EmailIdentifier.create!(
|
|
134
|
+
account:,
|
|
135
|
+
value: email
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
identifier.verify!
|
|
139
|
+
|
|
140
|
+
account
|
|
142
141
|
end
|
|
143
142
|
|
|
144
143
|
def generate_authorization_code
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Web
|
|
3
|
+
module VerifyEmail
|
|
4
|
+
class ConfirmController < BaseController
|
|
5
|
+
before_action :prepare_code_challenge
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if @challenge.nil?
|
|
9
|
+
render plain: "verify email confirm", status: :ok
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def update
|
|
13
|
+
return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if @challenge.nil?
|
|
14
|
+
|
|
15
|
+
identifier = StandardId::EmailIdentifier.find_by(value: @challenge.target)
|
|
16
|
+
if identifier.present?
|
|
17
|
+
identifier.verify!
|
|
18
|
+
end
|
|
19
|
+
@challenge.use!
|
|
20
|
+
|
|
21
|
+
redirect_to standard_id_web.login_path, notice: "Your email has been verified. Please sign in.", status: :see_other
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def prepare_code_challenge
|
|
27
|
+
email = params[:email].to_s.strip.downcase
|
|
28
|
+
code = params[:code].to_s
|
|
29
|
+
return @challenge = nil if email.blank? || code.blank?
|
|
30
|
+
|
|
31
|
+
@challenge = StandardId::CodeChallenge.active.find_by(
|
|
32
|
+
realm: "verification",
|
|
33
|
+
channel: "email",
|
|
34
|
+
target: email,
|
|
35
|
+
code: code
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Web
|
|
3
|
+
module VerifyEmail
|
|
4
|
+
class StartController < BaseController
|
|
5
|
+
def show
|
|
6
|
+
render plain: "verify email start", status: :ok
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def create
|
|
10
|
+
email = params[:email].to_s.strip.downcase
|
|
11
|
+
if email.blank?
|
|
12
|
+
flash[:alert] = "Please enter your email address"
|
|
13
|
+
render plain: "missing email", status: :unprocessable_content and return
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
challenge = StandardId::CodeChallenge.create!(
|
|
17
|
+
realm: "verification",
|
|
18
|
+
channel: "email",
|
|
19
|
+
target: email,
|
|
20
|
+
code: generate_otp_code,
|
|
21
|
+
expires_at: 10.minutes.from_now,
|
|
22
|
+
ip_address: request.remote_ip,
|
|
23
|
+
user_agent: request.user_agent
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
StandardId.config.passwordless_email_sender&.call(email, challenge.code)
|
|
27
|
+
|
|
28
|
+
redirect_to standard_id_web.login_path, notice: "Verification code sent to your email", status: :see_other
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def generate_otp_code
|
|
34
|
+
(SecureRandom.random_number(900_000) + 100_000).to_s
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Web
|
|
3
|
+
module VerifyPhone
|
|
4
|
+
class ConfirmController < BaseController
|
|
5
|
+
before_action :prepare_code_challenge
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if @challenge.nil?
|
|
9
|
+
render plain: "verify phone confirm", status: :ok
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def update
|
|
13
|
+
return redirect_to(standard_id_web.login_path, alert: "Invalid or expired verification code") if @challenge.nil?
|
|
14
|
+
|
|
15
|
+
identifier = StandardId::PhoneNumberIdentifier.find_by(value: @challenge.target)
|
|
16
|
+
if identifier.present?
|
|
17
|
+
identifier.verify!
|
|
18
|
+
end
|
|
19
|
+
@challenge.use!
|
|
20
|
+
|
|
21
|
+
redirect_to standard_id_web.login_path, notice: "Your phone number has been verified. Please sign in.", status: :see_other
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def prepare_code_challenge
|
|
27
|
+
phone = params[:phone_number].to_s.strip
|
|
28
|
+
code = params[:code].to_s
|
|
29
|
+
return @challenge = nil if phone.blank? || code.blank?
|
|
30
|
+
|
|
31
|
+
@challenge = StandardId::CodeChallenge.active.find_by(
|
|
32
|
+
realm: "verification",
|
|
33
|
+
channel: "sms",
|
|
34
|
+
target: phone,
|
|
35
|
+
code: code
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Web
|
|
3
|
+
module VerifyPhone
|
|
4
|
+
class StartController < BaseController
|
|
5
|
+
def show
|
|
6
|
+
render plain: "verify phone start", status: :ok
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def create
|
|
10
|
+
phone = params[:phone_number].to_s.strip
|
|
11
|
+
if phone.blank? || !(phone.match?(/\A\+?[1-9]\d{1,14}\z/))
|
|
12
|
+
flash[:alert] = "Please enter a valid phone number"
|
|
13
|
+
render plain: "invalid phone", status: :unprocessable_content and return
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
challenge = StandardId::CodeChallenge.create!(
|
|
17
|
+
realm: "verification",
|
|
18
|
+
channel: "sms",
|
|
19
|
+
target: phone,
|
|
20
|
+
code: generate_otp_code,
|
|
21
|
+
expires_at: 10.minutes.from_now,
|
|
22
|
+
ip_address: request.remote_ip,
|
|
23
|
+
user_agent: request.user_agent
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
StandardId.config.passwordless_sms_sender&.call(phone, challenge.code)
|
|
27
|
+
|
|
28
|
+
redirect_to standard_id_web.login_path, notice: "Verification code sent via SMS", status: :see_other
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def generate_otp_code
|
|
34
|
+
(SecureRandom.random_number(900_000) + 100_000).to_s
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -39,26 +39,15 @@ module StandardId
|
|
|
39
39
|
private
|
|
40
40
|
|
|
41
41
|
def account_params
|
|
42
|
-
|
|
43
|
-
{
|
|
44
|
-
name: (email.to_s.split("@").first.presence || "User"),
|
|
45
|
-
email: email
|
|
46
|
-
}
|
|
42
|
+
{ name: (email.to_s.split("@").first.presence || "User"), email: }
|
|
47
43
|
end
|
|
48
44
|
|
|
49
45
|
def email_identifier_params
|
|
50
|
-
{
|
|
51
|
-
value: email,
|
|
52
|
-
verified_at: Time.current,
|
|
53
|
-
type: "StandardId::EmailIdentifier"
|
|
54
|
-
}
|
|
46
|
+
{ type: "StandardId::EmailIdentifier", value: email }
|
|
55
47
|
end
|
|
56
48
|
|
|
57
49
|
def password_credential_params
|
|
58
|
-
{
|
|
59
|
-
login: email,
|
|
60
|
-
password: password
|
|
61
|
-
}
|
|
50
|
+
{ login: email, password: }
|
|
62
51
|
end
|
|
63
52
|
end
|
|
64
53
|
end
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
module StandardId
|
|
2
|
-
class
|
|
3
|
-
self.table_name = "
|
|
2
|
+
class CodeChallenge < ApplicationRecord
|
|
3
|
+
self.table_name = "standard_id_code_challenges"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
REALMS = %w[authentication verification].freeze
|
|
6
|
+
CHANNELS = %w[email sms].freeze
|
|
7
|
+
|
|
8
|
+
validates :realm, presence: true, inclusion: { in: REALMS }
|
|
9
|
+
validates :channel, presence: true, inclusion: { in: CHANNELS }
|
|
10
|
+
validates :target, presence: true
|
|
11
|
+
validates :code, presence: true
|
|
8
12
|
validates :expires_at, presence: true
|
|
9
13
|
|
|
10
14
|
scope :active, -> { where(used_at: nil).where("expires_at > ?", Time.current) }
|
|
@@ -10,6 +10,8 @@ module StandardId
|
|
|
10
10
|
# Shared validations
|
|
11
11
|
validates :value, presence: true, uniqueness: { scope: [:account_id, :type] }
|
|
12
12
|
|
|
13
|
+
after_commit :mark_account_verified!, on: :update, if: :just_verified?
|
|
14
|
+
|
|
13
15
|
def verified?
|
|
14
16
|
verified_at.present?
|
|
15
17
|
end
|
|
@@ -21,5 +23,20 @@ module StandardId
|
|
|
21
23
|
def unverify!
|
|
22
24
|
update!(verified_at: nil)
|
|
23
25
|
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def just_verified?
|
|
30
|
+
saved_change_to_verified_at? && verified_at.present?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def mark_account_verified!
|
|
34
|
+
return if account.nil?
|
|
35
|
+
|
|
36
|
+
return unless account.has_attribute?(:verified)
|
|
37
|
+
return unless account.has_attribute?(:verified_at)
|
|
38
|
+
|
|
39
|
+
account.update!(verified: true, verified_at: Time.current)
|
|
40
|
+
end
|
|
24
41
|
end
|
|
25
42
|
end
|