mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-24 07:39:39 +02:00
Add secure OAuth2-based mobile authentication
- Replace API keys with OAuth2 tokens for mobile apps - Add device tracking and management for mobile sessions - Implement 30-day token expiration with refresh tokens - Add MFA/2FA support for mobile login - Create dedicated auth endpoints (signup/login/refresh) - Skip CSRF protection for API endpoints - Return plaintext tokens (not hashed) in responses - Track devices with unique IDs and metadata - Enable seamless native mobile experience without OAuth redirects This provides enterprise-grade security for the iOS/Android apps while maintaining a completely native authentication flow. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
cba0bdf0e2
commit
9336719242
15 changed files with 761 additions and 6 deletions
210
app/controllers/api/v1/auth_controller.rb
Normal file
210
app/controllers/api/v1/auth_controller.rb
Normal file
|
@ -0,0 +1,210 @@
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class AuthController < BaseController
|
||||||
|
include Invitable
|
||||||
|
|
||||||
|
skip_before_action :authenticate_request!
|
||||||
|
skip_before_action :check_api_key_rate_limit
|
||||||
|
skip_before_action :log_api_access
|
||||||
|
|
||||||
|
def signup
|
||||||
|
# Check if invite code is required
|
||||||
|
if invite_code_required? && params[:invite_code].blank?
|
||||||
|
render json: { error: "Invite code is required" }, status: :forbidden
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate invite code if provided
|
||||||
|
if params[:invite_code].present? && !InviteCode.exists?(token: params[:invite_code]&.downcase)
|
||||||
|
render json: { error: "Invalid invite code" }, status: :forbidden
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate password
|
||||||
|
password_errors = validate_password(params[:user][:password])
|
||||||
|
if password_errors.any?
|
||||||
|
render json: { errors: password_errors }, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate device info
|
||||||
|
unless valid_device_info?
|
||||||
|
render json: { error: "Device information is required" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
user = User.new(user_signup_params)
|
||||||
|
|
||||||
|
# Create family for new user
|
||||||
|
family = Family.new
|
||||||
|
user.family = family
|
||||||
|
user.role = :admin
|
||||||
|
|
||||||
|
if user.save
|
||||||
|
# Claim invite code if provided
|
||||||
|
InviteCode.claim!(params[:invite_code]) if params[:invite_code].present?
|
||||||
|
|
||||||
|
# Create device and OAuth token
|
||||||
|
device = create_or_update_device(user)
|
||||||
|
token_response = create_oauth_token_for_device(user, device)
|
||||||
|
|
||||||
|
render json: token_response.merge(
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name
|
||||||
|
}
|
||||||
|
), status: :created
|
||||||
|
else
|
||||||
|
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def login
|
||||||
|
user = User.find_by(email: params[:email])
|
||||||
|
|
||||||
|
if user&.authenticate(params[:password])
|
||||||
|
# Check MFA if enabled
|
||||||
|
if user.otp_required?
|
||||||
|
unless params[:otp_code].present? && user.verify_otp?(params[:otp_code])
|
||||||
|
render json: {
|
||||||
|
error: "Two-factor authentication required",
|
||||||
|
mfa_required: true
|
||||||
|
}, status: :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate device info
|
||||||
|
unless valid_device_info?
|
||||||
|
render json: { error: "Device information is required" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create device and OAuth token
|
||||||
|
device = create_or_update_device(user)
|
||||||
|
token_response = create_oauth_token_for_device(user, device)
|
||||||
|
|
||||||
|
render json: token_response.merge(
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else
|
||||||
|
render json: { error: "Invalid email or password" }, status: :unauthorized
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh
|
||||||
|
# Find the refresh token
|
||||||
|
refresh_token = params[:refresh_token]
|
||||||
|
|
||||||
|
unless refresh_token.present?
|
||||||
|
render json: { error: "Refresh token is required" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find the access token associated with this refresh token
|
||||||
|
access_token = Doorkeeper::AccessToken.by_refresh_token(refresh_token)
|
||||||
|
|
||||||
|
if access_token.nil? || access_token.revoked?
|
||||||
|
render json: { error: "Invalid refresh token" }, status: :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create new access token
|
||||||
|
new_token = Doorkeeper::AccessToken.create!(
|
||||||
|
application: access_token.application,
|
||||||
|
resource_owner_id: access_token.resource_owner_id,
|
||||||
|
expires_in: 30.days.to_i,
|
||||||
|
scopes: access_token.scopes,
|
||||||
|
use_refresh_token: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Revoke old access token
|
||||||
|
access_token.revoke
|
||||||
|
|
||||||
|
# Update device last seen
|
||||||
|
user = User.find(access_token.resource_owner_id)
|
||||||
|
device = user.mobile_devices.find_by(device_id: params[:device][:device_id])
|
||||||
|
device&.update_last_seen!
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
access_token: new_token.plaintext_token,
|
||||||
|
refresh_token: new_token.plaintext_refresh_token,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: new_token.expires_in,
|
||||||
|
created_at: new_token.created_at.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def user_signup_params
|
||||||
|
params.require(:user).permit(:email, :password, :first_name, :last_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_password(password)
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if password.blank?
|
||||||
|
errors << "Password can't be blank"
|
||||||
|
return errors
|
||||||
|
end
|
||||||
|
|
||||||
|
errors << "Password must be at least 8 characters" if password.length < 8
|
||||||
|
errors << "Password must include both uppercase and lowercase letters" unless password.match?(/[A-Z]/) && password.match?(/[a-z]/)
|
||||||
|
errors << "Password must include at least one number" unless password.match?(/\d/)
|
||||||
|
errors << "Password must include at least one special character" unless password.match?(/[!@#$%^&*(),.?":{}|<>]/)
|
||||||
|
|
||||||
|
errors
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_device_info?
|
||||||
|
device = params[:device]
|
||||||
|
return false if device.nil?
|
||||||
|
|
||||||
|
required_fields = %w[device_id device_name device_type os_version app_version]
|
||||||
|
required_fields.all? { |field| device[field].present? }
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_or_update_device(user)
|
||||||
|
# Handle both string and symbol keys
|
||||||
|
device_data = params[:device].permit(:device_id, :device_name, :device_type, :os_version, :app_version)
|
||||||
|
|
||||||
|
device = user.mobile_devices.find_or_initialize_by(device_id: device_data[:device_id])
|
||||||
|
device.update!(device_data.merge(last_seen_at: Time.current))
|
||||||
|
device
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_oauth_token_for_device(user, device)
|
||||||
|
# Create OAuth application for this device if needed
|
||||||
|
oauth_app = device.create_oauth_application!
|
||||||
|
|
||||||
|
# Revoke any existing tokens for this device
|
||||||
|
device.revoke_all_tokens!
|
||||||
|
|
||||||
|
# Create new access token with 30-day expiration
|
||||||
|
access_token = Doorkeeper::AccessToken.create!(
|
||||||
|
application: oauth_app,
|
||||||
|
resource_owner_id: user.id,
|
||||||
|
expires_in: 30.days.to_i,
|
||||||
|
scopes: "read_write",
|
||||||
|
use_refresh_token: true
|
||||||
|
)
|
||||||
|
|
||||||
|
{
|
||||||
|
access_token: access_token.plaintext_token,
|
||||||
|
refresh_token: access_token.plaintext_refresh_token,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: access_token.expires_in,
|
||||||
|
created_at: access_token.created_at.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -6,6 +6,9 @@ class Api::V1::BaseController < ApplicationController
|
||||||
# Skip regular session-based authentication for API
|
# Skip regular session-based authentication for API
|
||||||
skip_authentication
|
skip_authentication
|
||||||
|
|
||||||
|
# Skip CSRF protection for API endpoints
|
||||||
|
skip_before_action :verify_authenticity_token
|
||||||
|
|
||||||
# Skip onboarding requirements for API endpoints
|
# Skip onboarding requirements for API endpoints
|
||||||
skip_before_action :require_onboarding_and_upgrade
|
skip_before_action :require_onboarding_and_upgrade
|
||||||
|
|
||||||
|
|
|
@ -4,12 +4,16 @@ class ApiKey < ApplicationRecord
|
||||||
# Use Rails built-in encryption for secure storage
|
# Use Rails built-in encryption for secure storage
|
||||||
encrypts :display_key, deterministic: true
|
encrypts :display_key, deterministic: true
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
SOURCES = [ "web", "mobile" ].freeze
|
||||||
|
|
||||||
# Validations
|
# Validations
|
||||||
validates :display_key, presence: true, uniqueness: true
|
validates :display_key, presence: true, uniqueness: true
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
validates :scopes, presence: true
|
validates :scopes, presence: true
|
||||||
|
validates :source, presence: true, inclusion: { in: SOURCES }
|
||||||
validate :scopes_not_empty
|
validate :scopes_not_empty
|
||||||
validate :one_active_key_per_user, on: :create
|
validate :one_active_key_per_user_per_source, on: :create
|
||||||
|
|
||||||
# Callbacks
|
# Callbacks
|
||||||
before_validation :set_display_key
|
before_validation :set_display_key
|
||||||
|
@ -82,9 +86,9 @@ class ApiKey < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def one_active_key_per_user
|
def one_active_key_per_user_per_source
|
||||||
if user&.api_keys&.active&.where&.not(id: id)&.exists?
|
if user&.api_keys&.active&.where(source: source)&.where&.not(id: id)&.exists?
|
||||||
errors.add(:user, "can only have one active API key")
|
errors.add(:user, "can only have one active API key per source (#{source})")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
55
app/models/mobile_device.rb
Normal file
55
app/models/mobile_device.rb
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
class MobileDevice < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :oauth_application, class_name: "Doorkeeper::Application", optional: true
|
||||||
|
|
||||||
|
validates :device_id, presence: true, uniqueness: { scope: :user_id }
|
||||||
|
validates :device_name, presence: true
|
||||||
|
validates :device_type, presence: true, inclusion: { in: %w[ios android] }
|
||||||
|
|
||||||
|
before_validation :set_last_seen_at, on: :create
|
||||||
|
|
||||||
|
scope :active, -> { where("last_seen_at > ?", 90.days.ago) }
|
||||||
|
|
||||||
|
def active?
|
||||||
|
last_seen_at > 90.days.ago
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_last_seen!
|
||||||
|
update_column(:last_seen_at, Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_oauth_application!
|
||||||
|
return oauth_application if oauth_application.present?
|
||||||
|
|
||||||
|
app = Doorkeeper::Application.create!(
|
||||||
|
name: "Mobile App - #{device_id}",
|
||||||
|
redirect_uri: "maybe://oauth/callback", # Custom scheme for mobile
|
||||||
|
scopes: "read_write", # Use the configured scope
|
||||||
|
confidential: false # Public client for mobile
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the association
|
||||||
|
update!(oauth_application: app)
|
||||||
|
app
|
||||||
|
end
|
||||||
|
|
||||||
|
def active_tokens
|
||||||
|
return Doorkeeper::AccessToken.none unless oauth_application
|
||||||
|
|
||||||
|
Doorkeeper::AccessToken
|
||||||
|
.where(application: oauth_application)
|
||||||
|
.where(resource_owner_id: user_id)
|
||||||
|
.where(revoked_at: nil)
|
||||||
|
.where("expires_in IS NULL OR created_at + expires_in * interval '1 second' > ?", Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke_all_tokens!
|
||||||
|
active_tokens.update_all(revoked_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_last_seen_at
|
||||||
|
self.last_seen_at ||= Time.current
|
||||||
|
end
|
||||||
|
end
|
|
@ -6,6 +6,7 @@ class User < ApplicationRecord
|
||||||
has_many :sessions, dependent: :destroy
|
has_many :sessions, dependent: :destroy
|
||||||
has_many :chats, dependent: :destroy
|
has_many :chats, dependent: :destroy
|
||||||
has_many :api_keys, dependent: :destroy
|
has_many :api_keys, dependent: :destroy
|
||||||
|
has_many :mobile_devices, dependent: :destroy
|
||||||
has_many :invitations, foreign_key: :inviter_id, dependent: :destroy
|
has_many :invitations, foreign_key: :inviter_id, dependent: :destroy
|
||||||
has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy
|
has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy
|
||||||
has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy
|
has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy
|
||||||
|
|
|
@ -255,7 +255,7 @@ Doorkeeper.configure do
|
||||||
# NOTE: you must also run the rails g doorkeeper:application_owner generator
|
# NOTE: you must also run the rails g doorkeeper:application_owner generator
|
||||||
# to provide the necessary support
|
# to provide the necessary support
|
||||||
#
|
#
|
||||||
# enable_application_owner confirmation: false
|
enable_application_owner confirmation: false
|
||||||
|
|
||||||
# Define access token scopes for your provider
|
# Define access token scopes for your provider
|
||||||
# For more information go to
|
# For more information go to
|
||||||
|
|
|
@ -185,6 +185,11 @@ Rails.application.routes.draw do
|
||||||
# API routes
|
# API routes
|
||||||
namespace :api do
|
namespace :api do
|
||||||
namespace :v1 do
|
namespace :v1 do
|
||||||
|
# Authentication endpoints
|
||||||
|
post "auth/signup", to: "auth#signup"
|
||||||
|
post "auth/login", to: "auth#login"
|
||||||
|
post "auth/refresh", to: "auth#refresh"
|
||||||
|
|
||||||
# Production API endpoints
|
# Production API endpoints
|
||||||
resources :accounts, only: [ :index ]
|
resources :accounts, only: [ :index ]
|
||||||
resources :transactions, only: [ :index, :show, :create, :update, :destroy ]
|
resources :transactions, only: [ :index, :show, :create, :update, :destroy ]
|
||||||
|
|
6
db/migrate/20250618104425_add_source_to_api_keys.rb
Normal file
6
db/migrate/20250618104425_add_source_to_api_keys.rb
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
class AddSourceToApiKeys < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :api_keys, :source, :string, default: "web"
|
||||||
|
add_index :api_keys, [:user_id, :source]
|
||||||
|
end
|
||||||
|
end
|
17
db/migrate/20250618110104_create_mobile_devices.rb
Normal file
17
db/migrate/20250618110104_create_mobile_devices.rb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
class CreateMobileDevices < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :mobile_devices, id: :uuid do |t|
|
||||||
|
t.references :user, null: false, foreign_key: true, type: :uuid
|
||||||
|
t.string :device_id
|
||||||
|
t.string :device_name
|
||||||
|
t.string :device_type
|
||||||
|
t.string :os_version
|
||||||
|
t.string :app_version
|
||||||
|
t.datetime :last_seen_at
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
add_index :mobile_devices, :device_id, unique: true
|
||||||
|
add_index :mobile_devices, [:user_id, :device_id], unique: true
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,7 @@
|
||||||
|
class AddOwnerToOauthApplications < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :oauth_applications, :owner_id, :uuid
|
||||||
|
add_column :oauth_applications, :owner_type, :string
|
||||||
|
add_index :oauth_applications, [:owner_id, :owner_type]
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,6 @@
|
||||||
|
class AddOauthApplicationToMobileDevices < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :mobile_devices, :oauth_application_id, :integer
|
||||||
|
add_index :mobile_devices, :oauth_application_id
|
||||||
|
end
|
||||||
|
end
|
23
db/schema.rb
generated
23
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2025_06_13_152743) do
|
ActiveRecord::Schema[7.2].define(version: 2025_06_18_110736) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -98,8 +98,10 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_13_152743) do
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "display_key", null: false
|
t.string "display_key", null: false
|
||||||
|
t.string "source", default: "web"
|
||||||
t.index ["display_key"], name: "index_api_keys_on_display_key", unique: true
|
t.index ["display_key"], name: "index_api_keys_on_display_key", unique: true
|
||||||
t.index ["revoked_at"], name: "index_api_keys_on_revoked_at"
|
t.index ["revoked_at"], name: "index_api_keys_on_revoked_at"
|
||||||
|
t.index ["user_id", "source"], name: "index_api_keys_on_user_id_and_source"
|
||||||
t.index ["user_id"], name: "index_api_keys_on_user_id"
|
t.index ["user_id"], name: "index_api_keys_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -429,6 +431,21 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_13_152743) do
|
||||||
t.index ["chat_id"], name: "index_messages_on_chat_id"
|
t.index ["chat_id"], name: "index_messages_on_chat_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "mobile_devices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.uuid "user_id", null: false
|
||||||
|
t.string "device_id"
|
||||||
|
t.string "device_name"
|
||||||
|
t.string "device_type"
|
||||||
|
t.string "os_version"
|
||||||
|
t.string "app_version"
|
||||||
|
t.datetime "last_seen_at"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["device_id"], name: "index_mobile_devices_on_device_id", unique: true
|
||||||
|
t.index ["user_id", "device_id"], name: "index_mobile_devices_on_user_id_and_device_id", unique: true
|
||||||
|
t.index ["user_id"], name: "index_mobile_devices_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "oauth_access_grants", force: :cascade do |t|
|
create_table "oauth_access_grants", force: :cascade do |t|
|
||||||
t.string "resource_owner_id", null: false
|
t.string "resource_owner_id", null: false
|
||||||
t.bigint "application_id", null: false
|
t.bigint "application_id", null: false
|
||||||
|
@ -468,6 +485,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_13_152743) do
|
||||||
t.boolean "confidential", default: true, null: false
|
t.boolean "confidential", default: true, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.uuid "owner_id"
|
||||||
|
t.string "owner_type"
|
||||||
|
t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type"
|
||||||
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
|
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -802,6 +822,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_13_152743) do
|
||||||
add_foreign_key "invitations", "users", column: "inviter_id"
|
add_foreign_key "invitations", "users", column: "inviter_id"
|
||||||
add_foreign_key "merchants", "families"
|
add_foreign_key "merchants", "families"
|
||||||
add_foreign_key "messages", "chats"
|
add_foreign_key "messages", "chats"
|
||||||
|
add_foreign_key "mobile_devices", "users"
|
||||||
add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
|
add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
|
||||||
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
|
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
|
||||||
add_foreign_key "plaid_accounts", "plaid_items"
|
add_foreign_key "plaid_accounts", "plaid_items"
|
||||||
|
|
410
test/controllers/api/v1/auth_controller_test.rb
Normal file
410
test/controllers/api/v1/auth_controller_test.rb
Normal file
|
@ -0,0 +1,410 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
# Clean up any existing invite codes
|
||||||
|
InviteCode.destroy_all
|
||||||
|
@device_info = {
|
||||||
|
device_id: "test-device-123",
|
||||||
|
device_name: "Test iPhone",
|
||||||
|
device_type: "ios",
|
||||||
|
os_version: "17.0",
|
||||||
|
app_version: "1.0.0"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should signup new user and return OAuth tokens" do
|
||||||
|
assert_difference("User.count", 1) do
|
||||||
|
assert_difference("MobileDevice.count", 1) do
|
||||||
|
assert_difference("Doorkeeper::Application.count", 1) do
|
||||||
|
assert_difference("Doorkeeper::AccessToken.count", 1) do
|
||||||
|
post "/api/v1/auth/signup", params: {
|
||||||
|
user: {
|
||||||
|
email: "newuser@example.com",
|
||||||
|
password: "SecurePass123!",
|
||||||
|
first_name: "New",
|
||||||
|
last_name: "User"
|
||||||
|
},
|
||||||
|
device: @device_info
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :created
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
|
||||||
|
assert response_data["user"]["id"].present?
|
||||||
|
assert_equal "newuser@example.com", response_data["user"]["email"]
|
||||||
|
assert_equal "New", response_data["user"]["first_name"]
|
||||||
|
assert_equal "User", response_data["user"]["last_name"]
|
||||||
|
|
||||||
|
# OAuth token assertions
|
||||||
|
assert response_data["access_token"].present?
|
||||||
|
assert response_data["refresh_token"].present?
|
||||||
|
assert_equal "Bearer", response_data["token_type"]
|
||||||
|
assert_equal 2592000, response_data["expires_in"] # 30 days
|
||||||
|
assert response_data["created_at"].present?
|
||||||
|
|
||||||
|
# Verify the device was created
|
||||||
|
new_user = User.find(response_data["user"]["id"])
|
||||||
|
device = new_user.mobile_devices.first
|
||||||
|
assert_equal @device_info[:device_id], device.device_id
|
||||||
|
assert_equal @device_info[:device_name], device.device_name
|
||||||
|
assert_equal @device_info[:device_type], device.device_type
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should not signup without device info" do
|
||||||
|
assert_no_difference("User.count") do
|
||||||
|
post "/api/v1/auth/signup", params: {
|
||||||
|
user: {
|
||||||
|
email: "newuser@example.com",
|
||||||
|
password: "SecurePass123!",
|
||||||
|
first_name: "New",
|
||||||
|
last_name: "User"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
assert_equal "Device information is required", response_data["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should not signup with invalid password" do
|
||||||
|
assert_no_difference("User.count") do
|
||||||
|
post "/api/v1/auth/signup", params: {
|
||||||
|
user: {
|
||||||
|
email: "newuser@example.com",
|
||||||
|
password: "weak",
|
||||||
|
first_name: "New",
|
||||||
|
last_name: "User"
|
||||||
|
},
|
||||||
|
device: @device_info
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :unprocessable_entity
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
assert response_data["errors"].include?("Password must be at least 8 characters")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should not signup with duplicate email" do
|
||||||
|
existing_user = users(:family_admin)
|
||||||
|
|
||||||
|
assert_no_difference("User.count") do
|
||||||
|
post "/api/v1/auth/signup", params: {
|
||||||
|
user: {
|
||||||
|
email: existing_user.email,
|
||||||
|
password: "SecurePass123!",
|
||||||
|
first_name: "Duplicate",
|
||||||
|
last_name: "User"
|
||||||
|
},
|
||||||
|
device: @device_info
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should create user with admin role and family" do
|
||||||
|
post "/api/v1/auth/signup", params: {
|
||||||
|
user: {
|
||||||
|
email: "newuser@example.com",
|
||||||
|
password: "SecurePass123!",
|
||||||
|
first_name: "New",
|
||||||
|
last_name: "User"
|
||||||
|
},
|
||||||
|
device: @device_info
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :created
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
|
||||||
|
new_user = User.find(response_data["user"]["id"])
|
||||||
|
assert_equal "admin", new_user.role
|
||||||
|
assert new_user.family.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should require invite code when enabled" do
|
||||||
|
# Mock invite code requirement
|
||||||
|
Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(true)
|
||||||
|
|
||||||
|
assert_no_difference("User.count") do
|
||||||
|
post "/api/v1/auth/signup", params: {
|
||||||
|
user: {
|
||||||
|
email: "newuser@example.com",
|
||||||
|
password: "SecurePass123!",
|
||||||
|
first_name: "New",
|
||||||
|
last_name: "User"
|
||||||
|
},
|
||||||
|
device: @device_info
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :forbidden
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
assert_equal "Invite code is required", response_data["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should signup with valid invite code when required" do
|
||||||
|
# Create a valid invite code
|
||||||
|
invite_code = InviteCode.create!
|
||||||
|
|
||||||
|
# Mock invite code requirement
|
||||||
|
Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(true)
|
||||||
|
|
||||||
|
assert_difference("User.count", 1) do
|
||||||
|
assert_difference("InviteCode.count", -1) do
|
||||||
|
post "/api/v1/auth/signup", params: {
|
||||||
|
user: {
|
||||||
|
email: "newuser@example.com",
|
||||||
|
password: "SecurePass123!",
|
||||||
|
first_name: "New",
|
||||||
|
last_name: "User"
|
||||||
|
},
|
||||||
|
device: @device_info,
|
||||||
|
invite_code: invite_code.token
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :created
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should reject invalid invite code" do
|
||||||
|
# Mock invite code requirement
|
||||||
|
Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(false)
|
||||||
|
|
||||||
|
assert_no_difference("User.count") do
|
||||||
|
post "/api/v1/auth/signup", params: {
|
||||||
|
user: {
|
||||||
|
email: "newuser@example.com",
|
||||||
|
password: "SecurePass123!",
|
||||||
|
first_name: "New",
|
||||||
|
last_name: "User"
|
||||||
|
},
|
||||||
|
device: @device_info,
|
||||||
|
invite_code: "invalid_code"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :forbidden
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
assert_equal "Invalid invite code", response_data["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should login existing user and return OAuth tokens" do
|
||||||
|
user = users(:family_admin)
|
||||||
|
password = user_password_test
|
||||||
|
|
||||||
|
# Ensure user has no mobile devices
|
||||||
|
user.mobile_devices.destroy_all
|
||||||
|
|
||||||
|
assert_difference("MobileDevice.count", 1) do
|
||||||
|
assert_difference("Doorkeeper::AccessToken.count", 1) do
|
||||||
|
post "/api/v1/auth/login", params: {
|
||||||
|
email: user.email,
|
||||||
|
password: password,
|
||||||
|
device: @device_info
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
|
||||||
|
assert_equal user.id.to_s, response_data["user"]["id"]
|
||||||
|
assert_equal user.email, response_data["user"]["email"]
|
||||||
|
|
||||||
|
# OAuth token assertions
|
||||||
|
assert response_data["access_token"].present?
|
||||||
|
assert response_data["refresh_token"].present?
|
||||||
|
assert_equal "Bearer", response_data["token_type"]
|
||||||
|
assert_equal 2592000, response_data["expires_in"] # 30 days
|
||||||
|
|
||||||
|
# Verify the device
|
||||||
|
device = user.mobile_devices.where(device_id: @device_info[:device_id]).first
|
||||||
|
assert device.present?
|
||||||
|
assert device.active?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should require MFA when enabled" do
|
||||||
|
user = users(:family_admin)
|
||||||
|
password = user_password_test
|
||||||
|
|
||||||
|
# Enable MFA for user
|
||||||
|
user.setup_mfa!
|
||||||
|
user.enable_mfa!
|
||||||
|
|
||||||
|
post "/api/v1/auth/login", params: {
|
||||||
|
email: user.email,
|
||||||
|
password: password,
|
||||||
|
device: @device_info
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
assert_equal "Two-factor authentication required", response_data["error"]
|
||||||
|
assert response_data["mfa_required"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should login with valid MFA code" do
|
||||||
|
user = users(:family_admin)
|
||||||
|
password = user_password_test
|
||||||
|
|
||||||
|
# Enable MFA for user
|
||||||
|
user.setup_mfa!
|
||||||
|
user.enable_mfa!
|
||||||
|
totp = ROTP::TOTP.new(user.otp_secret)
|
||||||
|
|
||||||
|
assert_difference("Doorkeeper::AccessToken.count", 1) do
|
||||||
|
post "/api/v1/auth/login", params: {
|
||||||
|
email: user.email,
|
||||||
|
password: password,
|
||||||
|
otp_code: totp.now,
|
||||||
|
device: @device_info
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
assert response_data["access_token"].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should revoke existing tokens for same device on login" do
|
||||||
|
user = users(:family_admin)
|
||||||
|
password = user_password_test
|
||||||
|
|
||||||
|
# Create an existing device and token
|
||||||
|
device = user.mobile_devices.create!(@device_info)
|
||||||
|
oauth_app = device.create_oauth_application!
|
||||||
|
existing_token = Doorkeeper::AccessToken.create!(
|
||||||
|
application: oauth_app,
|
||||||
|
resource_owner_id: user.id,
|
||||||
|
expires_in: 30.days.to_i,
|
||||||
|
scopes: "read_write"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert existing_token.accessible?
|
||||||
|
|
||||||
|
post "/api/v1/auth/login", params: {
|
||||||
|
email: user.email,
|
||||||
|
password: password,
|
||||||
|
device: @device_info
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
# Check that old token was revoked
|
||||||
|
existing_token.reload
|
||||||
|
assert existing_token.revoked?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should not login with invalid password" do
|
||||||
|
user = users(:family_admin)
|
||||||
|
|
||||||
|
assert_no_difference("Doorkeeper::AccessToken.count") do
|
||||||
|
post "/api/v1/auth/login", params: {
|
||||||
|
email: user.email,
|
||||||
|
password: "wrong_password",
|
||||||
|
device: @device_info
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
assert_equal "Invalid email or password", response_data["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should not login with non-existent email" do
|
||||||
|
assert_no_difference("Doorkeeper::AccessToken.count") do
|
||||||
|
post "/api/v1/auth/login", params: {
|
||||||
|
email: "nonexistent@example.com",
|
||||||
|
password: user_password_test,
|
||||||
|
device: @device_info
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
assert_equal "Invalid email or password", response_data["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should not login without device info" do
|
||||||
|
user = users(:family_admin)
|
||||||
|
|
||||||
|
assert_no_difference("Doorkeeper::AccessToken.count") do
|
||||||
|
post "/api/v1/auth/login", params: {
|
||||||
|
email: user.email,
|
||||||
|
password: user_password_test
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
assert_equal "Device information is required", response_data["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should refresh access token with valid refresh token" do
|
||||||
|
user = users(:family_admin)
|
||||||
|
device = user.mobile_devices.create!(@device_info)
|
||||||
|
oauth_app = device.create_oauth_application!
|
||||||
|
|
||||||
|
# Create initial token
|
||||||
|
initial_token = Doorkeeper::AccessToken.create!(
|
||||||
|
application: oauth_app,
|
||||||
|
resource_owner_id: user.id,
|
||||||
|
expires_in: 30.days.to_i,
|
||||||
|
scopes: "read_write",
|
||||||
|
use_refresh_token: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait to ensure different timestamps
|
||||||
|
sleep 0.1
|
||||||
|
|
||||||
|
assert_difference("Doorkeeper::AccessToken.count", 1) do
|
||||||
|
post "/api/v1/auth/refresh", params: {
|
||||||
|
refresh_token: initial_token.refresh_token,
|
||||||
|
device: @device_info
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
|
||||||
|
# New token assertions
|
||||||
|
assert response_data["access_token"].present?
|
||||||
|
assert response_data["refresh_token"].present?
|
||||||
|
assert_not_equal initial_token.token, response_data["access_token"]
|
||||||
|
assert_equal 2592000, response_data["expires_in"]
|
||||||
|
|
||||||
|
# Old token should be revoked
|
||||||
|
initial_token.reload
|
||||||
|
assert initial_token.revoked?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should not refresh with invalid refresh token" do
|
||||||
|
assert_no_difference("Doorkeeper::AccessToken.count") do
|
||||||
|
post "/api/v1/auth/refresh", params: {
|
||||||
|
refresh_token: "invalid_token",
|
||||||
|
device: @device_info
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
assert_equal "Invalid refresh token", response_data["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should not refresh without refresh token" do
|
||||||
|
post "/api/v1/auth/refresh", params: {
|
||||||
|
device: @device_info
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
response_data = JSON.parse(response.body)
|
||||||
|
assert_equal "Refresh token is required", response_data["error"]
|
||||||
|
end
|
||||||
|
end
|
3
test/fixtures/mobile_devices.yml
vendored
Normal file
3
test/fixtures/mobile_devices.yml
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
|
# Empty fixtures to avoid conflicts
|
7
test/models/mobile_device_test.rb
Normal file
7
test/models/mobile_device_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class MobileDeviceTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
Loading…
Add table
Add a link
Reference in a new issue