Clean up and secure web_authn controller
This commit is contained in:
183
README.md
183
README.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
# ====================
|
# ====================
|
||||||
|
|||||||
Reference in New Issue
Block a user