diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 8ce26c2..af4265f 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -43,9 +43,9 @@ module Authentication session.delete(:return_to_after_authenticating) || root_url end - def start_new_session_for(user, acr: "1") + def start_new_session_for(user, acr: "1", remember_me: false) user.update!(last_sign_in_at: Time.current) - user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr).tap do |session| + user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr, remember_me: remember_me).tap do |session| Current.session = session # Extract root domain for cross-subdomain cookies (required for forward auth) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 40b9d38..4acf818 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -76,6 +76,7 @@ class SessionsController < ApplicationController # TOTP is enabled, proceed to verification # Store user ID in session temporarily for TOTP verification session[:pending_totp_user_id] = user.id + session[:pending_remember_me] = remember_me? # Preserve the redirect URL through TOTP verification (after validation) if params[:rd].present? validated_url = validate_redirect_url(params[:rd]) @@ -86,7 +87,7 @@ class SessionsController < ApplicationController end # Sign in successful (password only) - start_new_session_for user, acr: "1" + start_new_session_for user, acr: "1", remember_me: remember_me? # Use status: :see_other to ensure browser makes a GET request # This prevents Turbo from converting it to a TURBO_STREAM request @@ -118,6 +119,8 @@ class SessionsController < ApplicationController return end + remember_me = session.delete(:pending_remember_me) || false + # Try TOTP verification first (password + TOTP = 2FA) if user.verify_totp(code) session.delete(:pending_totp_user_id) @@ -125,7 +128,7 @@ class SessionsController < ApplicationController if session[:totp_redirect_url].present? session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) end - start_new_session_for user, acr: "2" + start_new_session_for user, acr: "2", remember_me: remember_me redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true return end @@ -137,7 +140,7 @@ class SessionsController < ApplicationController if session[:totp_redirect_url].present? session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) end - start_new_session_for user, acr: "2" + start_new_session_for user, acr: "2", remember_me: remember_me redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true return end @@ -189,6 +192,7 @@ class SessionsController < ApplicationController # Store user ID in session for verification session[:pending_webauthn_user_id] = user.id + session[:pending_remember_me] = remember_me? # Store redirect URL if present if params[:rd].present? @@ -284,12 +288,13 @@ class SessionsController < ApplicationController # Clean up session session.delete(:pending_webauthn_user_id) + remember_me = session.delete(:pending_remember_me) || false if session[:webauthn_redirect_url].present? session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url) end # Create session (WebAuthn/passkey = phishing-resistant, ACR = "2") - start_new_session_for user, acr: "2" + start_new_session_for user, acr: "2", remember_me: remember_me render json: { success: true, @@ -310,6 +315,10 @@ class SessionsController < ApplicationController private + def remember_me? + ActiveModel::Type::Boolean.new.cast(params[:remember_me]) || false + end + def validate_redirect_url(url) return nil unless url.present? diff --git a/app/javascript/controllers/webauthn_controller.js b/app/javascript/controllers/webauthn_controller.js index f212f26..dda173f 100644 --- a/app/javascript/controllers/webauthn_controller.js +++ b/app/javascript/controllers/webauthn_controller.js @@ -180,7 +180,8 @@ export default class extends Controller { "X-CSRF-Token": this.getCSRFToken() }, body: JSON.stringify({ - email: this.getUserEmail() + email: this.getUserEmail(), + remember_me: this.getRememberMe() }) }); @@ -295,6 +296,11 @@ export default class extends Controller { return emailInput ? emailInput.value.trim() : ""; } + getRememberMe() { + const checkbox = document.querySelector('input[name="remember_me"][type="checkbox"]'); + return checkbox ? checkbox.checked : false; + } + isValidEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 98e185e..474bd81 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,6 +1,6 @@ -
+
-

Sign in to Clinch

+

Sign in to Clinch

<%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %> @@ -53,6 +53,11 @@ class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
+
+ <%= form.check_box :remember_me, { class: "rounded border-gray-400 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800" }, "1", "0" %> + <%= form.label :remember_me, "Remember me for 30 days", class: "ml-2 text-sm text-gray-600 dark:text-gray-400" %> +
+
<%= form.submit "Sign in", class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>