mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
REVERT: Patreon account connection
This commit is contained in:
parent
e6c8bd30df
commit
7bc579e563
11 changed files with 198 additions and 103 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" %>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
28
db/schema.rb
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue