Clean up and secure web_authn controller
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

This commit is contained in:
Dan Milne
2025-12-31 11:44:11 +11:00
parent 9530c8284f
commit 9d402fcd92
3 changed files with 267 additions and 17 deletions

183
README.md
View File

@@ -355,6 +355,189 @@ OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
--- ---
## 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 ## Technology Stack
- **Rails 8.1** - Modern Rails with authentication generator - **Rails 8.1** - Modern Rails with authentication generator

View File

@@ -109,14 +109,6 @@ class WebauthnController < ApplicationController
# DELETE /webauthn/:id # DELETE /webauthn/:id
# Remove a passkey # Remove a passkey
def destroy 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 nickname = @webauthn_credential.nickname
@webauthn_credential.destroy @webauthn_credential.destroy
@@ -180,16 +172,13 @@ class WebauthnController < ApplicationController
end end
def set_webauthn_credential 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 rescue ActiveRecord::RecordNotFound
respond_to do |format| respond_to do |format|
format.html { format.html { redirect_to profile_path, alert: "Passkey not found" }
redirect_to profile_path, format.json { render json: { error: "Passkey not found" }, status: :not_found }
alert: "Passkey not found"
}
format.json {
render json: { error: "Passkey not found" }, status: :not_found
}
end end
end end

View File

@@ -1,7 +1,7 @@
require "test_helper" require "test_helper"
require "webauthn/fake_client" require "webauthn/fake_client"
class WebauthnSecurityTest < ActionDispatch::SystemTest class WebauthnSecurityTest < ActionDispatch::SystemTestCase
# ==================== # ====================
# REPLAY ATTACK PREVENTION (SIGN COUNT TRACKING) TESTS # REPLAY ATTACK PREVENTION (SIGN COUNT TRACKING) TESTS
# ==================== # ====================
@@ -308,6 +308,84 @@ class WebauthnSecurityTest < ActionDispatch::SystemTest
user.destroy user.destroy
end 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 # WEBAUTHN AND PASSWORD LOGIN INTERACTION TESTS
# ==================== # ====================