From 9d402fcd9252ffc87a52cd7ab85ad607584b1af5 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Wed, 31 Dec 2025 11:44:11 +1100 Subject: [PATCH] Clean up and secure web_authn controller --- README.md | 183 +++++++++++++++++++++++++ app/controllers/webauthn_controller.rb | 21 +-- test/system/webauthn_security_test.rb | 80 ++++++++++- 3 files changed, 267 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d09eef7..45fdde2 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,189 @@ OIDC_PRIVATE_KEY= --- +## Rails Console + +One advantage of being a Rails application is direct access to the Rails console for administrative tasks. This is particularly useful for debugging, emergency access, or bulk operations. + +You can start the console with: + +`bin/rails console` + +or in Docker compose with: + +`docker compose exec -it clinch bin/rails console` + +### Starting the Console + +```bash +# Docker +docker exec -it clinch bin/rails console + +# Local development +bin/rails console +``` + +### Finding Users + +```ruby +# Find by email +user = User.find_by(email_address: 'alice@example.com') + +# Find by username +user = User.find_by(username: 'alice') + +# List all users +User.all.pluck(:id, :email_address, :status) + +# Find admins +User.admins.pluck(:email_address) + +# Find users in a specific status +User.active.count +User.disabled.pluck(:email_address) +User.pending_invitation.pluck(:email_address) +``` + +### Creating Users + +```ruby +# Create a regular user +User.create!( + email_address: 'newuser@example.com', + password: 'secure-password-here', + status: :active +) + +# Create an admin user +User.create!( + email_address: 'admin@example.com', + password: 'secure-password-here', + status: :active, + admin: true +) +``` + +### Managing Passwords + +```ruby +user = User.find_by(email_address: 'alice@example.com') +user.password = 'new-secure-password' +user.save! +``` + +### Two-Factor Authentication (TOTP) + +```ruby +user = User.find_by(email_address: 'alice@example.com') + +# Check if TOTP is enabled +user.totp_enabled? + +# Get current TOTP code (useful for testing/debugging) +puts user.console_totp + +# Enable TOTP (generates secret and backup codes) +backup_codes = user.enable_totp! +puts backup_codes # Display backup codes to give to user + +# Disable TOTP +user.disable_totp! + +# Force user to set up TOTP on next login +user.update!(totp_required: true) +``` + +### Managing User Status + +```ruby +user = User.find_by(email_address: 'alice@example.com') + +# Disable a user (prevents login) +user.disabled! + +# Re-enable a user +user.active! + +# Check current status +user.status # => "active", "disabled", or "pending_invitation" + +# Grant admin privileges +user.update!(admin: true) + +# Revoke admin privileges +user.update!(admin: false) +``` + +### Managing Groups + +```ruby +user = User.find_by(email_address: 'alice@example.com') + +# View user's groups +user.groups.pluck(:name) + +# Add user to a group +family = Group.find_by(name: 'family') +user.groups << family + +# Remove user from a group +user.groups.delete(family) + +# Create a new group +Group.create!(name: 'developers', description: 'Development team') +``` + +### Managing Sessions + +```ruby +user = User.find_by(email_address: 'alice@example.com') + +# View active sessions +user.sessions.pluck(:id, :device_name, :client_ip, :created_at) + +# Revoke all sessions (force logout everywhere) +user.sessions.destroy_all + +# Revoke a specific session +user.sessions.find(123).destroy +``` + +### Managing Applications + +```ruby +# List all OIDC applications +Application.oidc.pluck(:name, :client_id) + +# Find an application +app = Application.find_by(slug: 'kavita') + +# Regenerate client secret +new_secret = app.generate_new_client_secret! +puts new_secret # Display once - not stored in plain text + +# Check which users can access an app +app.allowed_groups.flat_map(&:users).uniq.pluck(:email_address) + +# Revoke all tokens for an application +app.oidc_access_tokens.destroy_all +app.oidc_refresh_tokens.destroy_all +``` + +### Revoking OIDC Consents + +```ruby +user = User.find_by(email_address: 'alice@example.com') +app = Application.find_by(slug: 'kavita') + +# Revoke consent for a specific app +user.revoke_consent!(app) + +# Revoke all OIDC consents +user.revoke_all_consents! +``` + +--- + ## Technology Stack - **Rails 8.1** - Modern Rails with authentication generator diff --git a/app/controllers/webauthn_controller.rb b/app/controllers/webauthn_controller.rb index 1f0603c..5c17446 100644 --- a/app/controllers/webauthn_controller.rb +++ b/app/controllers/webauthn_controller.rb @@ -109,14 +109,6 @@ class WebauthnController < ApplicationController # DELETE /webauthn/:id # Remove a passkey def destroy - user = Current.session&.user - return render json: { error: "Not authenticated" }, status: :unauthorized unless user - - if @webauthn_credential.user != user - render json: { error: "Unauthorized" }, status: :forbidden - return - end - nickname = @webauthn_credential.nickname @webauthn_credential.destroy @@ -180,16 +172,13 @@ class WebauthnController < ApplicationController end def set_webauthn_credential - @webauthn_credential = WebauthnCredential.find(params[:id]) + user = Current.session&.user + return render json: { error: "Not authenticated" }, status: :unauthorized unless user + @webauthn_credential = user.webauthn_credentials.find(params[:id]) rescue ActiveRecord::RecordNotFound respond_to do |format| - format.html { - redirect_to profile_path, - alert: "Passkey not found" - } - format.json { - render json: { error: "Passkey not found" }, status: :not_found - } + format.html { redirect_to profile_path, alert: "Passkey not found" } + format.json { render json: { error: "Passkey not found" }, status: :not_found } end end diff --git a/test/system/webauthn_security_test.rb b/test/system/webauthn_security_test.rb index 0853eca..3063129 100644 --- a/test/system/webauthn_security_test.rb +++ b/test/system/webauthn_security_test.rb @@ -1,7 +1,7 @@ require "test_helper" require "webauthn/fake_client" -class WebauthnSecurityTest < ActionDispatch::SystemTest +class WebauthnSecurityTest < ActionDispatch::SystemTestCase # ==================== # REPLAY ATTACK PREVENTION (SIGN COUNT TRACKING) TESTS # ==================== @@ -308,6 +308,84 @@ class WebauthnSecurityTest < ActionDispatch::SystemTest user.destroy end + # ==================== + # CREDENTIAL ENUMERATION PREVENTION TESTS + # ==================== + + test "prevents credential enumeration via delete endpoint" do + user1 = User.create!(email_address: "user1@example.com", password: "password123") + user2 = User.create!(email_address: "user2@example.com", password: "password123") + + # Create a credential for user1 + credential1 = user1.webauthn_credentials.create!( + external_id: Base64.urlsafe_encode64("user1_credential"), + public_key: Base64.urlsafe_encode64("public_key_1"), + sign_count: 0, + nickname: "User1 Key", + authenticator_type: "platform" + ) + + # Create a credential for user2 + credential2 = user2.webauthn_credentials.create!( + external_id: Base64.urlsafe_encode64("user2_credential"), + public_key: Base64.urlsafe_encode64("public_key_2"), + sign_count: 0, + nickname: "User2 Key", + authenticator_type: "platform" + ) + + # Sign in as user1 + post signin_path, params: { email_address: "user1@example.com", password: "password123" } + follow_redirect! + + # Try to delete user2's credential while authenticated as user1 + # This should return 404 (not 403) to prevent enumeration + delete webauthn_path(credential2.id), as: :json + + assert_response :not_found + assert_includes JSON.parse(@response.body)["error"], "not found" + + # Verify both credentials still exist + assert_equal 1, user1.webauthn_credentials.count + assert_equal 1, user2.webauthn_credentials.count + + # Verify trying to delete a non-existent credential also returns 404 + # This confirms identical responses for enumeration prevention + delete webauthn_path(99999), as: :json + + assert_response :not_found + assert_includes JSON.parse(@response.body)["error"], "not found" + + user1.destroy + user2.destroy + end + + test "allows users to delete their own credentials" do + user = User.create!(email_address: "user@example.com", password: "password123") + + credential = user.webauthn_credentials.create!( + external_id: Base64.urlsafe_encode64("user_credential"), + public_key: Base64.urlsafe_encode64("public_key"), + sign_count: 0, + nickname: "My Key", + authenticator_type: "platform" + ) + + # Sign in + post signin_path, params: { email_address: "user@example.com", password: "password123" } + follow_redirect! + + # Delete own credential - should succeed + assert_difference "user.webauthn_credentials.count", -1 do + delete webauthn_path(credential.id), as: :json + end + + assert_response :success + assert_includes JSON.parse(@response.body)["message"], "has been removed" + + user.destroy + end + # ==================== # WEBAUTHN AND PASSWORD LOGIN INTERACTION TESTS # ====================