From eb48eb1942d237e8203e75eec394af8d5d83ab0b Mon Sep 17 00:00:00 2001 From: ntxtthomas Date: Mon, 30 Mar 2026 10:05:24 -0500 Subject: [PATCH] harden signout --- .gitignore | 1 + .rspec_status | 65 +++++----- app/controllers/users/sessions_controller.rb | 11 ++ app/lib/users/failure_app.rb | 26 ++++ config/initializers/devise.rb | 3 + spec/requests/api/v1/me_spec.rb | 4 + spec/requests/api/v1/sessions_spec.rb | 82 +++++++++++-- swagger/v1/swagger.yaml | 123 +++++++++++++++++++ 8 files changed, 272 insertions(+), 43 deletions(-) create mode 100644 app/lib/users/failure_app.rb diff --git a/.gitignore b/.gitignore index da876ff..d069c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.rbc capybara-*.html .rspec +.rspec_status /db/*.sqlite3 /db/*.sqlite3-journal /db/*.sqlite3-[0-9]* diff --git a/.rspec_status b/.rspec_status index f410c73..7bdd7bd 100644 --- a/.rspec_status +++ b/.rspec_status @@ -1,33 +1,38 @@ example_id | status | run_time | ----------------------------------------------------------------------- | ------- | --------------- | -./spec/integration/attendance_entries_get_spec.rb[1:1:1:1:1] | passed | 0.03174 seconds | -./spec/integration/attendance_entries_get_spec.rb[1:1:1:2:1] | passed | 0.00362 seconds | -./spec/integration/attendance_entries_post_spec.rb[1:1:1:1:1] | passed | 0.022 seconds | -./spec/integration/attendance_entries_post_spec.rb[1:1:1:2:1] | passed | 0.00654 seconds | -./spec/integration/attendance_entries_post_spec.rb[1:1:1:3:1] | passed | 0.48501 seconds | -./spec/integration/attendance_entries_update_destroy_spec.rb[1:1:1:1:1] | passed | 0.01754 seconds | -./spec/integration/attendance_entries_update_destroy_spec.rb[1:1:1:2:1] | passed | 0.00522 seconds | -./spec/integration/attendance_entries_update_destroy_spec.rb[1:1:1:3:1] | passed | 0.01125 seconds | -./spec/integration/attendance_entries_update_destroy_spec.rb[1:2:1:1:1] | passed | 0.01602 seconds | -./spec/integration/attendance_entries_update_destroy_spec.rb[1:2:1:2:1] | passed | 0.00867 seconds | -./spec/integration/attendance_entries_update_destroy_spec.rb[1:2:1:3:1] | passed | 0.01458 seconds | -./spec/models/attendance_entry_spec.rb[1:1:1] | passed | 0.0027 seconds | -./spec/models/attendance_entry_spec.rb[1:1:2] | passed | 0.00446 seconds | -./spec/models/attendance_entry_spec.rb[1:1:3:1] | passed | 0.00776 seconds | -./spec/models/attendance_entry_spec.rb[1:1:4] | passed | 0.00786 seconds | -./spec/models/attendance_entry_spec.rb[1:1:5] | passed | 0.00938 seconds | -./spec/models/attendance_entry_spec.rb[1:1:6] | passed | 0.00447 seconds | -./spec/models/attendance_entry_spec.rb[1:1:7] | passed | 0.00162 seconds | -./spec/models/jwt_denylist_spec.rb[1:1] | pending | 0.00001 seconds | +./spec/integration/attendance_entries_get_spec.rb[1:1:1:1:1] | passed | 0.02298 seconds | +./spec/integration/attendance_entries_get_spec.rb[1:1:1:2:1] | passed | 0.00291 seconds | +./spec/integration/attendance_entries_post_spec.rb[1:1:1:1:1] | passed | 0.01303 seconds | +./spec/integration/attendance_entries_post_spec.rb[1:1:1:2:1] | passed | 0.26575 seconds | +./spec/integration/attendance_entries_post_spec.rb[1:1:1:3:1] | passed | 0.04712 seconds | +./spec/integration/attendance_entries_update_destroy_spec.rb[1:1:1:1:1] | passed | 0.01255 seconds | +./spec/integration/attendance_entries_update_destroy_spec.rb[1:1:1:2:1] | passed | 0.00317 seconds | +./spec/integration/attendance_entries_update_destroy_spec.rb[1:1:1:3:1] | passed | 0.00913 seconds | +./spec/integration/attendance_entries_update_destroy_spec.rb[1:2:1:1:1] | passed | 0.01171 seconds | +./spec/integration/attendance_entries_update_destroy_spec.rb[1:2:1:2:1] | passed | 0.0031 seconds | +./spec/integration/attendance_entries_update_destroy_spec.rb[1:2:1:3:1] | passed | 0.00935 seconds | +./spec/models/attendance_entry_spec.rb[1:1:1] | passed | 0.00203 seconds | +./spec/models/attendance_entry_spec.rb[1:1:2] | passed | 0.004 seconds | +./spec/models/attendance_entry_spec.rb[1:1:3:1] | passed | 0.00623 seconds | +./spec/models/attendance_entry_spec.rb[1:1:4] | passed | 0.00643 seconds | +./spec/models/attendance_entry_spec.rb[1:1:5] | passed | 0.00716 seconds | +./spec/models/attendance_entry_spec.rb[1:1:6] | passed | 0.0016 seconds | +./spec/models/attendance_entry_spec.rb[1:1:7] | passed | 0.00148 seconds | +./spec/models/jwt_denylist_spec.rb[1:1] | pending | 0 seconds | ./spec/models/user_spec.rb[1:1] | pending | 0.00002 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[1:1:1] | passed | 0.05573 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[1:1:2] | passed | 0.40941 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[1:2:1] | passed | 0.02259 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[1:2:2] | passed | 0.0185 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[1:3:1] | passed | 0.02146 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[1:3:2] | passed | 0.01857 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[2:1] | passed | 0.02031 seconds | -./spec/requests/api/v1/attendance_entries_spec.rb[2:2] | passed | 0.01355 seconds | -./spec/requests/api/v1/me_spec.rb[1:1:1] | passed | 0.04151 seconds | -./spec/requests/api/v1/me_spec.rb[1:1:2] | passed | 0.30706 seconds | -./spec/requests/api/v1/sessions_spec.rb[1:1] | passed | 0.03383 seconds | +./spec/requests/api/v1/attendance_entries_spec.rb[1:1:1] | passed | 0.02612 seconds | +./spec/requests/api/v1/attendance_entries_spec.rb[1:1:2] | passed | 0.01484 seconds | +./spec/requests/api/v1/attendance_entries_spec.rb[1:2:1] | passed | 0.01972 seconds | +./spec/requests/api/v1/attendance_entries_spec.rb[1:2:2] | passed | 0.01551 seconds | +./spec/requests/api/v1/attendance_entries_spec.rb[1:3:1] | passed | 0.01912 seconds | +./spec/requests/api/v1/attendance_entries_spec.rb[1:3:2] | passed | 0.01586 seconds | +./spec/requests/api/v1/attendance_entries_spec.rb[2:1] | passed | 0.01864 seconds | +./spec/requests/api/v1/attendance_entries_spec.rb[2:2] | passed | 0.01045 seconds | +./spec/requests/api/v1/me_spec.rb[1:1:1] | passed | 0.00842 seconds | +./spec/requests/api/v1/me_spec.rb[1:1:2] | passed | 0.00357 seconds | +./spec/requests/api/v1/sessions_spec.rb[1:1:1] | passed | 0.01037 seconds | +./spec/requests/api/v1/sessions_spec.rb[1:1:2] | passed | 0.01094 seconds | +./spec/requests/api/v1/sessions_spec.rb[1:1:3] | passed | 0.01727 seconds | +./spec/requests/api/v1/sessions_spec.rb[1:2:1] | passed | 0.01432 seconds | +./spec/requests/api/v1/sessions_spec.rb[1:2:2] | passed | 0.02521 seconds | +./spec/requests/api/v1/sessions_spec.rb[1:2:3] | passed | 0.00403 seconds | diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 6663ea3..b85ac43 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -2,11 +2,22 @@ module Users class SessionsController < Devise::SessionsController respond_to :json + prepend_before_action only: :destroy do + unless warden.authenticated?(:user) + render json: { error: "Unauthorized", code: "unauthorized" }, status: :unauthorized + end + end + def create resource = warden.authenticate!(auth_options) sign_in(resource_name, resource) token, = Warden::JWTAuth::UserEncoder.new.call(resource, :user, nil) render json: { token: token, user: { id: resource.id, email: resource.email } }, status: :ok end + + def destroy + sign_out(current_user) + head :no_content + end end end diff --git a/app/lib/users/failure_app.rb b/app/lib/users/failure_app.rb new file mode 100644 index 0000000..3d9d212 --- /dev/null +++ b/app/lib/users/failure_app.rb @@ -0,0 +1,26 @@ +module Users + class FailureApp < Devise::FailureApp + def respond + if json_request? + json_error_response + else + super + end + end + + private + + def json_request? + request.format.json? || request.path.start_with?("/api/", "/users/") + end + + def json_error_response + self.status = :unauthorized + self.content_type = "application/json" + self.response_body = { + error: i18n_message || "Unauthorized", + code: "unauthorized" + }.to_json + end + end +end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 115e81d..3fbd6eb 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -284,6 +284,9 @@ # warden_config.intercept_401 = false # warden_config.default_strategies(scope: :user).unshift :some_external_strategy # end + config.warden do |manager| + manager.failure_app = Users::FailureApp + end # ==> Mountable engine configurations # When using Devise inside an engine, let's call it `MyEngine`, and this engine diff --git a/spec/requests/api/v1/me_spec.rb b/spec/requests/api/v1/me_spec.rb index b615ae6..57362b1 100644 --- a/spec/requests/api/v1/me_spec.rb +++ b/spec/requests/api/v1/me_spec.rb @@ -20,6 +20,10 @@ get "/api/v1/me", as: :json expect(response).to have_http_status(:unauthorized) + expect(response.parsed_body).to include( + "error" => a_kind_of(String), + "code" => "unauthorized" + ) end end end diff --git a/spec/requests/api/v1/sessions_spec.rb b/spec/requests/api/v1/sessions_spec.rb index dc11de9..a1a43e9 100644 --- a/spec/requests/api/v1/sessions_spec.rb +++ b/spec/requests/api/v1/sessions_spec.rb @@ -3,25 +3,81 @@ RSpec.describe "Sessions", type: :request do let(:user) { create(:user, email: "test@example.com", password: "password") } - it "returns a token and lets an authenticated user create attandance entries" do + def sign_in_user(user) post "/users/sign_in", params: { user: { email: user.email, password: user.password } }, as: :json - token = - if response.headers["Authorization"].present? - response.headers["Authorization"].split.last - else - response.parsed_body.fetch("token") - end + if response.headers["Authorization"].present? + response.headers["Authorization"].split.last + else + response.parsed_body.fetch("token") + end + end - expect(token).to be_present + describe "POST /users/sign_in" do + it "returns a token on valid credentials" do + token = sign_in_user(user) + expect(token).to be_present + end - post "/api/v1/attendance_entries", - params: { attendance_entry: { student_name: "Jane Doe", status: "present" } }, - headers: { "Authorization" => "Bearer #{token}" }, - as: :json + it "returns unauthorized on bad credentials" do + post "/users/sign_in", + params: { user: { email: user.email, password: "wrong" } }, + as: :json + + expect(response).to have_http_status(:unauthorized) + expect(response.parsed_body).to include( + "error" => a_kind_of(String), + "code" => "unauthorized" + ) + end + + it "allows an authenticated user to access protected endpoints" do + token = sign_in_user(user) + + post "/api/v1/attendance_entries", + params: { attendance_entry: { student_name: "Jane Doe", status: "present" } }, + headers: { "Authorization" => "Bearer #{token}" }, + as: :json + + expect(response).to have_http_status(:created).or have_http_status(:ok) + end + end + + describe "DELETE /users/sign_out" do + it "returns no content and revokes the token" do + token = sign_in_user(user) + + delete "/users/sign_out", + headers: { "Authorization" => "Bearer #{token}" }, + as: :json + + expect(response).to have_http_status(:no_content) + end + + it "prevents a revoked token from accessing protected endpoints" do + token = sign_in_user(user) + + delete "/users/sign_out", + headers: { "Authorization" => "Bearer #{token}" }, + as: :json + + get "/api/v1/me", + headers: { "Authorization" => "Bearer #{token}" }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it "returns unauthorized when signing out without a token" do + delete "/users/sign_out", as: :json - expect(response).to have_http_status(:created).or have_http_status(:ok) + expect(response).to have_http_status(:unauthorized) + expect(response.parsed_body).to eq( + "error" => "Unauthorized", + "code" => "unauthorized" + ) + end end end diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 208c228..4815359 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -4,6 +4,115 @@ info: title: API V1 version: v1 paths: + "/users/sign_in": + post: + summary: Sign in and receive JWT + tags: + - Auth + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + email: + type: string + format: email + password: + type: string + required: + - email + - password + required: + - user + responses: + '200': + description: signed in + content: + application/json: + schema: + type: object + properties: + token: + type: string + user: + type: object + properties: + id: + type: integer + email: + type: string + format: email + required: + - token + - user + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/UnauthorizedError" + "/users/sign_out": + delete: + summary: Revoke current JWT + tags: + - Auth + parameters: + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '204': + description: signed out + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/UnauthorizedError" + "/api/v1/me": + get: + summary: Get current authenticated user + tags: + - Auth + parameters: + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: current user + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + id: + type: integer + email: + type: string + format: email + required: + - user + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/UnauthorizedError" "/api/v1/attendance_entries": get: summary: List attendance entries @@ -146,3 +255,17 @@ servers: variables: defaultHost: default: www.example.com +components: + schemas: + UnauthorizedError: + type: object + properties: + error: + type: string + example: Invalid Email or password. + code: + type: string + example: unauthorized + required: + - error + - code