diff options
Diffstat (limited to 'src/actions')
30 files changed, 463 insertions, 0 deletions
diff --git a/src/actions/api/me/show.cr b/src/actions/api/me/show.cr new file mode 100644 index 0000000..0060271 --- /dev/null +++ b/src/actions/api/me/show.cr @@ -0,0 +1,5 @@ +class Api::Me::Show < ApiAction + get "/api/me" do + json UserSerializer.new(current_user) + end +end diff --git a/src/actions/api/sign_ins/create.cr b/src/actions/api/sign_ins/create.cr new file mode 100644 index 0000000..3670356 --- /dev/null +++ b/src/actions/api/sign_ins/create.cr @@ -0,0 +1,13 @@ +class Api::SignIns::Create < ApiAction + include Api::Auth::SkipRequireAuthToken + + post "/api/sign_ins" do + SignInUser.run(params) do |operation, user| + if user + json({token: UserToken.generate(user)}) + else + raise Avram::InvalidOperationError.new(operation) + end + end + end +end diff --git a/src/actions/api/sign_ups/create.cr b/src/actions/api/sign_ups/create.cr new file mode 100644 index 0000000..15bbd04 --- /dev/null +++ b/src/actions/api/sign_ups/create.cr @@ -0,0 +1,8 @@ +class Api::SignUps::Create < ApiAction + include Api::Auth::SkipRequireAuthToken + + post "/api/sign_ups" do + user = SignUpUser.create!(params) + json({token: UserToken.generate(user)}) + end +end diff --git a/src/actions/api_action.cr b/src/actions/api_action.cr new file mode 100644 index 0000000..a16fd09 --- /dev/null +++ b/src/actions/api_action.cr @@ -0,0 +1,17 @@ +# Include modules and add methods that are for all API requests +abstract class ApiAction < Lucky::Action + # APIs typically do not need to send cookie/session data. + # Remove this line if you want to send cookies in the response header. + disable_cookies + accepted_formats [:json] + + include Api::Auth::Helpers + + # By default all actions require sign in. + # Add 'include Api::Auth::SkipRequireAuthToken' to your actions to allow all requests. + include Api::Auth::RequireAuthToken + + # By default all actions are required to use underscores to separate words. + # Add 'include Lucky::SkipRouteStyleCheck' to your actions if you wish to ignore this check for specific routes. + include Lucky::EnforceUnderscoredRoute +end diff --git a/src/actions/browser_action.cr b/src/actions/browser_action.cr new file mode 100644 index 0000000..674f208 --- /dev/null +++ b/src/actions/browser_action.cr @@ -0,0 +1,45 @@ +abstract class BrowserAction < Lucky::Action + include Lucky::ProtectFromForgery + + # By default all actions are required to use underscores. + # Add `include Lucky::SkipRouteStyleCheck` to your actions if you wish to ignore this check for specific routes. + include Lucky::EnforceUnderscoredRoute + + # This module disables Google FLoC by setting the + # [Permissions-Policy](https://github.com/WICG/floc) HTTP header to `interest-cohort=()`. + # + # This header is a part of Google's Federated Learning of Cohorts (FLoC) which is used + # to track browsing history instead of using 3rd-party cookies. + # + # Remove this include if you want to use the FLoC tracking. + include Lucky::SecureHeaders::DisableFLoC + + accepted_formats [:html, :json], default: :html + + # This module provides current_user, sign_in, and sign_out methods + include Authentic::ActionHelpers(User) + + # When testing you can skip normal sign in by using `visit` with the `as` param + # + # flow.visit Me::Show, as: UserFactory.create + include Auth::TestBackdoor + + # By default all actions that inherit 'BrowserAction' require sign in. + # + # You can remove the 'include Auth::RequireSignIn' below to allow anyone to + # access actions that inherit from 'BrowserAction' or you can + # 'include Auth::AllowGuests' in individual actions to skip sign in. + include Auth::RequireSignIn + + # `expose` means that `current_user` will be passed to pages automatically. + # + # In default Lucky apps, the `MainLayout` declares it `needs current_user : User` + # so that any page that inherits from MainLayout can use the `current_user` + expose current_user + + # This method tells Authentic how to find the current user + # The 'memoize' macro makes sure only one query is issued to find the user + private memoize def find_current_user(id : String | User::PrimaryKeyType) : User? + UserQuery.new.id(id).first? + end +end diff --git a/src/actions/errors/show.cr b/src/actions/errors/show.cr new file mode 100644 index 0000000..d01ed54 --- /dev/null +++ b/src/actions/errors/show.cr @@ -0,0 +1,63 @@ +# This class handles error responses and reporting. +# +# https://luckyframework.org/guides/http-and-routing/error-handling +class Errors::Show < Lucky::ErrorAction + DEFAULT_MESSAGE = "Something went wrong." + default_format :html + dont_report [Lucky::RouteNotFoundError, Avram::RecordNotFoundError] + + def render(error : Lucky::RouteNotFoundError | Avram::RecordNotFoundError) + if html? + error_html "Sorry, we couldn't find that page.", status: 404 + else + error_json "Not found", status: 404 + end + end + + # When the request is JSON and an InvalidOperationError is raised, show a + # helpful error with the param that is invalid, and what was wrong with it. + def render(error : Avram::InvalidOperationError) + if html? + error_html DEFAULT_MESSAGE, status: 500 + else + error_json \ + message: error.renderable_message, + details: error.renderable_details, + param: error.invalid_attribute_name, + status: 400 + end + end + + # Always keep this below other 'render' methods or it may override your + # custom 'render' methods. + def render(error : Lucky::RenderableError) + if html? + error_html DEFAULT_MESSAGE, status: error.renderable_status + else + error_json error.renderable_message, status: error.renderable_status + end + end + + # If none of the 'render' methods return a response for the raised Exception, + # Lucky will use this method. + def default_render(error : Exception) : Lucky::Response + if html? + error_html DEFAULT_MESSAGE, status: 500 + else + error_json DEFAULT_MESSAGE, status: 500 + end + end + + private def error_html(message : String, status : Int) + context.response.status_code = status + html_with_status Errors::ShowPage, status, message: message, status_code: status + end + + private def error_json(message : String, status : Int, details = nil, param = nil) + json ErrorSerializer.new(message: message, details: details, param: param), status: status + end + + private def report(error : Exception) : Nil + # Send to Rollbar, send an email, etc. + end +end diff --git a/src/actions/home/index.cr b/src/actions/home/index.cr new file mode 100644 index 0000000..f780130 --- /dev/null +++ b/src/actions/home/index.cr @@ -0,0 +1,18 @@ +class Home::Index < BrowserAction + include Auth::AllowGuests + + get "/" do + if current_user? + redirect Me::Show + else + # When you're ready change this line to: + # + # redirect SignIns::New + # + # Or maybe show signed out users a marketing page: + # + # html Marketing::IndexPage + html Lucky::WelcomePage + end + end +end diff --git a/src/actions/me/show.cr b/src/actions/me/show.cr new file mode 100644 index 0000000..5e35848 --- /dev/null +++ b/src/actions/me/show.cr @@ -0,0 +1,5 @@ +class Me::Show < BrowserAction + get "/me" do + html ShowPage + end +end diff --git a/src/actions/mixins/.keep b/src/actions/mixins/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/actions/mixins/.keep diff --git a/src/actions/mixins/api/auth/helpers.cr b/src/actions/mixins/api/auth/helpers.cr new file mode 100644 index 0000000..6b51cb5 --- /dev/null +++ b/src/actions/mixins/api/auth/helpers.cr @@ -0,0 +1,28 @@ +module Api::Auth::Helpers + # The 'memoize' macro makes sure only one query is issued to find the user + memoize def current_user? : User? + auth_token.try do |value| + user_from_auth_token(value) + end + end + + private def auth_token : String? + bearer_token || token_param + end + + private def bearer_token : String? + context.request.headers["Authorization"]? + .try(&.gsub("Bearer", "")) + .try(&.strip) + end + + private def token_param : String? + params.get?(:auth_token) + end + + private def user_from_auth_token(token : String) : User? + UserToken.decode_user_id(token).try do |user_id| + UserQuery.new.id(user_id).first? + end + end +end diff --git a/src/actions/mixins/api/auth/require_auth_token.cr b/src/actions/mixins/api/auth/require_auth_token.cr new file mode 100644 index 0000000..e018638 --- /dev/null +++ b/src/actions/mixins/api/auth/require_auth_token.cr @@ -0,0 +1,34 @@ +module Api::Auth::RequireAuthToken + macro included + before require_auth_token + end + + private def require_auth_token + if current_user? + continue + else + json auth_error_json, 401 + end + end + + private def auth_error_json + ErrorSerializer.new( + message: "Not authenticated.", + details: auth_error_details + ) + end + + private def auth_error_details : String + if auth_token + "The provided authentication token was incorrect." + else + "An authentication token is required. Please include a token in an 'auth_token' param or 'Authorization' header." + end + end + + # Tells the compiler that the current_user is not nil since we have checked + # that the user is signed in + private def current_user : User + current_user?.as(User) + end +end diff --git a/src/actions/mixins/api/auth/skip_require_auth_token.cr b/src/actions/mixins/api/auth/skip_require_auth_token.cr new file mode 100644 index 0000000..68098cf --- /dev/null +++ b/src/actions/mixins/api/auth/skip_require_auth_token.cr @@ -0,0 +1,10 @@ +module Api::Auth::SkipRequireAuthToken + macro included + skip require_auth_token + end + + # Since sign in is not required, current_user might be nil + def current_user : User? + current_user? + end +end diff --git a/src/actions/mixins/auth/allow_guests.cr b/src/actions/mixins/auth/allow_guests.cr new file mode 100644 index 0000000..3961399 --- /dev/null +++ b/src/actions/mixins/auth/allow_guests.cr @@ -0,0 +1,10 @@ +module Auth::AllowGuests + macro included + skip require_sign_in + end + + # Since sign in is not required, current_user might be nil + def current_user : User? + current_user? + end +end diff --git a/src/actions/mixins/auth/password_resets/base.cr b/src/actions/mixins/auth/password_resets/base.cr new file mode 100644 index 0000000..77166a9 --- /dev/null +++ b/src/actions/mixins/auth/password_resets/base.cr @@ -0,0 +1,7 @@ +module Auth::PasswordResets::Base + macro included + include Auth::RedirectSignedInUsers + include Auth::PasswordResets::FindUser + include Auth::PasswordResets::RequireToken + end +end diff --git a/src/actions/mixins/auth/password_resets/find_user.cr b/src/actions/mixins/auth/password_resets/find_user.cr new file mode 100644 index 0000000..cab02d5 --- /dev/null +++ b/src/actions/mixins/auth/password_resets/find_user.cr @@ -0,0 +1,5 @@ +module Auth::PasswordResets::FindUser + private def user : User + UserQuery.find(user_id) + end +end diff --git a/src/actions/mixins/auth/password_resets/require_token.cr b/src/actions/mixins/auth/password_resets/require_token.cr new file mode 100644 index 0000000..15da423 --- /dev/null +++ b/src/actions/mixins/auth/password_resets/require_token.cr @@ -0,0 +1,17 @@ +module Auth::PasswordResets::RequireToken + macro included + before require_valid_password_reset_token + end + + abstract def token : String + abstract def user : User + + private def require_valid_password_reset_token + if Authentic.valid_password_reset_token?(user, token) + continue + else + flash.failure = "The password reset link is incorrect or expired. Please try again." + redirect to: PasswordResetRequests::New + end + end +end diff --git a/src/actions/mixins/auth/password_resets/token_from_session.cr b/src/actions/mixins/auth/password_resets/token_from_session.cr new file mode 100644 index 0000000..820b91b --- /dev/null +++ b/src/actions/mixins/auth/password_resets/token_from_session.cr @@ -0,0 +1,5 @@ +module Auth::PasswordResets::TokenFromSession + private def token : String + session.get?(:password_reset_token) || raise "Password reset token not found in session" + end +end diff --git a/src/actions/mixins/auth/redirect_signed_in_users.cr b/src/actions/mixins/auth/redirect_signed_in_users.cr new file mode 100644 index 0000000..546bf7b --- /dev/null +++ b/src/actions/mixins/auth/redirect_signed_in_users.cr @@ -0,0 +1,19 @@ +module Auth::RedirectSignedInUsers + macro included + include Auth::AllowGuests + before redirect_signed_in_users + end + + private def redirect_signed_in_users + if current_user? + flash.success = "You are already signed in" + redirect to: Home::Index + else + continue + end + end + + # current_user returns nil because signed in users are redirected. + def current_user + end +end diff --git a/src/actions/mixins/auth/require_sign_in.cr b/src/actions/mixins/auth/require_sign_in.cr new file mode 100644 index 0000000..27a6f5e --- /dev/null +++ b/src/actions/mixins/auth/require_sign_in.cr @@ -0,0 +1,21 @@ +module Auth::RequireSignIn + macro included + before require_sign_in + end + + private def require_sign_in + if current_user? + continue + else + Authentic.remember_requested_path(self) + flash.info = "Please sign in first" + redirect to: SignIns::New + end + end + + # Tells the compiler that the current_user is not nil since we have checked + # that the user is signed in + private def current_user : User + current_user?.as(User) + end +end diff --git a/src/actions/mixins/auth/test_backdoor.cr b/src/actions/mixins/auth/test_backdoor.cr new file mode 100644 index 0000000..68c9d91 --- /dev/null +++ b/src/actions/mixins/auth/test_backdoor.cr @@ -0,0 +1,13 @@ +module Auth::TestBackdoor + macro included + before test_backdoor + end + + private def test_backdoor + if LuckyEnv.test? && (user_id = params.get?(:backdoor_user_id)) + user = UserQuery.find(user_id) + sign_in user + end + continue + end +end diff --git a/src/actions/password_reset_requests/create.cr b/src/actions/password_reset_requests/create.cr new file mode 100644 index 0000000..8f3c513 --- /dev/null +++ b/src/actions/password_reset_requests/create.cr @@ -0,0 +1,15 @@ +class PasswordResetRequests::Create < BrowserAction + include Auth::RedirectSignedInUsers + + post "/password_reset_requests" do + RequestPasswordReset.run(params) do |operation, user| + if user + PasswordResetRequestEmail.new(user).deliver + flash.success = "You should receive an email on how to reset your password shortly" + redirect SignIns::New + else + html NewPage, operation: operation + end + end + end +end diff --git a/src/actions/password_reset_requests/new.cr b/src/actions/password_reset_requests/new.cr new file mode 100644 index 0000000..7d16a7d --- /dev/null +++ b/src/actions/password_reset_requests/new.cr @@ -0,0 +1,7 @@ +class PasswordResetRequests::New < BrowserAction + include Auth::RedirectSignedInUsers + + get "/password_reset_requests/new" do + html NewPage, operation: RequestPasswordReset.new + end +end diff --git a/src/actions/password_resets/create.cr b/src/actions/password_resets/create.cr new file mode 100644 index 0000000..da1e711 --- /dev/null +++ b/src/actions/password_resets/create.cr @@ -0,0 +1,17 @@ +class PasswordResets::Create < BrowserAction + include Auth::PasswordResets::Base + include Auth::PasswordResets::TokenFromSession + + post "/password_resets/:user_id" do + ResetPassword.update(user, params) do |operation, user| + if operation.saved? + session.delete(:password_reset_token) + sign_in user + flash.success = "Your password has been reset" + redirect to: Home::Index + else + html NewPage, operation: operation, user_id: user_id.to_i64 + end + end + end +end diff --git a/src/actions/password_resets/edit.cr b/src/actions/password_resets/edit.cr new file mode 100644 index 0000000..9408109 --- /dev/null +++ b/src/actions/password_resets/edit.cr @@ -0,0 +1,8 @@ +class PasswordResets::Edit < BrowserAction + include Auth::PasswordResets::Base + include Auth::PasswordResets::TokenFromSession + + get "/password_resets/:user_id/edit" do + html NewPage, operation: ResetPassword.new, user_id: user_id.to_i64 + end +end diff --git a/src/actions/password_resets/new.cr b/src/actions/password_resets/new.cr new file mode 100644 index 0000000..5503468 --- /dev/null +++ b/src/actions/password_resets/new.cr @@ -0,0 +1,20 @@ +class PasswordResets::New < BrowserAction + include Auth::PasswordResets::Base + + param token : String + + get "/password_resets/:user_id" do + redirect_to_edit_form_without_token_param + end + + # This is to prevent password reset tokens from being scraped in the HTTP Referer header + # See more info here: https://github.com/thoughtbot/clearance/pull/707 + private def redirect_to_edit_form_without_token_param + make_token_available_to_future_actions + redirect to: PasswordResets::Edit.with(user_id) + end + + private def make_token_available_to_future_actions + session.set(:password_reset_token, token) + end +end diff --git a/src/actions/sign_ins/create.cr b/src/actions/sign_ins/create.cr new file mode 100644 index 0000000..af22588 --- /dev/null +++ b/src/actions/sign_ins/create.cr @@ -0,0 +1,16 @@ +class SignIns::Create < BrowserAction + include Auth::RedirectSignedInUsers + + post "/sign_in" do + SignInUser.run(params) do |operation, authenticated_user| + if authenticated_user + sign_in(authenticated_user) + flash.success = "You're now signed in" + Authentic.redirect_to_originally_requested_path(self, fallback: Home::Index) + else + flash.failure = "Sign in failed" + html NewPage, operation: operation + end + end + end +end diff --git a/src/actions/sign_ins/delete.cr b/src/actions/sign_ins/delete.cr new file mode 100644 index 0000000..8d34612 --- /dev/null +++ b/src/actions/sign_ins/delete.cr @@ -0,0 +1,7 @@ +class SignIns::Delete < BrowserAction + delete "/sign_out" do + sign_out + flash.info = "You have been signed out" + redirect to: SignIns::New + end +end diff --git a/src/actions/sign_ins/new.cr b/src/actions/sign_ins/new.cr new file mode 100644 index 0000000..3275b40 --- /dev/null +++ b/src/actions/sign_ins/new.cr @@ -0,0 +1,7 @@ +class SignIns::New < BrowserAction + include Auth::RedirectSignedInUsers + + get "/sign_in" do + html NewPage, operation: SignInUser.new + end +end diff --git a/src/actions/sign_ups/create.cr b/src/actions/sign_ups/create.cr new file mode 100644 index 0000000..a291ca6 --- /dev/null +++ b/src/actions/sign_ups/create.cr @@ -0,0 +1,16 @@ +class SignUps::Create < BrowserAction + include Auth::RedirectSignedInUsers + + post "/sign_up" do + SignUpUser.create(params) do |operation, user| + if user + flash.info = "Thanks for signing up" + sign_in(user) + redirect to: Home::Index + else + flash.info = "Couldn't sign you up" + html NewPage, operation: operation + end + end + end +end diff --git a/src/actions/sign_ups/new.cr b/src/actions/sign_ups/new.cr new file mode 100644 index 0000000..2299df6 --- /dev/null +++ b/src/actions/sign_ups/new.cr @@ -0,0 +1,7 @@ +class SignUps::New < BrowserAction + include Auth::RedirectSignedInUsers + + get "/sign_up" do + html NewPage, operation: SignUpUser.new + end +end |