├── .browserslistrc
├── .gitattributes
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .node-version
├── .ruby-version
├── .standard.yml
├── .tool-versions
├── Gemfile
├── Gemfile.lock
├── Procfile.dev
├── README.md
├── Rakefile
├── app
├── assets
│ ├── config
│ │ └── manifest.js
│ ├── images
│ │ └── .keep
│ └── stylesheets
│ │ ├── active_sessions.scss
│ │ ├── application.css
│ │ ├── confirmations.scss
│ │ ├── passwords.scss
│ │ ├── sessions.scss
│ │ ├── static_pages.scss
│ │ └── users.scss
├── channels
│ └── application_cable
│ │ ├── channel.rb
│ │ └── connection.rb
├── controllers
│ ├── active_sessions_controller.rb
│ ├── application_controller.rb
│ ├── concerns
│ │ ├── .keep
│ │ └── authentication.rb
│ ├── confirmations_controller.rb
│ ├── passwords_controller.rb
│ ├── sessions_controller.rb
│ ├── static_pages_controller.rb
│ └── users_controller.rb
├── helpers
│ ├── active_sessions_helper.rb
│ ├── application_helper.rb
│ ├── confirmations_helper.rb
│ ├── passwords_helper.rb
│ ├── sessions_helper.rb
│ ├── static_pages_helper.rb
│ └── users_helper.rb
├── javascript
│ ├── channels
│ │ ├── consumer.js
│ │ └── index.js
│ └── packs
│ │ └── application.js
├── jobs
│ └── application_job.rb
├── mailers
│ ├── application_mailer.rb
│ └── user_mailer.rb
├── models
│ ├── active_session.rb
│ ├── application_record.rb
│ ├── concerns
│ │ └── .keep
│ ├── current.rb
│ └── user.rb
└── views
│ ├── active_sessions
│ └── _active_session.html.erb
│ ├── confirmations
│ └── new.html.erb
│ ├── layouts
│ ├── application.html.erb
│ ├── mailer.html.erb
│ └── mailer.text.erb
│ ├── passwords
│ ├── edit.html.erb
│ └── new.html.erb
│ ├── sessions
│ └── new.html.erb
│ ├── shared
│ └── _form_errors.html.erb
│ ├── static_pages
│ └── home.html.erb
│ ├── user_mailer
│ ├── confirmation.html.erb
│ ├── confirmation.text.erb
│ ├── password_reset.html.erb
│ └── password_reset.text.erb
│ └── users
│ ├── edit.html.erb
│ └── new.html.erb
├── babel.config.js
├── bin
├── bundle
├── dev
├── rails
├── rake
├── setup
├── spring
├── webpack
├── webpack-dev-server
└── yarn
├── config.ru
├── config
├── application.rb
├── boot.rb
├── cable.yml
├── credentials.yml.enc
├── database.yml
├── environment.rb
├── environments
│ ├── development.rb
│ ├── production.rb
│ └── test.rb
├── initializers
│ ├── application_controller_renderer.rb
│ ├── assets.rb
│ ├── backtrace_silencers.rb
│ ├── content_security_policy.rb
│ ├── cookies_serializer.rb
│ ├── filter_parameter_logging.rb
│ ├── inflections.rb
│ ├── mime_types.rb
│ ├── permissions_policy.rb
│ └── wrap_parameters.rb
├── locales
│ └── en.yml
├── puma.rb
├── routes.rb
├── spring.rb
├── storage.yml
├── webpack
│ ├── development.js
│ ├── environment.js
│ ├── production.js
│ └── test.js
└── webpacker.yml
├── db
├── migrate
│ ├── 20211109214151_create_users.rb
│ ├── 20211112152821_add_confirmation_and_password_columns_to_users.rb
│ ├── 20211203155851_add_unconfirmed_email_to_users.rb
│ ├── 20211205165850_add_remember_token_to_users.rb
│ ├── 20220129144819_create_active_sessions.rb
│ ├── 20220201102359_add_request_columns_to_active_sessions.rb
│ └── 20220204201046_move_remember_token_from_users_to_active_sessions.rb
├── schema.rb
└── seeds.rb
├── lib
├── assets
│ └── .keep
└── tasks
│ ├── .keep
│ └── post_setup_instructions.rake
├── log
└── .keep
├── package.json
├── postcss.config.js
├── public
├── 404.html
├── 422.html
├── 500.html
├── apple-touch-icon-precomposed.png
├── apple-touch-icon.png
├── favicon.ico
└── robots.txt
├── storage
└── .keep
├── test
├── application_system_test_case.rb
├── channels
│ └── application_cable
│ │ └── connection_test.rb
├── controllers
│ ├── .keep
│ ├── active_sessions_controller_test.rb
│ ├── confirmations_controller_test.rb
│ ├── passwords_controller_test.rb
│ ├── sessions_controller_test.rb
│ ├── static_pages_controller_test.rb
│ └── users_controller_test.rb
├── fixtures
│ ├── active_sessions.yml
│ ├── files
│ │ └── .keep
│ └── users.yml
├── helpers
│ └── .keep
├── integration
│ ├── .keep
│ ├── friendly_redirects_test.rb
│ └── user_interface_test.rb
├── mailers
│ ├── .keep
│ ├── previews
│ │ └── user_mailer_preview.rb
│ └── user_mailer_test.rb
├── models
│ ├── .keep
│ ├── active_session_test.rb
│ └── user_test.rb
├── system
│ ├── .keep
│ └── logins_test.rb
└── test_helper.rb
├── tmp
├── .keep
└── pids
│ └── .keep
├── vendor
└── .keep
└── yarn.lock
/.browserslistrc:
--------------------------------------------------------------------------------
1 | defaults
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files.
2 |
3 | # Mark the database schema as having been generated.
4 | db/schema.rb linguist-generated
5 |
6 | # Mark the yarn lockfile as having been generated.
7 | yarn.lock linguist-generated
8 |
9 | # Mark any vendored files as having been vendored.
10 | vendor/* linguist-vendored
11 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: [push, pull_request]
3 |
4 | jobs:
5 | ci:
6 | runs-on: ubuntu-latest
7 | steps:
8 | - uses: actions/checkout@v1
9 | - name: Set up Ruby
10 | uses: ruby/setup-ruby@v1
11 | with:
12 | # runs 'bundle install' and caches installed gems automatically
13 | bundler-cache: true
14 | - name: Set up Node
15 | uses: actions/setup-node@v2
16 | with:
17 | node-version-file: '.node-version'
18 | - run: yarn install --frozen-lockfile
19 | - name: Run build
20 | run: bundle exec rails db:prepare
21 | - run: bundle exec rails assets:precompile
22 | - name: Run tests
23 | run: bundle exec rails test
24 | - name: Run linters
25 | run: bundle exec standardrb
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files.
2 | #
3 | # If you find yourself ignoring temporary files generated by your text editor
4 | # or operating system, you probably want to add a global ignore instead:
5 | # git config --global core.excludesfile '~/.gitignore_global'
6 |
7 | # Ignore bundler config.
8 | /.bundle
9 |
10 | # Ignore the default SQLite database.
11 | /db/*.sqlite3
12 | /db/*.sqlite3-*
13 |
14 | # Ignore all logfiles and tempfiles.
15 | /log/*
16 | /tmp/*
17 | !/log/.keep
18 | !/tmp/.keep
19 |
20 | # Ignore pidfiles, but keep the directory.
21 | /tmp/pids/*
22 | !/tmp/pids/
23 | !/tmp/pids/.keep
24 |
25 | # Ignore uploaded files in development.
26 | /storage/*
27 | !/storage/.keep
28 |
29 | /public/assets
30 | .byebug_history
31 |
32 | # Ignore master key for decrypting credentials and more.
33 | /config/master.key
34 |
35 | /public/packs
36 | /public/packs-test
37 | /node_modules
38 | /yarn-error.log
39 | yarn-debug.log*
40 | .yarn-integrity
41 |
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 16
--------------------------------------------------------------------------------
/.ruby-version:
--------------------------------------------------------------------------------
1 | 3.0.3
2 |
--------------------------------------------------------------------------------
/.standard.yml:
--------------------------------------------------------------------------------
1 | ignore:
2 | - 'db/schema.rb'
3 | - 'config/environments/production.rb'
4 | - 'config/puma.rb'
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | ruby 3.0.3
2 | nodejs 16.7.0
3 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" }
3 |
4 | ruby "3.0.3"
5 |
6 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails', branch: 'main'
7 | gem "rails", "~> 6.1.4", ">= 6.1.4.1"
8 | # Use sqlite3 as the database for Active Record
9 | gem "sqlite3", "~> 1.4"
10 | # Use Puma as the app server
11 | gem "puma", "~> 5.0"
12 | # Use SCSS for stylesheets
13 | gem "sass-rails", ">= 6"
14 | # Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker
15 | gem "webpacker", "~> 5.0"
16 | # Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
17 | gem "turbolinks", "~> 5"
18 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
19 | gem "jbuilder", "~> 2.7"
20 | # Use Redis adapter to run Action Cable in production
21 | # gem 'redis', '~> 4.0'
22 | # Use Active Model has_secure_password
23 | gem "bcrypt", "~> 3.1.7"
24 |
25 | # Use Active Storage variant
26 | # gem 'image_processing', '~> 1.2'
27 |
28 | # Reduces boot times through caching; required in config/boot.rb
29 | gem "bootsnap", ">= 1.4.4", require: false
30 |
31 | group :development, :test do
32 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console
33 | gem "byebug", platforms: [:mri, :mingw, :x64_mingw]
34 | gem "standard", "~> 1.3"
35 | end
36 |
37 | group :development do
38 | # Access an interactive console on exception pages or by calling 'console' anywhere in the code.
39 | gem "web-console", ">= 4.1.0"
40 | # Display performance information such as SQL time and flame graphs for each request in your browser.
41 | # Can be configured to work on production as well see: https://github.com/MiniProfiler/rack-mini-profiler/blob/master/README.md
42 | gem "rack-mini-profiler", "~> 2.0"
43 | gem "listen", "~> 3.3"
44 | # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
45 | gem "spring"
46 | end
47 |
48 | group :test do
49 | # Adds support for Capybara system testing and selenium driver
50 | gem "capybara", ">= 3.26"
51 | gem "selenium-webdriver"
52 | # Easy installation and use of web drivers to run system tests with browsers
53 | gem "webdrivers"
54 | end
55 |
56 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
57 | gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby]
58 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | actioncable (6.1.4.1)
5 | actionpack (= 6.1.4.1)
6 | activesupport (= 6.1.4.1)
7 | nio4r (~> 2.0)
8 | websocket-driver (>= 0.6.1)
9 | actionmailbox (6.1.4.1)
10 | actionpack (= 6.1.4.1)
11 | activejob (= 6.1.4.1)
12 | activerecord (= 6.1.4.1)
13 | activestorage (= 6.1.4.1)
14 | activesupport (= 6.1.4.1)
15 | mail (>= 2.7.1)
16 | actionmailer (6.1.4.1)
17 | actionpack (= 6.1.4.1)
18 | actionview (= 6.1.4.1)
19 | activejob (= 6.1.4.1)
20 | activesupport (= 6.1.4.1)
21 | mail (~> 2.5, >= 2.5.4)
22 | rails-dom-testing (~> 2.0)
23 | actionpack (6.1.4.1)
24 | actionview (= 6.1.4.1)
25 | activesupport (= 6.1.4.1)
26 | rack (~> 2.0, >= 2.0.9)
27 | rack-test (>= 0.6.3)
28 | rails-dom-testing (~> 2.0)
29 | rails-html-sanitizer (~> 1.0, >= 1.2.0)
30 | actiontext (6.1.4.1)
31 | actionpack (= 6.1.4.1)
32 | activerecord (= 6.1.4.1)
33 | activestorage (= 6.1.4.1)
34 | activesupport (= 6.1.4.1)
35 | nokogiri (>= 1.8.5)
36 | actionview (6.1.4.1)
37 | activesupport (= 6.1.4.1)
38 | builder (~> 3.1)
39 | erubi (~> 1.4)
40 | rails-dom-testing (~> 2.0)
41 | rails-html-sanitizer (~> 1.1, >= 1.2.0)
42 | activejob (6.1.4.1)
43 | activesupport (= 6.1.4.1)
44 | globalid (>= 0.3.6)
45 | activemodel (6.1.4.1)
46 | activesupport (= 6.1.4.1)
47 | activerecord (6.1.4.1)
48 | activemodel (= 6.1.4.1)
49 | activesupport (= 6.1.4.1)
50 | activestorage (6.1.4.1)
51 | actionpack (= 6.1.4.1)
52 | activejob (= 6.1.4.1)
53 | activerecord (= 6.1.4.1)
54 | activesupport (= 6.1.4.1)
55 | marcel (~> 1.0.0)
56 | mini_mime (>= 1.1.0)
57 | activesupport (6.1.4.1)
58 | concurrent-ruby (~> 1.0, >= 1.0.2)
59 | i18n (>= 1.6, < 2)
60 | minitest (>= 5.1)
61 | tzinfo (~> 2.0)
62 | zeitwerk (~> 2.3)
63 | addressable (2.8.0)
64 | public_suffix (>= 2.0.2, < 5.0)
65 | ast (2.4.2)
66 | bcrypt (3.1.16)
67 | bindex (0.8.1)
68 | bootsnap (1.9.3)
69 | msgpack (~> 1.0)
70 | builder (3.2.4)
71 | byebug (11.1.3)
72 | capybara (3.36.0)
73 | addressable
74 | matrix
75 | mini_mime (>= 0.1.3)
76 | nokogiri (~> 1.8)
77 | rack (>= 1.6.0)
78 | rack-test (>= 0.6.3)
79 | regexp_parser (>= 1.5, < 3.0)
80 | xpath (~> 3.2)
81 | childprocess (4.1.0)
82 | concurrent-ruby (1.1.9)
83 | crass (1.0.6)
84 | erubi (1.10.0)
85 | ffi (1.15.4)
86 | globalid (1.0.0)
87 | activesupport (>= 5.0)
88 | i18n (1.8.11)
89 | concurrent-ruby (~> 1.0)
90 | jbuilder (2.11.3)
91 | activesupport (>= 5.0.0)
92 | listen (3.7.0)
93 | rb-fsevent (~> 0.10, >= 0.10.3)
94 | rb-inotify (~> 0.9, >= 0.9.10)
95 | loofah (2.13.0)
96 | crass (~> 1.0.2)
97 | nokogiri (>= 1.5.9)
98 | mail (2.7.1)
99 | mini_mime (>= 0.1.1)
100 | marcel (1.0.2)
101 | matrix (0.4.2)
102 | method_source (1.0.0)
103 | mini_mime (1.1.2)
104 | minitest (5.14.4)
105 | msgpack (1.4.2)
106 | nio4r (2.5.8)
107 | nokogiri (1.12.5-arm64-darwin)
108 | racc (~> 1.4)
109 | nokogiri (1.12.5-x86_64-linux)
110 | racc (~> 1.4)
111 | parallel (1.21.0)
112 | parser (3.0.3.2)
113 | ast (~> 2.4.1)
114 | public_suffix (4.0.6)
115 | puma (5.5.2)
116 | nio4r (~> 2.0)
117 | racc (1.6.0)
118 | rack (2.2.3)
119 | rack-mini-profiler (2.3.3)
120 | rack (>= 1.2.0)
121 | rack-proxy (0.7.0)
122 | rack
123 | rack-test (1.1.0)
124 | rack (>= 1.0, < 3)
125 | rails (6.1.4.1)
126 | actioncable (= 6.1.4.1)
127 | actionmailbox (= 6.1.4.1)
128 | actionmailer (= 6.1.4.1)
129 | actionpack (= 6.1.4.1)
130 | actiontext (= 6.1.4.1)
131 | actionview (= 6.1.4.1)
132 | activejob (= 6.1.4.1)
133 | activemodel (= 6.1.4.1)
134 | activerecord (= 6.1.4.1)
135 | activestorage (= 6.1.4.1)
136 | activesupport (= 6.1.4.1)
137 | bundler (>= 1.15.0)
138 | railties (= 6.1.4.1)
139 | sprockets-rails (>= 2.0.0)
140 | rails-dom-testing (2.0.3)
141 | activesupport (>= 4.2.0)
142 | nokogiri (>= 1.6)
143 | rails-html-sanitizer (1.4.2)
144 | loofah (~> 2.3)
145 | railties (6.1.4.1)
146 | actionpack (= 6.1.4.1)
147 | activesupport (= 6.1.4.1)
148 | method_source
149 | rake (>= 0.13)
150 | thor (~> 1.0)
151 | rainbow (3.0.0)
152 | rake (13.0.6)
153 | rb-fsevent (0.11.0)
154 | rb-inotify (0.10.1)
155 | ffi (~> 1.0)
156 | regexp_parser (2.2.0)
157 | rexml (3.2.5)
158 | rubocop (1.23.0)
159 | parallel (~> 1.10)
160 | parser (>= 3.0.0.0)
161 | rainbow (>= 2.2.2, < 4.0)
162 | regexp_parser (>= 1.8, < 3.0)
163 | rexml
164 | rubocop-ast (>= 1.12.0, < 2.0)
165 | ruby-progressbar (~> 1.7)
166 | unicode-display_width (>= 1.4.0, < 3.0)
167 | rubocop-ast (1.14.0)
168 | parser (>= 3.0.1.1)
169 | rubocop-performance (1.12.0)
170 | rubocop (>= 1.7.0, < 2.0)
171 | rubocop-ast (>= 0.4.0)
172 | ruby-progressbar (1.11.0)
173 | rubyzip (2.3.2)
174 | sass-rails (6.0.0)
175 | sassc-rails (~> 2.1, >= 2.1.1)
176 | sassc (2.4.0)
177 | ffi (~> 1.9)
178 | sassc-rails (2.1.2)
179 | railties (>= 4.0.0)
180 | sassc (>= 2.0)
181 | sprockets (> 3.0)
182 | sprockets-rails
183 | tilt
184 | selenium-webdriver (4.1.0)
185 | childprocess (>= 0.5, < 5.0)
186 | rexml (~> 3.2, >= 3.2.5)
187 | rubyzip (>= 1.2.2)
188 | semantic_range (3.0.0)
189 | spring (4.0.0)
190 | sprockets (4.0.2)
191 | concurrent-ruby (~> 1.0)
192 | rack (> 1, < 3)
193 | sprockets-rails (3.4.2)
194 | actionpack (>= 5.2)
195 | activesupport (>= 5.2)
196 | sprockets (>= 3.0.0)
197 | sqlite3 (1.4.2)
198 | standard (1.5.0)
199 | rubocop (= 1.23.0)
200 | rubocop-performance (= 1.12.0)
201 | thor (1.1.0)
202 | tilt (2.0.10)
203 | turbolinks (5.2.1)
204 | turbolinks-source (~> 5.2)
205 | turbolinks-source (5.2.0)
206 | tzinfo (2.0.4)
207 | concurrent-ruby (~> 1.0)
208 | unicode-display_width (2.1.0)
209 | web-console (4.2.0)
210 | actionview (>= 6.0.0)
211 | activemodel (>= 6.0.0)
212 | bindex (>= 0.4.0)
213 | railties (>= 6.0.0)
214 | webdrivers (5.0.0)
215 | nokogiri (~> 1.6)
216 | rubyzip (>= 1.3.0)
217 | selenium-webdriver (~> 4.0)
218 | webpacker (5.4.3)
219 | activesupport (>= 5.2)
220 | rack-proxy (>= 0.6.1)
221 | railties (>= 5.2)
222 | semantic_range (>= 2.3.0)
223 | websocket-driver (0.7.5)
224 | websocket-extensions (>= 0.1.0)
225 | websocket-extensions (0.1.5)
226 | xpath (3.2.0)
227 | nokogiri (~> 1.8)
228 | zeitwerk (2.5.1)
229 |
230 | PLATFORMS
231 | arm64-darwin-21
232 | x86_64-linux
233 |
234 | DEPENDENCIES
235 | bcrypt (~> 3.1.7)
236 | bootsnap (>= 1.4.4)
237 | byebug
238 | capybara (>= 3.26)
239 | jbuilder (~> 2.7)
240 | listen (~> 3.3)
241 | puma (~> 5.0)
242 | rack-mini-profiler (~> 2.0)
243 | rails (~> 6.1.4, >= 6.1.4.1)
244 | sass-rails (>= 6)
245 | selenium-webdriver
246 | spring
247 | sqlite3 (~> 1.4)
248 | standard (~> 1.3)
249 | turbolinks (~> 5)
250 | tzinfo-data
251 | web-console (>= 4.1.0)
252 | webdrivers
253 | webpacker (~> 5.0)
254 |
255 | RUBY VERSION
256 | ruby 3.0.3p157
257 |
258 | BUNDLED WITH
259 | 2.2.32
260 |
--------------------------------------------------------------------------------
/Procfile.dev:
--------------------------------------------------------------------------------
1 | web: bin/rails server -p 3000
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Rails Authentication from Scratch
2 |
3 | If you're like me then you probably take Devise for granted because you're too intimidated to roll your own authentication system. As powerful as Devise is, it's not perfect. There are plenty of cases where I've reached for it only to end up constrained by its features and design, and wished I could customize it exactly to my liking.
4 |
5 | Fortunately, Rails gives you all the tools you need to roll your own authentication system from scratch without needing to depend on a gem. The challenge is just knowing how to account for edge cases while being cognizant of security and best practices.
6 |
7 | ## Previous Versions
8 |
9 | This guide is continuously updated to account for best practices. You can [view previous releases here](https://github.com/stevepolitodesign/rails-authentication-from-scratch/releases).
10 |
11 | ## Local Development
12 |
13 | Simply run the setup script and follow the prompts to see the final application.
14 |
15 | ```bash
16 | ./bin/setup
17 | ```
18 |
19 | ## Step 1: Build User Model
20 |
21 | 1. Generate User model.
22 |
23 | ```bash
24 | rails g model User email:string
25 | ```
26 |
27 | ```ruby
28 | # db/migrate/[timestamp]_create_users.rb
29 | class CreateUsers < ActiveRecord::Migration[6.1]
30 | def change
31 | create_table :users do |t|
32 | t.string :email, null: false
33 |
34 | t.timestamps
35 | end
36 |
37 | add_index :users, :email, unique: true
38 | end
39 | end
40 | ```
41 |
42 | 2. Run migrations.
43 |
44 | ```bash
45 | rails db:migrate
46 | ```
47 |
48 | 3. Add validations and callbacks.
49 |
50 | ```ruby
51 | # app/models/user.rb
52 | class User < ApplicationRecord
53 | before_save :downcase_email
54 |
55 | validates :email, format: {with: URI::MailTo::EMAIL_REGEXP}, presence: true, uniqueness: true
56 |
57 | private
58 |
59 | def downcase_email
60 | self.email = email.downcase
61 | end
62 | end
63 | ```
64 |
65 | > **What's Going On Here?**
66 | >
67 | > - We prevent empty values from being saved into the email column through a `null: false` constraint in addition to the [presence](https://guides.rubyonrails.org/active_record_validations.html#presence) validation.
68 | > - We enforce unique email addresses at the database level through `add_index :users, :email, unique: true` in addition to a [uniqueness](https://guides.rubyonrails.org/active_record_validations.html#uniqueness) validation.
69 | > - We ensure all emails are valid through a [format](https://guides.rubyonrails.org/active_record_validations.html#format) validation.
70 | > - We save all emails to the database in a downcase format via a [before_save](https://api.rubyonrails.org/v6.1.4/classes/ActiveRecord/Callbacks/ClassMethods.html#method-i-before_save) callback such that the values are saved in a consistent format.
71 | > - We use [URI::MailTo::EMAIL_REGEXP](https://ruby-doc.org/stdlib-3.0.0/libdoc/uri/rdoc/URI/MailTo.html) that comes with Ruby to validate that the email address is properly formatted.
72 |
73 | ## Step 2: Add Confirmation and Password Columns to Users Table
74 |
75 | 1. Create migration.
76 |
77 | ```bash
78 | rails g migration add_confirmation_and_password_columns_to_users confirmed_at:datetime password_digest:string
79 | ```
80 |
81 | 2. Update the migration.
82 |
83 | ```ruby
84 | # db/migrate/[timestamp]_add_confirmation_and_password_columns_to_users.rb
85 | class AddConfirmationAndPasswordColumnsToUsers < ActiveRecord::Migration[6.1]
86 | def change
87 | add_column :users, :confirmed_at, :datetime
88 | add_column :users, :password_digest, :string, null: false
89 | end
90 | end
91 | ```
92 |
93 | > **What's Going On Here?**
94 | >
95 | > - The `confirmed_at` column will be set when a user confirms their account. This will help us determine who has confirmed their account and who has not.
96 | > - The `password_digest` column will store a hashed version of the user's password. This is provided by the [has_secure_password](https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html#method-i-has_secure_password) method.
97 |
98 | 3. Run migrations.
99 |
100 | ```bash
101 | rails db:migrate
102 | ```
103 |
104 | 4. Enable and install BCrypt.
105 |
106 | This is needed to use `has_secure_password`.
107 |
108 | ```ruby
109 | # Gemfile
110 | gem 'bcrypt', '~> 3.1.7'
111 | ```
112 |
113 | ```
114 | bundle install
115 | ```
116 |
117 | 5. Update the User Model.
118 |
119 | ```ruby
120 | # app/models/user.rb
121 | class User < ApplicationRecord
122 | CONFIRMATION_TOKEN_EXPIRATION = 10.minutes
123 |
124 | has_secure_password
125 |
126 | before_save :downcase_email
127 |
128 | validates :email, format: {with: URI::MailTo::EMAIL_REGEXP}, presence: true, uniqueness: true
129 |
130 | def confirm!
131 | update_columns(confirmed_at: Time.current)
132 | end
133 |
134 | def confirmed?
135 | confirmed_at.present?
136 | end
137 |
138 | def generate_confirmation_token
139 | signed_id expires_in: CONFIRMATION_TOKEN_EXPIRATION, purpose: :confirm_email
140 | end
141 |
142 | def unconfirmed?
143 | !confirmed?
144 | end
145 |
146 | private
147 |
148 | def downcase_email
149 | self.email = email.downcase
150 | end
151 | end
152 | ```
153 |
154 | > **What's Going On Here?**
155 | >
156 | > - The `has_secure_password` method is added to give us an [API](https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html#method-i-has_secure_password) to work with the `password_digest` column.
157 | > - The `confirm!` method will be called when a user confirms their email address. We still need to build this feature.
158 | > - The `confirmed?` and `unconfirmed?` methods allow us to tell whether a user has confirmed their email address or not.
159 | > - The `generate_confirmation_token` method creates a [signed_id](https://api.rubyonrails.org/classes/ActiveRecord/SignedId.html#method-i-signed_id) that will be used to securely identify the user. For added security, we ensure that this ID will expire in 10 minutes (this can be controlled with the `CONFIRMATION_TOKEN_EXPIRATION` constant) and give it an explicit purpose of `:confirm_email`. This will be useful when we build the confirmation mailer.
160 |
161 | ## Step 3: Create Sign Up Pages
162 |
163 | 1. Create a simple home page since we'll need a place to redirect users to after they sign up.
164 |
165 | ```
166 | rails g controller StaticPages home
167 | ```
168 |
169 | 2. Create Users Controller.
170 |
171 | ```
172 | rails g controller Users
173 | ```
174 |
175 | ```ruby
176 | # app/controllers/users_controller.rb
177 | class UsersController < ApplicationController
178 |
179 | def create
180 | @user = User.new(user_params)
181 | if @user.save
182 | redirect_to root_path, notice: "Please check your email for confirmation instructions."
183 | else
184 | render :new, status: :unprocessable_entity
185 | end
186 | end
187 |
188 | def new
189 | @user = User.new
190 | end
191 |
192 | private
193 |
194 | def user_params
195 | params.require(:user).permit(:email, :password, :password_confirmation)
196 | end
197 | end
198 | ```
199 |
200 | 3. Build sign-up form.
201 |
202 | ```html+ruby
203 |
204 | <% if object.errors.any? %>
205 |
206 | <% object.errors.full_messages.each do |message| %>
207 | - <%= message %>
208 | <% end %>
209 |
210 | <% end %>
211 | ```
212 |
213 | ```html+ruby
214 |
215 | <%= form_with model: @user, url: sign_up_path do |form| %>
216 | <%= render partial: "shared/form_errors", locals: { object: form.object } %>
217 |
218 | <%= form.label :email %>
219 | <%= form.email_field :email, required: true %>
220 |
221 |
222 | <%= form.label :password %>
223 | <%= form.password_field :password, required: true %>
224 |
225 |
226 | <%= form.label :password_confirmation %>
227 | <%= form.password_field :password_confirmation, required: true %>
228 |
229 | <%= form.submit "Sign Up" %>
230 | <% end %>
231 | ```
232 |
233 | 4. Update routes.
234 |
235 | ```ruby
236 | # config/routes.rb
237 | Rails.application.routes.draw do
238 | root "static_pages#home"
239 | post "sign_up", to: "users#create"
240 | get "sign_up", to: "users#new"
241 | end
242 | ```
243 | ## Step 4: Create Confirmation Pages
244 |
245 | Users now have a way to sign up, but we need to verify their email address to prevent SPAM.
246 |
247 | 1. Create Confirmations Controller.
248 |
249 | ```
250 | rails g controller Confirmations
251 | ```
252 |
253 | ```ruby
254 | # app/controllers/confirmations_controller.rb
255 | class ConfirmationsController < ApplicationController
256 |
257 | def create
258 | @user = User.find_by(email: params[:user][:email].downcase)
259 |
260 | if @user.present? && @user.unconfirmed?
261 | redirect_to root_path, notice: "Check your email for confirmation instructions."
262 | else
263 | redirect_to new_confirmation_path, alert: "We could not find a user with that email or that email has already been confirmed."
264 | end
265 | end
266 |
267 | def edit
268 | @user = User.find_signed(params[:confirmation_token], purpose: :confirm_email)
269 |
270 | if @user.present?
271 | @user.confirm!
272 | redirect_to root_path, notice: "Your account has been confirmed."
273 | else
274 | redirect_to new_confirmation_path, alert: "Invalid token."
275 | end
276 | end
277 |
278 | def new
279 | @user = User.new
280 | end
281 |
282 | end
283 | ```
284 |
285 | 2. Build confirmation pages.
286 |
287 | This page will be used in the case where a user did not receive their confirmation instructions and needs to have them resent.
288 |
289 | ```html+ruby
290 |
291 | <%= form_with model: @user, url: confirmations_path do |form| %>
292 | <%= form.email_field :email, required: true %>
293 | <%= form.submit "Confirm Email" %>
294 | <% end %>
295 | ```
296 |
297 | 3. Update routes.
298 |
299 | ```ruby
300 | # config/routes.rb
301 | Rails.application.routes.draw do
302 | ...
303 | resources :confirmations, only: [:create, :edit, :new], param: :confirmation_token
304 | end
305 | ```
306 |
307 | > **What's Going On Here?**
308 | >
309 | > - The `create` action will be used to resend confirmation instructions to an unconfirmed user. We still need to build this mailer, and we still need to send this mailer when a user initially signs up. This action will be requested via the form on `app/views/confirmations/new.html.erb`. Note that we call `downcase` on the email to account for case sensitivity when searching.
310 | > - The `edit` action is used to confirm a user's email. This will be the page that a user lands on when they click the confirmation link in their email. We still need to build this. Note that we're looking up a user through the [find_signed](https://api.rubyonrails.org/classes/ActiveRecord/SignedId/ClassMethods.html#method-i-find_signed) method and not their email or ID. This is because The `confirmation_token` is randomly generated and can't be guessed or tampered with unlike an email or numeric ID. This is also why we added `param: :confirmation_token` as a [named route parameter](https://guides.rubyonrails.org/routing.html#overriding-named-route-parameters).
311 | > - You'll remember that the `confirmation_token` is a [signed_id](https://api.rubyonrails.org/classes/ActiveRecord/SignedId.html#method-i-signed_id), and is set to expire in 10 minutes. You'll also note that we need to pass the method `purpose: :confirm_email` to be consistent with the purpose that was set in the `generate_confirmation_token` method.
312 |
313 | ## Step 5: Create Confirmation Mailer
314 |
315 | Now we need a way to send a confirmation email to our users for them to actually confirm their accounts.
316 |
317 | 1. Create a confirmation mailer.
318 |
319 | ```bash
320 | rails g mailer User confirmation
321 | ```
322 |
323 | ```ruby
324 | # app/mailers/user_mailer.rb
325 | class UserMailer < ApplicationMailer
326 | default from: User::MAILER_FROM_EMAIL
327 |
328 | def confirmation(user, confirmation_token)
329 | @user = user
330 | @confirmation_token = confirmation_token
331 |
332 | mail to: @user.email, subject: "Confirmation Instructions"
333 | end
334 | end
335 | ```
336 |
337 | ```html+erb
338 |
339 | Confirmation Instructions
340 |
341 | <%= link_to "Click here to confirm your email.", edit_confirmation_url(@confirmation_token) %>
342 | ```
343 |
344 | ```html+erb
345 |
346 | Confirmation Instructions
347 |
348 | <%= edit_confirmation_url(@confirmation_token) %>
349 | ```
350 |
351 | 2. Update User Model.
352 |
353 | ```ruby
354 | # app/models/user.rb
355 | class User < ApplicationRecord
356 | ...
357 | MAILER_FROM_EMAIL = "no-reply@example.com"
358 | ...
359 | def send_confirmation_email!
360 | confirmation_token = generate_confirmation_token
361 | UserMailer.confirmation(self, confirmation_token).deliver_now
362 | end
363 |
364 | end
365 | ```
366 |
367 | > **What's Going On Here?**
368 | >
369 | > - The `MAILER_FROM_EMAIL` constant is a way for us to set the email used in the `UserMailer`. This is optional.
370 | > - The `send_confirmation_email!` method will create a new `confirmation_token`. This is to ensure confirmation links expire and cannot be reused. It will also send the confirmation email to the user.
371 | > - We call [update_columns](https://api.rubyonrails.org/classes/ActiveRecord/Persistence.html#method-i-update_columns) so that the `updated_at/updated_on` columns are not updated. This is personal preference, but those columns should typically only be updated when the user updates their email or password.
372 | > - The links in the mailer will take the user to `ConfirmationsController#edit` at which point they'll be confirmed.
373 |
374 | 3. Configure Action Mailer so that links work locally.
375 |
376 | Add a host to the test and development (and later the production) environments so that [urls will work in mailers](https://guides.rubyonrails.org/action_mailer_basics.html#generating-urls-in-action-mailer-views).
377 |
378 | ```ruby
379 | # config/environments/test.rb
380 | Rails.application.configure do
381 | ...
382 | config.action_mailer.default_url_options = { host: "example.com" }
383 | end
384 | ```
385 |
386 | ```ruby
387 | # config/environments/development.rb
388 | Rails.application.configure do
389 | ...
390 | config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
391 | end
392 | ```
393 |
394 | 4. Update Controllers.
395 |
396 | Now we can send a confirmation email when a user signs up or if they need to have it resent.
397 |
398 | ```ruby
399 | # app/controllers/confirmations_controller.rb
400 | class ConfirmationsController < ApplicationController
401 |
402 | def create
403 | @user = User.find_by(email: params[:user][:email].downcase)
404 |
405 | if @user.present? && @user.unconfirmed?
406 | @user.send_confirmation_email!
407 | ...
408 | end
409 | end
410 |
411 | end
412 | ```
413 |
414 | ```ruby
415 | # app/controllers/users_controller.rb
416 | class UsersController < ApplicationController
417 |
418 | def create
419 | @user = User.new(user_params)
420 | if @user.save
421 | @user.send_confirmation_email!
422 | ...
423 | end
424 | end
425 |
426 | end
427 | ```
428 |
429 | ## Step 6: Create Current Model and Authentication Concern
430 |
431 | 1. Create a model to store the current user.
432 |
433 | ```ruby
434 | # app/models/current.rb
435 | class Current < ActiveSupport::CurrentAttributes
436 | attribute :user
437 | end
438 | ```
439 |
440 | 2. Create a Concern to store helper methods that will be shared across the application.
441 |
442 | ```ruby
443 | # app/controllers/concerns/authentication.rb
444 | module Authentication
445 | extend ActiveSupport::Concern
446 |
447 | included do
448 | before_action :current_user
449 | helper_method :current_user
450 | helper_method :user_signed_in?
451 | end
452 |
453 | def login(user)
454 | reset_session
455 | session[:current_user_id] = user.id
456 | end
457 |
458 | def logout
459 | reset_session
460 | end
461 |
462 | def redirect_if_authenticated
463 | redirect_to root_path, alert: "You are already logged in." if user_signed_in?
464 | end
465 |
466 | private
467 |
468 | def current_user
469 | Current.user ||= session[:current_user_id] && User.find_by(id: session[:current_user_id])
470 | end
471 |
472 | def user_signed_in?
473 | Current.user.present?
474 | end
475 |
476 | end
477 | ```
478 |
479 | 3. Load the Authentication Concern into the Application Controller.
480 |
481 | ```ruby
482 | # app/controllers/application_controller.rb
483 | class ApplicationController < ActionController::Base
484 | include Authentication
485 | end
486 | ```
487 |
488 | > **What's Going On Here?**
489 | >
490 | > - The `Current` class inherits from [ActiveSupport::CurrentAttributes](https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html) which allows us to keep all per-request attributes easily available to the whole system. In essence, this will allow us to set a current user and have access to that user during each request to the server.
491 | > - The `Authentication` Concern provides an interface for logging the user in and out. We load it into the `ApplicationController` so that it will be used across the whole application.
492 | > - The `login` method first [resets the session](https://api.rubyonrails.org/classes/ActionController/Metal.html#method-i-reset_session) to account for [session fixation](https://guides.rubyonrails.org/security.html#session-fixation-countermeasures).
493 | > - We set the user's ID in the [session](https://guides.rubyonrails.org/action_controller_overview.html#session) so that we can have access to the user across requests. The user's ID won't be stored in plain text. The cookie data is cryptographically signed to make it tamper-proof. And it is also encrypted so anyone with access to it can't read its contents.
494 | > - The `logout` method simply [resets the session](https://api.rubyonrails.org/classes/ActionController/Metal.html#method-i-reset_session).
495 | > - The `redirect_if_authenticated` method checks to see if the user is logged in. If they are, they'll be redirected to the `root_path`. This will be useful on pages an authenticated user should not be able to access, such as the login page.
496 | > - The `current_user` method returns a `User` and sets it as the user on the `Current` class we created. We use [memoization](https://www.honeybadger.io/blog/ruby-rails-memoization/) to avoid fetching the User each time we call the method. We call the `before_action` [filter](https://guides.rubyonrails.org/action_controller_overview.html#filters) so that we have access to the current user before each request. We also add this as a [helper_method](https://api.rubyonrails.org/classes/AbstractController/Helpers/ClassMethods.html#method-i-helper_method) so that we have access to `current_user` in the views.
497 | > - The `user_signed_in?` method simply returns true or false depending on whether the user is signed in or not. This is helpful for conditionally rendering items in views.
498 |
499 | ## Step 7: Create Login Page
500 |
501 | 1. Generate Sessions Controller.
502 |
503 | ```bash
504 | rails g controller Sessions
505 | ```
506 |
507 | ```ruby
508 | # app/controllers/sessions_controller.rb
509 | class SessionsController < ApplicationController
510 | before_action :redirect_if_authenticated, only: [:create, :new]
511 |
512 | def create
513 | @user = User.find_by(email: params[:user][:email].downcase)
514 | if @user
515 | if @user.unconfirmed?
516 | redirect_to new_confirmation_path, alert: "Please confirm your email first."
517 | elsif @user.authenticate(params[:user][:password])
518 | login @user
519 | redirect_to root_path, notice: "Signed in."
520 | else
521 | flash.now[:alert] = "Incorrect email or password."
522 | render :new, status: :unprocessable_entity
523 | end
524 | else
525 | flash.now[:alert] = "Incorrect email or password."
526 | render :new, status: :unprocessable_entity
527 | end
528 | end
529 |
530 | def destroy
531 | logout
532 | redirect_to root_path, notice: "Signed out."
533 | end
534 |
535 | def new
536 | end
537 |
538 | end
539 | ```
540 |
541 | 2. Update routes.
542 |
543 | ```ruby
544 | # config/routes.rb
545 | Rails.application.routes.draw do
546 | ...
547 | post "login", to: "sessions#create"
548 | delete "logout", to: "sessions#destroy"
549 | get "login", to: "sessions#new"
550 | end
551 | ```
552 |
553 | 3. Add sign-in form.
554 |
555 | ```html+ruby
556 |
557 | <%= form_with url: login_path, scope: :user do |form| %>
558 |
559 | <%= form.label :email %>
560 | <%= form.email_field :email, required: true %>
561 |
562 |
563 | <%= form.label :password %>
564 | <%= form.password_field :password, required: true %>
565 |
566 | <%= form.submit "Sign In" %>
567 | <% end %>
568 | ```
569 |
570 | > **What's Going On Here?**
571 | >
572 | > - The `create` method simply checks if the user exists and is confirmed. If they are, then we check their password. If the password is correct, we log them in via the `login` method we created in the `Authentication` Concern. Otherwise, we render an alert.
573 | > - We're able to call `user.authenticate` because of [has_secure_password](https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html#method-i-has_secure_password)
574 | > - Note that we call `downcase` on the email to account for case sensitivity when searching.
575 | > - Note that we set the flash to "Incorrect email or password." if the user is unconfirmed. This prevents leaking email addresses.
576 | > - The `destroy` method simply calls the `logout` method we created in the `Authentication` Concern.
577 | > - The login form is passed a `scope: :user` option so that the params are namespaced as `params[:user][:some_value]`. This is not required, but it helps keep things organized.
578 |
579 | ## Step 8: Update Existing Controllers
580 |
581 | 1. Update Controllers to prevent authenticated users from accessing pages intended for anonymous users.
582 |
583 | ```ruby
584 | # app/controllers/confirmations_controller.rb
585 | class ConfirmationsController < ApplicationController
586 | before_action :redirect_if_authenticated, only: [:create, :new]
587 |
588 | def edit
589 | ...
590 | if @user.present?
591 | @user.confirm!
592 | login @user
593 | ...
594 | else
595 | end
596 | ...
597 | end
598 | end
599 | ```
600 |
601 | Note that we also call `login @user` once a user is confirmed. That way they'll be automatically logged in after confirming their email.
602 |
603 | ```ruby
604 | # app/controllers/users_controller.rb
605 | class UsersController < ApplicationController
606 | before_action :redirect_if_authenticated, only: [:create, :new]
607 | ...
608 | end
609 | ```
610 |
611 | ## Step 9: Add Password Reset Functionality
612 |
613 | 1. Update User Model.
614 |
615 | ```ruby
616 | # app/models/user.rb
617 | class User < ApplicationRecord
618 | ...
619 | PASSWORD_RESET_TOKEN_EXPIRATION = 10.minutes
620 | ...
621 | def generate_password_reset_token
622 | signed_id expires_in: PASSWORD_RESET_TOKEN_EXPIRATION, purpose: :reset_password
623 | end
624 | ...
625 | def send_password_reset_email!
626 | password_reset_token = generate_password_reset_token
627 | UserMailer.password_reset(self, password_reset_token).deliver_now
628 | end
629 | ...
630 | end
631 | ```
632 |
633 | 2. Update User Mailer.
634 |
635 | ```ruby
636 | # app/mailers/user_mailer.rb
637 | class UserMailer < ApplicationMailer
638 | ...
639 | def password_reset(user, password_reset_token)
640 | @user = user
641 | @password_reset_token = password_reset_token
642 |
643 | mail to: @user.email, subject: "Password Reset Instructions"
644 | end
645 | end
646 | ```
647 |
648 | ```html+erb
649 |
650 | Password Reset Instructions
651 |
652 | <%= link_to "Click here to reset your password.", edit_password_url(@password_reset_token) %>
653 | ```
654 |
655 | ```text
656 |
657 | Password Reset Instructions
658 |
659 | <%= edit_password_url(@password_reset_token) %>
660 | ```
661 |
662 | > **What's Going On Here?**
663 | >
664 | > - The `generate_password_reset_token` method creates a [signed_id](https://api.rubyonrails.org/classes/ActiveRecord/SignedId.html#method-i-signed_id) that will be used to securely identify the user. For added security, we ensure that this ID will expire in 10 minutes (this can be controlled with the `PASSWORD_RESET_TOKEN_EXPIRATION` constant) and give it an explicit purpose of `:reset_password`.
665 | > - The `send_password_reset_email!` method will create a new `password_reset_token`. This is to ensure password reset links expire and cannot be reused. It will also send the password reset email to the user.
666 |
667 | ## Step 10: Build Password Reset Forms
668 |
669 | 1. Create Passwords Controller.
670 |
671 | ```bash
672 | rails g controller Passwords
673 | ```
674 |
675 | ```ruby
676 | # app/controllers/passwords_controller.rb
677 | class PasswordsController < ApplicationController
678 | before_action :redirect_if_authenticated
679 |
680 | def create
681 | @user = User.find_by(email: params[:user][:email].downcase)
682 | if @user.present?
683 | if @user.confirmed?
684 | @user.send_password_reset_email!
685 | redirect_to root_path, notice: "If that user exists we've sent instructions to their email."
686 | else
687 | redirect_to new_confirmation_path, alert: "Please confirm your email first."
688 | end
689 | else
690 | redirect_to root_path, notice: "If that user exists we've sent instructions to their email."
691 | end
692 | end
693 |
694 | def edit
695 | @user = User.find_signed(params[:password_reset_token], purpose: :reset_password)
696 | if @user.present? && @user.unconfirmed?
697 | redirect_to new_confirmation_path, alert: "You must confirm your email before you can sign in."
698 | elsif @user.nil?
699 | redirect_to new_password_path, alert: "Invalid or expired token."
700 | end
701 | end
702 |
703 | def new
704 | end
705 |
706 | def update
707 | @user = User.find_signed(params[:password_reset_token], purpose: :reset_password)
708 | if @user
709 | if @user.unconfirmed?
710 | redirect_to new_confirmation_path, alert: "You must confirm your email before you can sign in."
711 | elsif @user.update(password_params)
712 | redirect_to login_path, notice: "Sign in."
713 | else
714 | flash.now[:alert] = @user.errors.full_messages.to_sentence
715 | render :edit, status: :unprocessable_entity
716 | end
717 | else
718 | flash.now[:alert] = "Invalid or expired token."
719 | render :new, status: :unprocessable_entity
720 | end
721 | end
722 |
723 | private
724 |
725 | def password_params
726 | params.require(:user).permit(:password, :password_confirmation)
727 | end
728 | end
729 | ```
730 |
731 | > **What's Going On Here?**
732 | >
733 | > - The `create` action will send an email to the user containing a link that will allow them to reset the password. The link will contain their `password_reset_token` which is unique and expires. Note that we call `downcase` on the email to account for case sensitivity when searching.
734 | > - You'll remember that the `password_reset_token` is a [signed_id](https://api.rubyonrails.org/classes/ActiveRecord/SignedId.html#method-i-signed_id), and is set to expire in 10 minutes. You'll also note that we need to pass the method `purpose: :reset_password` to be consistent with the purpose that was set in the `generate_password_reset_token` method.
735 | > - Note that we return `Invalid or expired token.` if the user is not found. This makes it difficult for a bad actor to use the reset form to see which email accounts exist on the application.
736 | > - The `edit` action simply renders the form for the user to update their password. It attempts to find a user by their `password_reset_token`. You can think of the `password_reset_token` as a way to identify the user much like how we normally identify records by their ID. However, the `password_reset_token` is randomly generated and will expire so it's more secure.
737 | > - The `new` action simply renders a form for the user to put their email address in to receive the password reset email.
738 | > - The `update` also ensures the user is identified by their `password_reset_token`. It's not enough to just do this on the `edit` action since a bad actor could make a `PUT` request to the server and bypass the form.
739 | > - If the user exists and is confirmed we update their password to the one they will set in the form. Otherwise, we handle each failure case differently.
740 |
741 | 2. Update Routes.
742 |
743 | ```ruby
744 | # config/routes.rb
745 | Rails.application.routes.draw do
746 | ...
747 | resources :passwords, only: [:create, :edit, :new, :update], param: :password_reset_token
748 | end
749 | ```
750 |
751 | > **What's Going On Here?**
752 | >
753 | > - We add `param: :password_reset_token` as a [named route parameter](https://guides.rubyonrails.org/routing.html#overriding-named-route-parameters) so that we can identify users by their `password_reset_token` and not `id`. This is similar to what we did with the confirmations routes and ensures a user cannot be identified by their ID.
754 |
755 | 3. Build forms.
756 |
757 | ```html+ruby
758 |
759 | <%= form_with url: passwords_path, scope: :user do |form| %>
760 | <%= form.email_field :email, required: true %>
761 | <%= form.submit "Reset Password" %>
762 | <% end %>
763 | ```
764 |
765 | ```html+ruby
766 |
767 | <%= form_with url: password_path(params[:password_reset_token]), scope: :user, method: :put do |form| %>
768 |
769 | <%= form.label :password %>
770 | <%= form.password_field :password, required: true %>
771 |
772 |
773 | <%= form.label :password_confirmation %>
774 | <%= form.password_field :password_confirmation, required: true %>
775 |
776 | <%= form.submit "Update Password" %>
777 | <% end %>
778 | ```
779 |
780 | > **What's Going On Here?**
781 | >
782 | > - The password reset form is passed a `scope: :user` option so that the params are namespaced as `params[:user][:some_value]`. This is not required, but it helps keep things organized.
783 |
784 | ## Step 11: Add Unconfirmed Email Column To Users Table
785 |
786 | 1. Create and run migration.
787 |
788 | ```bash
789 | rails g migration add_unconfirmed_email_to_users unconfirmed_email:string
790 | rails db:migrate
791 | ```
792 |
793 | 2. Update User Model.
794 |
795 | ```ruby
796 | # app/models/user.rb
797 | class User < ApplicationRecord
798 | ...
799 | attr_accessor :current_password
800 | ...
801 | before_save :downcase_unconfirmed_email
802 | ...
803 | validates :unconfirmed_email, format: {with: URI::MailTo::EMAIL_REGEXP, allow_blank: true}
804 |
805 | def confirm!
806 | if unconfirmed_or_reconfirming?
807 | if unconfirmed_email.present?
808 | return false unless update(email: unconfirmed_email, unconfirmed_email: nil)
809 | end
810 | update_columns(confirmed_at: Time.current)
811 | else
812 | false
813 | end
814 | end
815 | ...
816 | def confirmable_email
817 | if unconfirmed_email.present?
818 | unconfirmed_email
819 | else
820 | email
821 | end
822 | end
823 | ...
824 | def reconfirming?
825 | unconfirmed_email.present?
826 | end
827 |
828 | def unconfirmed_or_reconfirming?
829 | unconfirmed? || reconfirming?
830 | end
831 |
832 | private
833 | ...
834 | def downcase_unconfirmed_email
835 | return if unconfirmed_email.nil?
836 | self.unconfirmed_email = unconfirmed_email.downcase
837 | end
838 |
839 | end
840 | ```
841 |
842 | > **What's Going On Here?**
843 | >
844 | > - We add a `unconfirmed_email` column to the `users` table so that we have a place to store the email a user is trying to use after their account has been confirmed with their original email.
845 | > - We add `attr_accessor :current_password` so that we'll be able to use `f.password_field :current_password` in the user form (which doesn't exist yet). This will allow us to require the user to submit their current password before they can update their account.
846 | > - We ensure to format the `unconfirmed_email` before saving it to the database. This ensures all data is saved consistently.
847 | > - We add validations to the `unconfirmed_email` column ensuring it's a valid email address.
848 | > - We update the `confirm!` method to set the `email` column to the value of the `unconfirmed_email` column, and then clear out the `unconfirmed_email` column. This will only happen if a user is trying to confirm a new email address. Note that we return `false` if updating the email address fails. This could happen if a user tries to confirm an email address that has already been confirmed.
849 | > - We add the `confirmable_email` method so that we can call the correct email in the updated `UserMailer`.
850 | > - We add `reconfirming?` and `unconfirmed_or_reconfirming?` to help us determine what state a user is in. This will come in handy later in our controllers.
851 |
852 | 3. Update User Mailer.
853 |
854 | ```ruby
855 | # app/mailers/user_mailer.rb
856 | class UserMailer < ApplicationMailer
857 |
858 | def confirmation(user, confirmation_token)
859 | ...
860 | mail to: @user.confirmable_email, subject: "Confirmation Instructions"
861 | end
862 | end
863 | ```
864 |
865 | 3. Update Confirmations Controller.
866 |
867 | ```ruby
868 | # app/controllers/confirmations_controller.rb
869 | class ConfirmationsController < ApplicationController
870 | ...
871 | def edit
872 | ...
873 | if @user.present?
874 | if @user.confirm!
875 | login @user
876 | redirect_to root_path, notice: "Your account has been confirmed."
877 | else
878 | redirect_to new_confirmation_path, alert: "Something went wrong."
879 | end
880 | else
881 | ...
882 | end
883 | end
884 | ...
885 | end
886 | ```
887 |
888 | > **What's Going On Here?**
889 | >
890 | > - We update the `edit` method to account for the return value of `@user.confirm!`. If for some reason `@user.confirm!` returns `false` (which would most likely happen if the email has already been taken) then we render a generic error. This prevents leaking email addresses.
891 |
892 | ## Step 12: Update Users Controller
893 |
894 | 1. Update Authentication Concern.
895 |
896 | ```ruby
897 | # app/controllers/concerns/authentication.rb
898 | module Authentication
899 | ...
900 | def authenticate_user!
901 | redirect_to login_path, alert: "You need to login to access that page." unless user_signed_in?
902 | end
903 | ...
904 | end
905 | ```
906 |
907 | > **What's Going On Here?**
908 | >
909 | > - The `authenticate_user!` method can be called to ensure an anonymous user cannot access a page that requires a user to be logged in. We'll need this when we build the page allowing a user to edit or delete their profile.
910 |
911 | 2. Add destroy, edit and update methods. Modify create method and user_params.
912 |
913 | ```ruby
914 | # app/controllers/users_controller.rb
915 | class UsersController < ApplicationController
916 | before_action :authenticate_user!, only: [:edit, :destroy, :update]
917 | ...
918 | def create
919 | @user = User.new(create_user_params)
920 | ...
921 | end
922 |
923 | def destroy
924 | current_user.destroy
925 | reset_session
926 | redirect_to root_path, notice: "Your account has been deleted."
927 | end
928 |
929 | def edit
930 | @user = current_user
931 | end
932 | ...
933 | def update
934 | @user = current_user
935 | if @user.authenticate(params[:user][:current_password])
936 | if @user.update(update_user_params)
937 | if params[:user][:unconfirmed_email].present?
938 | @user.send_confirmation_email!
939 | redirect_to root_path, notice: "Check your email for confirmation instructions."
940 | else
941 | redirect_to root_path, notice: "Account updated."
942 | end
943 | else
944 | render :edit, status: :unprocessable_entity
945 | end
946 | else
947 | flash.now[:error] = "Incorrect password"
948 | render :edit, status: :unprocessable_entity
949 | end
950 | end
951 |
952 | private
953 |
954 | def create_user_params
955 | params.require(:user).permit(:email, :password, :password_confirmation)
956 | end
957 |
958 | def update_user_params
959 | params.require(:user).permit(:current_password, :password, :password_confirmation, :unconfirmed_email)
960 | end
961 | end
962 | ```
963 |
964 | > **What's Going On Here?**
965 | >
966 | > - We call `authenticate_user!` before editing, destroying, or updating a user since only an authenticated user should be able to do this.
967 | > - We update the `create` method to accept `create_user_params` (formerly `user_params`). This is because we're going to require different parameters for creating an account vs. editing an account.
968 | > - The `destroy` action simply deletes the user and logs them out. Note that we're calling `current_user`, so this action can only be scoped to the user who is logged in.
969 | > - The `edit` action simply assigns `@user` to the `current_user` so that we have access to the user in the edit form.
970 | > - The `update` action first checks if their password is correct. Note that we're passing this in as `current_password` and not `password`. This is because we still want a user to be able to change their password and therefore we need another parameter to store this value. This is also why we have a private `update_user_params` method.
971 | > - If the user is updating their email address (via `unconfirmed_email`) we send a confirmation email to that new email address before setting it as the `email` value.
972 | > - We force a user to always put in their `current_password` as an extra security measure in case someone leaves their browser open on a public computer.
973 |
974 | 3. Update routes.
975 |
976 | ```ruby
977 | # config/routes.rb
978 | Rails.application.routes.draw do
979 | ...
980 | put "account", to: "users#update"
981 | get "account", to: "users#edit"
982 | delete "account", to: "users#destroy"
983 | ...
984 | end
985 | ```
986 |
987 | 4. Create an edit form.
988 |
989 | ```html+ruby
990 |
991 | <%= form_with model: @user, url: account_path, method: :put do |form| %>
992 | <%= render partial: "shared/form_errors", locals: { object: form.object } %>
993 |
994 | <%= form.label :email, "Current Email" %>
995 | <%= form.email_field :email, disabled: true %>
996 |
997 |
998 | <%= form.label :unconfirmed_email, "New Email" %>
999 | <%= form.text_field :unconfirmed_email %>
1000 |
1001 |
1002 | <%= form.label :password, "Password (leave blank if you don't want to change it)" %>
1003 | <%= form.password_field :password %>
1004 |
1005 |
1006 | <%= form.label :password_confirmation %>
1007 | <%= form.password_field :password_confirmation %>
1008 |
1009 |
1010 |
1011 | <%= form.label :current_password, "Current password (we need your current password to confirm your changes)" %>
1012 | <%= form.password_field :current_password, required: true %>
1013 |
1014 | <%= form.submit "Update Account" %>
1015 | <% end %>
1016 | ```
1017 |
1018 | > **What's Going On Here?**
1019 | >
1020 | > - We `disable` the `email` field to ensure we're not passing that value back to the controller. This is just so the user can see what their current email is.
1021 | > - We `require` the `current_password` field since we'll always want a user to confirm their password before making changes.
1022 | > - The `password` and `password_confirmation` fields are there if a user wants to update their current password.
1023 |
1024 | ## Step 13: Update Confirmations Controller
1025 |
1026 | 1. Update edit action.
1027 |
1028 | ```ruby
1029 | # app/controllers/confirmations_controller.rb
1030 | class ConfirmationsController < ApplicationController
1031 | ...
1032 | def edit
1033 | ...
1034 | if @user.present? && @user.unconfirmed_or_reconfirming?
1035 | ...
1036 | end
1037 | end
1038 | ...
1039 | end
1040 | ```
1041 |
1042 | > **What's Going On Here?**
1043 | >
1044 | > - We add `@user.unconfirmed_or_reconfirming?` to the conditional to ensure only unconfirmed users or users who are reconfirming can access this page. This is necessary since we're now allowing users to confirm new email addresses.
1045 |
1046 | ## Step 14: Add Remember Token Column to Users Table
1047 |
1048 | 1. Create migration.
1049 |
1050 | ```bash
1051 | rails g migration add_remember_token_to_users remember_token:string
1052 | ```
1053 |
1054 | 2. Update migration.
1055 |
1056 | ```ruby
1057 | # db/migrate/[timestamp]_add_remember_token_to_users.rb
1058 | class AddRememberTokenToUsers < ActiveRecord::Migration[6.1]
1059 | def change
1060 | add_column :users, :remember_token, :string, null: false
1061 | add_index :users, :remember_token, unique: true
1062 | end
1063 | end
1064 | ```
1065 |
1066 | > **What's Going On Here?**
1067 | >
1068 | > - We add `null: false` to ensure this column always has a value.
1069 | > - We add a [unique index](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/Table.html#method-i-index) to ensure this column has unique data.
1070 |
1071 | 3. Run migrations.
1072 |
1073 | ```bash
1074 | rails db:migrate
1075 | ```
1076 |
1077 | 4. Update the User model.
1078 |
1079 | ```ruby
1080 | # app/models/user.rb
1081 | class User < ApplicationRecord
1082 | ...
1083 | has_secure_token :remember_token
1084 | ...
1085 | end
1086 | ```
1087 |
1088 | > **What's Going On Here?**
1089 | >
1090 | > - We call [has_secure_token](https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token) on the `remember_token`. This ensures that the value for this column will be set when the record is created. This value will be used later to securely identify the user.
1091 |
1092 | ## Step 15: Update Authentication Concern
1093 |
1094 | 1. Add new helper methods.
1095 |
1096 | ```ruby
1097 | # app/controllers/concerns/authentication.rb
1098 | module Authentication
1099 | extend ActiveSupport::Concern
1100 | ...
1101 | def forget(user)
1102 | cookies.delete :remember_token
1103 | user.regenerate_remember_token
1104 | end
1105 | ...
1106 | def remember(user)
1107 | user.regenerate_remember_token
1108 | cookies.permanent.encrypted[:remember_token] = user.remember_token
1109 | end
1110 | ...
1111 | private
1112 |
1113 | def current_user
1114 | Current.user ||= if session[:current_user_id].present?
1115 | User.find_by(id: session[:current_user_id])
1116 | elsif cookies[:remember_token]
1117 | User.find_by(remember_token: cookies.encrypted[:remember_token])
1118 | end
1119 | end
1120 | ...
1121 | end
1122 | ```
1123 |
1124 | > **What's Going On Here?**
1125 | >
1126 | > - The `remember` method first regenerates a new `remember_token` to ensure these values are being rotated and can't be used more than once. We get the `regenerate_remember_token` method from [has_secure_token](https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token). Next, we assign this value to a [cookie](https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html). The call to [permanent](https://api.rubyonrails.org/classes/ActionDispatch/Cookies/ChainedCookieJars.html#method-i-permanent) ensures the cookie won't expire until 20 years from now. The call to [encrypted](https://api.rubyonrails.org/classes/ActionDispatch/Cookies/ChainedCookieJars.html#method-i-encrypted) ensures the value will be encrypted. This is vital since this value is used to identify the user and is being set in the browser.
1127 | > - The `forget` method deletes the cookie and regenerates a new `remember_token` to ensure these values are being rotated and can't be used more than once.
1128 | > - We update the `current_user` method by adding a conditional to first try and find the user by the session, and then fallback to finding the user by the cookie. This is the logic that allows a user to completely exit their browser and remain logged in when they return to the website since the cookie will still be set.
1129 |
1130 | ## Step 16: Update Sessions Controller
1131 |
1132 | 1. Update the `create` and `destroy` methods.
1133 |
1134 | ```ruby
1135 | # app/controllers/sessions_controller.rb
1136 | class SessionsController < ApplicationController
1137 | ...
1138 | before_action :authenticate_user!, only: [:destroy]
1139 |
1140 | def create
1141 | ...
1142 | if @user
1143 | if @user.unconfirmed?
1144 | ...
1145 | elsif @user.authenticate(params[:user][:password])
1146 | login @user
1147 | remember(@user) if params[:user][:remember_me] == "1"
1148 | ...
1149 | else
1150 | ...
1151 | end
1152 | else
1153 | ...
1154 | end
1155 | end
1156 |
1157 | def destroy
1158 | forget(current_user)
1159 | ...
1160 | end
1161 | ...
1162 | end
1163 | ```
1164 |
1165 | > **What's Going On Here?**
1166 | >
1167 | > - We conditionally call `remember(@user)` in the `create` method if the user has checked the "Remember me" checkbox. We still need to add this to our form.
1168 | > - We call `forget(current_user)` in the `destroy` method to ensure we delete the `remember_me` cookie and regenerate the user's `remember_token` token.
1169 | > - We also add a `before_action` to ensure only authenticated users can access the `destroy` action.
1170 |
1171 | 2. Add the "Remember me" checkbox to the login form.
1172 |
1173 | ```html+ruby
1174 |
1175 | <%= form_with url: login_path, scope: :user do |form| %>
1176 | ...
1177 |
1178 | <%= form.label :remember_me %>
1179 | <%= form.check_box :remember_me %>
1180 |
1181 | <%= form.submit "Sign In" %>
1182 | <% end %>
1183 | ```
1184 |
1185 | ## Step 17: Add Friendly Redirects
1186 |
1187 | 1. Update Authentication Concern.
1188 |
1189 | ```ruby
1190 | # app/controllers/concerns/authentication.rb
1191 | module Authentication
1192 | ...
1193 | def authenticate_user!
1194 | store_location
1195 | ...
1196 | end
1197 | ...
1198 | private
1199 | ...
1200 | def store_location
1201 | session[:user_return_to] = request.original_url if request.get? && request.local?
1202 | end
1203 |
1204 | end
1205 | ```
1206 |
1207 | > **What's Going On Here?**
1208 | >
1209 | > - The `store_location` method stores the [request.original_url](https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-original_url) in the [session](https://guides.rubyonrails.org/action_controller_overview.html#session) so it can be retrieved later. We only do this if the request made was a `get` request. We also call `request.local?` to ensure it was a local request. This prevents redirecting to an external application.
1210 | > - We call `store_location` in the `authenticate_user!` method so that we can save the path to the page the user was trying to visit before they were redirected to the login page. We need to do this before visiting the login page otherwise the call to `request.original_url` will always return the url to the login page.
1211 |
1212 | 2. Update Sessions Controller.
1213 |
1214 | ```ruby
1215 | # app/controllers/sessions_controller.rb
1216 | class SessionsController < ApplicationController
1217 | ...
1218 | def create
1219 | ...
1220 | if @user
1221 | if @user.unconfirmed?
1222 | ...
1223 | elsif @user.authenticate(params[:user][:password])
1224 | after_login_path = session[:user_return_to] || root_path
1225 | login @user
1226 | remember(@user) if params[:user][:remember_me] == "1"
1227 | redirect_to after_login_path, notice: "Signed in."
1228 | else
1229 | ...
1230 | end
1231 | else
1232 | ...
1233 | end
1234 | end
1235 | ...
1236 | end
1237 | ```
1238 |
1239 | > **What's Going On Here?**
1240 | >
1241 | > - The `after_login_path` variable it set to be whatever is in the `session[:user_return_to]`. If there's nothing in `session[:user_return_to]` then it defaults to the `root_path`.
1242 | > - Note that we call this method before calling `login`. This is because `login` calls `reset_session` which would deleted the `session[:user_return_to]`.
1243 |
1244 | ## Step 17: Account for Timing Attacks
1245 |
1246 | 1. Update the User model.
1247 |
1248 | **[Note that this class method will be available in Rails 7.1](https://edgeapi.rubyonrails.org/classes/ActiveRecord/SecurePassword/ClassMethods.html#method-i-authenticate_by)**
1249 |
1250 | ```ruby
1251 | # app/models/user.rb
1252 | class User < ApplicationRecord
1253 | ...
1254 | def self.authenticate_by(attributes)
1255 | passwords, identifiers = attributes.to_h.partition do |name, value|
1256 | !has_attribute?(name) && has_attribute?("#{name}_digest")
1257 | end.map(&:to_h)
1258 |
1259 | raise ArgumentError, "One or more password arguments are required" if passwords.empty?
1260 | raise ArgumentError, "One or more finder arguments are required" if identifiers.empty?
1261 | if (record = find_by(identifiers))
1262 | record if passwords.count { |name, value| record.public_send(:"authenticate_#{name}", value) } == passwords.size
1263 | else
1264 | new(passwords)
1265 | nil
1266 | end
1267 | end
1268 | ...
1269 | end
1270 | ```
1271 |
1272 | > **What's Going On Here?**
1273 | >
1274 | > - This class method serves to find a user using the non-password attributes (such as email), and then authenticates that record using the password attributes. Regardless of whether a user is found or authentication succeeds, `authenticate_by` will take the same amount of time. This prevents [timing-based enumeration attacks](https://en.wikipedia.org/wiki/Timing_attack), wherein an attacker can determine if a password record exists even without knowing the password.
1275 |
1276 | 2. Update the Sessions Controller.
1277 |
1278 | ```ruby
1279 | # app/controllers/sessions_controller.rb
1280 | class SessionsController < ApplicationController
1281 | ...
1282 | def create
1283 | @user = User.authenticate_by(email: params[:user][:email].downcase, password: params[:user][:password])
1284 | if @user
1285 | if @user.unconfirmed?
1286 | redirect_to new_confirmation_path, alert: "Please confirm your email first."
1287 | else
1288 | after_login_path = session[:user_return_to] || root_path
1289 | login @user
1290 | remember(@user) if params[:user][:remember_me] == "1"
1291 | redirect_to after_login_path, notice: "Signed in."
1292 | end
1293 | else
1294 | flash.now[:alert] = "Incorrect email or password."
1295 | render :new, status: :unprocessable_entity
1296 | end
1297 | end
1298 | ...
1299 | end
1300 | ```
1301 |
1302 | > **What's Going On Here?**
1303 | >
1304 | > - We refactor the `create` method to always start by finding and authenticating the user. Not only does this prevent timing attacks, but it also prevents accidentally leaking email addresses. This is because we were originally checking if a user was confirmed before authenticating them. That means a bad actor could try and sign in with an email address to see if it exists on the system without needing to know the password.
1305 |
1306 | ## Step 18: Store Session in the Database
1307 |
1308 | We're currently setting the user's ID in the session. Even though that value is encrypted, the encrypted value doesn't change since it's based on the user id which doesn't change. This means that if a bad actor were to get a copy of the session they would have access to a victim's account in perpetuity. One solution is to [rotate encrypted and signed cookie configurations](https://guides.rubyonrails.org/security.html#rotating-encrypted-and-signed-cookies-configurations). Another option is to configure the [Rails session store](https://guides.rubyonrails.org/configuring.html#config-session-store) to use `mem_cache_store` to store session data.
1309 |
1310 | The solution we will implement is to set a rotating value to identify the user and store that value in the database.
1311 |
1312 | 1. Generate ActiveSession model.
1313 |
1314 | ```bash
1315 | rails g model active_session user:references
1316 | ```
1317 |
1318 | 2. Update the migration.
1319 |
1320 | ```ruby
1321 | class CreateActiveSessions < ActiveRecord::Migration[6.1]
1322 | def change
1323 | create_table :active_sessions do |t|
1324 | t.references :user, null: false, foreign_key: {on_delete: :cascade}
1325 |
1326 | t.timestamps
1327 | end
1328 | end
1329 | end
1330 | ```
1331 |
1332 | > **What's Going On Here?**
1333 | >
1334 | > - We update the `foreign_key` option from `true` to `{on_delete: :cascade}`. The [on_delete](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_foreign_key-label-Creating+a+cascading+foreign+key) option will delete any `active_session` record if its associated `user` is deleted from the database.
1335 |
1336 | 3. Run migration.
1337 |
1338 | ```bash
1339 | rails db:migrate
1340 | ```
1341 |
1342 | 4. Update User model.
1343 |
1344 | ```ruby
1345 | # app/models/user.rb
1346 | class User < ApplicationRecord
1347 | ...
1348 | has_many :active_sessions, dependent: :destroy
1349 | ...
1350 | end
1351 | ```
1352 |
1353 | 5. Update Authentication Concern
1354 |
1355 | ```ruby
1356 | # app/controllers/concerns/authentication.rb
1357 | module Authentication
1358 | ...
1359 | def login(user)
1360 | reset_session
1361 | active_session = user.active_sessions.create!
1362 | session[:current_active_session_id] = active_session.id
1363 | end
1364 | ...
1365 | def logout
1366 | active_session = ActiveSession.find_by(id: session[:current_active_session_id])
1367 | reset_session
1368 | active_session.destroy! if active_session.present?
1369 | end
1370 | ...
1371 | private
1372 |
1373 | def current_user
1374 | Current.user = if session[:current_active_session_id].present?
1375 | ActiveSession.find_by(id: session[:current_active_session_id]).user
1376 | elsif cookies[:remember_token]
1377 | User.find_by(remember_token: cookies.encrypted[:remember_token])
1378 | end
1379 | end
1380 | ...
1381 | end
1382 | ```
1383 |
1384 | > **What's Going On Here?**
1385 | >
1386 | > - We update the `login` method by creating a new `active_session` record and then storing it's ID in the `session`. Note that we replaced `session[:current_user_id]` with `session[:current_active_session_id]`.
1387 | > - We update the `logout` method by first finding the `active_session` record from the `session`. After we call `reset_session` we then delete the `active_session` record if it exists. We need to check if it exists because in a future section we will allow a user to log out all current active sessions.
1388 | > - We update the `current_user` method by finding the `active_session` record from the `session`, and then returning its associated `user`. Note that we've replaced all instances of `session[:current_user_id]` with `session[:current_active_session_id]`.
1389 |
1390 | 6. Force SSL.
1391 |
1392 | ```ruby
1393 | # config/environments/production.rb
1394 | Rails.application.configure do
1395 | ...
1396 | config.force_ssl = true
1397 | end
1398 | ```
1399 |
1400 | > **What's Going On Here?**
1401 | >
1402 | > - We force SSL in production to prevent [session hijacking](https://guides.rubyonrails.org/security.html#session-hijacking). Even though the session is encrypted we want to prevent the cookie from being exposed through an insecure network. If it were exposed, a bad actor could sign in as the victim.
1403 |
1404 | ## Step 19: Capture Request Details for Each New Session
1405 |
1406 | 1. Add new columns to the active_sessions table.
1407 |
1408 | ```bash
1409 | rails g migration add_request_columns_to_active_sessions user_agent:string ip_address:string
1410 | rails db:migrate
1411 | ```
1412 |
1413 | 2. Update login method to capture request details.
1414 |
1415 | ```ruby
1416 | # app/controllers/concerns/authentication.rb
1417 | module Authentication
1418 | ...
1419 | def login(user)
1420 | reset_session
1421 | active_session = user.active_sessions.create!(user_agent: request.user_agent, ip_address: request.ip)
1422 | session[:current_active_session_id] = active_session.id
1423 | end
1424 | ...
1425 | end
1426 | ```
1427 |
1428 | > **What's Going On Here?**
1429 | >
1430 | > - We add columns to the `active_sessions` table to store data about when and where these sessions are being created. We are able to do this by tapping into the [request object](https://api.rubyonrails.org/classes/ActionDispatch/Request.html) and returning the [ip](https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-ip) and user agent. The user agent is simply the browser and device.
1431 |
1432 |
1433 | 4. Update Users Controller.
1434 |
1435 | ```ruby
1436 | # app/controllers/users_controller.rb
1437 | class UsersController < ApplicationController
1438 | ...
1439 | def edit
1440 | @user = current_user
1441 | @active_sessions = @user.active_sessions.order(created_at: :desc)
1442 | end
1443 | ...
1444 | def update
1445 | @user = current_user
1446 | @active_sessions = @user.active_sessions.order(created_at: :desc)
1447 | ...
1448 | end
1449 | end
1450 | ```
1451 |
1452 | 5. Create active session partial.
1453 |
1454 | ```html+ruby
1455 |
1456 |
1457 | <%= active_session.user_agent %> |
1458 | <%= active_session.ip_address %> |
1459 | <%= active_session.created_at %> |
1460 |
1461 | ```
1462 |
1463 | 6. Update account page.
1464 |
1465 | ```html+ruby
1466 |
1467 | ...
1468 | Current Logins
1469 | <% if @active_sessions.any? %>
1470 |
1471 |
1472 |
1473 | User Agent |
1474 | IP Address |
1475 | Signed In At |
1476 |
1477 |
1478 |
1479 | <%= render @active_sessions %>
1480 |
1481 |
1482 | <% end %>
1483 | ```
1484 |
1485 | > **What's Going On Here?**
1486 | >
1487 | > - We're simply showing any `active_session` associated with the `current_user`. By rendering the `user_agent`, `ip_address`, and `created_at` values we're giving the `current_user` all the information they need to know if there's any suspicious activity happening with their account. For example, if there's an `active_session` with a unfamiliar IP address or browser, this could indicate that the user's account has been compromised.
1488 | > - Note that we also instantiate `@active_sessions` in the `update` method. This is because the `update` method renders the `edit` method during failure cases.
1489 |
1490 | ## Step 20: Allow User to Sign Out Specific Active Sessions
1491 |
1492 | 1. Generate the Active Sessions Controller and update routes.
1493 |
1494 | ```
1495 | rails g controller active_sessions
1496 | ```
1497 |
1498 | ```ruby
1499 | # app/controllers/active_sessions_controller.rb
1500 | class ActiveSessionsController < ApplicationController
1501 | before_action :authenticate_user!
1502 |
1503 | def destroy
1504 | @active_session = current_user.active_sessions.find(params[:id])
1505 |
1506 | @active_session.destroy
1507 |
1508 | if current_user
1509 | redirect_to account_path, notice: "Session deleted."
1510 | else
1511 | reset_session
1512 | redirect_to root_path, notice: "Signed out."
1513 | end
1514 | end
1515 |
1516 | def destroy_all
1517 | current_user.active_sessions.destroy_all
1518 | reset_session
1519 |
1520 | redirect_to root_path, notice: "Signed out."
1521 | end
1522 | end
1523 | ```
1524 |
1525 | ```ruby
1526 | # config/routes.rb
1527 | Rails.application.routes.draw do
1528 | ...
1529 | resources :active_sessions, only: [:destroy] do
1530 | collection do
1531 | delete "destroy_all"
1532 | end
1533 | end
1534 | end
1535 | ```
1536 |
1537 | > **What's Going On Here?**
1538 | >
1539 | > - We ensure only users who are logged in can access these endpoints by calling `before_action :authenticate_user!`.
1540 | > - The `destroy` method simply looks for an `active_session` associated with the `current_user`. This ensures that a user can only delete sessions associated with their account.
1541 | > - Once we destroy the `active_session` we then redirect back to the account page or to the homepage. This is because a user may not be deleting a session for the device or browser they're currently logged into. Note that we only call [reset_session](https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-reset_session) if the user has deleted a session for the device or browser they're currently logged into, as this is the same as logging out.
1542 | > - The `destroy_all` method is a [collection route](https://guides.rubyonrails.org/routing.html#adding-collection-routes) that will destroy all `active_session` records associated with the `current_user`. Note that we call `reset_session` because we will be logging out the `current_user` during this request.
1543 |
1544 | 2. Update views by adding buttons to destroy sessions.
1545 |
1546 | ```html+ruby
1547 |
1548 | ...
1549 | Current Logins
1550 | <% if @active_sessions.any? %>
1551 | <%= button_to "Log out of all other sessions", destroy_all_active_sessions_path, method: :delete %>
1552 |
1553 |
1554 |
1555 | User Agent |
1556 | IP Address |
1557 | Signed In At |
1558 | Sign Out |
1559 |
1560 |
1561 |
1562 | <%= render @active_sessions %>
1563 |
1564 |
1565 | <% end %>
1566 | ```
1567 |
1568 | ```html+ruby
1569 |
1570 |
1571 | <%= active_session.user_agent %> |
1572 | <%= active_session.ip_address %> |
1573 | <%= active_session.created_at %> |
1574 | <%= button_to "Sign Out", active_session_path(active_session), method: :delete %> |
1575 |
1576 | ```
1577 |
1578 | 3. Update Authentication Concern.
1579 |
1580 | ```ruby
1581 | # app/controllers/concerns/authentication.rb
1582 | module Authentication
1583 | ...
1584 | private
1585 |
1586 | def current_user
1587 | Current.user = if session[:current_active_session_id].present?
1588 | ActiveSession.find_by(id: session[:current_active_session_id])&.user
1589 | elsif cookies[:remember_token]
1590 | User.find_by(remember_token: cookies.encrypted[:remember_token])
1591 | end
1592 | end
1593 | ...
1594 | end
1595 | ```
1596 |
1597 | > **What's Going On Here?**
1598 | >
1599 | > - This is a very subtle change, but we've added a [safe navigation operator](https://ruby-doc.org/core-2.6/doc/syntax/calling_methods_rdoc.html#label-Safe+navigation+operator) via the `&.user` call. This is because `ActiveSession.find_by(id: session[:current_active_session_id])` can now return `nil` since we're able to delete other `active_session` records.
1600 |
1601 | ## Step 21: Refactor Remember Logic
1602 |
1603 | Since we're now associating our sessions with an `active_session` and not a `user`, we'll want to remove the `remember_token` token from the `users` table and onto the `active_sessions`.
1604 |
1605 | 1. Move remember_token column from users to active_sessions table.
1606 |
1607 | ```bash
1608 | rails g migration move_remember_token_from_users_to_active_sessions
1609 | ```
1610 |
1611 | ```ruby
1612 | # db/migrate/[timestamp]_move_remember_token_from_users_to_active_sessions.rb
1613 | class MoveRememberTokenFromUsersToActiveSessions < ActiveRecord::Migration[6.1]
1614 | def change
1615 | remove_column :users, :remember_token
1616 | add_column :active_sessions, :remember_token, :string, null: false
1617 |
1618 | add_index :active_sessions, :remember_token, unique: true
1619 | end
1620 | end
1621 | ```
1622 |
1623 | 2. Run migration.
1624 |
1625 | ```bash
1626 | rails db:migrate
1627 | ```
1628 |
1629 | > **What's Going On Here?**
1630 | >
1631 | > - We add `null: false` to ensure this column always has a value.
1632 | > - We add a [unique index](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/Table.html#method-i-index) to ensure this column has unique data.
1633 |
1634 | 3. Update User Model.
1635 |
1636 | ```diff
1637 | class User < ApplicationRecord
1638 | ...
1639 | - has_secure_token :remember_token
1640 | ...
1641 | end
1642 | ```
1643 |
1644 | 4. Update Active Session Model.
1645 |
1646 | ```ruby
1647 | # app/models/active_session.rb
1648 | class ActiveSession < ApplicationRecord
1649 | ...
1650 | has_secure_token :remember_token
1651 | end
1652 | ```
1653 |
1654 | > **What's Going On Here?**
1655 | >
1656 | > - We call [has_secure_token](https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token) on the `remember_token`. This ensures that the value for this column will be set when the record is created. This value will be used later to securely identify the user.
1657 | > - Note that we remove this from the `user` model.
1658 |
1659 | 5. Refactor the Authentication Concern.
1660 |
1661 | ```ruby
1662 | # app/controllers/concerns/authentication.rb
1663 | module Authentication
1664 | ...
1665 | def login(user)
1666 | reset_session
1667 | active_session = user.active_sessions.create!(user_agent: request.user_agent, ip_address: request.ip)
1668 | session[:current_active_session_id] = active_session.id
1669 |
1670 | active_session
1671 | end
1672 |
1673 | def forget_active_session
1674 | cookies.delete :remember_token
1675 | end
1676 | ...
1677 | def remember(active_session)
1678 | cookies.encrypted[:remember_token] = active_session.remember_token
1679 | end
1680 | ...
1681 | private
1682 |
1683 | def current_user
1684 | Current.user = if session[:current_active_session_id].present?
1685 | ActiveSession.find_by(id: session[:current_active_session_id])&.user
1686 | elsif cookies[:remember_token]
1687 | ActiveSession.find_by(remember_token: cookies.encrypted[:remember_token])&.user
1688 | end
1689 | end
1690 | ...
1691 | end
1692 | ```
1693 |
1694 | > **What's Going On Here?**
1695 | >
1696 | > - The `login` method now returns the `active_session`. This will be used later when calling `SessionsController#create`.
1697 | > - The `forget` method has been renamed to `forget_active_session` and no longer takes any arguments. This method simply deletes the `cookie`. We don't need to call `active_session.regenerate_remember_token` since the `active_session` will be deleted, and therefor cannot be referenced again.
1698 | > - The `remember` method now accepts an `active_session` and not a `user`. We do not need to call `active_session.regenerate_remember_token` since a new `active_session` record will be created each time a user logs in. Note that we now save `active_session.remember_token` to the cookie.
1699 | > - The `current_user` method now finds the `active_session` record if the `remember_token` is present and returns the user via the [safe navigation operator](https://ruby-doc.org/core-2.6/doc/syntax/calling_methods_rdoc.html#label-Safe+navigation+operator).
1700 |
1701 | 6. Refactor the Sessions Controller.
1702 |
1703 | ```ruby
1704 | # app/controllers/sessions_controller.rb
1705 | class SessionsController < ApplicationController
1706 | def create
1707 | ...
1708 | if @user
1709 | if @user.unconfirmed?
1710 | ...
1711 | else
1712 | ...
1713 | active_session = login @user
1714 | remember(active_session) if params[:user][:remember_me] == "1"
1715 | end
1716 | else
1717 | ...
1718 | end
1719 | end
1720 |
1721 | def destroy
1722 | forget_active_session
1723 | ...
1724 | end
1725 | end
1726 | ```
1727 |
1728 | > **What's Going On Here?**
1729 | >
1730 | > - Since the `login` method now returns an `active_session`, we can take that value and pass it to `remember`.
1731 | > - We replace `forget(current_user)` with `forget_active_session` to reflect changes to the method name and structure.
1732 |
1733 | 7. Refactor Active Sessions Controller
1734 |
1735 | ```ruby
1736 | # app/controllers/active_sessions_controller.rb
1737 | class ActiveSessionsController < ApplicationController
1738 | ...
1739 | def destroy
1740 | ...
1741 | if current_user
1742 | ...
1743 | else
1744 | forget_active_session
1745 | ...
1746 | end
1747 | end
1748 |
1749 | def destroy_all
1750 | forget_active_session
1751 | current_user.active_sessions.destroy_all
1752 | ...
1753 | end
1754 | end
1755 | ```
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # Add your own tasks in files placed in lib/tasks ending in .rake,
2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3 |
4 | require_relative "config/application"
5 |
6 | Rails.application.load_tasks
7 |
--------------------------------------------------------------------------------
/app/assets/config/manifest.js:
--------------------------------------------------------------------------------
1 | //= link_tree ../images
2 | //= link_directory ../stylesheets .css
3 |
--------------------------------------------------------------------------------
/app/assets/images/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/app/assets/images/.keep
--------------------------------------------------------------------------------
/app/assets/stylesheets/active_sessions.scss:
--------------------------------------------------------------------------------
1 | // Place all the styles related to the active_sessions controller here.
2 | // They will automatically be included in application.css.
3 | // You can use Sass (SCSS) here: https://sass-lang.com/
4 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/application.css:
--------------------------------------------------------------------------------
1 | /*
2 | * This is a manifest file that'll be compiled into application.css, which will include all the files
3 | * listed below.
4 | *
5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
6 | * vendor/assets/stylesheets directory can be referenced here using a relative path.
7 | *
8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10 | * files in this directory. Styles in this file should be added after the last require_* statement.
11 | * It is generally better to create a new file per style scope.
12 | *
13 | *= require_tree .
14 | *= require_self
15 | */
16 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/confirmations.scss:
--------------------------------------------------------------------------------
1 | // Place all the styles related to the Confirmations controller here.
2 | // They will automatically be included in application.css.
3 | // You can use Sass (SCSS) here: https://sass-lang.com/
4 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/passwords.scss:
--------------------------------------------------------------------------------
1 | // Place all the styles related to the Passwords controller here.
2 | // They will automatically be included in application.css.
3 | // You can use Sass (SCSS) here: https://sass-lang.com/
4 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/sessions.scss:
--------------------------------------------------------------------------------
1 | // Place all the styles related to the Sessions controller here.
2 | // They will automatically be included in application.css.
3 | // You can use Sass (SCSS) here: https://sass-lang.com/
4 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/static_pages.scss:
--------------------------------------------------------------------------------
1 | // Place all the styles related to the static_pages controller here.
2 | // They will automatically be included in application.css.
3 | // You can use Sass (SCSS) here: https://sass-lang.com/
4 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/users.scss:
--------------------------------------------------------------------------------
1 | // Place all the styles related to the Users controller here.
2 | // They will automatically be included in application.css.
3 | // You can use Sass (SCSS) here: https://sass-lang.com/
4 |
--------------------------------------------------------------------------------
/app/channels/application_cable/channel.rb:
--------------------------------------------------------------------------------
1 | module ApplicationCable
2 | class Channel < ActionCable::Channel::Base
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/app/channels/application_cable/connection.rb:
--------------------------------------------------------------------------------
1 | module ApplicationCable
2 | class Connection < ActionCable::Connection::Base
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/app/controllers/active_sessions_controller.rb:
--------------------------------------------------------------------------------
1 | class ActiveSessionsController < ApplicationController
2 | before_action :authenticate_user!
3 |
4 | def destroy
5 | @active_session = current_user.active_sessions.find(params[:id])
6 |
7 | @active_session.destroy
8 |
9 | if current_user
10 | redirect_to account_path, notice: "Session deleted."
11 | else
12 | forget_active_session
13 | reset_session
14 | redirect_to root_path, notice: "Signed out."
15 | end
16 | end
17 |
18 | def destroy_all
19 | forget_active_session
20 | current_user.active_sessions.destroy_all
21 | reset_session
22 |
23 | redirect_to root_path, notice: "Signed out."
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | class ApplicationController < ActionController::Base
2 | include Authentication
3 | end
4 |
--------------------------------------------------------------------------------
/app/controllers/concerns/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/app/controllers/concerns/.keep
--------------------------------------------------------------------------------
/app/controllers/concerns/authentication.rb:
--------------------------------------------------------------------------------
1 | module Authentication
2 | extend ActiveSupport::Concern
3 |
4 | included do
5 | before_action :current_user
6 | helper_method :current_user
7 | helper_method :user_signed_in?
8 | end
9 |
10 | def authenticate_user!
11 | store_location
12 | redirect_to login_path, alert: "You need to login to access that page." unless user_signed_in?
13 | end
14 |
15 | def login(user)
16 | reset_session
17 | active_session = user.active_sessions.create!(user_agent: request.user_agent, ip_address: request.ip)
18 | session[:current_active_session_id] = active_session.id
19 |
20 | active_session
21 | end
22 |
23 | def forget_active_session
24 | cookies.delete :remember_token
25 | end
26 |
27 | def logout
28 | active_session = ActiveSession.find_by(id: session[:current_active_session_id])
29 | reset_session
30 | active_session.destroy! if active_session.present?
31 | end
32 |
33 | def redirect_if_authenticated
34 | redirect_to root_path, alert: "You are already logged in." if user_signed_in?
35 | end
36 |
37 | def remember(active_session)
38 | cookies.permanent.encrypted[:remember_token] = active_session.remember_token
39 | end
40 |
41 | private
42 |
43 | def current_user
44 | Current.user = if session[:current_active_session_id].present?
45 | ActiveSession.find_by(id: session[:current_active_session_id])&.user
46 | elsif cookies[:remember_token]
47 | ActiveSession.find_by(remember_token: cookies.encrypted[:remember_token])&.user
48 | end
49 | end
50 |
51 | def user_signed_in?
52 | Current.user.present?
53 | end
54 |
55 | def store_location
56 | session[:user_return_to] = request.original_url if request.get? && request.local?
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/app/controllers/confirmations_controller.rb:
--------------------------------------------------------------------------------
1 | class ConfirmationsController < ApplicationController
2 | before_action :redirect_if_authenticated, only: [:create, :new]
3 |
4 | def create
5 | @user = User.find_by(email: params[:user][:email].downcase)
6 |
7 | if @user.present? && @user.unconfirmed?
8 | @user.send_confirmation_email!
9 | redirect_to root_path, notice: "Check your email for confirmation instructions."
10 | else
11 | redirect_to new_confirmation_path, alert: "We could not find a user with that email or that email has already been confirmed."
12 | end
13 | end
14 |
15 | def edit
16 | @user = User.find_signed(params[:confirmation_token], purpose: :confirm_email)
17 | if @user.present? && @user.unconfirmed_or_reconfirming?
18 | if @user.confirm!
19 | login @user
20 | redirect_to root_path, notice: "Your account has been confirmed."
21 | else
22 | redirect_to new_confirmation_path, alert: "Something went wrong."
23 | end
24 | else
25 | redirect_to new_confirmation_path, alert: "Invalid token."
26 | end
27 | end
28 |
29 | def new
30 | @user = User.new
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/app/controllers/passwords_controller.rb:
--------------------------------------------------------------------------------
1 | class PasswordsController < ApplicationController
2 | before_action :redirect_if_authenticated
3 |
4 | def create
5 | @user = User.find_by(email: params[:user][:email].downcase)
6 | if @user.present?
7 | if @user.confirmed?
8 | @user.send_password_reset_email!
9 | redirect_to root_path, notice: "If that user exists we've sent instructions to their email."
10 | else
11 | redirect_to new_confirmation_path, alert: "Please confirm your email first."
12 | end
13 | else
14 | redirect_to root_path, notice: "If that user exists we've sent instructions to their email."
15 | end
16 | end
17 |
18 | def edit
19 | @user = User.find_signed(params[:password_reset_token], purpose: :reset_password)
20 | if @user.present? && @user.unconfirmed?
21 | redirect_to new_confirmation_path, alert: "You must confirm your email before you can sign in."
22 | elsif @user.nil?
23 | redirect_to new_password_path, alert: "Invalid or expired token."
24 | end
25 | end
26 |
27 | def new
28 | end
29 |
30 | def update
31 | @user = User.find_signed(params[:password_reset_token], purpose: :reset_password)
32 | if @user
33 | if @user.unconfirmed?
34 | redirect_to new_confirmation_path, alert: "You must confirm your email before you can sign in."
35 | elsif @user.update(password_params)
36 | redirect_to login_path, notice: "Sign in."
37 | else
38 | flash.now[:alert] = @user.errors.full_messages.to_sentence
39 | render :edit, status: :unprocessable_entity
40 | end
41 | else
42 | flash.now[:alert] = "Invalid or expired token."
43 | render :new, status: :unprocessable_entity
44 | end
45 | end
46 |
47 | private
48 |
49 | def password_params
50 | params.require(:user).permit(:password, :password_confirmation)
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/app/controllers/sessions_controller.rb:
--------------------------------------------------------------------------------
1 | class SessionsController < ApplicationController
2 | before_action :redirect_if_authenticated, only: [:create, :new]
3 | before_action :authenticate_user!, only: [:destroy]
4 |
5 | def create
6 | @user = User.authenticate_by(email: params[:user][:email].downcase, password: params[:user][:password])
7 | if @user
8 | if @user.unconfirmed?
9 | redirect_to new_confirmation_path, alert: "Please confirm your email first."
10 | else
11 | after_login_path = session[:user_return_to] || root_path
12 | active_session = login @user
13 | remember(active_session) if params[:user][:remember_me] == "1"
14 | redirect_to after_login_path, notice: "Signed in."
15 | end
16 | else
17 | flash.now[:alert] = "Incorrect email or password."
18 | render :new, status: :unprocessable_entity
19 | end
20 | end
21 |
22 | def destroy
23 | forget_active_session
24 | logout
25 | redirect_to root_path, notice: "Signed out."
26 | end
27 |
28 | def new
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/app/controllers/static_pages_controller.rb:
--------------------------------------------------------------------------------
1 | class StaticPagesController < ApplicationController
2 | def home
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/app/controllers/users_controller.rb:
--------------------------------------------------------------------------------
1 | class UsersController < ApplicationController
2 | before_action :authenticate_user!, only: [:edit, :destroy, :update]
3 | before_action :redirect_if_authenticated, only: [:create, :new]
4 |
5 | def create
6 | @user = User.new(create_user_params)
7 | if @user.save
8 | @user.send_confirmation_email!
9 | redirect_to root_path, notice: "Please check your email for confirmation instructions."
10 | else
11 | render :new, status: :unprocessable_entity
12 | end
13 | end
14 |
15 | def destroy
16 | current_user.destroy
17 | reset_session
18 | redirect_to root_path, notice: "Your account has been deleted."
19 | end
20 |
21 | def edit
22 | @user = current_user
23 | @active_sessions = @user.active_sessions.order(created_at: :desc)
24 | end
25 |
26 | def new
27 | @user = User.new
28 | end
29 |
30 | def update
31 | @user = current_user
32 | @active_sessions = @user.active_sessions.order(created_at: :desc)
33 | if @user.authenticate(params[:user][:current_password])
34 | if @user.update(update_user_params)
35 | if params[:user][:unconfirmed_email].present?
36 | @user.send_confirmation_email!
37 | redirect_to root_path, notice: "Check your email for confirmation instructions."
38 | else
39 | redirect_to root_path, notice: "Account updated."
40 | end
41 | else
42 | render :edit, status: :unprocessable_entity
43 | end
44 | else
45 | flash.now[:error] = "Incorrect password"
46 | render :edit, status: :unprocessable_entity
47 | end
48 | end
49 |
50 | private
51 |
52 | def create_user_params
53 | params.require(:user).permit(:email, :password, :password_confirmation)
54 | end
55 |
56 | def update_user_params
57 | params.require(:user).permit(:current_password, :password, :password_confirmation, :unconfirmed_email)
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/app/helpers/active_sessions_helper.rb:
--------------------------------------------------------------------------------
1 | module ActiveSessionsHelper
2 | end
3 |
--------------------------------------------------------------------------------
/app/helpers/application_helper.rb:
--------------------------------------------------------------------------------
1 | module ApplicationHelper
2 | end
3 |
--------------------------------------------------------------------------------
/app/helpers/confirmations_helper.rb:
--------------------------------------------------------------------------------
1 | module ConfirmationsHelper
2 | end
3 |
--------------------------------------------------------------------------------
/app/helpers/passwords_helper.rb:
--------------------------------------------------------------------------------
1 | module PasswordsHelper
2 | end
3 |
--------------------------------------------------------------------------------
/app/helpers/sessions_helper.rb:
--------------------------------------------------------------------------------
1 | module SessionsHelper
2 | end
3 |
--------------------------------------------------------------------------------
/app/helpers/static_pages_helper.rb:
--------------------------------------------------------------------------------
1 | module StaticPagesHelper
2 | end
3 |
--------------------------------------------------------------------------------
/app/helpers/users_helper.rb:
--------------------------------------------------------------------------------
1 | module UsersHelper
2 | end
3 |
--------------------------------------------------------------------------------
/app/javascript/channels/consumer.js:
--------------------------------------------------------------------------------
1 | // Action Cable provides the framework to deal with WebSockets in Rails.
2 | // You can generate new channels where WebSocket features live using the `bin/rails generate channel` command.
3 |
4 | import { createConsumer } from "@rails/actioncable"
5 |
6 | export default createConsumer()
7 |
--------------------------------------------------------------------------------
/app/javascript/channels/index.js:
--------------------------------------------------------------------------------
1 | // Load all the channels within this directory and all subdirectories.
2 | // Channel files must be named *_channel.js.
3 |
4 | const channels = require.context('.', true, /_channel\.js$/)
5 | channels.keys().forEach(channels)
6 |
--------------------------------------------------------------------------------
/app/javascript/packs/application.js:
--------------------------------------------------------------------------------
1 | // This file is automatically compiled by Webpack, along with any other files
2 | // present in this directory. You're encouraged to place your actual application logic in
3 | // a relevant structure within app/javascript and only use these pack files to reference
4 | // that code so it'll be compiled.
5 |
6 | import Rails from "@rails/ujs"
7 | import Turbolinks from "turbolinks"
8 | import * as ActiveStorage from "@rails/activestorage"
9 | import "channels"
10 |
11 | Rails.start()
12 | Turbolinks.start()
13 | ActiveStorage.start()
14 |
--------------------------------------------------------------------------------
/app/jobs/application_job.rb:
--------------------------------------------------------------------------------
1 | class ApplicationJob < ActiveJob::Base
2 | # Automatically retry jobs that encountered a deadlock
3 | # retry_on ActiveRecord::Deadlocked
4 |
5 | # Most jobs are safe to ignore if the underlying records are no longer available
6 | # discard_on ActiveJob::DeserializationError
7 | end
8 |
--------------------------------------------------------------------------------
/app/mailers/application_mailer.rb:
--------------------------------------------------------------------------------
1 | class ApplicationMailer < ActionMailer::Base
2 | default from: "from@example.com"
3 | layout "mailer"
4 | end
5 |
--------------------------------------------------------------------------------
/app/mailers/user_mailer.rb:
--------------------------------------------------------------------------------
1 | class UserMailer < ApplicationMailer
2 | default from: User::MAILER_FROM_EMAIL
3 |
4 | # Subject can be set in your I18n file at config/locales/en.yml
5 | # with the following lookup:
6 | #
7 | # en.user_mailer.confirmation.subject
8 | #
9 | def confirmation(user, confirmation_token)
10 | @user = user
11 | @confirmation_token = confirmation_token
12 |
13 | mail to: @user.confirmable_email, subject: "Confirmation Instructions"
14 | end
15 |
16 | def password_reset(user, password_reset_token)
17 | @user = user
18 | @password_reset_token = password_reset_token
19 |
20 | mail to: @user.email, subject: "Password Reset Instructions"
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/app/models/active_session.rb:
--------------------------------------------------------------------------------
1 | class ActiveSession < ApplicationRecord
2 | belongs_to :user
3 |
4 | has_secure_token :remember_token
5 | end
6 |
--------------------------------------------------------------------------------
/app/models/application_record.rb:
--------------------------------------------------------------------------------
1 | class ApplicationRecord < ActiveRecord::Base
2 | self.abstract_class = true
3 | end
4 |
--------------------------------------------------------------------------------
/app/models/concerns/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/app/models/concerns/.keep
--------------------------------------------------------------------------------
/app/models/current.rb:
--------------------------------------------------------------------------------
1 | class Current < ActiveSupport::CurrentAttributes
2 | attribute :user
3 | end
4 |
--------------------------------------------------------------------------------
/app/models/user.rb:
--------------------------------------------------------------------------------
1 | class User < ApplicationRecord
2 | CONFIRMATION_TOKEN_EXPIRATION = 10.minutes
3 | MAILER_FROM_EMAIL = "no-reply@example.com"
4 | PASSWORD_RESET_TOKEN_EXPIRATION = 10.minutes
5 |
6 | attr_accessor :current_password
7 |
8 | has_secure_password
9 |
10 | has_many :active_sessions, dependent: :destroy
11 |
12 | before_save :downcase_email
13 | before_save :downcase_unconfirmed_email
14 |
15 | validates :email, format: {with: URI::MailTo::EMAIL_REGEXP}, presence: true, uniqueness: true
16 | validates :unconfirmed_email, format: {with: URI::MailTo::EMAIL_REGEXP, allow_blank: true}
17 |
18 | def self.authenticate_by(attributes)
19 | passwords, identifiers = attributes.to_h.partition do |name, value|
20 | !has_attribute?(name) && has_attribute?("#{name}_digest")
21 | end.map(&:to_h)
22 |
23 | raise ArgumentError, "One or more password arguments are required" if passwords.empty?
24 | raise ArgumentError, "One or more finder arguments are required" if identifiers.empty?
25 | if (record = find_by(identifiers))
26 | record if passwords.count { |name, value| record.public_send(:"authenticate_#{name}", value) } == passwords.size
27 | else
28 | new(passwords)
29 | nil
30 | end
31 | end
32 |
33 | def confirm!
34 | if unconfirmed_or_reconfirming?
35 | if unconfirmed_email.present?
36 | return false unless update(email: unconfirmed_email, unconfirmed_email: nil)
37 | end
38 | update_columns(confirmed_at: Time.current)
39 | else
40 | false
41 | end
42 | end
43 |
44 | def confirmed?
45 | confirmed_at.present?
46 | end
47 |
48 | def confirmable_email
49 | if unconfirmed_email.present?
50 | unconfirmed_email
51 | else
52 | email
53 | end
54 | end
55 |
56 | def generate_confirmation_token
57 | signed_id expires_in: CONFIRMATION_TOKEN_EXPIRATION, purpose: :confirm_email
58 | end
59 |
60 | def generate_password_reset_token
61 | signed_id expires_in: PASSWORD_RESET_TOKEN_EXPIRATION, purpose: :reset_password
62 | end
63 |
64 | def send_confirmation_email!
65 | confirmation_token = generate_confirmation_token
66 | UserMailer.confirmation(self, confirmation_token).deliver_now
67 | end
68 |
69 | def send_password_reset_email!
70 | password_reset_token = generate_password_reset_token
71 | UserMailer.password_reset(self, password_reset_token).deliver_now
72 | end
73 |
74 | def reconfirming?
75 | unconfirmed_email.present?
76 | end
77 |
78 | def unconfirmed?
79 | !confirmed?
80 | end
81 |
82 | def unconfirmed_or_reconfirming?
83 | unconfirmed? || reconfirming?
84 | end
85 |
86 | private
87 |
88 | def downcase_email
89 | self.email = email.downcase
90 | end
91 |
92 | def downcase_unconfirmed_email
93 | return if unconfirmed_email.nil?
94 | self.unconfirmed_email = unconfirmed_email.downcase
95 | end
96 | end
97 |
--------------------------------------------------------------------------------
/app/views/active_sessions/_active_session.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <%= active_session.user_agent %> |
3 | <%= active_session.ip_address %> |
4 | <%= active_session.created_at %> |
5 | <%= button_to "Sign Out", active_session_path(active_session), method: :delete %> |
6 |
--------------------------------------------------------------------------------
/app/views/confirmations/new.html.erb:
--------------------------------------------------------------------------------
1 | <%= form_with model: @user, url: confirmations_path do |form| %>
2 | <%= form.email_field :email, required: true %>
3 | <%= form.submit "Confirm Email" %>
4 | <% end %>
--------------------------------------------------------------------------------
/app/views/layouts/application.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | RailsAuthenticationFromScratch
5 |
6 | <%= csrf_meta_tags %>
7 | <%= csp_meta_tag %>
8 |
9 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
10 | <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
11 |
12 |
13 |
14 |
15 | <% flash.each do |message_type, message| %>
16 | <%= message %>
17 | <% end %>
18 |
19 | <% if user_signed_in? %>
20 | - <%= link_to "My Account", account_path %>
21 | - <%= button_to "Logout", logout_path, method: :delete %>
22 | <% else %>
23 | - <%= link_to "Login", login_path %>
24 | - <%= link_to "Sign Up", sign_up_path %>
25 | - <%= link_to "Forgot my password", new_password_path %>
26 | - <%= link_to "Didn't receive confirmation instructions", new_confirmation_path %>
27 | <% end %>
28 |
29 |
30 |
31 |
32 | <%= yield %>
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/app/views/layouts/mailer.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 | <%= yield %>
12 |
13 |
14 |
--------------------------------------------------------------------------------
/app/views/layouts/mailer.text.erb:
--------------------------------------------------------------------------------
1 | <%= yield %>
2 |
--------------------------------------------------------------------------------
/app/views/passwords/edit.html.erb:
--------------------------------------------------------------------------------
1 | <%= form_with url: password_path(params[:password_reset_token]), scope: :user, method: :put do |form| %>
2 |
3 | <%= form.label :password %>
4 | <%= form.password_field :password, required: true %>
5 |
6 |
7 | <%= form.label :password_confirmation %>
8 | <%= form.password_field :password_confirmation, required: true %>
9 |
10 | <%= form.submit "Update Password" %>
11 | <% end %>
--------------------------------------------------------------------------------
/app/views/passwords/new.html.erb:
--------------------------------------------------------------------------------
1 | <%= form_with url: passwords_path, scope: :user do |form| %>
2 | <%= form.email_field :email, required: true %>
3 | <%= form.submit "Reset Password" %>
4 | <% end %>
--------------------------------------------------------------------------------
/app/views/sessions/new.html.erb:
--------------------------------------------------------------------------------
1 | <%= form_with url: login_path, scope: :user do |form| %>
2 |
3 | <%= form.label :email %>
4 | <%= form.email_field :email, required: true %>
5 |
6 |
7 | <%= form.label :password %>
8 | <%= form.password_field :password, required: true %>
9 |
10 |
11 | <%= form.label :remember_me %>
12 | <%= form.check_box :remember_me %>
13 |
14 | <%= form.submit "Sign In" %>
15 | <% end %>
--------------------------------------------------------------------------------
/app/views/shared/_form_errors.html.erb:
--------------------------------------------------------------------------------
1 | <% if object.errors.any? %>
2 |
3 | <% object.errors.full_messages.each do |message| %>
4 | - <%= message %>
5 | <% end %>
6 |
7 | <% end %>
--------------------------------------------------------------------------------
/app/views/static_pages/home.html.erb:
--------------------------------------------------------------------------------
1 | Rails Authentication From Scratch
--------------------------------------------------------------------------------
/app/views/user_mailer/confirmation.html.erb:
--------------------------------------------------------------------------------
1 | Confirmation Instructions
2 |
3 | <%= link_to "Click here to confirm your email.", edit_confirmation_url(@confirmation_token) %>
--------------------------------------------------------------------------------
/app/views/user_mailer/confirmation.text.erb:
--------------------------------------------------------------------------------
1 | Confirmation Instructions
2 |
3 | <%= edit_confirmation_url(@confirmation_token) %>
--------------------------------------------------------------------------------
/app/views/user_mailer/password_reset.html.erb:
--------------------------------------------------------------------------------
1 | Password Reset Instructions
2 |
3 | <%= link_to "Click here to reset your password.", edit_password_url(@password_reset_token) %>
--------------------------------------------------------------------------------
/app/views/user_mailer/password_reset.text.erb:
--------------------------------------------------------------------------------
1 | Password Reset Instructions
2 |
3 | <%= edit_password_url(@password_reset_token) %>
--------------------------------------------------------------------------------
/app/views/users/edit.html.erb:
--------------------------------------------------------------------------------
1 | <%= form_with model: @user, url: account_path, method: :put do |form| %>
2 | <%= render partial: "shared/form_errors", locals: { object: form.object } %>
3 |
4 | <%= form.label :email, "Current Email" %>
5 | <%= form.email_field :email, disabled: true %>
6 |
7 |
8 | <%= form.label :unconfirmed_email, "New Email" %>
9 | <%= form.text_field :unconfirmed_email %>
10 |
11 |
12 | <%= form.label :password, "Password (leave blank if you don't want to change it)" %>
13 | <%= form.password_field :password %>
14 |
15 |
16 | <%= form.label :password_confirmation %>
17 | <%= form.password_field :password_confirmation %>
18 |
19 |
20 |
21 | <%= form.label :current_password, "Current password (we need your current password to confirm your changes)" %>
22 | <%= form.password_field :current_password, required: true %>
23 |
24 | <%= form.submit "Update Account" %>
25 | <% end %>
26 | Current Logins
27 | <% if @active_sessions.any? %>
28 | <%= button_to "Log out of all other sessions", destroy_all_active_sessions_path, method: :delete %>
29 |
30 |
31 |
32 | User Agent |
33 | IP Address |
34 | Signed In At |
35 | Sign Out |
36 |
37 |
38 |
39 | <%= render @active_sessions %>
40 |
41 |
42 | <% end %>
--------------------------------------------------------------------------------
/app/views/users/new.html.erb:
--------------------------------------------------------------------------------
1 | <%= form_with model: @user, url: sign_up_path do |form| %>
2 | <%= render partial: "shared/form_errors", locals: { object: form.object } %>
3 |
4 | <%= form.label :email %>
5 | <%= form.email_field :email, required: true %>
6 |
7 |
8 | <%= form.label :password %>
9 | <%= form.password_field :password, required: true %>
10 |
11 |
12 | <%= form.label :password_confirmation %>
13 | <%= form.password_field :password_confirmation, required: true %>
14 |
15 | <%= form.submit "Sign Up" %>
16 | <% end %>
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function(api) {
2 | var validEnv = ['development', 'test', 'production']
3 | var currentEnv = api.env()
4 | var isDevelopmentEnv = api.env('development')
5 | var isProductionEnv = api.env('production')
6 | var isTestEnv = api.env('test')
7 |
8 | if (!validEnv.includes(currentEnv)) {
9 | throw new Error(
10 | 'Please specify a valid `NODE_ENV` or ' +
11 | '`BABEL_ENV` environment variables. Valid values are "development", ' +
12 | '"test", and "production". Instead, received: ' +
13 | JSON.stringify(currentEnv) +
14 | '.'
15 | )
16 | }
17 |
18 | return {
19 | presets: [
20 | isTestEnv && [
21 | '@babel/preset-env',
22 | {
23 | targets: {
24 | node: 'current'
25 | }
26 | }
27 | ],
28 | (isProductionEnv || isDevelopmentEnv) && [
29 | '@babel/preset-env',
30 | {
31 | forceAllTransforms: true,
32 | useBuiltIns: 'entry',
33 | corejs: 3,
34 | modules: false,
35 | exclude: ['transform-typeof-symbol']
36 | }
37 | ]
38 | ].filter(Boolean),
39 | plugins: [
40 | 'babel-plugin-macros',
41 | '@babel/plugin-syntax-dynamic-import',
42 | isTestEnv && 'babel-plugin-dynamic-import-node',
43 | '@babel/plugin-transform-destructuring',
44 | [
45 | '@babel/plugin-proposal-class-properties',
46 | {
47 | loose: true
48 | }
49 | ],
50 | [
51 | '@babel/plugin-proposal-object-rest-spread',
52 | {
53 | useBuiltIns: true
54 | }
55 | ],
56 | [
57 | '@babel/plugin-proposal-private-methods',
58 | {
59 | loose: true
60 | }
61 | ],
62 | [
63 | '@babel/plugin-proposal-private-property-in-object',
64 | {
65 | loose: true
66 | }
67 | ],
68 | [
69 | '@babel/plugin-transform-runtime',
70 | {
71 | helpers: false
72 | }
73 | ],
74 | [
75 | '@babel/plugin-transform-regenerator',
76 | {
77 | async: false
78 | }
79 | ]
80 | ].filter(Boolean)
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/bin/bundle:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | #
5 | # This file was generated by Bundler.
6 | #
7 | # The application 'bundle' is installed as part of a gem, and
8 | # this file is here to facilitate running it.
9 | #
10 |
11 | require "rubygems"
12 |
13 | m = Module.new do
14 | module_function
15 |
16 | def invoked_as_script?
17 | File.expand_path($0) == File.expand_path(__FILE__)
18 | end
19 |
20 | def env_var_version
21 | ENV["BUNDLER_VERSION"]
22 | end
23 |
24 | def cli_arg_version
25 | return unless invoked_as_script? # don't want to hijack other binstubs
26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update`
27 | bundler_version = nil
28 | update_index = nil
29 | ARGV.each_with_index do |a, i|
30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN
31 | bundler_version = a
32 | end
33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/
34 | bundler_version = $1
35 | update_index = i
36 | end
37 | bundler_version
38 | end
39 |
40 | def gemfile
41 | gemfile = ENV["BUNDLE_GEMFILE"]
42 | return gemfile if gemfile && !gemfile.empty?
43 |
44 | File.expand_path("../../Gemfile", __FILE__)
45 | end
46 |
47 | def lockfile
48 | lockfile =
49 | case File.basename(gemfile)
50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile)
51 | else "#{gemfile}.lock"
52 | end
53 | File.expand_path(lockfile)
54 | end
55 |
56 | def lockfile_version
57 | return unless File.file?(lockfile)
58 | lockfile_contents = File.read(lockfile)
59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/
60 | Regexp.last_match(1)
61 | end
62 |
63 | def bundler_requirement
64 | @bundler_requirement ||=
65 | env_var_version || cli_arg_version ||
66 | bundler_requirement_for(lockfile_version)
67 | end
68 |
69 | def bundler_requirement_for(version)
70 | return "#{Gem::Requirement.default}.a" unless version
71 |
72 | bundler_gem_version = Gem::Version.new(version)
73 |
74 | requirement = bundler_gem_version.approximate_recommendation
75 |
76 | return requirement unless Gem::Version.new(Gem::VERSION) < Gem::Version.new("2.7.0")
77 |
78 | requirement += ".a" if bundler_gem_version.prerelease?
79 |
80 | requirement
81 | end
82 |
83 | def load_bundler!
84 | ENV["BUNDLE_GEMFILE"] ||= gemfile
85 |
86 | activate_bundler
87 | end
88 |
89 | def activate_bundler
90 | gem_error = activation_error_handling do
91 | gem "bundler", bundler_requirement
92 | end
93 | return if gem_error.nil?
94 | require_error = activation_error_handling do
95 | require "bundler/version"
96 | end
97 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION))
98 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`"
99 | exit 42
100 | end
101 |
102 | def activation_error_handling
103 | yield
104 | nil
105 | rescue StandardError, LoadError => e
106 | e
107 | end
108 | end
109 |
110 | m.load_bundler!
111 |
112 | if m.invoked_as_script?
113 | load Gem.bin_path("bundler", "bundle")
114 | end
115 |
--------------------------------------------------------------------------------
/bin/dev:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | if ! command -v foreman &> /dev/null
4 | then
5 | echo "Installing foreman..."
6 | gem install foreman
7 | fi
8 |
9 | foreman start -f Procfile.dev
--------------------------------------------------------------------------------
/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | load File.expand_path("spring", __dir__)
3 | APP_PATH = File.expand_path('../config/application', __dir__)
4 | require_relative "../config/boot"
5 | require "rails/commands"
6 |
--------------------------------------------------------------------------------
/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | load File.expand_path("spring", __dir__)
3 | require_relative "../config/boot"
4 | require "rake"
5 | Rake.application.run
6 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require "fileutils"
3 |
4 | # path to your application root.
5 | APP_ROOT = File.expand_path('..', __dir__)
6 |
7 | def system!(*args)
8 | system(*args) || abort("\n== Command #{args} failed ==")
9 | end
10 |
11 | FileUtils.chdir APP_ROOT do
12 | # This script is a way to set up or update your development environment automatically.
13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome.
14 | # Add necessary setup steps to this file.
15 |
16 | puts '== Installing dependencies =='
17 | system! 'gem install bundler --conservative'
18 | system('bundle check') || system!('bundle install')
19 |
20 | # Install JavaScript dependencies
21 | system! 'bin/yarn'
22 |
23 | # puts "\n== Copying sample files =="
24 | # unless File.exist?('config/database.yml')
25 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml'
26 | # end
27 |
28 | puts "\n== Preparing database =="
29 | system! 'bin/rails db:reset'
30 | system! 'bin/rails db:prepare'
31 |
32 | puts "\n== Removing old logs and tempfiles =="
33 | system! 'bin/rails log:clear tmp:clear'
34 |
35 | puts "\n== Restarting application server =="
36 | system! 'bin/rails restart'
37 |
38 | system! 'bin/rails post_setup_instructions:perform'
39 | end
40 |
--------------------------------------------------------------------------------
/bin/spring:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | if !defined?(Spring) && [nil, "development", "test"].include?(ENV["RAILS_ENV"])
3 | gem "bundler"
4 | require "bundler"
5 |
6 | # Load Spring without loading other gems in the Gemfile, for speed.
7 | Bundler.locked_gems&.specs&.find { |spec| spec.name == "spring" }&.tap do |spring|
8 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path
9 | gem "spring", spring.version
10 | require "spring/binstub"
11 | rescue Gem::LoadError
12 | # Ignore when Spring is not installed.
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/bin/webpack:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
4 | ENV["NODE_ENV"] ||= "development"
5 |
6 | require "pathname"
7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
8 | Pathname.new(__FILE__).realpath)
9 |
10 | require "bundler/setup"
11 |
12 | require "webpacker"
13 | require "webpacker/webpack_runner"
14 |
15 | APP_ROOT = File.expand_path("..", __dir__)
16 | Dir.chdir(APP_ROOT) do
17 | Webpacker::WebpackRunner.run(ARGV)
18 | end
19 |
--------------------------------------------------------------------------------
/bin/webpack-dev-server:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
4 | ENV["NODE_ENV"] ||= "development"
5 |
6 | require "pathname"
7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
8 | Pathname.new(__FILE__).realpath)
9 |
10 | require "bundler/setup"
11 |
12 | require "webpacker"
13 | require "webpacker/dev_server_runner"
14 |
15 | APP_ROOT = File.expand_path("..", __dir__)
16 | Dir.chdir(APP_ROOT) do
17 | Webpacker::DevServerRunner.run(ARGV)
18 | end
19 |
--------------------------------------------------------------------------------
/bin/yarn:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | APP_ROOT = File.expand_path('..', __dir__)
3 | Dir.chdir(APP_ROOT) do
4 | yarn = ENV["PATH"].split(File::PATH_SEPARATOR).
5 | select { |dir| File.expand_path(dir) != __dir__ }.
6 | product(["yarn", "yarn.cmd", "yarn.ps1"]).
7 | map { |dir, file| File.expand_path(file, dir) }.
8 | find { |file| File.executable?(file) }
9 |
10 | if yarn
11 | exec yarn, *ARGV
12 | else
13 | $stderr.puts "Yarn executable was not detected in the system."
14 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install"
15 | exit 1
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/config.ru:
--------------------------------------------------------------------------------
1 | # This file is used by Rack-based servers to start the application.
2 |
3 | require_relative "config/environment"
4 |
5 | run Rails.application
6 | Rails.application.load_server
7 |
--------------------------------------------------------------------------------
/config/application.rb:
--------------------------------------------------------------------------------
1 | require_relative "boot"
2 |
3 | require "rails/all"
4 |
5 | # Require the gems listed in Gemfile, including any gems
6 | # you've limited to :test, :development, or :production.
7 | Bundler.require(*Rails.groups)
8 |
9 | module RailsAuthenticationFromScratch
10 | class Application < Rails::Application
11 | # Initialize configuration defaults for originally generated Rails version.
12 | config.load_defaults 6.1
13 |
14 | # Configuration for the application, engines, and railties goes here.
15 | #
16 | # These settings can be overridden in specific environments using the files
17 | # in config/environments, which are processed later.
18 | #
19 | # config.time_zone = "Central Time (US & Canada)"
20 | # config.eager_load_paths << Rails.root.join("extras")
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/config/boot.rb:
--------------------------------------------------------------------------------
1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
2 |
3 | require "bundler/setup" # Set up gems listed in the Gemfile.
4 | require "bootsnap/setup" # Speed up boot time by caching expensive operations.
5 |
--------------------------------------------------------------------------------
/config/cable.yml:
--------------------------------------------------------------------------------
1 | development:
2 | adapter: async
3 |
4 | test:
5 | adapter: test
6 |
7 | production:
8 | adapter: redis
9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
10 | channel_prefix: rails_authentication_from_scratch_production
11 |
--------------------------------------------------------------------------------
/config/credentials.yml.enc:
--------------------------------------------------------------------------------
1 | rhAzTzSxKa9lipCZrNbJOo2zgo4pVWUxLm48N/RIfv7Ti/+QrZ0tmKRJPgjW3PByyHBTe2j8o5Zybs1eOknTaMBew0woq/PDQNz291JKilA1OsZUiwZiZK+EeX/PHEvnxLP2IfVPmfmjkJzM2DsSBptL0zV8UTgOxLo7IJzVL2o3SYdL4XU//kNvGFKIF7AR+6SGJ87KelZDJzqu4qCS6rBJ0inZRWJ4/9TPSJxcuk2osD0V/DmOUcJX14Sv430oJmckrSpF0IJQiZMUYRioBRMxFXm8JOV5t68V6MoSEvwLQiIIKYmgrO1/FmW+7BtX8qinv/UFO1hkDEbK+/wXjDYDlU0t6Kg1KCcmh6OP7T/izEMSUCxnYO/LVuxEE4vLAfMJB7ij6TV746nyqOr4mBWYUw+XpEIo5Nix--d65cpHzXL0iYBhrA--T1H3XgxsMTBGsYsb8phjFQ==
--------------------------------------------------------------------------------
/config/database.yml:
--------------------------------------------------------------------------------
1 | # SQLite. Versions 3.8.0 and up are supported.
2 | # gem install sqlite3
3 | #
4 | # Ensure the SQLite 3 gem is defined in your Gemfile
5 | # gem 'sqlite3'
6 | #
7 | default: &default
8 | adapter: sqlite3
9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
10 | timeout: 5000
11 |
12 | development:
13 | <<: *default
14 | database: db/development.sqlite3
15 |
16 | # Warning: The database defined as "test" will be erased and
17 | # re-generated from your development database when you run "rake".
18 | # Do not set this db to the same as development or production.
19 | test:
20 | <<: *default
21 | database: db/test.sqlite3
22 |
23 | production:
24 | <<: *default
25 | database: db/production.sqlite3
26 |
--------------------------------------------------------------------------------
/config/environment.rb:
--------------------------------------------------------------------------------
1 | # Load the Rails application.
2 | require_relative "application"
3 |
4 | # Initialize the Rails application.
5 | Rails.application.initialize!
6 |
--------------------------------------------------------------------------------
/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | require "active_support/core_ext/integer/time"
2 |
3 | Rails.application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # In the development environment your application's code is reloaded any time
7 | # it changes. This slows down response time but is perfect for development
8 | # since you don't have to restart the web server when you make code changes.
9 | config.cache_classes = false
10 |
11 | # Do not eager load code on boot.
12 | config.eager_load = false
13 |
14 | # Show full error reports.
15 | config.consider_all_requests_local = true
16 |
17 | # Enable/disable caching. By default caching is disabled.
18 | # Run rails dev:cache to toggle caching.
19 | if Rails.root.join("tmp", "caching-dev.txt").exist?
20 | config.action_controller.perform_caching = true
21 | config.action_controller.enable_fragment_cache_logging = true
22 |
23 | config.cache_store = :memory_store
24 | config.public_file_server.headers = {
25 | "Cache-Control" => "public, max-age=#{2.days.to_i}"
26 | }
27 | else
28 | config.action_controller.perform_caching = false
29 |
30 | config.cache_store = :null_store
31 | end
32 |
33 | # Store uploaded files on the local file system (see config/storage.yml for options).
34 | config.active_storage.service = :local
35 |
36 | # Don't care if the mailer can't send.
37 | config.action_mailer.raise_delivery_errors = false
38 |
39 | config.action_mailer.default_url_options = {host: "localhost", port: 3000}
40 |
41 | config.action_mailer.perform_caching = false
42 |
43 | # Print deprecation notices to the Rails logger.
44 | config.active_support.deprecation = :log
45 |
46 | # Raise exceptions for disallowed deprecations.
47 | config.active_support.disallowed_deprecation = :raise
48 |
49 | # Tell Active Support which deprecation messages to disallow.
50 | config.active_support.disallowed_deprecation_warnings = []
51 |
52 | # Raise an error on page load if there are pending migrations.
53 | config.active_record.migration_error = :page_load
54 |
55 | # Highlight code that triggered database queries in logs.
56 | config.active_record.verbose_query_logs = true
57 |
58 | # Debug mode disables concatenation and preprocessing of assets.
59 | # This option may cause significant delays in view rendering with a large
60 | # number of complex assets.
61 | config.assets.debug = true
62 |
63 | # Suppress logger output for asset requests.
64 | config.assets.quiet = true
65 |
66 | # Raises error for missing translations.
67 | # config.i18n.raise_on_missing_translations = true
68 |
69 | # Annotate rendered view with file names.
70 | # config.action_view.annotate_rendered_view_with_filenames = true
71 |
72 | # Use an evented file watcher to asynchronously detect changes in source code,
73 | # routes, locales, etc. This feature depends on the listen gem.
74 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker
75 |
76 | # Uncomment if you wish to allow Action Cable access from any origin.
77 | # config.action_cable.disable_request_forgery_protection = true
78 | end
79 |
--------------------------------------------------------------------------------
/config/environments/production.rb:
--------------------------------------------------------------------------------
1 | require "active_support/core_ext/integer/time"
2 |
3 | Rails.application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # Code is not reloaded between requests.
7 | config.cache_classes = true
8 |
9 | # Eager load code on boot. This eager loads most of Rails and
10 | # your application in memory, allowing both threaded web servers
11 | # and those relying on copy on write to perform better.
12 | # Rake tasks automatically ignore this option for performance.
13 | config.eager_load = true
14 |
15 | # Full error reports are disabled and caching is turned on.
16 | config.consider_all_requests_local = false
17 | config.action_controller.perform_caching = true
18 |
19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"]
20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files).
21 | # config.require_master_key = true
22 |
23 | # Disable serving static files from the `/public` folder by default since
24 | # Apache or NGINX already handles this.
25 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present?
26 |
27 | # Compress CSS using a preprocessor.
28 | # config.assets.css_compressor = :sass
29 |
30 | # Do not fallback to assets pipeline if a precompiled asset is missed.
31 | config.assets.compile = false
32 |
33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server.
34 | # config.asset_host = 'http://assets.example.com'
35 |
36 | # Specifies the header that your server uses for sending files.
37 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache
38 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
39 |
40 | # Store uploaded files on the local file system (see config/storage.yml for options).
41 | config.active_storage.service = :local
42 |
43 | # Mount Action Cable outside main process or domain.
44 | # config.action_cable.mount_path = nil
45 | # config.action_cable.url = 'wss://example.com/cable'
46 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ]
47 |
48 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
49 | config.force_ssl = true
50 |
51 | # Include generic and useful information about system operation, but avoid logging too much
52 | # information to avoid inadvertent exposure of personally identifiable information (PII).
53 | config.log_level = :info
54 |
55 | # Prepend all log lines with the following tags.
56 | config.log_tags = [:request_id]
57 |
58 | # Use a different cache store in production.
59 | # config.cache_store = :mem_cache_store
60 |
61 | # Use a real queuing backend for Active Job (and separate queues per environment).
62 | # config.active_job.queue_adapter = :resque
63 | # config.active_job.queue_name_prefix = "rails_authentication_from_scratch_production"
64 |
65 | config.action_mailer.perform_caching = false
66 |
67 | # Ignore bad email addresses and do not raise email delivery errors.
68 | # Set this to true and configure the email server for immediate delivery to raise delivery errors.
69 | # config.action_mailer.raise_delivery_errors = false
70 |
71 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
72 | # the I18n.default_locale when a translation cannot be found).
73 | config.i18n.fallbacks = true
74 |
75 | # Send deprecation notices to registered listeners.
76 | config.active_support.deprecation = :notify
77 |
78 | # Log disallowed deprecations.
79 | config.active_support.disallowed_deprecation = :log
80 |
81 | # Tell Active Support which deprecation messages to disallow.
82 | config.active_support.disallowed_deprecation_warnings = []
83 |
84 | # Use default logging formatter so that PID and timestamp are not suppressed.
85 | config.log_formatter = ::Logger::Formatter.new
86 |
87 | # Use a different logger for distributed setups.
88 | # require "syslog/logger"
89 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name')
90 |
91 | if ENV["RAILS_LOG_TO_STDOUT"].present?
92 | logger = ActiveSupport::Logger.new(STDOUT)
93 | logger.formatter = config.log_formatter
94 | config.logger = ActiveSupport::TaggedLogging.new(logger)
95 | end
96 |
97 | # Do not dump schema after migrations.
98 | config.active_record.dump_schema_after_migration = false
99 |
100 | # Inserts middleware to perform automatic connection switching.
101 | # The `database_selector` hash is used to pass options to the DatabaseSelector
102 | # middleware. The `delay` is used to determine how long to wait after a write
103 | # to send a subsequent read to the primary.
104 | #
105 | # The `database_resolver` class is used by the middleware to determine which
106 | # database is appropriate to use based on the time delay.
107 | #
108 | # The `database_resolver_context` class is used by the middleware to set
109 | # timestamps for the last write to the primary. The resolver uses the context
110 | # class timestamps to determine how long to wait before reading from the
111 | # replica.
112 | #
113 | # By default Rails will store a last write timestamp in the session. The
114 | # DatabaseSelector middleware is designed as such you can define your own
115 | # strategy for connection switching and pass that into the middleware through
116 | # these configuration options.
117 | # config.active_record.database_selector = { delay: 2.seconds }
118 | # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
119 | # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
120 | end
121 |
--------------------------------------------------------------------------------
/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | require "active_support/core_ext/integer/time"
2 |
3 | # The test environment is used exclusively to run your application's
4 | # test suite. You never need to work with it otherwise. Remember that
5 | # your test database is "scratch space" for the test suite and is wiped
6 | # and recreated between test runs. Don't rely on the data there!
7 |
8 | Rails.application.configure do
9 | # Settings specified here will take precedence over those in config/application.rb.
10 |
11 | config.cache_classes = false
12 | config.action_view.cache_template_loading = true
13 |
14 | # Do not eager load code on boot. This avoids loading your whole application
15 | # just for the purpose of running a single test. If you are using a tool that
16 | # preloads Rails for running tests, you may have to set it to true.
17 | config.eager_load = false
18 |
19 | # Configure public file server for tests with Cache-Control for performance.
20 | config.public_file_server.enabled = true
21 | config.public_file_server.headers = {
22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}"
23 | }
24 |
25 | # Show full error reports and disable caching.
26 | config.consider_all_requests_local = true
27 | config.action_controller.perform_caching = false
28 | config.cache_store = :null_store
29 |
30 | # Raise exceptions instead of rendering exception templates.
31 | config.action_dispatch.show_exceptions = false
32 |
33 | # Disable request forgery protection in test environment.
34 | config.action_controller.allow_forgery_protection = false
35 |
36 | # Store uploaded files on the local file system in a temporary directory.
37 | config.active_storage.service = :test
38 |
39 | config.action_mailer.perform_caching = false
40 |
41 | # Tell Action Mailer not to deliver emails to the real world.
42 | # The :test delivery method accumulates sent emails in the
43 | # ActionMailer::Base.deliveries array.
44 | config.action_mailer.delivery_method = :test
45 |
46 | config.action_mailer.default_url_options = {host: "example.com"}
47 |
48 | # Print deprecation notices to the stderr.
49 | config.active_support.deprecation = :stderr
50 |
51 | # Raise exceptions for disallowed deprecations.
52 | config.active_support.disallowed_deprecation = :raise
53 |
54 | # Tell Active Support which deprecation messages to disallow.
55 | config.active_support.disallowed_deprecation_warnings = []
56 |
57 | # Raises error for missing translations.
58 | # config.i18n.raise_on_missing_translations = true
59 |
60 | # Annotate rendered view with file names.
61 | # config.action_view.annotate_rendered_view_with_filenames = true
62 | end
63 |
--------------------------------------------------------------------------------
/config/initializers/application_controller_renderer.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # ActiveSupport::Reloader.to_prepare do
4 | # ApplicationController.renderer.defaults.merge!(
5 | # http_host: 'example.org',
6 | # https: false
7 | # )
8 | # end
9 |
--------------------------------------------------------------------------------
/config/initializers/assets.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Version of your assets, change this if you want to expire all your assets.
4 | Rails.application.config.assets.version = "1.0"
5 |
6 | # Add additional assets to the asset load path.
7 | # Rails.application.config.assets.paths << Emoji.images_path
8 | # Add Yarn node_modules folder to the asset load path.
9 | Rails.application.config.assets.paths << Rails.root.join("node_modules")
10 |
11 | # Precompile additional assets.
12 | # application.js, application.css, and all non-JS/CSS in the app/assets
13 | # folder are already added.
14 | # Rails.application.config.assets.precompile += %w( admin.js admin.css )
15 |
--------------------------------------------------------------------------------
/config/initializers/backtrace_silencers.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
4 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) }
5 |
6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code
7 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'".
8 | Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"]
9 |
--------------------------------------------------------------------------------
/config/initializers/content_security_policy.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Define an application-wide content security policy
4 | # For further information see the following documentation
5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
6 |
7 | # Rails.application.config.content_security_policy do |policy|
8 | # policy.default_src :self, :https
9 | # policy.font_src :self, :https, :data
10 | # policy.img_src :self, :https, :data
11 | # policy.object_src :none
12 | # policy.script_src :self, :https
13 | # policy.style_src :self, :https
14 | # # If you are using webpack-dev-server then specify webpack-dev-server host
15 | # policy.connect_src :self, :https, "http://localhost:3035", "ws://localhost:3035" if Rails.env.development?
16 |
17 | # # Specify URI for violation reports
18 | # # policy.report_uri "/csp-violation-report-endpoint"
19 | # end
20 |
21 | # If you are using UJS then enable automatic nonce generation
22 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) }
23 |
24 | # Set the nonce only to specific directives
25 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src)
26 |
27 | # Report CSP violations to a specified URI
28 | # For further information see the following documentation:
29 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only
30 | # Rails.application.config.content_security_policy_report_only = true
31 |
--------------------------------------------------------------------------------
/config/initializers/cookies_serializer.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Specify a serializer for the signed and encrypted cookie jars.
4 | # Valid options are :json, :marshal, and :hybrid.
5 | Rails.application.config.action_dispatch.cookies_serializer = :json
6 |
--------------------------------------------------------------------------------
/config/initializers/filter_parameter_logging.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Configure sensitive parameters which will be filtered from the log file.
4 | Rails.application.config.filter_parameters += [
5 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
6 | ]
7 |
--------------------------------------------------------------------------------
/config/initializers/inflections.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new inflection rules using the following format. Inflections
4 | # are locale specific, and you may define rules for as many different
5 | # locales as you wish. All of these examples are active by default:
6 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
7 | # inflect.plural /^(ox)$/i, '\1en'
8 | # inflect.singular /^(ox)en/i, '\1'
9 | # inflect.irregular 'person', 'people'
10 | # inflect.uncountable %w( fish sheep )
11 | # end
12 |
13 | # These inflection rules are supported but not enabled by default:
14 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
15 | # inflect.acronym 'RESTful'
16 | # end
17 |
--------------------------------------------------------------------------------
/config/initializers/mime_types.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new mime types for use in respond_to blocks:
4 | # Mime::Type.register "text/richtext", :rtf
5 |
--------------------------------------------------------------------------------
/config/initializers/permissions_policy.rb:
--------------------------------------------------------------------------------
1 | # Define an application-wide HTTP permissions policy. For further
2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy
3 | #
4 | # Rails.application.config.permissions_policy do |f|
5 | # f.camera :none
6 | # f.gyroscope :none
7 | # f.microphone :none
8 | # f.usb :none
9 | # f.fullscreen :self
10 | # f.payment :self, "https://secure.example.com"
11 | # end
12 |
--------------------------------------------------------------------------------
/config/initializers/wrap_parameters.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # This file contains settings for ActionController::ParamsWrapper which
4 | # is enabled by default.
5 |
6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
7 | ActiveSupport.on_load(:action_controller) do
8 | wrap_parameters format: [:json]
9 | end
10 |
11 | # To enable root element in JSON for ActiveRecord objects.
12 | # ActiveSupport.on_load(:active_record) do
13 | # self.include_root_in_json = true
14 | # end
15 |
--------------------------------------------------------------------------------
/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | # Files in the config/locales directory are used for internationalization
2 | # and are automatically loaded by Rails. If you want to use locales other
3 | # than English, add the necessary files in this directory.
4 | #
5 | # To use the locales, use `I18n.t`:
6 | #
7 | # I18n.t 'hello'
8 | #
9 | # In views, this is aliased to just `t`:
10 | #
11 | # <%= t('hello') %>
12 | #
13 | # To use a different locale, set it with `I18n.locale`:
14 | #
15 | # I18n.locale = :es
16 | #
17 | # This would use the information in config/locales/es.yml.
18 | #
19 | # The following keys must be escaped otherwise they will not be retrieved by
20 | # the default I18n backend:
21 | #
22 | # true, false, on, off, yes, no
23 | #
24 | # Instead, surround them with single quotes.
25 | #
26 | # en:
27 | # 'true': 'foo'
28 | #
29 | # To learn more, please read the Rails Internationalization guide
30 | # available at https://guides.rubyonrails.org/i18n.html.
31 |
32 | en:
33 | hello: "Hello world"
34 |
--------------------------------------------------------------------------------
/config/puma.rb:
--------------------------------------------------------------------------------
1 | # Puma can serve each request in a thread from an internal thread pool.
2 | # The `threads` method setting takes two numbers: a minimum and maximum.
3 | # Any libraries that use thread pools should be configured to match
4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum
5 | # and maximum; this matches the default thread size of Active Record.
6 | #
7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
9 | threads min_threads_count, max_threads_count
10 |
11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before
12 | # terminating a worker in development environments.
13 | #
14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development"
15 |
16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000.
17 | #
18 | port ENV.fetch("PORT") { 3000 }
19 |
20 | # Specifies the `environment` that Puma will run in.
21 | #
22 | environment ENV.fetch("RAILS_ENV") { "development" }
23 |
24 | # Specifies the `pidfile` that Puma will use.
25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
26 |
27 | # Specifies the number of `workers` to boot in clustered mode.
28 | # Workers are forked web server processes. If using threads and workers together
29 | # the concurrency of the application would be max `threads` * `workers`.
30 | # Workers do not work on JRuby or Windows (both of which do not support
31 | # processes).
32 | #
33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 }
34 |
35 | # Use the `preload_app!` method when specifying a `workers` number.
36 | # This directive tells Puma to first boot the application and load code
37 | # before forking the application. This takes advantage of Copy On Write
38 | # process behavior so workers use less memory.
39 | #
40 | # preload_app!
41 |
42 | # Allow puma to be restarted by `rails restart` command.
43 | plugin :tmp_restart
44 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | Rails.application.routes.draw do
2 | root "static_pages#home"
3 | post "sign_up", to: "users#create"
4 | get "sign_up", to: "users#new"
5 | put "account", to: "users#update"
6 | get "account", to: "users#edit"
7 | delete "account", to: "users#destroy"
8 | resources :confirmations, only: [:create, :edit, :new], param: :confirmation_token
9 | post "login", to: "sessions#create"
10 | delete "logout", to: "sessions#destroy"
11 | get "login", to: "sessions#new"
12 | resources :passwords, only: [:create, :edit, :new, :update], param: :password_reset_token
13 | resources :active_sessions, only: [:destroy] do
14 | collection do
15 | delete "destroy_all"
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/config/spring.rb:
--------------------------------------------------------------------------------
1 | Spring.watch(
2 | ".ruby-version",
3 | ".rbenv-vars",
4 | "tmp/restart.txt",
5 | "tmp/caching-dev.txt"
6 | )
7 |
--------------------------------------------------------------------------------
/config/storage.yml:
--------------------------------------------------------------------------------
1 | test:
2 | service: Disk
3 | root: <%= Rails.root.join("tmp/storage") %>
4 |
5 | local:
6 | service: Disk
7 | root: <%= Rails.root.join("storage") %>
8 |
9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
10 | # amazon:
11 | # service: S3
12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
14 | # region: us-east-1
15 | # bucket: your_own_bucket
16 |
17 | # Remember not to checkin your GCS keyfile to a repository
18 | # google:
19 | # service: GCS
20 | # project: your_project
21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
22 | # bucket: your_own_bucket
23 |
24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
25 | # microsoft:
26 | # service: AzureStorage
27 | # storage_account_name: your_account_name
28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
29 | # container: your_container_name
30 |
31 | # mirror:
32 | # service: Mirror
33 | # primary: local
34 | # mirrors: [ amazon, google, microsoft ]
35 |
--------------------------------------------------------------------------------
/config/webpack/development.js:
--------------------------------------------------------------------------------
1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development'
2 |
3 | const environment = require('./environment')
4 |
5 | module.exports = environment.toWebpackConfig()
6 |
--------------------------------------------------------------------------------
/config/webpack/environment.js:
--------------------------------------------------------------------------------
1 | const { environment } = require('@rails/webpacker')
2 |
3 | module.exports = environment
4 |
--------------------------------------------------------------------------------
/config/webpack/production.js:
--------------------------------------------------------------------------------
1 | process.env.NODE_ENV = process.env.NODE_ENV || 'production'
2 |
3 | const environment = require('./environment')
4 |
5 | module.exports = environment.toWebpackConfig()
6 |
--------------------------------------------------------------------------------
/config/webpack/test.js:
--------------------------------------------------------------------------------
1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development'
2 |
3 | const environment = require('./environment')
4 |
5 | module.exports = environment.toWebpackConfig()
6 |
--------------------------------------------------------------------------------
/config/webpacker.yml:
--------------------------------------------------------------------------------
1 | # Note: You must restart bin/webpack-dev-server for changes to take effect
2 |
3 | default: &default
4 | source_path: app/javascript
5 | source_entry_path: packs
6 | public_root_path: public
7 | public_output_path: packs
8 | cache_path: tmp/cache/webpacker
9 | webpack_compile_output: true
10 |
11 | # Additional paths webpack should lookup modules
12 | # ['app/assets', 'engine/foo/app/assets']
13 | additional_paths: []
14 |
15 | # Reload manifest.json on all requests so we reload latest compiled packs
16 | cache_manifest: false
17 |
18 | # Extract and emit a css file
19 | extract_css: false
20 |
21 | static_assets_extensions:
22 | - .jpg
23 | - .jpeg
24 | - .png
25 | - .gif
26 | - .tiff
27 | - .ico
28 | - .svg
29 | - .eot
30 | - .otf
31 | - .ttf
32 | - .woff
33 | - .woff2
34 |
35 | extensions:
36 | - .mjs
37 | - .js
38 | - .sass
39 | - .scss
40 | - .css
41 | - .module.sass
42 | - .module.scss
43 | - .module.css
44 | - .png
45 | - .svg
46 | - .gif
47 | - .jpeg
48 | - .jpg
49 |
50 | development:
51 | <<: *default
52 | compile: true
53 |
54 | # Reference: https://webpack.js.org/configuration/dev-server/
55 | dev_server:
56 | https: false
57 | host: localhost
58 | port: 3035
59 | public: localhost:3035
60 | hmr: false
61 | # Inline should be set to true if using HMR
62 | inline: true
63 | overlay: true
64 | compress: true
65 | disable_host_check: true
66 | use_local_ip: false
67 | quiet: false
68 | pretty: false
69 | headers:
70 | 'Access-Control-Allow-Origin': '*'
71 | watch_options:
72 | ignored: '**/node_modules/**'
73 |
74 |
75 | test:
76 | <<: *default
77 | compile: true
78 |
79 | # Compile test packs to a separate directory
80 | public_output_path: packs-test
81 |
82 | production:
83 | <<: *default
84 |
85 | # Production depends on precompilation of packs prior to booting for performance.
86 | compile: false
87 |
88 | # Extract and emit a css file
89 | extract_css: true
90 |
91 | # Cache manifest.json for performance
92 | cache_manifest: true
93 |
--------------------------------------------------------------------------------
/db/migrate/20211109214151_create_users.rb:
--------------------------------------------------------------------------------
1 | class CreateUsers < ActiveRecord::Migration[6.1]
2 | def change
3 | create_table :users do |t|
4 | # Prevent blank values
5 | t.string :email, null: false
6 |
7 | t.timestamps
8 | end
9 |
10 | # Enforce unique values
11 | # https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_index
12 | add_index :users, :email, unique: true
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/db/migrate/20211112152821_add_confirmation_and_password_columns_to_users.rb:
--------------------------------------------------------------------------------
1 | class AddConfirmationAndPasswordColumnsToUsers < ActiveRecord::Migration[6.1]
2 | def change
3 | add_column :users, :confirmed_at, :datetime
4 | add_column :users, :password_digest, :string, null: false
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/db/migrate/20211203155851_add_unconfirmed_email_to_users.rb:
--------------------------------------------------------------------------------
1 | class AddUnconfirmedEmailToUsers < ActiveRecord::Migration[6.1]
2 | def change
3 | add_column :users, :unconfirmed_email, :string
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/db/migrate/20211205165850_add_remember_token_to_users.rb:
--------------------------------------------------------------------------------
1 | class AddRememberTokenToUsers < ActiveRecord::Migration[6.1]
2 | def change
3 | add_column :users, :remember_token, :string, null: false
4 | add_index :users, :remember_token, unique: true
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/db/migrate/20220129144819_create_active_sessions.rb:
--------------------------------------------------------------------------------
1 | class CreateActiveSessions < ActiveRecord::Migration[6.1]
2 | def change
3 | create_table :active_sessions do |t|
4 | t.references :user, null: false, foreign_key: {on_delete: :cascade}
5 |
6 | t.timestamps
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/db/migrate/20220201102359_add_request_columns_to_active_sessions.rb:
--------------------------------------------------------------------------------
1 | class AddRequestColumnsToActiveSessions < ActiveRecord::Migration[6.1]
2 | def change
3 | add_column :active_sessions, :user_agent, :string
4 | add_column :active_sessions, :ip_address, :string
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/db/migrate/20220204201046_move_remember_token_from_users_to_active_sessions.rb:
--------------------------------------------------------------------------------
1 | # TODO: Remove comment
2 | # rails g migration move_remember_token_from_users_to_active_sessions
3 | class MoveRememberTokenFromUsersToActiveSessions < ActiveRecord::Migration[6.1]
4 | def change
5 | remove_column :users, :remember_token
6 | add_column :active_sessions, :remember_token, :string, null: false
7 |
8 | add_index :active_sessions, :remember_token, unique: true
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/db/schema.rb:
--------------------------------------------------------------------------------
1 | # This file is auto-generated from the current state of the database. Instead
2 | # of editing this file, please use the migrations feature of Active Record to
3 | # incrementally modify your database, and then regenerate this schema definition.
4 | #
5 | # This file is the source Rails uses to define your schema when running `bin/rails
6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
7 | # be faster and is potentially less error prone than running all of your
8 | # migrations from scratch. Old migrations may fail to apply correctly if those
9 | # migrations use external dependencies or application code.
10 | #
11 | # It's strongly recommended that you check this file into your version control system.
12 |
13 | ActiveRecord::Schema.define(version: 2022_02_04_201046) do
14 |
15 | create_table "active_sessions", force: :cascade do |t|
16 | t.integer "user_id", null: false
17 | t.datetime "created_at", precision: 6, null: false
18 | t.datetime "updated_at", precision: 6, null: false
19 | t.string "user_agent"
20 | t.string "ip_address"
21 | t.string "remember_token", null: false
22 | t.index ["remember_token"], name: "index_active_sessions_on_remember_token", unique: true
23 | t.index ["user_id"], name: "index_active_sessions_on_user_id"
24 | end
25 |
26 | create_table "users", force: :cascade do |t|
27 | t.string "email", null: false
28 | t.datetime "created_at", precision: 6, null: false
29 | t.datetime "updated_at", precision: 6, null: false
30 | t.datetime "confirmed_at"
31 | t.string "password_digest", null: false
32 | t.string "unconfirmed_email"
33 | t.index ["email"], name: "index_users_on_email", unique: true
34 | end
35 |
36 | add_foreign_key "active_sessions", "users", on_delete: :cascade
37 | end
38 |
--------------------------------------------------------------------------------
/db/seeds.rb:
--------------------------------------------------------------------------------
1 | # This file should contain all the record creation needed to seed the database with its default values.
2 | # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
3 | #
4 | # Examples:
5 | #
6 | # movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }])
7 | # Character.create(name: 'Luke', movie: movies.first)
8 | puts "\n== Creating user =="
9 |
10 | User.create!(
11 | email: "confirmed_user@example.com",
12 | password: "password",
13 | password_confirmation: "password",
14 | confirmed_at: Time.current
15 | )
16 |
--------------------------------------------------------------------------------
/lib/assets/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/lib/assets/.keep
--------------------------------------------------------------------------------
/lib/tasks/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/lib/tasks/.keep
--------------------------------------------------------------------------------
/lib/tasks/post_setup_instructions.rake:
--------------------------------------------------------------------------------
1 | namespace :post_setup_instructions do
2 | desc "Prints instructions after running the setup script"
3 | task perform: :environment do
4 | puts "\n== Setup complete 🎉 =="
5 | puts "👉 Run ./bin/dev to start the development server"
6 | puts "\n== You can login with the following account 🔐 =="
7 | puts "Email: confirmed_user@example.com"
8 | puts "Password: password"
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/log/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/log/.keep
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rails-authentication-from-scratch",
3 | "private": true,
4 | "dependencies": {
5 | "@rails/actioncable": "^6.0.0",
6 | "@rails/activestorage": "^6.0.0",
7 | "@rails/ujs": "^6.0.0",
8 | "@rails/webpacker": "5.4.3",
9 | "turbolinks": "^5.2.0",
10 | "webpack": "^4.46.0",
11 | "webpack-cli": "^3.3.12"
12 | },
13 | "version": "0.1.0",
14 | "devDependencies": {
15 | "webpack-dev-server": "^3"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | require('postcss-import'),
4 | require('postcss-flexbugs-fixes'),
5 | require('postcss-preset-env')({
6 | autoprefixer: {
7 | flexbox: 'no-2009'
8 | },
9 | stage: 3
10 | })
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The page you were looking for doesn't exist (404)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
The page you were looking for doesn't exist.
62 |
You may have mistyped the address or the page may have moved.
63 |
64 |
If you are the application owner check the logs for more information.
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/public/422.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The change you wanted was rejected (422)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
The change you wanted was rejected.
62 |
Maybe you tried to change something you didn't have access to.
63 |
64 |
If you are the application owner check the logs for more information.
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/public/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | We're sorry, but something went wrong (500)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
We're sorry, but something went wrong.
62 |
63 |
If you are the application owner check the logs for more information.
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/public/apple-touch-icon-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/public/apple-touch-icon-precomposed.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/public/favicon.ico
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 |
--------------------------------------------------------------------------------
/storage/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/storage/.keep
--------------------------------------------------------------------------------
/test/application_system_test_case.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
4 | driven_by :selenium, using: :chrome, screen_size: [1400, 1400]
5 | end
6 |
--------------------------------------------------------------------------------
/test/channels/application_cable/connection_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
4 | # test "connects with cookies" do
5 | # cookies.signed[:user_id] = 42
6 | #
7 | # connect
8 | #
9 | # assert_equal connection.user_id, "42"
10 | # end
11 | end
12 |
--------------------------------------------------------------------------------
/test/controllers/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/test/controllers/.keep
--------------------------------------------------------------------------------
/test/controllers/active_sessions_controller_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class ActiveSessionsControllerTest < ActionDispatch::IntegrationTest
4 | setup do
5 | @confirmed_user = User.create!(email: "confirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: Time.current)
6 | end
7 |
8 | test "should destroy all active sessions" do
9 | login @confirmed_user
10 | @confirmed_user.active_sessions.create!
11 |
12 | assert_difference("ActiveSession.count", -2) do
13 | delete destroy_all_active_sessions_path
14 | end
15 |
16 | assert_redirected_to root_path
17 | assert_nil current_user
18 | assert_not_nil flash[:notice]
19 | end
20 |
21 | test "should destroy all active sessions and forget active sessions" do
22 | login @confirmed_user, remember_user: true
23 | @confirmed_user.active_sessions.create!
24 |
25 | assert_difference("ActiveSession.count", -2) do
26 | delete destroy_all_active_sessions_path
27 | end
28 |
29 | assert_nil current_user
30 | assert cookies[:remember_token].blank?
31 | end
32 |
33 | test "should destroy another session" do
34 | login @confirmed_user
35 | @confirmed_user.active_sessions.create!
36 |
37 | assert_difference("ActiveSession.count", -1) do
38 | delete active_session_path(@confirmed_user.active_sessions.last)
39 | end
40 |
41 | assert_redirected_to account_path
42 | assert_not_nil current_user
43 | assert_not_nil flash[:notice]
44 | end
45 |
46 | test "should destroy current session" do
47 | login @confirmed_user
48 |
49 | assert_difference("ActiveSession.count", -1) do
50 | delete active_session_path(@confirmed_user.active_sessions.last)
51 | end
52 |
53 | assert_redirected_to root_path
54 | assert_nil current_user
55 | assert_not_nil flash[:notice]
56 | end
57 |
58 | test "should destroy current session and forget current active session" do
59 | login @confirmed_user, remember_user: true
60 |
61 | assert_difference("ActiveSession.count", -1) do
62 | delete active_session_path(@confirmed_user.active_sessions.last)
63 | end
64 |
65 | assert_nil current_user
66 | assert cookies[:remember_token].blank?
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/test/controllers/confirmations_controller_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
4 | setup do
5 | @reconfirmed_user = User.create!(email: "reconfirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: 1.week.ago, unconfirmed_email: "unconfirmed_email@example.com")
6 | @confirmed_user = User.create!(email: "confirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: 1.week.ago)
7 | @unconfirmed_user = User.create!(email: "unconfirmed_user@example.com", password: "password", password_confirmation: "password")
8 | end
9 |
10 | test "should confirm unconfirmed user" do
11 | freeze_time do
12 | confirmation_token = @unconfirmed_user.generate_confirmation_token
13 |
14 | get edit_confirmation_path(confirmation_token)
15 |
16 | assert @unconfirmed_user.reload.confirmed?
17 | assert_equal Time.now, @unconfirmed_user.confirmed_at
18 | assert_redirected_to root_path
19 | assert_not_nil flash[:notice]
20 | end
21 | end
22 |
23 | test "should reconfirm confirmed user" do
24 | unconfirmed_email = @reconfirmed_user.unconfirmed_email
25 |
26 | freeze_time do
27 | confirmation_token = @reconfirmed_user.generate_confirmation_token
28 |
29 | get edit_confirmation_path(confirmation_token)
30 |
31 | assert @reconfirmed_user.reload.confirmed?
32 | assert_equal Time.current, @reconfirmed_user.reload.confirmed_at
33 | assert_equal unconfirmed_email, @reconfirmed_user.reload.email
34 | assert_nil @reconfirmed_user.reload.unconfirmed_email
35 | assert_redirected_to root_path
36 | assert_not_nil flash[:notice]
37 | end
38 | end
39 |
40 | test "should not update email address if already taken" do
41 | original_email = @reconfirmed_user.email
42 | @reconfirmed_user.update(unconfirmed_email: @confirmed_user.email)
43 |
44 | freeze_time do
45 | confirmation_token = @reconfirmed_user.generate_confirmation_token
46 |
47 | get edit_confirmation_path(confirmation_token)
48 |
49 | assert_equal original_email, @reconfirmed_user.reload.email
50 | assert_redirected_to new_confirmation_path
51 | assert_not_nil flash[:alert]
52 | end
53 | end
54 |
55 | test "should redirect if confirmation link expired" do
56 | confirmation_token = @unconfirmed_user.generate_confirmation_token
57 |
58 | travel_to 601.seconds.from_now do
59 | get edit_confirmation_path(confirmation_token)
60 |
61 | assert_nil @unconfirmed_user.reload.confirmed_at
62 | assert_not @unconfirmed_user.reload.confirmed?
63 | assert_redirected_to new_confirmation_path
64 | assert_not_nil flash[:alert]
65 | end
66 | end
67 |
68 | test "should redirect if confirmation link is incorrect" do
69 | get edit_confirmation_path("not_a_real_token")
70 | assert_redirected_to new_confirmation_path
71 | assert_not_nil flash[:alert]
72 | end
73 |
74 | test "should resend confirmation email if user is unconfirmed" do
75 | assert_emails 1 do
76 | post confirmations_path, params: {user: {email: @unconfirmed_user.email}}
77 | end
78 |
79 | assert_redirected_to root_path
80 | assert_not_nil flash[:notice]
81 | end
82 |
83 | test "should prevent user from confirming if they are already confirmed" do
84 | assert_no_emails do
85 | post confirmations_path, params: {user: {email: @confirmed_user.email}}
86 | end
87 | assert_redirected_to new_confirmation_path
88 | assert_not_nil flash[:alert]
89 | end
90 |
91 | test "should get new if not authenticated" do
92 | get new_confirmation_path
93 | assert_response :ok
94 | end
95 |
96 | test "should prevent authenticated user from confirming" do
97 | freeze_time do
98 | confirmation_token = @confirmed_user.generate_confirmation_token
99 |
100 | login @confirmed_user
101 |
102 | get edit_confirmation_path(confirmation_token)
103 |
104 | assert_not_equal Time.current, @confirmed_user.reload.confirmed_at
105 | assert_redirected_to new_confirmation_path
106 | assert_not_nil flash[:alert]
107 | end
108 | end
109 |
110 | test "should not prevent authenticated user confirming their unconfirmed_email" do
111 | unconfirmed_email = @reconfirmed_user.unconfirmed_email
112 |
113 | freeze_time do
114 | login @reconfirmed_user
115 |
116 | confirmation_token = @reconfirmed_user.generate_confirmation_token
117 |
118 | get edit_confirmation_path(confirmation_token)
119 |
120 | assert_equal Time.current, @reconfirmed_user.reload.confirmed_at
121 | assert @reconfirmed_user.reload.confirmed?
122 | assert_equal unconfirmed_email, @reconfirmed_user.reload.email
123 | assert_nil @reconfirmed_user.reload.unconfirmed_email
124 | assert_redirected_to root_path
125 | assert_not_nil flash[:notice]
126 | end
127 | end
128 |
129 | test "should prevent authenticated user from submitting the confirmation form" do
130 | login @confirmed_user
131 |
132 | get new_confirmation_path
133 | assert_redirected_to root_path
134 | assert_not_nil flash[:alert]
135 |
136 | assert_no_emails do
137 | post confirmations_path, params: {user: {email: @confirmed_user.email}}
138 | end
139 |
140 | assert_redirected_to root_path
141 | assert_not_nil flash[:alert]
142 | end
143 | end
144 |
--------------------------------------------------------------------------------
/test/controllers/passwords_controller_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class PasswordsControllerTest < ActionDispatch::IntegrationTest
4 | setup do
5 | @confirmed_user = User.create!(email: "confirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: 1.week.ago)
6 | end
7 |
8 | test "should get edit" do
9 | password_reset_token = @confirmed_user.generate_password_reset_token
10 |
11 | get edit_password_path(password_reset_token)
12 | assert_response :ok
13 | end
14 |
15 | test "should redirect from edit if password link expired" do
16 | password_reset_token = @confirmed_user.generate_password_reset_token
17 |
18 | travel_to 601.seconds.from_now
19 | get edit_password_path(password_reset_token)
20 |
21 | assert_redirected_to new_password_path
22 | assert_not_nil flash[:alert]
23 | end
24 |
25 | test "should redirect from edit if password link is incorrect" do
26 | get edit_password_path("not_a_real_token")
27 |
28 | assert_redirected_to new_password_path
29 | assert_not_nil flash[:alert]
30 | end
31 |
32 | test "should redirect from edit if user is not confirmed" do
33 | @confirmed_user.update!(confirmed_at: nil)
34 | password_reset_token = @confirmed_user.generate_password_reset_token
35 |
36 | get edit_password_path(password_reset_token)
37 |
38 | assert_redirected_to new_confirmation_path
39 | assert_not_nil flash[:alert]
40 | end
41 |
42 | test "should redirect from edit if user is authenticated" do
43 | password_reset_token = @confirmed_user.generate_password_reset_token
44 |
45 | login @confirmed_user
46 |
47 | get edit_password_path(password_reset_token)
48 | assert_redirected_to root_path
49 | end
50 |
51 | test "should get new" do
52 | get new_password_path
53 | assert_response :ok
54 | end
55 |
56 | test "should redirect from new if user is authenticated" do
57 | login @confirmed_user
58 |
59 | get new_password_path
60 | assert_redirected_to root_path
61 | end
62 |
63 | test "should send password reset mailer" do
64 | assert_emails 1 do
65 | post passwords_path, params: {
66 | user: {
67 | email: @confirmed_user.email.upcase
68 | }
69 | }
70 | end
71 |
72 | assert_redirected_to root_path
73 | assert_not_nil flash[:notice]
74 | end
75 |
76 | test "should update password" do
77 | password_reset_token = @confirmed_user.generate_password_reset_token
78 |
79 | put password_path(password_reset_token), params: {
80 | user: {
81 | password: "password",
82 | password_confirmation: "password"
83 | }
84 | }
85 |
86 | assert_redirected_to login_path
87 | assert_not_nil flash[:notice]
88 | end
89 |
90 | test "should handle errors" do
91 | password_reset_token = @confirmed_user.generate_password_reset_token
92 |
93 | put password_path(password_reset_token), params: {
94 | user: {
95 | password: "password",
96 | password_confirmation: "password_that_does_not_match"
97 | }
98 | }
99 |
100 | assert_not_nil flash[:alert]
101 | end
102 |
103 | test "should not update password if authenticated" do
104 | password_reset_token = @confirmed_user.generate_password_reset_token
105 |
106 | login @confirmed_user
107 |
108 | put password_path(password_reset_token), params: {
109 | user: {
110 | password: "password",
111 | password_confirmation: "password"
112 |
113 | }
114 | }
115 |
116 | get new_password_path
117 | assert_redirected_to root_path
118 | end
119 | end
120 |
--------------------------------------------------------------------------------
/test/controllers/sessions_controller_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class SessionsControllerTest < ActionDispatch::IntegrationTest
4 | setup do
5 | @unconfirmed_user = User.create!(email: "unconfirmed_user@example.com", password: "password", password_confirmation: "password")
6 | @confirmed_user = User.create!(email: "confirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: Time.current)
7 | end
8 |
9 | test "should get login if anonymous" do
10 | get login_path
11 | assert_response :ok
12 | end
13 |
14 | test "should redirect from login if authenticated" do
15 | login @confirmed_user
16 |
17 | get login_path
18 | assert_redirected_to root_path
19 | end
20 |
21 | test "should login and create active session if confirmed" do
22 | assert_difference("@confirmed_user.active_sessions.count") do
23 | post login_path, params: {
24 | user: {
25 | email: @confirmed_user.email,
26 | password: @confirmed_user.password
27 | }
28 | }
29 | end
30 | assert_redirected_to root_path
31 | assert_equal @confirmed_user, current_user
32 | end
33 |
34 | test "should remember user when logging in" do
35 | assert_nil cookies[:remember_token]
36 |
37 | post login_path, params: {
38 | user: {
39 | email: @confirmed_user.email,
40 | password: @confirmed_user.password,
41 | remember_me: 1
42 | }
43 | }
44 |
45 | assert_not_nil current_user
46 | assert_not_nil cookies[:remember_token]
47 | end
48 |
49 | test "should forget user when logging out" do
50 | login @confirmed_user, remember_user: true
51 |
52 | delete logout_path
53 |
54 | # FIXME: Expected "" to be nil.
55 | # When I run byebug in SessionsController#destroy cookies[:remember_token] does == nil.
56 | # I think this might be a bug in Rails?
57 | # assert_nil cookies[:remember_token]
58 | assert cookies[:remember_token].blank?
59 | assert_nil current_user
60 | assert_redirected_to root_path
61 | assert_not_nil flash[:notice]
62 | end
63 |
64 | test "should not login if unconfirmed" do
65 | post login_path, params: {
66 | user: {
67 | email: @unconfirmed_user.email,
68 | password: @unconfirmed_user.password
69 | }
70 | }
71 | assert_equal "Please confirm your email first.", flash[:alert]
72 | assert_nil current_user
73 | assert_redirected_to new_confirmation_path
74 | end
75 |
76 | test "should handle invalid login" do
77 | post login_path, params: {
78 | user: {
79 | email: @confirmed_user.email,
80 | password: "foo"
81 | }
82 | }
83 | assert_not_nil flash[:alert]
84 | assert_nil current_user
85 | end
86 |
87 | test "should logout and delete current active session if authenticated" do
88 | login @confirmed_user
89 |
90 | assert_difference("@confirmed_user.active_sessions.count", -1) do
91 | delete logout_path
92 | end
93 |
94 | assert_nil current_user
95 | assert_redirected_to root_path
96 | assert_not_nil flash[:notice]
97 | end
98 |
99 | test "should not logout if anonymous" do
100 | login @confirmed_user
101 |
102 | delete logout_path
103 | assert_redirected_to root_path
104 | end
105 | end
106 |
--------------------------------------------------------------------------------
/test/controllers/static_pages_controller_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class StaticPagesControllerTest < ActionDispatch::IntegrationTest
4 | # test "the truth" do
5 | # assert true
6 | # end
7 | end
8 |
--------------------------------------------------------------------------------
/test/controllers/users_controller_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class UsersControllerTest < ActionDispatch::IntegrationTest
4 | setup do
5 | @confirmed_user = User.create!(email: "confirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: Time.current)
6 | end
7 |
8 | test "should load sign up page for anonymous users" do
9 | get sign_up_path
10 | assert_response :ok
11 | end
12 |
13 | test "should redirect authenticated users from signing up" do
14 | login @confirmed_user
15 |
16 | get sign_up_path
17 | assert_redirected_to root_path
18 |
19 | assert_no_difference("User.count") do
20 | post sign_up_path, params: {
21 | user: {
22 | email: "some_unique_email@example.com",
23 | password: "password",
24 | password_confirmation: "password"
25 | }
26 | }
27 | end
28 | end
29 |
30 | test "should create user and send confirmation instructions" do
31 | assert_difference("User.count", 1) do
32 | assert_emails 1 do
33 | post sign_up_path, params: {
34 | user: {
35 | email: "some_unique_email@example.com",
36 | password: "password",
37 | password_confirmation: "password"
38 | }
39 | }
40 | end
41 | end
42 |
43 | assert_redirected_to root_path
44 | assert_not_nil flash[:notice]
45 | end
46 |
47 | test "should handle errors when signing up" do
48 | assert_no_difference("User.count") do
49 | assert_no_emails do
50 | post sign_up_path, params: {
51 | user: {
52 | email: "some_unique_email@example.com",
53 | password: "password",
54 | password_confirmation: "wrong_password"
55 | }
56 | }
57 | end
58 | end
59 | end
60 |
61 | test "should get edit if authorized" do
62 | login(@confirmed_user)
63 |
64 | get account_path
65 | assert_response :ok
66 | end
67 |
68 | test "should redirect unauthorized user from editing account" do
69 | get account_path
70 | assert_redirected_to login_path
71 | assert_not_nil flash[:alert]
72 | end
73 |
74 | test "should edit email" do
75 | unconfirmed_email = "unconfirmed_user@example.com"
76 | current_email = @confirmed_user.email
77 |
78 | login(@confirmed_user)
79 |
80 | assert_emails 1 do
81 | put account_path, params: {
82 | user: {
83 | unconfirmed_email: unconfirmed_email,
84 | current_password: "password"
85 | }
86 | }
87 | end
88 |
89 | assert_not_nil flash[:notice]
90 | assert_equal current_email, @confirmed_user.reload.email
91 | end
92 |
93 | test "should not edit email if current_password is incorrect" do
94 | unconfirmed_email = "unconfirmed_user@example.com"
95 | current_email = @confirmed_user.email
96 |
97 | login(@confirmed_user)
98 |
99 | assert_no_emails do
100 | put account_path, params: {
101 | user: {
102 | unconfirmed_email: unconfirmed_email,
103 | current_password: "wrong_password"
104 | }
105 | }
106 | end
107 |
108 | assert_not_nil flash[:notice]
109 | assert_equal current_email, @confirmed_user.reload.email
110 | end
111 |
112 | test "should update password" do
113 | login(@confirmed_user)
114 |
115 | put account_path, params: {
116 | user: {
117 | current_password: "password",
118 | password: "new_password",
119 | password_confirmation: "new_password"
120 | }
121 | }
122 |
123 | assert_redirected_to root_path
124 | assert_not_nil flash[:notice]
125 | end
126 |
127 | test "should not update password if current_password is incorrect" do
128 | login(@confirmed_user)
129 |
130 | put account_path, params: {
131 | user: {
132 | current_password: "wrong_password",
133 | password: "new_password",
134 | password_confirmation: "new_password"
135 | }
136 | }
137 |
138 | assert_response :unprocessable_entity
139 | end
140 |
141 | test "should delete user" do
142 | login(@confirmed_user)
143 |
144 | delete account_path(@confirmed_user)
145 |
146 | assert_nil current_user
147 | assert_redirected_to root_path
148 | assert_not_nil flash[:notice]
149 | end
150 | end
151 |
--------------------------------------------------------------------------------
/test/fixtures/active_sessions.yml:
--------------------------------------------------------------------------------
1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
--------------------------------------------------------------------------------
/test/fixtures/files/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/test/fixtures/files/.keep
--------------------------------------------------------------------------------
/test/fixtures/users.yml:
--------------------------------------------------------------------------------
1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
--------------------------------------------------------------------------------
/test/helpers/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/test/helpers/.keep
--------------------------------------------------------------------------------
/test/integration/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/test/integration/.keep
--------------------------------------------------------------------------------
/test/integration/friendly_redirects_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class FriendlyRedirectsTest < ActionDispatch::IntegrationTest
4 | setup do
5 | @confirmed_user = User.create!(email: "confirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: Time.current)
6 | end
7 |
8 | test "redirect to requested url after sign in" do
9 | get account_path
10 |
11 | assert_redirected_to login_path
12 | login(@confirmed_user)
13 |
14 | assert_redirected_to account_path
15 | end
16 |
17 | test "redirects to root path after sign in" do
18 | get login_path
19 | login(@confirmed_user)
20 |
21 | assert_redirected_to root_path
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/test/integration/user_interface_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class UserInterfaceTest < ActionDispatch::IntegrationTest
4 | setup do
5 | @confirmed_user = User.create!(email: "confirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: Time.current)
6 | end
7 |
8 | test "should render active sessions on account page" do
9 | login @confirmed_user
10 | @confirmed_user.active_sessions.last.update!(user_agent: "Mozilla", ip_address: "123.457.789")
11 |
12 | get account_path
13 |
14 | assert_match "Mozilla", @response.body
15 | assert_match "123.457.789", @response.body
16 | end
17 |
18 | test "should render buttons to delete specific active sessions" do
19 | login @confirmed_user
20 |
21 | get account_path
22 |
23 | assert_select "input[type='submit']" do
24 | assert_select "[value=?]", "Log out of all other sessions"
25 | end
26 | assert_match destroy_all_active_sessions_path, @response.body
27 |
28 | assert_select "table" do
29 | assert_select "input[type='submit']" do
30 | assert_select "[value=?]", "Sign Out"
31 | end
32 | end
33 | assert_match active_session_path(@confirmed_user.active_sessions.last), @response.body
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/test/mailers/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/test/mailers/.keep
--------------------------------------------------------------------------------
/test/mailers/previews/user_mailer_preview.rb:
--------------------------------------------------------------------------------
1 | # Preview all emails at http://localhost:3000/rails/mailers/user_mailer
2 | class UserMailerPreview < ActionMailer::Preview
3 | # Preview this email at http://localhost:3000/rails/mailers/user_mailer/confirmation
4 | def confirmation
5 | @unconfirmed_user = User.find_by(email: "unconfirmed_user@example.com") || User.create!(email: "unconfirmed_user@example.com", password: "password", password_confirmation: "password")
6 | @unconfirmed_user.update!(confirmed_at: nil)
7 | confirmation_token = @unconfirmed_user.generate_confirmation_token
8 | UserMailer.confirmation(@unconfirmed_user, confirmation_token)
9 | end
10 |
11 | # Preview this email at http://localhost:3000/rails/mailers/user_mailer/password_reset
12 | def password_reset
13 | @password_reset_user = User.find_by(email: "password_reset_user@example.com") || User.create!(email: "password_reset_user@example.com", password: "password", password_confirmation: "password", confirmed_at: Time.current)
14 | password_reset_token = @password_reset_user.generate_password_reset_token
15 | UserMailer.password_reset(@password_reset_user, password_reset_token)
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/mailers/user_mailer_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class UserMailerTest < ActionMailer::TestCase
4 | setup do
5 | @user = User.create!(email: "some_unique_email@example.com", password: "password", password_confirmation: "password")
6 | end
7 |
8 | test "confirmation" do
9 | confirmation_token = @user.generate_confirmation_token
10 | mail = UserMailer.confirmation(@user, confirmation_token)
11 | assert_equal "Confirmation Instructions", mail.subject
12 | assert_equal [@user.email], mail.to
13 | assert_equal [User::MAILER_FROM_EMAIL], mail.from
14 | assert_match confirmation_token, mail.body.encoded
15 | end
16 |
17 | test "password_reset" do
18 | password_reset_token = @user.generate_password_reset_token
19 | mail = UserMailer.password_reset(@user, password_reset_token)
20 | assert_equal "Password Reset Instructions", mail.subject
21 | assert_equal [@user.email], mail.to
22 | assert_equal [User::MAILER_FROM_EMAIL], mail.from
23 | assert_match password_reset_token, mail.body.encoded
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/test/models/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/test/models/.keep
--------------------------------------------------------------------------------
/test/models/active_session_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class ActiveSessionTest < ActiveSupport::TestCase
4 | setup do
5 | @user = User.new(email: "unique_email@example.com", password: "password", password_confirmation: "password")
6 | @active_session = @user.active_sessions.build
7 | end
8 |
9 | test "should be valid" do
10 | assert @active_session.valid?
11 | end
12 |
13 | test "should have a user" do
14 | @active_session.user = nil
15 |
16 | assert_not @active_session.valid?
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/test/models/user_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class UserTest < ActiveSupport::TestCase
4 | include ActionMailer::TestHelper
5 |
6 | setup do
7 | @user = User.new(email: "unique_email@example.com", password: "password", password_confirmation: "password")
8 | end
9 |
10 | test "should be valid" do
11 | assert @user.valid?
12 | end
13 |
14 | test "should have email" do
15 | @user.email = nil
16 | assert_not @user.valid?
17 | end
18 |
19 | test "email should be unique" do
20 | @user.save!
21 | @invalid_user = User.new(email: @user.email)
22 |
23 | assert_not @invalid_user.valid?
24 | end
25 |
26 | test "email should be saved as lowercase" do
27 | email = "unique_email@example.com"
28 |
29 | @user = User.new(email: email.upcase, password: "password", password_confirmation: "password")
30 | @user.save!
31 |
32 | assert_equal email.downcase, @user.email
33 | end
34 |
35 | test "email should be valid" do
36 | invalid_emails = %w[foo foo@ foo@bar.]
37 |
38 | invalid_emails.each do |invalid_email|
39 | @user.email = invalid_email
40 | assert_not @user.valid?
41 | end
42 | end
43 |
44 | test "should respond to confirmed?" do
45 | assert_not @user.confirmed?
46 |
47 | @user.confirmed_at = Time.now
48 |
49 | assert @user.confirmed?
50 | end
51 |
52 | test "should respond to unconfirmed?" do
53 | assert @user.unconfirmed?
54 |
55 | @user.confirmed_at = Time.now
56 |
57 | assert_not @user.unconfirmed?
58 | end
59 |
60 | test "should respond to reconfirming?" do
61 | assert_not @user.reconfirming?
62 |
63 | @user.unconfirmed_email = "unconfirmed_email@example.com"
64 |
65 | assert @user.reconfirming?
66 | end
67 |
68 | test "should respond to unconfirmed_or_reconfirming?" do
69 | assert @user.unconfirmed_or_reconfirming?
70 |
71 | @user.unconfirmed_email = "unconfirmed_email@example.com"
72 | @user.confirmed_at = Time.now
73 |
74 | assert @user.unconfirmed_or_reconfirming?
75 | end
76 |
77 | test "should send confirmation email" do
78 | @user.save!
79 |
80 | assert_emails 1 do
81 | @user.send_confirmation_email!
82 | end
83 |
84 | assert_equal @user.email, ActionMailer::Base.deliveries.last.to[0]
85 | end
86 |
87 | test "should send confirmation email to unconfirmed_email" do
88 | @user.save!
89 | @user.update!(unconfirmed_email: "unconfirmed_email@example.com")
90 |
91 | assert_emails 1 do
92 | @user.send_confirmation_email!
93 | end
94 |
95 | assert_equal @user.unconfirmed_email, ActionMailer::Base.deliveries.last.to[0]
96 | end
97 |
98 | test "should respond to send_password_reset_email!" do
99 | @user.save!
100 |
101 | assert_emails 1 do
102 | @user.send_password_reset_email!
103 | end
104 | end
105 |
106 | test "should downcase unconfirmed_email" do
107 | email = "UNCONFIRMED_EMAIL@EXAMPLE.COM"
108 | @user.unconfirmed_email = email
109 | @user.save!
110 |
111 | assert_equal email.downcase, @user.unconfirmed_email
112 | end
113 |
114 | test "unconfirmed_email should be valid" do
115 | invalid_emails = %w[foo foo@ foo@bar.]
116 |
117 | invalid_emails.each do |invalid_email|
118 | @user.unconfirmed_email = invalid_email
119 | assert_not @user.valid?
120 | end
121 | end
122 |
123 | test "unconfirmed_email does not need to be available" do
124 | @user.save!
125 | @user.unconfirmed_email = @user.email
126 | assert @user.valid?
127 | end
128 |
129 | test ".confirm! should return false if already confirmed" do
130 | @confirmed_user = User.new(email: "unique_email@example.com", password: "password", password_confirmation: "password", confirmed_at: Time.current)
131 |
132 | assert_not @confirmed_user.confirm!
133 | end
134 |
135 | test ".confirm! should update email if reconfirming" do
136 | @reconfirmed_user = User.new(email: "unique_email@example.com", password: "password", password_confirmation: "password", confirmed_at: 1.week.ago, unconfirmed_email: "unconfirmed_email@example.com")
137 | new_email = @reconfirmed_user.unconfirmed_email
138 |
139 | freeze_time do
140 | @reconfirmed_user.confirm!
141 |
142 | assert_equal new_email, @reconfirmed_user.reload.email
143 | assert_nil @reconfirmed_user.reload.unconfirmed_email
144 | assert_equal Time.current, @reconfirmed_user.reload.confirmed_at
145 | end
146 | end
147 |
148 | test ".confirm! should not update email if already taken" do
149 | @confirmed_user = User.create!(email: "user1@example.com", password: "password", password_confirmation: "password")
150 | @reconfirmed_user = User.create!(email: "user2@example.com", password: "password", password_confirmation: "password", confirmed_at: 1.week.ago, unconfirmed_email: @confirmed_user.email)
151 |
152 | freeze_time do
153 | assert_not @reconfirmed_user.confirm!
154 | end
155 | end
156 |
157 | test ".confirm! should set confirmed_at" do
158 | @unconfirmed_user = User.create!(email: "unique_email@example.com", password: "password", password_confirmation: "password")
159 |
160 | freeze_time do
161 | @unconfirmed_user.confirm!
162 |
163 | assert_equal Time.current, @unconfirmed_user.reload.confirmed_at
164 | end
165 | end
166 |
167 | test "should create active session" do
168 | @user.save!
169 |
170 | assert_difference("@user.active_sessions.count", 1) do
171 | @user.active_sessions.create!
172 | end
173 | end
174 |
175 | test "should destroy associated active session when destryoed" do
176 | @user.save!
177 | @user.active_sessions.create!
178 |
179 | assert_difference("@user.active_sessions.count", -1) do
180 | @user.destroy!
181 | end
182 | end
183 | end
184 |
--------------------------------------------------------------------------------
/test/system/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/test/system/.keep
--------------------------------------------------------------------------------
/test/system/logins_test.rb:
--------------------------------------------------------------------------------
1 | require "application_system_test_case"
2 |
3 | class LoginsTest < ApplicationSystemTestCase
4 | setup do
5 | @confirmed_user = User.create!(email: "confirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: Time.current)
6 | end
7 |
8 | test "should login and create active session if confirmed" do
9 | visit login_path
10 |
11 | fill_in "Email", with: @confirmed_user.email
12 | fill_in "Password", with: @confirmed_user.password
13 | click_on "Sign In"
14 |
15 | assert_not_nil @confirmed_user.active_sessions.last.user_agent
16 | assert_not_nil @confirmed_user.active_sessions.last.ip_address
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | ENV["RAILS_ENV"] ||= "test"
2 | require_relative "../config/environment"
3 | require "rails/test_help"
4 |
5 | class ActiveSupport::TestCase
6 | # Run tests in parallel with specified workers
7 | parallelize(workers: :number_of_processors)
8 |
9 | # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
10 | fixtures :all
11 |
12 | # Add more helper methods to be used by all tests here...
13 | def current_user
14 | if session[:current_active_session_id].present?
15 | ActiveSession.find_by(id: session[:current_active_session_id])&.user
16 | elsif cookies[:remember_token]
17 | ActiveSession.find_by(remember_token: cookies[:remember_token])&.user
18 | end
19 | end
20 |
21 | def login(user, remember_user: nil)
22 | post login_path, params: {
23 | user: {
24 | email: user.email,
25 | password: user.password,
26 | remember_me: remember_user == true ? 1 : 0
27 | }
28 | }
29 | end
30 |
31 | def logout
32 | session.delete(:current_active_session_id)
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/tmp/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/tmp/.keep
--------------------------------------------------------------------------------
/tmp/pids/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/tmp/pids/.keep
--------------------------------------------------------------------------------
/vendor/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stevepolitodesign/rails-authentication-from-scratch/0e9d1de6b214958df37b362786413c9ed5a289b5/vendor/.keep
--------------------------------------------------------------------------------