Add remember me checkbox, center and narrow sign-in form
- Add "Remember me for 30 days" checkbox (30-day vs 24-hour session expiry) - Center heading and constrain form width to max-w-md - Preserve remember_me preference through TOTP and WebAuthn auth flows Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,9 +43,9 @@ module Authentication
|
|||||||
session.delete(:return_to_after_authenticating) || root_url
|
session.delete(:return_to_after_authenticating) || root_url
|
||||||
end
|
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.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
|
Current.session = session
|
||||||
|
|
||||||
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class SessionsController < ApplicationController
|
|||||||
# TOTP is enabled, proceed to verification
|
# TOTP is enabled, proceed to verification
|
||||||
# Store user ID in session temporarily for TOTP verification
|
# Store user ID in session temporarily for TOTP verification
|
||||||
session[:pending_totp_user_id] = user.id
|
session[:pending_totp_user_id] = user.id
|
||||||
|
session[:pending_remember_me] = remember_me?
|
||||||
# Preserve the redirect URL through TOTP verification (after validation)
|
# Preserve the redirect URL through TOTP verification (after validation)
|
||||||
if params[:rd].present?
|
if params[:rd].present?
|
||||||
validated_url = validate_redirect_url(params[:rd])
|
validated_url = validate_redirect_url(params[:rd])
|
||||||
@@ -86,7 +87,7 @@ class SessionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Sign in successful (password only)
|
# 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
|
# Use status: :see_other to ensure browser makes a GET request
|
||||||
# This prevents Turbo from converting it to a TURBO_STREAM request
|
# This prevents Turbo from converting it to a TURBO_STREAM request
|
||||||
@@ -118,6 +119,8 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
remember_me = session.delete(:pending_remember_me) || false
|
||||||
|
|
||||||
# Try TOTP verification first (password + TOTP = 2FA)
|
# Try TOTP verification first (password + TOTP = 2FA)
|
||||||
if user.verify_totp(code)
|
if user.verify_totp(code)
|
||||||
session.delete(:pending_totp_user_id)
|
session.delete(:pending_totp_user_id)
|
||||||
@@ -125,7 +128,7 @@ class SessionsController < ApplicationController
|
|||||||
if session[:totp_redirect_url].present?
|
if session[:totp_redirect_url].present?
|
||||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||||
end
|
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
|
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -137,7 +140,7 @@ class SessionsController < ApplicationController
|
|||||||
if session[:totp_redirect_url].present?
|
if session[:totp_redirect_url].present?
|
||||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||||
end
|
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
|
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -189,6 +192,7 @@ class SessionsController < ApplicationController
|
|||||||
|
|
||||||
# Store user ID in session for verification
|
# Store user ID in session for verification
|
||||||
session[:pending_webauthn_user_id] = user.id
|
session[:pending_webauthn_user_id] = user.id
|
||||||
|
session[:pending_remember_me] = remember_me?
|
||||||
|
|
||||||
# Store redirect URL if present
|
# Store redirect URL if present
|
||||||
if params[:rd].present?
|
if params[:rd].present?
|
||||||
@@ -284,12 +288,13 @@ class SessionsController < ApplicationController
|
|||||||
|
|
||||||
# Clean up session
|
# Clean up session
|
||||||
session.delete(:pending_webauthn_user_id)
|
session.delete(:pending_webauthn_user_id)
|
||||||
|
remember_me = session.delete(:pending_remember_me) || false
|
||||||
if session[:webauthn_redirect_url].present?
|
if session[:webauthn_redirect_url].present?
|
||||||
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
|
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Create session (WebAuthn/passkey = phishing-resistant, ACR = "2")
|
# 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: {
|
render json: {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -310,6 +315,10 @@ class SessionsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def remember_me?
|
||||||
|
ActiveModel::Type::Boolean.new.cast(params[:remember_me]) || false
|
||||||
|
end
|
||||||
|
|
||||||
def validate_redirect_url(url)
|
def validate_redirect_url(url)
|
||||||
return nil unless url.present?
|
return nil unless url.present?
|
||||||
|
|
||||||
|
|||||||
@@ -180,7 +180,8 @@ export default class extends Controller {
|
|||||||
"X-CSRF-Token": this.getCSRFToken()
|
"X-CSRF-Token": this.getCSRFToken()
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
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() : "";
|
return emailInput ? emailInput.value.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRememberMe() {
|
||||||
|
const checkbox = document.querySelector('input[name="remember_me"][type="checkbox"]');
|
||||||
|
return checkbox ? checkbox.checked : false;
|
||||||
|
}
|
||||||
|
|
||||||
isValidEmail(email) {
|
isValidEmail(email) {
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="mx-auto md:w-2/3 w-full" data-controller="webauthn login-form" data-webauthn-check-url-value="/webauthn/check">
|
<div class="mx-auto max-w-md w-full" data-controller="webauthn login-form" data-webauthn-check-url-value="/webauthn/check">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="font-bold text-4xl">Sign in to Clinch</h1>
|
<h1 class="font-bold text-4xl text-center">Sign in to Clinch</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
<%= 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" %>
|
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" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="my-5 flex items-center">
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.submit "Sign in",
|
<%= 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" %>
|
class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
|
||||||
|
|||||||
Reference in New Issue
Block a user