-
Notifications
You must be signed in to change notification settings - Fork 10
Add user authentication from scratch #149
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
| end | ||
|
|
||
| def edit | ||
| @user = User.find_by_token_for(:password_reset, params[:password_reset_token]) |
Check warning
Code scanning / CodeQL
Sensitive data read from GET request
| return redirect_to new_users_confirmation_path, alert: "You must confirm your email before you can sign in" | ||
| end | ||
|
|
||
| render Users::Passwords::EditView.new(user: @user, password_reset_token: params[:password_reset_token]) |
Check warning
Code scanning / CodeQL
Sensitive data read from GET request
|
|
||
| describe "#save" do | ||
| def create_user!(email:, password: "password", password_confirmation: "password") | ||
| User.create!(email: email, password: password, password_confirmation: password_confirmation) |
Check failure
Code scanning / CodeQL
Clear-text storage of sensitive information
|
|
||
| describe "#save" do | ||
| def create_user!(email:, password: "password", password_confirmation: "password") | ||
| User.create!(email: email, password: password, password_confirmation: password_confirmation) |
Check failure
Code scanning / CodeQL
Clear-text storage of sensitive information
We prevent empty values from being saved into the email column through a null: false constraint in addition to the presence validation. We enforce unique email addresses at the database level through add_index :users, :email, unique: true in addition to a uniqueness validation. We ensure all emails are valid through a format validation. We save all emails to the database in a downcase format via a before_save callback such that the values are saved in a consistent format. We use URI::MailTo::EMAIL_REGEXP that comes with Ruby to validate that the email address is properly formatted. https://stevepolito.design/blog/rails-authentication-from-scratch#step-1-build-user-model
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. The password_digest column will store a hashed version of the user’s password. This is provided by the has_secure_password method. https://stevepolito.design/blog/rails-authentication-from-scratch#step-2-add-confirmation-and-password-columns-to-users-table
The has_secure_password method is added to give us an API to work with the password_digest column. The confirm! method will be called when a user confirms their email address. We still need to build this feature. The confirmed? and unconfirmed? methods allow us to tell whether a user has confirmed their email address or not. The generate_confirmation_token method creates a signed_id that will be used to securely identify the user. For added security, we ensure that this ID will expire. This will be useful when we build the confirmation mailer. https://stevepolito.design/blog/rails-authentication-from-scratch#step-2-add-confirmation-and-password-columns-to-users-table Fix for add password to user
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. 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 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://stevepolito.design/blog/rails-authentication-from-scratch#step-3-create-sign-up-pages
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. We call 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. The links in the mailer will take the user to ConfirmationsController#edit at which point they’ll be confirmed. https://stevepolito.design/blog/rails-authentication-from-scratch#step-5-create-confirmation-mailer
The Current class inherits from ActiveSupport::CurrentAttributes 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. 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. We use warden to serialize the user’s ID in the 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. 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. The current_user method returns a User and sets it as the user on the Current class we created. We use memoization to avoid fetching the User each time we call the method. We call the before_action filter so that we have access to the current user before each request. We also add this as a helper_method so that we have access to current_user in the views. 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. https://stevepolito.design/blog/rails-authentication-from-scratch#step-6-create-current-model-and-authentication-concern
Adds a Sessions controller and views for new sessions 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. We’re able to call user.authenticate because of has_secure_password Note that we call downcase on the email to account for case sensitivity when searching. Note that we set the flash to “Incorrect email or password.” if the user is unconfirmed. This prevents leaking email addresses. The destroy method simply calls the logout method we created in the Authentication Concern. 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. https://stevepolito.design/blog/rails-authentication-from-scratch#step-7-create-login-page
Creates a signed token 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. 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. https://stevepolito.design/blog/rails-authentication-from-scratch#step-9-add-password-reset-functionality
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. You’ll remember that the password_reset_token is a 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. 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. 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. The new action simply renders a form for the user to put their email address in to receive the password reset email. 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. 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. We add param: :password_reset_token as a named route parameter 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. https://stevepolito.design/blog/rails-authentication-from-scratch#step-9-add-password-reset-functionality
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. https://stevepolito.design/blog/rails-authentication-from-scratch#step-9-add-password-reset-functionality
We add a unconfirmed_email 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. We ensure to format the unconfirmed_email before saving it to the database. This ensures all data is saved consistently. We add validations to the unconfirmed_email table ensuring it’s a valid email address. We update the confirm! method to set the email column to the value of the unconfirmed_email table, and then archive all pending unconfirmed emails. 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. We add the confirmable_email method so that we can call the correct email in the updated UserMailer. 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.
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. Adapted from https://stevepolito.design/blog/rails-authentication-from-scratch#step-11-add-unconfirmed-email-column-to-users-table
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. We call authenticate_user! before editing, destroying, or updating a user since only an authenticated user should be able to do this. 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. 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. The edit action simply assigns @user to the current_user so that we have access to the user in the edit form. 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. 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. 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. 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. We require the current_password field since we’ll always want a user to confirm their password before making changes. The password and password_confirmation fields are there if a user wants to update their current password. Adapted from https://stevepolito.design/blog/rails-authentication-from-scratch#step-12-update-users-controller
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. https://stevepolito.design/blog/rails-authentication-from-scratch#step-13-update-confirmations-controller
The store_location method stores the request.original_url in the 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. 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. 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. Note that we call this method before calling login. This is because login calls reset_session which would deleted the session[:user_return_to]. Adapted from https://stevepolito.design/blog/rails-authentication-from-scratch#step-17-add-friendly-redirects
Use form_with for reg edit view form_with was added to replace form_for rails/rails#26976 Extract helpers for front door form Extract FormDoorForm component for registration Refactor user forms to FrontDoorForm
The password challenge feature was recently added to go with has_secure_password rails/rails#43688
Adds ability to create User accounts using email and password. Includes functionality for confirming email address, resetting password, and login/logout.
Authentication from scratch design adapted from https://stevepolito.design/blog/rails-authentication-from-scratch and updated to use new Rails 7 token generation.
Auth views created with Phlex.
Adds notification model to record events for sending emails adapted from https://github.com/excid3/noticed.
Pull request checklist
bin/verify