diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd214ad..1d058ab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Implemented authentication via GitHub and Google for Dawarich Cloud. - Implemented OpenID Connect authentication for self-hosted Dawarich instances. #66 +- Added Patreon OAuth integration for Dawarich Cloud, allowing users to connect their Patreon accounts in account settings. + +## TODO: + +- [ ] Disable OIDC authentication for Dawarich Cloud +- [ ] Disable GitHub and Google authentication for self-hosted Dawarich +- [ ] In selfhosted env, no registrations are allowed, we need to account OIDC into that # [0.34.0] - 2025-10-10 diff --git a/README.md b/README.md index 2efd0299..797e2177 100644 --- a/README.md +++ b/README.md @@ -126,14 +126,6 @@ Feel free to change them in the account settings. - Provide credentials for Immich or Photoprism (or both!) and Dawarich will automatically import geodata from your photos. - You'll also be able to visualize your photos on the map! -### 🔐 Authentication -- Multiple authentication options: - - Email/Password (built-in) - - GitHub OAuth - - Google OAuth2 - - OpenID Connect (OIDC) - works with Authelia, Authentik, Keycloak, and other OIDC providers -- See [OIDC Setup Guide](OIDC_SETUP.md) for detailed configuration instructions - ### 📥 Import Your Data - Import from various sources: - Google Maps Timeline diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 1a34fed4..d1f78c03 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -28,6 +28,14 @@ class SettingsController < ApplicationController redirect_back(fallback_location: root_path) end + def disconnect_patreon + if current_user.disconnect_patreon! + redirect_to settings_path, notice: 'Patreon account disconnected successfully' + else + redirect_to settings_path, alert: 'Unable to disconnect Patreon account' + end + end + private def settings_params diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 10fd37ac..e56dfd5e 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -13,6 +13,10 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController handle_auth('OpenID Connect') end + def patreon + handle_auth('Patreon') + end + def failure error_type = request.env['omniauth.error.type'] error = request.env['omniauth.error'] diff --git a/app/models/user.rb b/app/models/user.rb index 8740ef3d..a4390ce2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,9 +2,10 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength include UserFamily + devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :trackable, - :omniauthable, omniauth_providers: %i[github google_oauth2 openid_connect] + :omniauthable, omniauth_providers: ::OMNIAUTH_PROVIDERS has_many :points, dependent: :destroy has_many :imports, dependent: :destroy @@ -148,14 +149,87 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength def self.from_omniauth(access_token) data = access_token.info - user = User.where(email: data['email']).first + provider = access_token.provider + uid = access_token.uid - return user if user + # First, try to find user by provider and uid (for linked accounts) + user = User.find_by(provider: provider, uid: uid) - User.create( + if user + # Update tokens for existing user + if provider == 'patreon' + user.update_patreon_tokens(access_token) + end + return user + end + + # If not found, try to find by email + user = User.find_by(email: data['email']) + + if user + # Update provider and uid for existing user (first-time linking) + user.update(provider: provider, uid: uid) + if provider == 'patreon' + user.update_patreon_tokens(access_token) + end + return user + end + + # Create new user if not found + user = User.create( email: data['email'], - password: Devise.friendly_token[0, 20] + password: Devise.friendly_token[0, 20], + provider: provider, + uid: uid ) + + if provider == 'patreon' + user.update_patreon_tokens(access_token) + end + + user + end + + def patreon_connected? + provider == 'patreon' && uid.present? + end + + def disconnect_patreon! + return false unless patreon_connected? + + update( + provider: nil, + uid: nil, + patreon_access_token: nil, + patreon_refresh_token: nil, + patreon_token_expires_at: nil + ) + end + + def update_patreon_tokens(auth) + credentials = auth.credentials + update( + patreon_access_token: credentials.token, + patreon_refresh_token: credentials.refresh_token, + patreon_token_expires_at: Time.at(credentials.expires_at) + ) + end + + # Check if user is a patron of a specific creator + # @param creator_id [String] The Patreon creator ID to check + # @return [Boolean] true if user is an active patron + def patron_of?(creator_id) + return false unless patreon_connected? + + Patreon::PatronChecker.new(self).patron_of?(creator_id) + end + + # Get all campaigns the user is supporting + # @return [Array] Array of campaign data + def patreon_memberships + return [] unless patreon_connected? + + Patreon::PatronChecker.new(self).memberships end private diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb index 2226c69a..85ac469f 100644 --- a/app/views/settings/index.html.erb +++ b/app/views/settings/index.html.erb @@ -68,6 +68,49 @@ %> + + <% unless DawarichSettings.self_hosted? %> +
+

+ + + + Connected Accounts +

+
+
+
+ + + +
+

Patreon

+

+ <% if current_user.patreon_connected? %> + Connected + <% else %> + Not connected + <% end %> +

+
+
+ <% if current_user.patreon_connected? %> + <%= button_to "Disconnect", disconnect_patreon_settings_path, + method: :delete, + class: "btn btn-sm btn-outline btn-error", + form: { data: { turbo_confirm: "Are you sure you want to disconnect your Patreon account?" } } %> + <% else %> + <%= link_to "Connect", user_patreon_omniauth_authorize_path, + class: "btn btn-sm btn-primary", + method: :post %> + <% end %> +
+

+ Connect your Patreon account to support the development of Dawarich and get access to exclusive features. +

+
+
+ <% end %>
<%= f.submit "Save changes", class: "btn btn-primary" %> diff --git a/config/initializers/01_constants.rb b/config/initializers/01_constants.rb index 1129be64..8bb2de8e 100644 --- a/config/initializers/01_constants.rb +++ b/config/initializers/01_constants.rb @@ -36,3 +36,18 @@ MANAGER_URL = SELF_HOSTED ? nil : ENV.fetch('MANAGER_URL', nil) METRICS_USERNAME = ENV.fetch('METRICS_USERNAME', 'prometheus') METRICS_PASSWORD = ENV.fetch('METRICS_PASSWORD', 'prometheus') # /Prometheus metrics + +# Configure OAuth providers based on environment +# Self-hosted: only OpenID Connect, Cloud: only GitHub, Google, and Patreon +OMNIAUTH_PROVIDERS = + if SELF_HOSTED + # Self-hosted: only OpenID Connect + ENV['OIDC_CLIENT_ID'].present? ? %i[openid_connect] : [] + else + # Cloud: only GitHub, Google, and Patreon + providers = [] + providers << :github if ENV['GITHUB_OAUTH_CLIENT_ID'].present? + providers << :google_oauth2 if ENV['GOOGLE_OAUTH_CLIENT_ID'].present? + providers << :patreon if ENV['PATREON_CLIENT_ID'].present? + providers + end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 4fa03d98..5fd04c74 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -265,13 +265,31 @@ Devise.setup do |config| # ==> OmniAuth # Add a new OmniAuth provider. Check the wiki for more information on setting # up on your models and hooks. - config.omniauth :github, ENV['GITHUB_OAUTH_CLIENT_ID'], ENV['GITHUB_OAUTH_CLIENT_SECRET'], scope: 'user:email' - config.omniauth :google_oauth2, ENV['GOOGLE_OAUTH_CLIENT_ID'], ENV['GOOGLE_OAUTH_CLIENT_SECRET'], - scope: 'userinfo.email,userinfo.profile' + # Cloud version: only GitHub, Google, and Patreon OAuth (when env vars present) + unless SELF_HOSTED + if ENV['GITHUB_OAUTH_CLIENT_ID'].present? && ENV['GITHUB_OAUTH_CLIENT_SECRET'].present? + config.omniauth :github, ENV['GITHUB_OAUTH_CLIENT_ID'], ENV['GITHUB_OAUTH_CLIENT_SECRET'], scope: 'user:email' + Rails.logger.info 'OAuth: GitHub configured' + end + + if ENV['GOOGLE_OAUTH_CLIENT_ID'].present? && ENV['GOOGLE_OAUTH_CLIENT_SECRET'].present? + config.omniauth :google_oauth2, ENV['GOOGLE_OAUTH_CLIENT_ID'], ENV['GOOGLE_OAUTH_CLIENT_SECRET'], + scope: 'userinfo.email,userinfo.profile' + Rails.logger.info 'OAuth: Google configured' + end + + if ENV['PATREON_CLIENT_ID'].present? && ENV['PATREON_CLIENT_SECRET'].present? + config.omniauth :patreon, ENV['PATREON_CLIENT_ID'], ENV['PATREON_CLIENT_SECRET'], + scope: 'identity identity[email]' + Rails.logger.info 'OAuth: Patreon configured' + end + end + + # Self-hosted version: only OpenID Connect (when env vars present) # Generic OpenID Connect provider (Authelia, Authentik, Keycloak, etc.) # Supports both discovery mode (preferred) and manual endpoint configuration - if ENV['OIDC_CLIENT_ID'].present? && ENV['OIDC_CLIENT_SECRET'].present? + if SELF_HOSTED && ENV['OIDC_CLIENT_ID'].present? && ENV['OIDC_CLIENT_SECRET'].present? oidc_config = { name: :openid_connect, scope: %i[openid email profile], diff --git a/config/routes.rb b/config/routes.rb index 16961f7e..dfbcee4c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -50,6 +50,7 @@ Rails.application.routes.draw do patch 'settings', to: 'settings#update' get 'settings/theme', to: 'settings#theme' post 'settings/generate_api_key', to: 'settings#generate_api_key', as: :generate_api_key + delete 'settings/disconnect_patreon', to: 'settings#disconnect_patreon', as: :disconnect_patreon_settings resources :imports resources :visits, only: %i[index update] diff --git a/db/schema.rb b/db/schema.rb index c0f8d0cd..1a9e1ae4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_09_30_150256) do +ActiveRecord::Schema[8.0].define(version: 2025_10_28_160950) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "postgis" @@ -113,10 +113,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_30_150256) do t.integer "status", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["email"], name: "index_family_invitations_on_email" - t.index ["expires_at"], name: "index_family_invitations_on_expires_at" - t.index ["family_id"], name: "index_family_invitations_on_family_id" - t.index ["status"], name: "index_family_invitations_on_status" + t.index ["family_id", "email"], name: "index_family_invitations_on_family_id_and_email" + t.index ["family_id", "status", "expires_at"], name: "index_family_invitations_on_family_status_expires" + t.index ["status", "expires_at"], name: "index_family_invitations_on_status_and_expires_at" + t.index ["status", "updated_at"], name: "index_family_invitations_on_status_and_updated_at" t.index ["token"], name: "index_family_invitations_on_token", unique: true end @@ -126,8 +126,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_30_150256) do t.integer "role", default: 1, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["family_id", "role"], name: "index_family_memberships_on_family_id_and_role" - t.index ["family_id"], name: "index_family_memberships_on_family_id" + t.index ["family_id", "role"], name: "index_family_memberships_on_family_and_role" t.index ["user_id"], name: "index_family_memberships_on_user_id", unique: true end @@ -316,6 +315,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_30_150256) do t.integer "status", default: 0 t.datetime "active_until" t.integer "points_count", default: 0, null: false + t.string "provider" + t.string "uid" + t.text "patreon_access_token" + t.text "patreon_refresh_token" + t.datetime "patreon_token_expires_at" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end @@ -342,11 +346,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_30_150256) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "areas", "users" - add_foreign_key "families", "users", column: "creator_id", validate: false - add_foreign_key "family_invitations", "families", validate: false - add_foreign_key "family_invitations", "users", column: "invited_by_id", validate: false - add_foreign_key "family_memberships", "families", validate: false - add_foreign_key "family_memberships", "users", validate: false + add_foreign_key "families", "users", column: "creator_id" + add_foreign_key "family_invitations", "families" + add_foreign_key "family_invitations", "users", column: "invited_by_id" + add_foreign_key "family_memberships", "families" + add_foreign_key "family_memberships", "users" add_foreign_key "notifications", "users" add_foreign_key "place_visits", "places" add_foreign_key "place_visits", "visits" diff --git a/spec/requests/users/omniauth_callbacks_spec.rb b/spec/requests/users/omniauth_callbacks_spec.rb index 85d0d680..7fbc1b07 100644 --- a/spec/requests/users/omniauth_callbacks_spec.rb +++ b/spec/requests/users/omniauth_callbacks_spec.rb @@ -56,22 +56,7 @@ RSpec.describe 'Users::OmniauthCallbacks', type: :request do end end - describe 'GET /users/auth/github/callback' do - before do - mock_github_auth(email: email) - end - - include_examples 'successful OAuth authentication', :github, 'GitHub' - end - - describe 'GET /users/auth/google_oauth2/callback' do - before do - mock_google_auth(email: email) - end - - include_examples 'successful OAuth authentication', :google_oauth2, 'Google' - end - + # Self-hosted configuration (SELF_HOSTED=true) uses OpenID Connect describe 'GET /users/auth/openid_connect/callback' do before do mock_openid_connect_auth(email: email) @@ -80,54 +65,16 @@ RSpec.describe 'Users::OmniauthCallbacks', type: :request do include_examples 'successful OAuth authentication', :openid_connect, 'OpenID Connect' end - describe 'OAuth flow integration' do - context 'with GitHub' do - before { mock_github_auth(email: 'github@example.com') } - - it 'completes the full OAuth flow' do - # Simulate OAuth callback - expect do - Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:github] - get '/users/auth/github/callback' - end.to change(User, :count).by(1) - - # Verify user is created - user = User.find_by(email: 'github@example.com') - expect(user).to be_present - expect(user.email).to eq('github@example.com') - expect(response).to redirect_to(root_path) - end - end - - context 'with Google' do - before { mock_google_auth(email: 'google@example.com') } - - it 'completes the full OAuth flow' do - # Simulate OAuth callback - expect do - Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:google_oauth2] - get '/users/auth/google_oauth2/callback' - end.to change(User, :count).by(1) - - # Verify user is created - user = User.find_by(email: 'google@example.com') - expect(user).to be_present - expect(user.email).to eq('google@example.com') - expect(response).to redirect_to(root_path) - end - end - - context 'with OpenID Connect (Authelia/Authentik)' do + describe 'OAuth flow integration with OpenID Connect' do + context 'with OpenID Connect (Authelia/Authentik/Keycloak)' do before { mock_openid_connect_auth(email: 'oidc@example.com') } it 'completes the full OAuth flow' do - # Simulate OAuth callback expect do Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:openid_connect] get '/users/auth/openid_connect/callback' end.to change(User, :count).by(1) - # Verify user is created user = User.find_by(email: 'oidc@example.com') expect(user).to be_present expect(user.email).to eq('oidc@example.com') @@ -137,24 +84,6 @@ RSpec.describe 'Users::OmniauthCallbacks', type: :request do end describe 'CSRF protection' do - it 'does not raise CSRF error for GitHub callback' do - mock_github_auth(email: email) - - expect do - Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:github] - get '/users/auth/github/callback' - end.not_to raise_error - end - - it 'does not raise CSRF error for Google callback' do - mock_google_auth(email: email) - - expect do - Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:google_oauth2] - get '/users/auth/google_oauth2/callback' - end.not_to raise_error - end - it 'does not raise CSRF error for OpenID Connect callback' do mock_openid_connect_auth(email: email)