diff --git a/examples/blog/app/controllers/email_confirmations_controller.rb b/examples/blog/app/controllers/email_confirmations_controller.rb new file mode 100644 index 0000000..3c7be11 --- /dev/null +++ b/examples/blog/app/controllers/email_confirmations_controller.rb @@ -0,0 +1,42 @@ +class EmailConfirmationsController < ApplicationController + # Resend form - for whoever's original confirmation link expired out of + # their inbox, landed in spam, or was simply never sent because a typo in + # the signup form pointed User#generates_token_for at an address they + # don't control. + def new + render(:new) + end + + # Same enumeration-safe shape as PasswordResetsController#create: the + # response never reveals whether the address has an account here, or + # whether it is already confirmed - branching on either would turn this + # form into a one-guess-at-a-time oracle for both facts. + def create + email = params.require('email_confirmation').permit(:email).to_h['email'] + user = User.find_by(email: email) + if user && !user.confirmed? + NotificationMailer.confirmation_instructions(user.id, user.generate_token_for(:email_confirmation)).deliver_later + end + flash[:notice] = t('email_confirmations.instructions_sent_notice') + redirect url_for(:new_session) + end + + # Reached via a plain GET from the link in the confirmation email, like + # PasswordResetsController#edit - safe to expose over GET (and to visit + # more than once) because, unlike a password reset, confirming does not + # hand the visitor anything sensitive and does not itself change the + # fingerprint (email) the token was minted under, so a bookmarked or + # double-clicked link keeps working instead of dying after first use. + def show + user = User.find_by_token_for(:email_confirmation, params['token']) + unless user + flash[:notice] = t('email_confirmations.invalid_token_notice') + redirect url_for(:new_email_confirmation) + return + end + + user.update!(confirmed_at: Time.now.utc) unless user.confirmed? + flash[:notice] = t('email_confirmations.confirmed_notice') + redirect url_for(:root) + end +end diff --git a/examples/blog/app/controllers/likes_controller.rb b/examples/blog/app/controllers/likes_controller.rb new file mode 100644 index 0000000..2b153df --- /dev/null +++ b/examples/blog/app/controllers/likes_controller.rb @@ -0,0 +1,43 @@ +class LikesController < ApplicationController + before_action :require_authentication + + # Only Post/Comment may be liked - likeable_type otherwise rides in fully + # client-controlled request params, so without this whitelist a crafted + # request could aim a Like at an arbitrary model by name (e.g. User) and + # #likeable would happily resolve it. + LIKEABLE_TYPES = { 'Post' => Post, 'Comment' => Comment }.freeze + + def create + likeable = find_likeable! + Like.create(user_id: current_user.id, likeable_id: likeable.id, likeable_type: likeable.class.name) + redirect url_for(:post, slug: post_slug_for(likeable)) + rescue Prism::Orm::RecordNotFound + respond_not_found(t('likes.not_found')) + end + + def destroy + like = Like.find(params['id']) + authorize(like) + likeable = like.likeable + # really_destroy!, not the paranoid-aware plain #destroy - unliking is + # a permanent, unremarkable action with no "restore my unlike" concept + # in this app, unlike Post.acts_as_paranoid's actual trash/restore UI. + # Left as a soft delete, every unlike/relike cycle would pile up a + # dead Like row forever (uniqueness scope only sees live rows, so + # nothing would ever stop a visitor from accumulating one per cycle), + # and a post's dependent: :destroy cascade would then have no way to + # tell "trashed along with its post" apart from "explicitly unliked + # ages ago" when a restore brings the post's likes back. + like.really_destroy! + redirect url_for(:post, slug: post_slug_for(likeable)) + rescue Prism::Orm::RecordNotFound + respond_not_found(t('likes.not_found')) + end + + private + + def find_likeable! + klass = LIKEABLE_TYPES.fetch(params['likeable_type']) { raise Prism::Orm::RecordNotFound } + klass.find(params['likeable_id']) + end +end diff --git a/examples/blog/app/controllers/login_sessions_controller.rb b/examples/blog/app/controllers/login_sessions_controller.rb new file mode 100644 index 0000000..171bd7a --- /dev/null +++ b/examples/blog/app/controllers/login_sessions_controller.rb @@ -0,0 +1,41 @@ +class LoginSessionsController < ApplicationController + before_action :require_authentication + + # "Your devices": every remembered browser for the signed-in user, with + # whichever one answered THIS request called out separately so the view + # can label it "this device" instead of just another row in the list. + def index + render(:index, locals: { + current_session: current_login_session, + other_sessions: other_login_sessions.order(:created_at, :desc).to_a, + notice: flash[:notice] + }) + end + + # Revokes one remembered browser. revoke_login_session! already checks + # ownership (a foreign id, or one belonging to someone else, is a no-op), + # so there is nothing else to authorize here. + def destroy + flash[:notice] = if revoke_login_session!(params['id']) + t('login_sessions.revoked_notice') + else + t('login_sessions.not_found') + end + redirect url_for(:login_sessions) + end + + def sign_out_others + sign_out_all_other_sessions + flash[:notice] = t('login_sessions.signed_out_others_notice') + redirect url_for(:login_sessions) + end + + # "TestBrowser/1.0 from 203.0.113.7" - falls back to the generic + # "unknown browser"/"unknown address" strings for rows remembered before + # this metadata started being captured (a plain nil column, not a bug). + def device_detail_for(login_session) + t('login_sessions.device_detail', + user_agent: login_session.user_agent || t('login_sessions.unknown_user_agent'), + ip_address: login_session.ip_address || t('login_sessions.unknown_ip_address')) + end +end diff --git a/examples/blog/app/controllers/password_resets_controller.rb b/examples/blog/app/controllers/password_resets_controller.rb new file mode 100644 index 0000000..52f00aa --- /dev/null +++ b/examples/blog/app/controllers/password_resets_controller.rb @@ -0,0 +1,62 @@ +class PasswordResetsController < ApplicationController + def new + render(:new) + end + + # Always shows the SAME generic "check your email" notice whether or not + # the address belongs to an account - branching the response on + # User.find_by(email:) here would turn this form into an email-enumeration + # oracle one guess at a time, the same concern app.rb's signups/ip + # throttle exists for one step earlier in the account lifecycle (and this + # action gets its own password_resets/ip throttle below for the same + # reason: even a constant-response endpoint still leaks timing/rate + # information if it can be hit without limit). + def create + email = params.require('password_reset').permit(:email).to_h['email'] + user = User.find_by(email: email) + if user + token = user.generate_token_for(:password_reset) + NotificationMailer.password_reset(user.id, token).deliver_later + end + flash[:notice] = t('password_resets.instructions_sent_notice') + redirect url_for(:new_session) + end + + def edit + user = User.find_by_token_for(:password_reset, params['token']) + if user + render(:edit, locals: { token: params['token'], errors: [] }) + else + flash[:notice] = t('password_resets.invalid_token_notice') + redirect url_for(:new_password_reset) + end + end + + # find_by_token_for's fingerprint block (see User#generates_token_for) + # snapshots password_digest at #new/#create time - so the very save + # below that sets the new password also produces a new digest, which + # means the SAME token this action just accepted can never verify again + # afterwards. A stolen-but-unused link still dies once its owner resets + # through it; there is no separate "used" flag or token table to keep + # that guarantee true. + def update + user = User.find_by_token_for(:password_reset, params['token']) + unless user + flash[:notice] = t('password_resets.invalid_token_notice') + redirect url_for(:new_password_reset) + return + end + + attrs = params.require('password_reset').permit(:password, :password_confirmation).to_h + user.password = attrs['password'] + user.password_confirmation = attrs['password_confirmation'] + + if user.save + flash[:notice] = t('password_resets.reset_notice') + redirect url_for(:new_session) + else + status(422) + render(:edit, locals: { token: params['token'], errors: user.errors.full_messages }) + end + end +end