Skip to content

Conversation

@rossta
Copy link
Contributor

@rossta rossta commented Jun 10, 2024

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

  • I have written tests for code I have added or modified.
  • I linted and tested the project with bin/verify

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

[Route handler](1) for GET requests uses query parameter as sensitive data.
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

[Route handler](1) for GET requests uses query parameter as sensitive data.

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

This stores sensitive data returned by [a parameter password](1) as clear text.

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

This stores sensitive data returned by [a parameter password_confirmation](1) as clear text.
rossta added 29 commits June 10, 2024 09:03
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
rossta added 2 commits June 13, 2024 08:52
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
@rossta rossta merged commit 35d0f70 into main Jun 17, 2024
@rossta rossta deleted the feat/auth branch June 17, 2024 02:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants