REVERT: Patreon account connection

This commit is contained in:
Eugene Burmakin 2025-10-29 13:27:43 +01:00
parent e6c8bd30df
commit 7bc579e563
11 changed files with 198 additions and 103 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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']

View file

@ -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<Hash>] Array of campaign data
def patreon_memberships
return [] unless patreon_connected?
Patreon::PatronChecker.new(self).memberships
end
private

View file

@ -68,6 +68,49 @@
</div> %>
</div>
</div>
<% unless DawarichSettings.self_hosted? %>
<div>
<h2 class="text-2xl font-bold mb-4 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-link mr-2 text-primary">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>Connected Accounts
</h2>
<div class="bg-base-100 p-5 rounded-lg shadow-sm space-y-4">
<div class="flex items-center justify-between p-4 border border-base-300 rounded-lg">
<div class="flex items-center space-x-3">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="text-[#FF424D]">
<path d="M0 .48v23.04h24V.48zm22.89 21.93H1.11V1.61h21.78zm-3.2-6.15c-1.05 0-2.08-.28-2.99-.81.88 1.99 2.83 3.37 5.08 3.37 3.09 0 5.59-2.5 5.59-5.59 0-3.09-2.5-5.59-5.59-5.59-2.25 0-4.2 1.33-5.08 3.25.9-.48 1.93-.73 3-.73 3.56 0 6.44 2.88 6.44 6.44s-2.88 6.44-6.44 6.44z"/>
</svg>
<div>
<h3 class="font-semibold">Patreon</h3>
<p class="text-sm text-base-content/70">
<% if current_user.patreon_connected? %>
Connected
<% else %>
Not connected
<% end %>
</p>
</div>
</div>
<% 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 %>
</div>
<p class="text-sm text-base-content/70 mt-2">
Connect your Patreon account to support the development of Dawarich and get access to exclusive features.
</p>
</div>
</div>
<% end %>
</div>
<div class="card-actions justify-end mt-6">
<%= f.submit "Save changes", class: "btn btn-primary" %>

View file

@ -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

View file

@ -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],

View file

@ -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]

28
db/schema.rb generated
View file

@ -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"

View file

@ -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)