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
2 changes: 2 additions & 0 deletions app/controllers/api/v1/base_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
module Api
module V1
class BaseController < ApplicationController
include Authentication

rescue_from StandardError, with: :internal_server_error
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::ParameterMissing, with: :bad_request
Expand Down
5 changes: 4 additions & 1 deletion app/controllers/api/v1/registrations_controller.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
module Api
module V1
class RegistrationsController < BaseController
allow_unauthenticated_access

def create
user = User.new(registration_params)
user.save!

render json: UserSerializer.new(user).serialize, status: :created
session = user.sessions.create!(ip_address: request.remote_ip, user_agent: request.user_agent)
render json: { token: session.token, user: UserSerializer.new(user).as_json }, status: :created
end

private
Expand Down
33 changes: 33 additions & 0 deletions app/controllers/api/v1/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
module Api
module V1
class SessionsController < BaseController
allow_unauthenticated_access only: [ :create ]

def create
user = User.authenticate_by(email: session_params[:email].to_s.downcase.strip,
password: session_params[:password])

unless user
return render_error(code: "invalid_credentials",
message: "Invalid email or password",
status: :unauthorized)
end

session = user.sessions.create!(ip_address: request.remote_ip, user_agent: request.user_agent)

render json: { token: session.token, user: UserSerializer.new(user).as_json }, status: :created
end

def destroy
Current.session.destroy!
head :no_content
end

private

def session_params
params.expect(session: [ :email, :password ])
end
end
end
end
34 changes: 34 additions & 0 deletions app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module Authentication
extend ActiveSupport::Concern

included do
before_action :require_authentication
end

class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end

private

def require_authentication
Current.session = find_session_by_bearer_token
return if Current.session

render_error(code: "unauthorized", message: "Authentication required", status: :unauthorized)
end

def find_session_by_bearer_token
token = request.headers["Authorization"]&.split(" ")&.last
return if token.blank?

session = Session.includes(:user).find_by(token: token)
session unless session.nil? || session.expired?
end

def current_user
Current.user
end
end
5 changes: 5 additions & 0 deletions app/models/current.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session

delegate :user, to: :session, allow_nil: true
end
11 changes: 11 additions & 0 deletions app/models/session.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class Session < ApplicationRecord
belongs_to :user

has_secure_token :token, length: 32

scope :active, -> { where(created_at: 30.days.ago..) }

def expired?
created_at < 30.days.ago
end
end
1 change: 1 addition & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy

EMAIL_FORMAT = URI::MailTo::EMAIL_REGEXP
MINIMUM_PASSWORD_LENGTH = 12
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
namespace :v1 do
resources :users, only: [ :show ]
resource :registration, only: [ :create ]
resource :session, only: [ :create, :destroy ]
end
end

Expand Down
14 changes: 14 additions & 0 deletions db/migrate/20260807104358_create_sessions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class CreateSessions < ActiveRecord::Migration[8.1]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :token, null: false
t.string :ip_address
t.string :user_agent

t.timestamps
end

add_index :sessions, :token, unique: true
end
end
15 changes: 14 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions spec/factories/sessions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FactoryBot.define do
factory :session do
user
end
end
37 changes: 37 additions & 0 deletions spec/models/session_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
require "rails_helper"

RSpec.describe Session, type: :model do
it { is_expected.to belong_to(:user) }

it "generates a token on create" do
session = create(:session)
expect(session.token).to be_present
end

it "generates a unique token per session" do
expect(create(:session).token).not_to eq(create(:session).token)
end

describe "#expired?" do
it "is false for a fresh session" do
expect(create(:session)).not_to be_expired
end

it "is true after 30 days" do
session = create(:session)
session.update_column(:created_at, 31.days.ago)
expect(session.reload).to be_expired
end
end

describe ".active" do
it "excludes expired sessions" do
fresh = create(:session)
old = create(:session)
old.update_column(:created_at, 31.days.ago)

expect(Session.active).to include(fresh)
expect(Session.active).not_to include(old)
end
end
end
3 changes: 2 additions & 1 deletion spec/requests/api/v1/registrations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
}.to change(User, :count).by(1)

expect(response).to have_http_status(:created)
expect(json_body["email"]).to eq("new@example.com")
expect(json_body["user"]["email"]).to eq("new@example.com")
expect(json_body["token"]).to be_present
end

it "never returns the password digest" do
Expand Down
57 changes: 57 additions & 0 deletions spec/requests/api/v1/sessions_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
require "rails_helper"

RSpec.describe "Api::V1::Sessions", type: :request do
let(:password) { "password1234" }
let!(:user) { create(:user, password: password) }

describe "POST /api/v1/session" do
it "returns a token for valid credentials" do
post "/api/v1/session", params: { session: { email: user.email, password: password } }

expect(response).to have_http_status(:created)
expect(json_body["token"]).to be_present
end

it "rejects a wrong password without revealing which field failed" do
post "/api/v1/session", params: { session: { email: user.email, password: "wrong" } }

expect(response).to have_http_status(:unauthorized)
expect(json_body["error"]["message"]).to eq("Invalid email or password")
end

it "rejects an unknown email with the same message" do
post "/api/v1/session", params: { session: { email: "nobody@example.com", password: password } }

expect(json_body["error"]["message"]).to eq("Invalid email or password")
end
end

describe "DELETE /api/v1/session" do
it "revokes the session" do
post "/api/v1/session", params: { session: { email: user.email, password: password } }
token = json_body["token"]

expect {
delete "/api/v1/session", headers: { "Authorization" => "Bearer #{token}" }
}.to change(Session, :count).by(-1)

expect(response).to have_http_status(:no_content)
end
end

describe "authentication" do
it "rejects requests without a token" do
get "/api/v1/users/#{user.id}"
expect(response).to have_http_status(:unauthorized)
end

it "rejects a revoked token" do
session = user.sessions.create!
token = session.token
session.destroy!

get "/api/v1/users/#{user.id}", headers: { "Authorization" => "Bearer #{token}" }
expect(response).to have_http_status(:unauthorized)
end
end
end
8 changes: 4 additions & 4 deletions spec/requests/api/v1/users_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@

RSpec.describe "Api::V1::Users", type: :request do
describe "GET /api/v1/users/:id" do
it "returns the user" do
user = create(:user)
let(:user) { create(:user) }

get "/api/v1/users/#{user.id}"
it "returns the user" do
get "/api/v1/users/#{user.id}", headers: auth_headers_for(user)

expect(response).to have_http_status(:ok)
expect(json_body["id"]).to eq(user.id)
expect(json_body["email"]).to eq(user.email)
end

it "returns a structured 404 for an unknown id" do
get "/api/v1/users/999999"
get "/api/v1/users/999999", headers: auth_headers_for(user)

expect(response).to have_http_status(:not_found)
expect(json_body["error"]["code"]).to eq("not_found")
Expand Down
10 changes: 10 additions & 0 deletions spec/support/auth_helpers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module AuthHelpers
def auth_headers_for(user)
session = user.sessions.create!
{ "Authorization" => "Bearer #{session.token}" }
end
end

RSpec.configure do |config|
config.include AuthHelpers, type: :request
end