Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@ SDK (8.x).

## What's demonstrated

- **sign up** – new registrations are scored with the `risk` endpoint
(`$registration`); a `deny` verdict rolls the sign-up back, mirroring login.
- **login** – successful logins are scored with the `risk` endpoint; failed
logins are sent to `filter`. The returned verdict (`allow`, `challenge` or
`deny`) drives whether the session is allowed.
- **logout & profile updates** – recorded with the non-blocking `log` endpoint.
- **logout, profile updates & custom events** – recorded with the non-blocking
`log` endpoint. The custom event is available from the profile page, once
signed in.
- **Twitter/X OAuth login** – the same risk assessment applied to social sign-in.
- **webhooks** – incoming Castle webhooks are signature-verified with
`Castle::Webhooks::Verify` and listed in the app.
- **browser SDK** – the `@castleio/castle-js` SDK mints a request token in the
browser that is submitted with the login form and forwarded to the API.
browser for every Castle-bound form (sign up, login, profile update, custom
event, logout) and forwards it to the backend.

## Screenshots

Expand Down
2 changes: 1 addition & 1 deletion app/assets/builds/tailwind.css

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions app/assets/stylesheets/application.tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@
@apply text-[0.92rem] text-muted hover:text-ink hover:no-underline;
}

.nav-links form {
@apply m-0 inline;
}

.nav-links form button {
@apply cursor-pointer border-0 bg-transparent p-0 text-[0.92rem] text-muted hover:text-ink;
}

.tag {
@apply rounded-full border border-accent/40 bg-accent/10 px-2 py-0.5 text-xs font-semibold text-accent;
}
Expand Down
25 changes: 25 additions & 0 deletions app/controllers/users/custom_events_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module Users
# Sends an ad-hoc custom event to Castle for the signed-in user. Custom events
# are only meaningful once a user is authenticated, so this lives behind the
# default `authenticate_user!` before_action.
class CustomEventsController < ApplicationController
layout 'devise'

# Records a custom event with the non-blocking log endpoint.
def create
castle.log(
type: '$custom',
name: 'Demo custom event',
status: '$succeeded',
request_token: castle_request_token,
user: { id: current_user.id, email: current_user.email }
)
rescue Castle::Error
nil
ensure
redirect_to edit_users_profile_path, notice: t('.sent')
end
end
end
1 change: 1 addition & 0 deletions app/controllers/users/profiles_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def track_profile_update
castle.log(
type: '$profile_update',
status: status,
request_token: castle_request_token,
user: { id: current_user.id, email: current_user.email }
)
rescue Castle::Error
Expand Down
59 changes: 58 additions & 1 deletion app/controllers/users/registrations_controller.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,65 @@
# frozen_string_literal: true

module Users
# User registration Devise actions
# User registration Devise actions with integrated Castle.io risk assessment.
class RegistrationsController < Devise::RegistrationsController
layout 'devise'

# Sign up with Castle risk assessment.
# @note A 'challenge' verdict is treated as 'allow' here; a real app would
# step up to MFA. 'deny' rolls the registration back.
def create
build_resource(sign_up_params)

if resource.save
if evaluate_registration(resource) == 'deny'
resource.destroy
flash[:error] = t('.access_denied')
redirect_to new_user_registration_url
else
sign_up(resource_name, resource)
set_flash_message! :notice, :signed_up
respond_with resource, location: after_sign_up_path_for(resource)
end
else
track_failed_registration
clean_up_passwords resource
set_minimum_password_length
respond_with resource
end
end

private

# Sends a successful registration to the risk endpoint and returns the verdict.
# @param user [User]
# @return [String] the Castle policy action: 'allow', 'challenge' or 'deny'
def evaluate_registration(user)
castle.risk(
type: '$registration',
status: '$succeeded',
request_token: castle_request_token,
user: { id: user.id, email: user.email }
).dig(:policy, :action)
rescue Castle::Error
# Never block a sign-up because Castle is unhappy with the request.
'allow'
end

# Reports an invalid registration attempt (e.g. an email already taken) to
# the filter endpoint.
def track_failed_registration
email = sign_up_params[:email]

castle.filter(
type: '$registration',
status: '$failed',
request_token: castle_request_token,
user: { email: email },
params: { email: email }
)
rescue Castle::Error
nil
end
end
end
2 changes: 2 additions & 0 deletions app/controllers/users/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ def destroy
# This is a failover just in case there is no user because an unauthenticated user
# tried to logout
user_id = current_user&.id
token = castle_request_token
super
castle.log(
type: '$logout',
status: '$succeeded',
request_token: token,
user: { id: user_id }
)
end
Expand Down
2 changes: 1 addition & 1 deletion app/views/layouts/application.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
- if user_signed_in?
= link_to t('.nav.edit_password'), edit_user_registration_path
= link_to t('.nav.edit_profile'), edit_users_profile_path
= link_to t('.nav.sign_out'), destroy_user_session_path, method: :delete
= button_to t('.nav.sign_out'), destroy_user_session_path, method: :delete, form: { data: { castle: true } }
- else
= link_to t('.nav.login'), new_user_session_path
= link_to t('.nav.register'), new_user_registration_path
Expand Down
7 changes: 6 additions & 1 deletion app/views/main/index.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
%h2{ class: 'text-[1.2rem]' } What's demonstrated

%ul.prose-list.list-disc.pl-5
%li
%strong Sign up
&mdash; new registrations are scored with the
%code risk
endpoint (<code>$registration</code>); a denied verdict rolls the sign-up back.
%li
%strong Login
&mdash; successful logins are scored with the
Expand All @@ -19,7 +24,7 @@
%code filter
and the verdict can allow, challenge or deny the user.
%li
%strong Logout & profile updates
%strong Logout, profile updates & custom events
&mdash; recorded with the non-blocking
%code log
endpoint.
Expand Down
4 changes: 3 additions & 1 deletion app/views/users/profiles/edit.html.haml
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
%h2= t('.title')

= simple_form_for(current_user, url: users_profile_path, html: { method: :put }) do |f|
= simple_form_for(current_user, url: users_profile_path, html: { method: :put, data: { castle: true } }) do |f|
= f.error_notification
.form-inputs
= f.input :email, required: true, autofocus: true
.form-actions
= f.button :submit, t('.button'), class: 'btn btn-primary w-full'

= button_to t('.custom_event'), users_custom_event_path, class: 'btn w-full mt-3', form: { data: { castle: true } }

.btn-row
= link_to t('.back'), root_path, class: 'btn'
2 changes: 1 addition & 1 deletion app/views/users/registrations/new.html.haml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
%h2.mb-4 Sign up

= simple_form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f|
= simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { data: { castle: true } }) do |f|
= f.error_notification
.form-inputs
= f.input :email, required: true, autofocus: true
Expand Down
6 changes: 6 additions & 0 deletions config/locales/en/users.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ en:
new:
button: Sign up
hint: "%{min} characters minimum"
create:
access_denied: Access denied. Please contact the administrator.
edit:
title: Editing account
confirmation_pending: 'Currently waiting confirmation for: %#{email}'
Expand All @@ -26,7 +28,11 @@ en:
edit:
title: Profile editing
button: Update profile
custom_event: Send a custom event
back: Back
custom_events:
create:
sent: Custom event sent to Castle.
shared:
links:
log_in: Log in
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

namespace :users do
resource :profile, only: %i[edit update]
resource :custom_event, only: %i[create]
end

namespace :integrations do
Expand Down
45 changes: 45 additions & 0 deletions spec/controllers/users/custom_events_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

RSpec.describe Users::CustomEventsController do
describe 'POST #create' do
context 'when unauthenticated' do
before { post :create }

it { expect(response).to redirect_to new_user_session_path }
end

context 'when authenticated' do
with_user

before do
allow(controller.castle).to receive(:log)
post :create
end

it { expect(response).to redirect_to edit_users_profile_path }

it 'logs a custom event for the current user' do
expect(controller.castle).to have_received(:log).with(
type: '$custom',
name: 'Demo custom event',
status: '$succeeded',
request_token: nil,
user: { id: user.id, email: user.email }
)
end
end

context 'when Castle raises' do
with_user

before do
allow(controller.castle).to receive(:log).and_raise(Castle::Error)
post :create
end

it 'still redirects without surfacing the error' do
expect(response).to redirect_to edit_users_profile_path
end
end
end
end
2 changes: 2 additions & 0 deletions spec/controllers/users/profiles_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
{
type: '$profile_update',
status: '$failed',
request_token: nil,
user: { id: controller.current_user.id, email: controller.current_user.email }
}
end
Expand All @@ -60,6 +61,7 @@
{
type: '$profile_update',
status: '$succeeded',
request_token: nil,
user: { id: controller.current_user.id, email: controller.current_user.email }
}
end
Expand Down
68 changes: 61 additions & 7 deletions spec/controllers/users/registrations_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,73 @@

describe 'POST #create' do
let(:password) { 'sup3r-s3cret' }
let(:email) { Faker::Internet.email }
let(:params) do
{ user: { email: Faker::Internet.email, password: password, password_confirmation: password } }
{ user: { email: email, password: password, password_confirmation: password } }
end

it 'creates a new user' do
expect { post :create, params: params }.to change(User, :count).by(1)
context 'when the registration is allowed' do
before { allow(controller.castle).to receive(:risk).and_return(policy: { action: 'allow' }) }

it 'creates a new user' do
expect { post :create, params: params }.to change(User, :count).by(1)
end

it 'signs the user in' do
post :create, params: params

expect(controller.current_user).to be_present
expect(response).to redirect_to root_path
end

it 'scores the registration with the risk endpoint' do
post :create, params: params

expect(controller.castle).to have_received(:risk).with(
type: '$registration',
status: '$succeeded',
request_token: nil,
user: hash_including(email: email)
)
end
end

it 'signs the user in' do
post :create, params: params
context 'when the registration is denied' do
before { allow(controller.castle).to receive(:risk).and_return(policy: { action: 'deny' }) }

it 'rolls the registration back' do
expect { post :create, params: params }.not_to change(User, :count)
end

it 'redirects back to the sign-up form' do
post :create, params: params

expect(response).to redirect_to new_user_registration_url
end
end

context 'when Castle raises during risk assessment' do
before { allow(controller.castle).to receive(:risk).and_raise(Castle::Error) }

it 'fails open and keeps the user' do
expect { post :create, params: params }.to change(User, :count).by(1)
expect(response).to redirect_to root_path
end
end

context 'when the registration is invalid' do
let(:params) { { user: { email: '', password: password, password_confirmation: password } } }

before { allow(controller.castle).to receive(:filter) }

it 're-renders the form and reports a failed registration' do
expect { post :create, params: params }.not_to change(User, :count)

expect(controller.current_user).to be_present
expect(response).to redirect_to root_path
expect(response).to render_template(:new)
expect(controller.castle).to have_received(:filter).with(
hash_including(type: '$registration', status: '$failed')
)
end
end
end
end
2 changes: 1 addition & 1 deletion spec/controllers/users/sessions_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
describe 'DELETE destroy' do
with_user

let(:log_args) { { type: '$logout', status: '$succeeded', user: { id: user.id } } }
let(:log_args) { { type: '$logout', status: '$succeeded', request_token: nil, user: { id: user.id } } }

before do
allow(controller.castle).to receive(:log)
Expand Down