1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-24 15:49:39 +02:00
Maybe/app/models/user.rb
Josh Pigford b803ddac96
Add comprehensive API v1 with OAuth and API key authentication (#2389)
* OAuth

* Add API test routes and update Doorkeeper token handling for test environment

- Introduced API namespace with test routes for controller testing in the test environment.
- Updated Doorkeeper configuration to allow fallback to plain tokens in the test environment for easier testing.
- Modified schema to change resource_owner_id type from bigint to string.

* Implement API key authentication and enhance access control

- Replaced Doorkeeper OAuth authentication with a custom method supporting both OAuth and API keys in the BaseController.
- Added methods for API key authentication, including validation and logging.
- Introduced scope-based authorization for API keys in the TestController.
- Updated routes to include API key management endpoints.
- Enhanced logging for API access to include authentication method details.
- Added tests for API key functionality, including validation, scope checks, and access control enforcement.

* Add API key rate limiting and usage tracking

- Implemented rate limiting for API key authentication in BaseController.
- Added methods to check rate limits, render appropriate responses, and include rate limit headers in responses.
- Updated routes to include a new usage resource for tracking API usage.
- Enhanced tests to verify rate limit functionality, including exceeding limits and per-key tracking.
- Cleaned up Redis data in tests to ensure isolation between test cases.

* Add Jbuilder for JSON rendering and refactor AccountsController

- Added Jbuilder gem for improved JSON response handling.
- Refactored index action in AccountsController to utilize Jbuilder for rendering JSON.
- Removed manual serialization of accounts and streamlined response structure.
- Implemented a before_action in BaseController to enforce JSON format for all API requests.

* Add transactions resource to API routes

- Added routes for transactions, allowing index, show, create, update, and destroy actions.
- This enhancement supports comprehensive transaction management within the API.

* Enhance API authentication and onboarding handling

- Updated BaseController to skip onboarding requirements for API endpoints and added manual token verification for OAuth authentication.
- Improved error handling and logging for invalid access tokens.
- Introduced a method to set up the current context for API requests, ensuring compatibility with session-like behavior.
- Excluded API paths from onboarding redirects in the Onboardable concern.
- Updated database schema to change resource_owner_id type from bigint to string for OAuth access grants.

* Fix rubocop offenses

- Fix indentation and spacing issues
- Convert single quotes to double quotes
- Add spaces inside array brackets
- Fix comment alignment
- Add missing trailing newlines
- Correct else/end alignment

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix API test failures and improve test reliability

- Fix ApiRateLimiterTest by removing mock users method and using fixtures
- Fix UsageControllerTest by removing mock users method and using fixtures
- Fix BaseControllerTest by using different users for multiple API keys
- Use unique display_key values with SecureRandom to avoid conflicts
- Fix double render issue in UsageController by returning after authorize_scope\!
- Specify controller name in routes for usage resource
- Remove trailing whitespace and empty lines per Rubocop

All tests now pass and linting is clean.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add API transactions controller warning to brakeman ignore

The account_id parameter in the API transactions controller is properly
validated on line 79: family.accounts.find(transaction_params[:account_id])
This ensures users can only create transactions in accounts belonging to
their family, making this a false positive.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Signed-off-by: Josh Pigford <josh@joshpigford.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-06-17 15:57:05 -05:00

210 lines
5.3 KiB
Ruby

class User < ApplicationRecord
has_secure_password
belongs_to :family
belongs_to :last_viewed_chat, class_name: "Chat", optional: true
has_many :sessions, dependent: :destroy
has_many :chats, dependent: :destroy
has_many :api_keys, 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 :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy
accepts_nested_attributes_for :family, update_only: true
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validate :ensure_valid_profile_image
validates :default_period, inclusion: { in: Period::PERIODS.keys }
normalizes :email, with: ->(email) { email.strip.downcase }
normalizes :unconfirmed_email, with: ->(email) { email&.strip&.downcase }
normalizes :first_name, :last_name, with: ->(value) { value.strip.presence }
enum :role, { member: "member", admin: "admin", super_admin: "super_admin" }, validate: true
has_one_attached :profile_image do |attachable|
attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ], convert: :webp, saver: { quality: 80 }
attachable.variant :small, resize_to_fill: [ 72, 72 ], convert: :webp, saver: { quality: 80 }, preprocessed: true
end
validate :profile_image_size
generates_token_for :password_reset, expires_in: 15.minutes do
password_salt&.last(10)
end
generates_token_for :email_confirmation, expires_in: 1.day do
unconfirmed_email
end
def pending_email_change?
unconfirmed_email.present?
end
def initiate_email_change(new_email)
return false if new_email == email
return false if new_email == unconfirmed_email
if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation
update(email: new_email)
else
if update(unconfirmed_email: new_email)
EmailConfirmationMailer.with(user: self).confirmation_email.deliver_later
true
else
false
end
end
end
def request_impersonation_for(user_id)
impersonated = User.find(user_id)
impersonator_support_sessions.create!(impersonated: impersonated)
end
def admin?
super_admin? || role == "admin"
end
def display_name
[ first_name, last_name ].compact.join(" ").presence || email
end
def initial
(display_name&.first || email.first).upcase
end
def initials
if first_name.present? && last_name.present?
"#{first_name.first}#{last_name.first}".upcase
else
initial
end
end
def show_ai_sidebar?
show_ai_sidebar
end
def ai_available?
!Rails.application.config.app_mode.self_hosted? || ENV["OPENAI_ACCESS_TOKEN"].present?
end
def ai_enabled?
ai_enabled && ai_available?
end
# Deactivation
validate :can_deactivate, if: -> { active_changed? && !active }
after_update_commit :purge_later, if: -> { saved_change_to_active?(from: true, to: false) }
def deactivate
update active: false, email: deactivated_email
end
def can_deactivate
if admin? && family.users.count > 1
errors.add(:base, :cannot_deactivate_admin_with_other_users)
end
end
def purge_later
UserPurgeJob.perform_later(self)
end
def purge
if last_user_in_family?
family.destroy
else
destroy
end
end
# MFA
def setup_mfa!
update!(
otp_secret: ROTP::Base32.random(32),
otp_required: false,
otp_backup_codes: []
)
end
def enable_mfa!
update!(
otp_required: true,
otp_backup_codes: generate_backup_codes
)
end
def disable_mfa!
update!(
otp_secret: nil,
otp_required: false,
otp_backup_codes: []
)
end
def verify_otp?(code)
return false if otp_secret.blank?
return true if verify_backup_code?(code)
totp.verify(code, drift_behind: 15)
end
def provisioning_uri
return nil unless otp_secret.present?
totp.provisioning_uri(email)
end
def onboarded?
onboarded_at.present?
end
def needs_onboarding?
!onboarded?
end
private
def ensure_valid_profile_image
return unless profile_image.attached?
unless profile_image.content_type.in?(%w[image/jpeg image/png])
errors.add(:profile_image, "must be a JPEG or PNG")
profile_image.purge
end
end
def last_user_in_family?
family.users.count == 1
end
def deactivated_email
email.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
end
def profile_image_size
if profile_image.attached? && profile_image.byte_size > 10.megabytes
errors.add(:profile_image, :invalid_file_size, max_megabytes: 10)
end
end
def totp
ROTP::TOTP.new(otp_secret, issuer: "Maybe Finance")
end
def verify_backup_code?(code)
return false if otp_backup_codes.blank?
# Find and remove the used backup code
if (index = otp_backup_codes.index(code))
remaining_codes = otp_backup_codes.dup
remaining_codes.delete_at(index)
update_column(:otp_backup_codes, remaining_codes)
true
else
false
end
end
def generate_backup_codes
8.times.map { SecureRandom.hex(4) }
end
end