source_monitor 0.2.0 → 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/.claude/agents/rails-concern.md +464 -0
- data/.claude/agents/rails-controller.md +424 -0
- data/.claude/agents/rails-hotwire.md +446 -0
- data/.claude/agents/rails-implement.md +374 -0
- data/.claude/agents/rails-job.md +334 -0
- data/.claude/agents/rails-lint.md +294 -0
- data/.claude/agents/rails-mailer.md +371 -0
- data/.claude/agents/rails-migration.md +449 -0
- data/.claude/agents/rails-model.md +420 -0
- data/.claude/agents/rails-policy.md +443 -0
- data/.claude/agents/rails-presenter.md +427 -0
- data/.claude/agents/rails-query.md +412 -0
- data/.claude/agents/rails-review.md +490 -0
- data/.claude/agents/rails-service.md +458 -0
- data/.claude/agents/rails-state-records.md +465 -0
- data/.claude/agents/rails-tdd.md +314 -0
- data/.claude/agents/rails-test.md +441 -0
- data/.claude/agents/rails-view-component.md +418 -0
- data/.claude/hooks/block-secrets.sh +52 -0
- data/.claude/settings.json +85 -0
- data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
- data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
- data/.claude/skills/active-storage-setup/SKILL.md +311 -0
- data/.claude/skills/api-versioning/SKILL.md +294 -0
- data/.claude/skills/authentication-flow/SKILL.md +335 -0
- data/.claude/skills/authentication-flow/reference/current.md +248 -0
- data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
- data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
- data/.claude/skills/authorization-pundit/SKILL.md +462 -0
- data/.claude/skills/caching-strategies/SKILL.md +350 -0
- data/.claude/skills/database-migrations/SKILL.md +354 -0
- data/.claude/skills/form-object-patterns/SKILL.md +399 -0
- data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
- data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
- data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
- data/.claude/skills/i18n-patterns/SKILL.md +320 -0
- data/.claude/skills/install/SKILL.md +367 -0
- data/.claude/skills/performance-optimization/SKILL.md +311 -0
- data/.claude/skills/rails-architecture/SKILL.md +259 -0
- data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
- data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
- data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
- data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
- data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
- data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
- data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
- data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
- data/.claude/skills/rails-concern/SKILL.md +399 -0
- data/.claude/skills/rails-controller/SKILL.md +336 -0
- data/.claude/skills/rails-model-generator/SKILL.md +321 -0
- data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
- data/.claude/skills/rails-presenter/SKILL.md +274 -0
- data/.claude/skills/rails-query-object/SKILL.md +289 -0
- data/.claude/skills/rails-service-object/SKILL.md +349 -0
- data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
- data/.claude/skills/tdd-cycle/SKILL.md +359 -0
- data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +2 -0
- data/.ruby-version +1 -1
- data/.vbw-planning/.notification-log.jsonl +192 -0
- data/.vbw-planning/.session-log.jsonl +871 -0
- data/.vbw-planning/PROJECT.md +51 -0
- data/.vbw-planning/REQUIREMENTS.md +50 -0
- data/.vbw-planning/SHIPPED.md +28 -0
- data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
- data/.vbw-planning/codebase/CONCERNS.md +99 -0
- data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
- data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
- data/.vbw-planning/codebase/INDEX.md +86 -0
- data/.vbw-planning/codebase/META.md +42 -0
- data/.vbw-planning/codebase/PATTERNS.md +262 -0
- data/.vbw-planning/codebase/STACK.md +101 -0
- data/.vbw-planning/codebase/STRUCTURE.md +324 -0
- data/.vbw-planning/codebase/TESTING.md +154 -0
- data/.vbw-planning/config.json +12 -0
- data/.vbw-planning/discovery.json +24 -0
- data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
- data/.vbw-planning/milestones/default/STATE.md +83 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +179 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +114 -101
- data/Rakefile +2 -0
- data/app/assets/builds/source_monitor/application.css +2076 -0
- data/app/assets/builds/source_monitor/application.js +2758 -0
- data/app/assets/builds/source_monitor/application.js.map +7 -0
- data/app/controllers/source_monitor/application_controller.rb +2 -0
- data/app/controllers/source_monitor/health_controller.rb +2 -0
- data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
- data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
- data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
- data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
- data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
- data/app/controllers/source_monitor/items_controller.rb +2 -0
- data/app/controllers/source_monitor/sources_controller.rb +0 -14
- data/app/helpers/source_monitor/application_helper.rb +4 -112
- data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
- data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
- data/app/jobs/source_monitor/application_job.rb +2 -0
- data/app/models/source_monitor/application_record.rb +2 -0
- data/app/models/source_monitor/log_entry.rb +0 -2
- data/config/coverage_baseline.json +217 -1862
- data/config/routes.rb +2 -0
- data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
- data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
- data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
- data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
- data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
- data/lib/source_monitor/assets/bundler.rb +2 -0
- data/lib/source_monitor/assets.rb +2 -0
- data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
- data/lib/source_monitor/configuration/events.rb +60 -0
- data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
- data/lib/source_monitor/configuration/health_settings.rb +27 -0
- data/lib/source_monitor/configuration/http_settings.rb +43 -0
- data/lib/source_monitor/configuration/model_definition.rb +108 -0
- data/lib/source_monitor/configuration/models.rb +36 -0
- data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
- data/lib/source_monitor/configuration/retention_settings.rb +45 -0
- data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
- data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
- data/lib/source_monitor/configuration/validation_definition.rb +32 -0
- data/lib/source_monitor/configuration.rb +12 -579
- data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
- data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
- data/lib/source_monitor/dashboard/queries.rb +2 -195
- data/lib/source_monitor/engine.rb +2 -0
- data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
- data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
- data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
- data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
- data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
- data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
- data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
- data/lib/source_monitor/items/item_creator.rb +28 -455
- data/lib/source_monitor/setup/bundle_installer.rb +2 -0
- data/lib/source_monitor/setup/cli.rb +2 -0
- data/lib/source_monitor/setup/dependency_checker.rb +2 -0
- data/lib/source_monitor/setup/detectors.rb +2 -0
- data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
- data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
- data/lib/source_monitor/setup/install_generator.rb +2 -0
- data/lib/source_monitor/setup/migration_installer.rb +2 -0
- data/lib/source_monitor/setup/node_installer.rb +2 -0
- data/lib/source_monitor/setup/prompter.rb +2 -0
- data/lib/source_monitor/setup/requirements.rb +2 -0
- data/lib/source_monitor/setup/shell_runner.rb +2 -0
- data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/printer.rb +2 -0
- data/lib/source_monitor/setup/verification/result.rb +2 -0
- data/lib/source_monitor/setup/verification/runner.rb +2 -0
- data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
- data/lib/source_monitor/setup/workflow.rb +2 -0
- data/lib/source_monitor/version.rb +3 -1
- data/lib/source_monitor.rb +140 -58
- data/lib/tasks/source_monitor_assets.rake +2 -0
- data/lib/tasks/source_monitor_setup.rake +2 -0
- data/lib/tasks/source_monitor_tasks.rake +2 -0
- data/source_monitor.gemspec +3 -1
- metadata +144 -4
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# Passwordless Authentication (Magic Links)
|
|
2
|
+
|
|
3
|
+
Alternative to password-based auth. Based on 37signals patterns.
|
|
4
|
+
|
|
5
|
+
## Philosophy
|
|
6
|
+
|
|
7
|
+
Auth is simple. A basic system is ~150 lines of code total. You get full control, no bloat, and easier maintenance.
|
|
8
|
+
|
|
9
|
+
## Core Models
|
|
10
|
+
|
|
11
|
+
### Identity Model
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# app/models/identity.rb
|
|
15
|
+
class Identity < ApplicationRecord
|
|
16
|
+
has_secure_password validations: false
|
|
17
|
+
|
|
18
|
+
has_many :sessions, dependent: :destroy
|
|
19
|
+
has_many :magic_links, dependent: :destroy
|
|
20
|
+
has_one :user, dependent: :destroy
|
|
21
|
+
|
|
22
|
+
validates :email_address, presence: true, uniqueness: { case_sensitive: false }
|
|
23
|
+
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
24
|
+
|
|
25
|
+
normalizes :email_address, with: -> { _1.strip.downcase }
|
|
26
|
+
|
|
27
|
+
def send_magic_link(purpose: "sign_in")
|
|
28
|
+
magic_link = magic_links.create!(purpose: purpose)
|
|
29
|
+
MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
|
|
30
|
+
magic_link
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Magic Link Model
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# app/models/magic_link.rb
|
|
39
|
+
class MagicLink < ApplicationRecord
|
|
40
|
+
CODE_LENGTH = 6
|
|
41
|
+
|
|
42
|
+
belongs_to :identity
|
|
43
|
+
|
|
44
|
+
before_create :set_code
|
|
45
|
+
before_create :set_expiration
|
|
46
|
+
|
|
47
|
+
scope :unused, -> { where(used_at: nil) }
|
|
48
|
+
scope :active, -> { unused.where("expires_at > ?", Time.current) }
|
|
49
|
+
|
|
50
|
+
def self.authenticate(code)
|
|
51
|
+
active.find_by(code: code.upcase)&.tap do |magic_link|
|
|
52
|
+
magic_link.update!(used_at: Time.current)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def expired?
|
|
57
|
+
expires_at < Time.current
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def used?
|
|
61
|
+
used_at.present?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def valid_for_use?
|
|
65
|
+
!expired? && !used?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def set_code
|
|
71
|
+
self.code = SecureRandom.alphanumeric(CODE_LENGTH).upcase
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def set_expiration
|
|
75
|
+
self.expires_at = 15.minutes.from_now
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Session Model
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# app/models/session.rb
|
|
84
|
+
class Session < ApplicationRecord
|
|
85
|
+
belongs_to :identity
|
|
86
|
+
|
|
87
|
+
has_secure_token length: 36
|
|
88
|
+
|
|
89
|
+
def active?
|
|
90
|
+
created_at > 30.days.ago
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Controllers
|
|
96
|
+
|
|
97
|
+
### Sessions Controller
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
class SessionsController < ApplicationController
|
|
101
|
+
allow_unauthenticated_access only: [:new, :create]
|
|
102
|
+
|
|
103
|
+
def new
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def create
|
|
107
|
+
if identity = Identity.find_by(email_address: params[:email_address])
|
|
108
|
+
identity.send_magic_link
|
|
109
|
+
redirect_to new_session_path, notice: "Check your email for a sign-in link"
|
|
110
|
+
else
|
|
111
|
+
redirect_to new_session_path, alert: "No account found with that email"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def destroy
|
|
116
|
+
terminate_session
|
|
117
|
+
redirect_to root_path
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Magic Links Controller
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
class Sessions::MagicLinksController < ApplicationController
|
|
126
|
+
allow_unauthenticated_access
|
|
127
|
+
|
|
128
|
+
def show
|
|
129
|
+
if magic_link = MagicLink.authenticate(params[:code])
|
|
130
|
+
start_new_session_for(magic_link.identity)
|
|
131
|
+
redirect_to session.delete(:return_to) || root_path, notice: "Signed in successfully"
|
|
132
|
+
else
|
|
133
|
+
redirect_to new_session_path, alert: "Invalid or expired link"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Testing
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
# test/models/identity_test.rb
|
|
143
|
+
class IdentityTest < ActiveSupport::TestCase
|
|
144
|
+
test "normalizes email address to lowercase" do
|
|
145
|
+
identity = Identity.create!(email_address: "TEST@EXAMPLE.COM")
|
|
146
|
+
assert_equal "test@example.com", identity.email_address
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
test "validates email format" do
|
|
150
|
+
identity = Identity.new(email_address: "invalid")
|
|
151
|
+
assert_not identity.valid?
|
|
152
|
+
assert_includes identity.errors[:email_address], "is invalid"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
test "sends magic link" do
|
|
156
|
+
identity = identities(:david)
|
|
157
|
+
|
|
158
|
+
assert_difference -> { identity.magic_links.count }, 1 do
|
|
159
|
+
assert_enqueued_emails 1 do
|
|
160
|
+
identity.send_magic_link
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# test/models/magic_link_test.rb
|
|
167
|
+
class MagicLinkTest < ActiveSupport::TestCase
|
|
168
|
+
test "generates 6-character code" do
|
|
169
|
+
magic_link = MagicLink.create!(identity: identities(:david))
|
|
170
|
+
assert_equal 6, magic_link.code.length
|
|
171
|
+
assert_match(/\A[A-Z0-9]+\z/, magic_link.code)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
test "expires after 15 minutes" do
|
|
175
|
+
magic_link = MagicLink.create!(identity: identities(:david))
|
|
176
|
+
assert magic_link.valid_for_use?
|
|
177
|
+
|
|
178
|
+
travel 16.minutes do
|
|
179
|
+
assert magic_link.expired?
|
|
180
|
+
assert_not magic_link.valid_for_use?
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
test "authenticates with valid code" do
|
|
185
|
+
magic_link = MagicLink.create!(identity: identities(:david))
|
|
186
|
+
authenticated = MagicLink.authenticate(magic_link.code)
|
|
187
|
+
|
|
188
|
+
assert_equal magic_link, authenticated
|
|
189
|
+
assert authenticated.used?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
test "does not authenticate used codes" do
|
|
193
|
+
magic_link = MagicLink.create!(identity: identities(:david))
|
|
194
|
+
MagicLink.authenticate(magic_link.code)
|
|
195
|
+
assert_nil MagicLink.authenticate(magic_link.code)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# test/controllers/sessions_controller_test.rb
|
|
200
|
+
class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|
201
|
+
test "create sends magic link" do
|
|
202
|
+
identity = identities(:david)
|
|
203
|
+
|
|
204
|
+
assert_enqueued_emails 1 do
|
|
205
|
+
post session_path, params: { email_address: identity.email_address }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
assert_redirected_to new_session_path
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
test "destroy terminates session" do
|
|
212
|
+
sign_in_as identities(:david)
|
|
213
|
+
delete session_path
|
|
214
|
+
|
|
215
|
+
assert_redirected_to root_path
|
|
216
|
+
assert_nil cookies[:session_token]
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Test Helper
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
# test/test_helper.rb
|
|
225
|
+
class ActionDispatch::IntegrationTest
|
|
226
|
+
def sign_in_as(identity)
|
|
227
|
+
session_record = identity.sessions.create!
|
|
228
|
+
cookies.signed[:session_token] = session_record.token
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def sign_out
|
|
232
|
+
cookies.delete(:session_token)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Security
|
|
238
|
+
|
|
239
|
+
- Use signed cookies with `httponly: true` and `same_site: :lax`
|
|
240
|
+
- Magic links expire in 15 minutes
|
|
241
|
+
- Magic links are one-time use
|
|
242
|
+
- Rate limit login attempts
|
|
243
|
+
- Clean up old sessions with a recurring job
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
# app/jobs/session_cleanup_job.rb
|
|
247
|
+
class SessionCleanupJob < ApplicationJob
|
|
248
|
+
def perform
|
|
249
|
+
Session.where("created_at < ?", 30.days.ago).delete_all
|
|
250
|
+
MagicLink.where("expires_at < ?", 1.day.ago).delete_all
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
```
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Session Management Reference
|
|
2
|
+
|
|
3
|
+
## Session Model
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# app/models/session.rb
|
|
7
|
+
class Session < ApplicationRecord
|
|
8
|
+
belongs_to :user
|
|
9
|
+
|
|
10
|
+
before_create :generate_token
|
|
11
|
+
before_create :set_metadata
|
|
12
|
+
|
|
13
|
+
scope :active, -> { where('created_at > ?', 30.days.ago) }
|
|
14
|
+
scope :expired, -> { where('created_at <= ?', 30.days.ago) }
|
|
15
|
+
|
|
16
|
+
def expired?
|
|
17
|
+
created_at <= 30.days.ago
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def generate_token
|
|
23
|
+
self.token = SecureRandom.urlsafe_base64(32)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def set_metadata
|
|
27
|
+
self.ip_address = Current.ip_address
|
|
28
|
+
self.user_agent = Current.user_agent
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Session Table Schema
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# db/migrate/xxx_create_sessions.rb
|
|
37
|
+
class CreateSessions < ActiveRecord::Migration[8.0]
|
|
38
|
+
def change
|
|
39
|
+
create_table :sessions do |t|
|
|
40
|
+
t.references :user, null: false, foreign_key: true
|
|
41
|
+
t.string :token, null: false
|
|
42
|
+
t.string :ip_address
|
|
43
|
+
t.string :user_agent
|
|
44
|
+
t.timestamps
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
add_index :sessions, :token, unique: true
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Cookie Security
|
|
53
|
+
|
|
54
|
+
### Secure Cookie Settings
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
def start_new_session_for(user)
|
|
58
|
+
session = user.sessions.create!
|
|
59
|
+
|
|
60
|
+
cookies.signed.permanent[:session_token] = {
|
|
61
|
+
value: session.token,
|
|
62
|
+
httponly: true, # JavaScript can't access
|
|
63
|
+
secure: Rails.env.production?, # HTTPS only in production
|
|
64
|
+
same_site: :lax # CSRF protection
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
Current.session = session
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Cookie Options
|
|
72
|
+
|
|
73
|
+
| Option | Purpose | Value |
|
|
74
|
+
|--------|---------|-------|
|
|
75
|
+
| `httponly` | Prevent XSS access | `true` |
|
|
76
|
+
| `secure` | HTTPS only | `true` in production |
|
|
77
|
+
| `same_site` | CSRF protection | `:lax` or `:strict` |
|
|
78
|
+
| `expires` | Cookie lifetime | `2.weeks.from_now` |
|
|
79
|
+
| `domain` | Cookie scope | `.example.com` for subdomains |
|
|
80
|
+
|
|
81
|
+
## Session Lifecycle
|
|
82
|
+
|
|
83
|
+
### Starting Session
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
def start_new_session_for(user)
|
|
87
|
+
# Terminate existing sessions if desired
|
|
88
|
+
# user.sessions.destroy_all
|
|
89
|
+
|
|
90
|
+
session = user.sessions.create!
|
|
91
|
+
cookies.signed.permanent[:session_token] = {
|
|
92
|
+
value: session.token,
|
|
93
|
+
httponly: true
|
|
94
|
+
}
|
|
95
|
+
Current.session = session
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Resuming Session
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
def resume_session
|
|
103
|
+
return unless (token = cookies.signed[:session_token])
|
|
104
|
+
return unless (session = Session.find_by_token(token))
|
|
105
|
+
return if session.expired?
|
|
106
|
+
|
|
107
|
+
# Update last seen
|
|
108
|
+
session.touch(:last_seen_at)
|
|
109
|
+
Current.session = session
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Terminating Session
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
def terminate_session
|
|
117
|
+
Current.session&.destroy
|
|
118
|
+
cookies.delete(:session_token)
|
|
119
|
+
reset_session # Clear Rails session too
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Multiple Device Sessions
|
|
124
|
+
|
|
125
|
+
### Viewing Active Sessions
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# app/controllers/sessions_controller.rb
|
|
129
|
+
def index
|
|
130
|
+
@sessions = Current.user.sessions.active.order(created_at: :desc)
|
|
131
|
+
@current_session = Current.session
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```erb
|
|
136
|
+
<%# app/views/sessions/index.html.erb %>
|
|
137
|
+
<h2>Active Sessions</h2>
|
|
138
|
+
|
|
139
|
+
<% @sessions.each do |session| %>
|
|
140
|
+
<div class="session <%= 'current' if session == @current_session %>">
|
|
141
|
+
<p><%= session.ip_address %></p>
|
|
142
|
+
<p><%= session.user_agent %></p>
|
|
143
|
+
<p>Started: <%= time_ago_in_words(session.created_at) %> ago</p>
|
|
144
|
+
|
|
145
|
+
<% unless session == @current_session %>
|
|
146
|
+
<%= button_to "Revoke", session_path(session), method: :delete %>
|
|
147
|
+
<% end %>
|
|
148
|
+
</div>
|
|
149
|
+
<% end %>
|
|
150
|
+
|
|
151
|
+
<%= button_to "Sign out all other devices",
|
|
152
|
+
revoke_all_sessions_path, method: :post %>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Revoking Other Sessions
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# app/controllers/sessions_controller.rb
|
|
159
|
+
def revoke_all
|
|
160
|
+
Current.user.sessions.where.not(id: Current.session.id).destroy_all
|
|
161
|
+
redirect_to sessions_path, notice: "All other sessions terminated"
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Session Cleanup
|
|
166
|
+
|
|
167
|
+
### Scheduled Cleanup Job
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
# app/jobs/cleanup_expired_sessions_job.rb
|
|
171
|
+
class CleanupExpiredSessionsJob < ApplicationJob
|
|
172
|
+
queue_as :low
|
|
173
|
+
|
|
174
|
+
def perform
|
|
175
|
+
Session.expired.delete_all
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# config/recurring.yml
|
|
180
|
+
cleanup_sessions:
|
|
181
|
+
class: CleanupExpiredSessionsJob
|
|
182
|
+
schedule: every day at 3am
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Security Considerations
|
|
186
|
+
|
|
187
|
+
1. **Token Rotation**: Regenerate token after password change
|
|
188
|
+
2. **IP Binding**: Optional - bind session to IP address
|
|
189
|
+
3. **User Agent Tracking**: Detect suspicious changes
|
|
190
|
+
4. **Concurrent Session Limits**: Limit active sessions per user
|
|
191
|
+
5. **Session Timeout**: Expire inactive sessions
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# Rotate token on sensitive actions
|
|
195
|
+
def rotate_session_token
|
|
196
|
+
new_session = Current.user.sessions.create!
|
|
197
|
+
Current.session.destroy
|
|
198
|
+
cookies.signed.permanent[:session_token] = new_session.token
|
|
199
|
+
Current.session = new_session
|
|
200
|
+
end
|
|
201
|
+
```
|