1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-03 04:25:21 +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:
Josh Pigford 2025-06-18 08:20:22 -05:00
parent cba0bdf0e2
commit 9336719242
15 changed files with 761 additions and 6 deletions

View 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

View file

@ -6,6 +6,9 @@ class Api::V1::BaseController < ApplicationController
# Skip regular session-based authentication for API
skip_authentication
# Skip CSRF protection for API endpoints
skip_before_action :verify_authenticity_token
# Skip onboarding requirements for API endpoints
skip_before_action :require_onboarding_and_upgrade

View file

@ -4,12 +4,16 @@ class ApiKey < ApplicationRecord
# Use Rails built-in encryption for secure storage
encrypts :display_key, deterministic: true
# Constants
SOURCES = [ "web", "mobile" ].freeze
# Validations
validates :display_key, presence: true, uniqueness: true
validates :name, presence: true
validates :scopes, presence: true
validates :source, presence: true, inclusion: { in: SOURCES }
validate :scopes_not_empty
validate :one_active_key_per_user, on: :create
validate :one_active_key_per_user_per_source, on: :create
# Callbacks
before_validation :set_display_key
@ -82,9 +86,9 @@ class ApiKey < ApplicationRecord
end
end
def one_active_key_per_user
if user&.api_keys&.active&.where&.not(id: id)&.exists?
errors.add(:user, "can only have one active API key")
def one_active_key_per_user_per_source
if user&.api_keys&.active&.where(source: source)&.where&.not(id: id)&.exists?
errors.add(:user, "can only have one active API key per source (#{source})")
end
end
end

View 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

View file

@ -6,6 +6,7 @@ class User < ApplicationRecord
has_many :sessions, dependent: :destroy
has_many :chats, 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 :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