├── .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 | 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 | 1474 | 1475 | 1476 | 1477 | 1478 | 1479 | <%= render @active_sessions %> 1480 | 1481 |
User AgentIP AddressSigned In At
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 | 1556 | 1557 | 1558 | 1559 | 1560 | 1561 | 1562 | <%= render @active_sessions %> 1563 | 1564 |
User AgentIP AddressSigned In AtSign Out
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 | 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 | 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 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | <%= render @active_sessions %> 40 | 41 |
User AgentIP AddressSigned In AtSign Out
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 --------------------------------------------------------------------------------