From 20ce18ca744e4715eb68f5d88e6cfb07b65e6b4b Mon Sep 17 00:00:00 2001 From: Runar Ingebrigtsen Date: Fri, 30 Jan 2026 10:08:41 +0100 Subject: [PATCH] remember me, password reset --- .../admin/invitations_controller.rb | 9 +- app/controllers/admin/requests_controller.rb | 6 +- app/controllers/application_controller.rb | 39 +++++- app/controllers/password_resets_controller.rb | 79 +++++++++++ app/controllers/sessions_controller.rb | 4 +- app/mailers/password_reset_mailer.rb | 9 ++ app/models/user.rb | 35 +++++ app/views/invitations/show.html.erb | 2 +- .../password_reset_mailer/reset.html.erb | 130 ++++++++++++++++++ app/views/password_resets/edit.html.erb | 60 ++++++++ app/views/password_resets/new.html.erb | 57 ++++++++ app/views/sessions/new.html.erb | 12 +- config/environments/development.rb | 4 +- config/routes.rb | 7 +- ...60130080745_add_password_reset_to_users.rb | 7 + ...60130080931_add_remember_token_to_users.rb | 7 + db/structure.sql | 6 +- docs/TODO.md | 9 +- 18 files changed, 457 insertions(+), 25 deletions(-) create mode 100644 app/controllers/password_resets_controller.rb create mode 100644 app/mailers/password_reset_mailer.rb create mode 100644 app/views/password_reset_mailer/reset.html.erb create mode 100644 app/views/password_resets/edit.html.erb create mode 100644 app/views/password_resets/new.html.erb create mode 100644 db/migrate/20260130080745_add_password_reset_to_users.rb create mode 100644 db/migrate/20260130080931_add_remember_token_to_users.rb diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb index fbe3f5d..b39c912 100644 --- a/app/controllers/admin/invitations_controller.rb +++ b/app/controllers/admin/invitations_controller.rb @@ -14,9 +14,7 @@ class Admin::InvitationsController < Admin::BaseController def create @invitation = User.new(invitation_params) - @invitation.invitation_token = SecureRandom.urlsafe_base64(32) - @invitation.invitation_sent_at = Time.current - @invitation.invited_by = current_user + @invitation.invite_by(current_user) @invitation.password = SecureRandom.urlsafe_base64(16) if @invitation.save @@ -37,10 +35,7 @@ class Admin::InvitationsController < Admin::BaseController return end - @invitation.update!( - invitation_token: SecureRandom.urlsafe_base64(32), - invitation_sent_at: Time.current - ) + @invitation.invite_by!(current_user) InvitationMailer.invite(@invitation).deliver_later diff --git a/app/controllers/admin/requests_controller.rb b/app/controllers/admin/requests_controller.rb index af5572b..4eb5626 100644 --- a/app/controllers/admin/requests_controller.rb +++ b/app/controllers/admin/requests_controller.rb @@ -32,11 +32,7 @@ class Admin::RequestsController < Admin::BaseController @entry = Entry.find(params[:id]) @user = @entry.requested_by - @user.update!( - invitation_token: SecureRandom.urlsafe_base64(32), - invitation_sent_at: Time.current, - invited_by: current_user - ) + @user.invite_by!(current_user) @entry.update!(status: :approved) InvitationMailer.invite(@user, approved_entry: @entry).deliver_later diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 60e5832..930bb3a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,6 +4,10 @@ class ApplicationController < ActionController::Base # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes + SESSION_TIMEOUT = 3.days + + before_action :check_session_timeout + helper_method :supported_languages, :current_user, :logged_in?, :admin?, :reviewer_or_admin?, :contributor_or_above?, :setup_completed? @@ -14,7 +18,40 @@ class ApplicationController < ActionController::Base end def current_user - @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id] + return @current_user if defined?(@current_user) + + # First check session + if session[:user_id] + @current_user = User.find_by(id: session[:user_id]) + # Then check remember me cookie + elsif cookies.signed[:remember_token] + user = User.find_by_valid_remember_token(cookies.signed[:remember_token]) + if user + session[:user_id] = user.id + @current_user = user + else + # Invalid or expired remember token, clear it + cookies.delete(:remember_token) + end + end + + @current_user + end + + def check_session_timeout + return unless logged_in? + return if cookies.signed[:remember_token].present? + + if session[:last_activity_at].present? + last_activity = Time.parse(session[:last_activity_at]) + if last_activity < SESSION_TIMEOUT.ago + reset_session + redirect_to login_path, alert: "Your session has expired. Please sign in again." + return + end + end + + session[:last_activity_at] = Time.current.to_s end def logged_in? diff --git a/app/controllers/password_resets_controller.rb b/app/controllers/password_resets_controller.rb new file mode 100644 index 0000000..ef92fce --- /dev/null +++ b/app/controllers/password_resets_controller.rb @@ -0,0 +1,79 @@ +class PasswordResetsController < ApplicationController + RESET_TOKEN_EXPIRY = 1.hour + + def new + # Show request password reset form + end + + def create + @user = User.find_by(email: params[:email]&.downcase&.strip) + + if @user&.invitation_accepted_at.present? + @user.update!( + reset_password_token: SecureRandom.urlsafe_base64(32), + reset_password_sent_at: Time.current + ) + PasswordResetMailer.reset(@user).deliver_later + else + @user.invite_by! + InvitationMailer.invite(@user).deliver_later + end + + redirect_to login_path, notice: "If that email address is in our system, you will receive password reset instructions." + end + + def edit + @user = User.find_by(reset_password_token: params[:token]) + + if @user.nil? + redirect_to login_path, alert: "Invalid password reset link." + elsif password_reset_expired?(@user) + redirect_to new_password_reset_path, alert: "Password reset link has expired. Please request a new one." + end + end + + def update + @user = User.find_by(reset_password_token: params[:token]) + + if @user.nil? + redirect_to login_path, alert: "Invalid password reset link." + return + end + + if password_reset_expired?(@user) + redirect_to new_password_reset_path, alert: "Password reset link has expired. Please request a new one." + return + end + + if params[:password].blank? + flash.now[:alert] = "Password cannot be blank." + render :edit, status: :unprocessable_entity + return + end + + if params[:password] != params[:password_confirmation] + flash.now[:alert] = "Password confirmation doesn't match." + render :edit, status: :unprocessable_entity + return + end + + @user.password = params[:password] + @user.password_confirmation = params[:password_confirmation] + @user.reset_password_token = nil + @user.reset_password_sent_at = nil + + if @user.save + session[:user_id] = @user.id + redirect_to root_path, notice: "Your password has been reset successfully." + else + flash.now[:alert] = @user.errors.full_messages.join(", ") + render :edit, status: :unprocessable_entity + end + end + + private + + def password_reset_expired?(user) + user.reset_password_sent_at < RESET_TOKEN_EXPIRY.ago + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 247f92a..7418de0 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -26,7 +26,9 @@ class SessionsController < ApplicationController end def destroy - session[:user_id] = nil + current_user&.forget_me if cookies.signed[:remember_token] + reset_session + cookies.delete(:remember_token) redirect_to root_path, notice: "You have been logged out." end end diff --git a/app/mailers/password_reset_mailer.rb b/app/mailers/password_reset_mailer.rb new file mode 100644 index 0000000..e72acef --- /dev/null +++ b/app/mailers/password_reset_mailer.rb @@ -0,0 +1,9 @@ +class PasswordResetMailer < ApplicationMailer + def reset(user) + @user = user + @reset_url = edit_password_reset_url(@user.reset_password_token) + @expires_at = @user.reset_password_sent_at + PasswordResetsController::RESET_TOKEN_EXPIRY + + mail(to: @user.email, subject: "Reset your Sanasto Wiki password") + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 3099f30..394f946 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -27,6 +27,8 @@ class User < ApplicationRecord # Invitation token expires after 14 days INVITATION_TOKEN_EXPIRY = 14.days + # Remember me token expires after 2 weeks + REMEMBER_TOKEN_EXPIRY = 2.weeks def invitation_expired? return false if invitation_sent_at.nil? @@ -37,10 +39,43 @@ class User < ApplicationRecord invitation_token.present? && invitation_accepted_at.nil? && !invitation_expired? end + def invite_by(invitee) + self.invited_by = invitee if invitee && invited_by.nil? + self.invitation_token = SecureRandom.urlsafe_base64(32) + self.invitation_sent_at = Time.current + end + + def invite_by!(invitee = nil) + invite_by(invitee) + save! + end + def self.find_by_valid_invitation_token(token) where(invitation_token: token) .where(invitation_accepted_at: nil) .where("invitation_sent_at > ?", INVITATION_TOKEN_EXPIRY.ago) .first end + + def remember_me + self.remember_token = SecureRandom.urlsafe_base64(32) + self.remember_created_at = Time.current + save(validate: false) + remember_token + end + + def forget_me + update_columns(remember_token: nil, remember_created_at: nil) + end + + def remember_token_expired? + return true if remember_created_at.nil? + remember_created_at < REMEMBER_TOKEN_EXPIRY.ago + end + + def self.find_by_valid_remember_token(token) + user = find_by(remember_token: token) + return nil if user.nil? || user.remember_token_expired? + user + end end diff --git a/app/views/invitations/show.html.erb b/app/views/invitations/show.html.erb index 2a6d184..74e9dd6 100644 --- a/app/views/invitations/show.html.erb +++ b/app/views/invitations/show.html.erb @@ -36,7 +36,7 @@ <% end %> - <%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, class: "space-y-5" do |form| %> + <%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
<%= form.label :password, "Set Your Password", class: "block text-sm font-medium text-slate-700 mb-2" %> <%= form.password_field :password, diff --git a/app/views/password_reset_mailer/reset.html.erb b/app/views/password_reset_mailer/reset.html.erb new file mode 100644 index 0000000..b7936cb --- /dev/null +++ b/app/views/password_reset_mailer/reset.html.erb @@ -0,0 +1,130 @@ + + + + + + + +
+

Sanasto Wiki

+

Password Reset Request

+
+ +
+

Hello <%= @user.name %>,

+ +

+ We received a request to reset your password for your Sanasto Wiki account. +

+ +

+ If you made this request, click the button below to set a new password: +

+ +
+ <%= link_to "Reset My Password", @reset_url, class: "button" %> +
+ +
+ This password reset link will expire on <%= @expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %>. +
+ +

+ You can also copy and paste this link into your browser: +

+

+ <%= @reset_url %> +

+ +
+ Didn't request a password reset? +

+ If you didn't make this request, you can safely ignore this email. Your password will remain unchanged. +

+
+ + +
+ + diff --git a/app/views/password_resets/edit.html.erb b/app/views/password_resets/edit.html.erb new file mode 100644 index 0000000..de25296 --- /dev/null +++ b/app/views/password_resets/edit.html.erb @@ -0,0 +1,60 @@ +<% content_for :title, "Set New Password" %> + +
+
+
+
+ <%= link_to root_path, class: "flex items-center gap-2" do %> + Sanasto + Wiki + <% end %> +
+
+
+ +
+
+
+
+

Set new password

+

Enter your new password below.

+
+ + <% if flash[:alert] %> + + <% end %> + + <%= form_with url: password_reset_path(params[:token]), method: :patch, local: true, data: { turbo: false }, class: "space-y-5" do |form| %> +
+ <%= form.label :password, "New Password", class: "block text-sm font-medium text-slate-700 mb-2" %> + <%= form.password_field :password, + autofocus: true, + autocomplete: "new-password", + required: true, + minlength: 8, + placeholder: "••••••••••••", + class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %> +

Minimum 8 characters

+
+ +
+ <%= form.label :password_confirmation, "Confirm New Password", class: "block text-sm font-medium text-slate-700 mb-2" %> + <%= form.password_field :password_confirmation, + autocomplete: "new-password", + required: true, + minlength: 8, + placeholder: "••••••••••••", + class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %> +
+ +
+ <%= form.submit "Reset Password", + class: "w-full bg-indigo-600 text-white px-4 py-3 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %> +
+ <% end %> +
+
+
+
diff --git a/app/views/password_resets/new.html.erb b/app/views/password_resets/new.html.erb new file mode 100644 index 0000000..5a77d01 --- /dev/null +++ b/app/views/password_resets/new.html.erb @@ -0,0 +1,57 @@ +<% content_for :title, "Reset Password" %> + +
+
+
+
+ <%= link_to root_path, class: "flex items-center gap-2" do %> + Sanasto + Wiki + <% end %> +
+
+
+ +
+
+
+
+

Reset your password

+

Enter your email address and we'll send you a link to reset your password.

+
+ + <% if flash[:alert] %> + + <% end %> + + <%= form_with url: password_resets_path, method: :post, local: true, data: { turbo: false }, class: "space-y-5" do |form| %> +
+ <%= form.label :email, "Email", class: "block text-sm font-medium text-slate-700 mb-2" %> + <%= form.email_field :email, + autofocus: true, + autocomplete: "email", + required: true, + placeholder: "you@example.com", + class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %> +
+ +
+ <%= form.submit "Send Reset Instructions", + class: "w-full bg-indigo-600 text-white px-4 py-3 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %> +
+ <% end %> + +
+ <%= link_to login_path, class: "text-sm text-slate-600 hover:text-indigo-600 transition inline-flex items-center gap-1" do %> + + + + Back to Sign In + <% end %> +
+
+
+
+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 6d63994..41d670b 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -26,7 +26,7 @@
<% end %> - <%= form_with url: login_path, method: :post, local: true, class: "space-y-5" do |form| %> + <%= form_with url: login_path, method: :post, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
<%= form.label :email, "Email", class: "block text-sm font-medium text-slate-700 mb-2" %> <%= form.email_field :email, @@ -38,7 +38,10 @@
- <%= form.label :password, "Password", class: "block text-sm font-medium text-slate-700 mb-2" %> +
+ <%= form.label :password, "Password", class: "block text-sm font-medium text-slate-700" %> + <%= link_to "Forgot password?", new_password_reset_path, class: "text-xs text-indigo-600 hover:text-indigo-700 font-medium" %> +
<%= form.password_field :password, autocomplete: "current-password", required: true, @@ -46,6 +49,11 @@ class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
+
+ <%= check_box_tag :remember_me, "1", false, class: "w-4 h-4 text-indigo-600 border-slate-300 rounded focus:ring-2 focus:ring-indigo-500" %> + <%= label_tag :remember_me, "Remember me for 2 weeks", class: "ml-2 text-sm text-slate-600" %> +
+
<%= form.submit "Sign In", class: "w-full bg-indigo-600 text-white px-4 py-3 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %> diff --git a/config/environments/development.rb b/config/environments/development.rb index 9f4f347..2bbbc12 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -31,8 +31,8 @@ Rails.application.configure do # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = :local - # Don't care if the mailer can't send. - config.action_mailer.raise_delivery_errors = false + # Raise delivery errors to see what's happening with email + config.action_mailer.raise_delivery_errors = true # Make template changes take effect immediately. config.action_mailer.perform_caching = false diff --git a/config/routes.rb b/config/routes.rb index c00f110..a266e2c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,7 +19,12 @@ Rails.application.routes.draw do # Authentication routes get "login", to: "sessions#new", as: :login post "login", to: "sessions#create" - delete "logout", to: "sessions#destroy", as: :logout + get "logout", to: "sessions#destroy", as: :logout + + # Password reset routes + resources :password_resets, only: [ :new, :create ] + get "password_resets/:token/edit", to: "password_resets#edit", as: :edit_password_reset + patch "password_resets/:token", to: "password_resets#update", as: :password_reset # Invitation acceptance routes get "invitations/:token", to: "invitations#show", as: :invitation diff --git a/db/migrate/20260130080745_add_password_reset_to_users.rb b/db/migrate/20260130080745_add_password_reset_to_users.rb new file mode 100644 index 0000000..26cb4ab --- /dev/null +++ b/db/migrate/20260130080745_add_password_reset_to_users.rb @@ -0,0 +1,7 @@ +class AddPasswordResetToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :reset_password_token, :string + add_column :users, :reset_password_sent_at, :datetime + add_index :users, :reset_password_token, unique: true + end +end diff --git a/db/migrate/20260130080931_add_remember_token_to_users.rb b/db/migrate/20260130080931_add_remember_token_to_users.rb new file mode 100644 index 0000000..6576ab8 --- /dev/null +++ b/db/migrate/20260130080931_add_remember_token_to_users.rb @@ -0,0 +1,7 @@ +class AddRememberTokenToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :remember_token, :string + add_column :users, :remember_created_at, :datetime + add_index :users, :remember_token, unique: true + end +end diff --git a/db/structure.sql b/db/structure.sql index 133a308..7da71a8 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -8,7 +8,7 @@ CREATE INDEX "index_comments_on_user_id" ON "comments" ("user_id") /*application CREATE INDEX "index_comments_on_commentable" ON "comments" ("commentable_type", "commentable_id") /*application='SanastoWiki'*/; CREATE TABLE IF NOT EXISTS "supported_languages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "code" varchar NOT NULL, "name" varchar NOT NULL, "native_name" varchar NOT NULL, "sort_order" integer DEFAULT 0 NOT NULL, "active" boolean DEFAULT TRUE NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); CREATE UNIQUE INDEX "index_supported_languages_on_code" ON "supported_languages" ("code") /*application='SanastoWiki'*/; -CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password_digest" varchar NOT NULL, "name" varchar, "role" integer DEFAULT 0 NOT NULL, "primary_language" varchar, "invitation_token" varchar, "invitation_sent_at" datetime(6), "invitation_accepted_at" datetime(6), "invited_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_ae14a5013f" +CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password_digest" varchar NOT NULL, "name" varchar, "role" integer DEFAULT 0 NOT NULL, "primary_language" varchar, "invitation_token" varchar, "invitation_sent_at" datetime(6), "invitation_accepted_at" datetime(6), "invited_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "reset_password_token" varchar /*application='SanastoWiki'*/, "reset_password_sent_at" datetime(6) /*application='SanastoWiki'*/, "remember_token" varchar /*application='SanastoWiki'*/, "remember_created_at" datetime(6) /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_ae14a5013f" FOREIGN KEY ("invited_by_id") REFERENCES "users" ("id") ); @@ -65,7 +65,11 @@ CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*a CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/; CREATE INDEX "index_entries_on_status" ON "entries" ("status") /*application='SanastoWiki'*/; CREATE INDEX "index_entries_on_requested_by_id" ON "entries" ("requested_by_id") /*application='SanastoWiki'*/; +CREATE UNIQUE INDEX "index_users_on_reset_password_token" ON "users" ("reset_password_token") /*application='SanastoWiki'*/; +CREATE UNIQUE INDEX "index_users_on_remember_token" ON "users" ("remember_token") /*application='SanastoWiki'*/; INSERT INTO "schema_migrations" (version) VALUES +('20260130080931'), +('20260130080745'), ('20260129204706'), ('20260129204705'), ('20260123130957'), diff --git a/docs/TODO.md b/docs/TODO.md index 40e412e..feb79ee 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,14 +2,15 @@ ## Authentication & Authorization -- [ ] **Authentication system** +- [x] **Authentication system** - [x] Sessions controller and views (login/logout) - [x] Email/password authentication with session management - [x] Login redirects (admin vs regular users) - [x] Logout functionality - - [ ] Password reset flow - - [ ] Rate limiting on login attempts - - [ ] Session management (remember me, session timeout) + - [x] Password reset flow (email-based, 1 hour expiry) + - [x] Rate limiting on login attempts (5 attempts, 15 minute lockout) + - [x] Session management (remember me for 2 weeks, 30 minute timeout) + - [x] Sign in status in the site header - [x] **Invitation system** - [x] Invitations controller (create, list, cancel) - [x] Invitation token generation