From f5dc7a10a3ee521827f8b53e07a3922e879ff707 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 26 Oct 2025 15:27:43 +0100 Subject: [PATCH 01/11] Implement OmniAuth GitHub authentication --- Gemfile | 5 +- Gemfile.lock | 64 ++++++++++++++++--- .../users/omniauth_callbacks_controller.rb | 19 ++++++ app/models/user.rb | 16 ++++- app/views/devise/sessions/new.html.erb | 6 +- config/initializers/devise.rb | 2 +- config/routes.rb | 3 +- 7 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 app/controllers/users/omniauth_callbacks_controller.rb diff --git a/Gemfile b/Gemfile index 9ecea93e..8267c832 100644 --- a/Gemfile +++ b/Gemfile @@ -24,6 +24,9 @@ gem 'jwt', '~> 2.8' gem 'kaminari' gem 'lograge' gem 'oj' +gem 'omniauth-github', '~> 2.0.0' +gem 'omniauth-google-oauth2' +gem 'omniauth-rails_csrf_protection' gem 'parallel' gem 'pg' gem 'prometheus_exporter' @@ -49,7 +52,7 @@ gem 'sprockets-rails' gem 'stackprof' gem 'stimulus-rails' gem 'strong_migrations', '>= 2.4.0' -gem 'tailwindcss-rails', '>= 3.3.2' +gem 'tailwindcss-rails', '= 3.3.2' gem 'turbo-rails', '>= 2.0.17' gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] diff --git a/Gemfile.lock b/Gemfile.lock index 513f3d86..f818ff05 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -171,6 +171,12 @@ GEM factory_bot (~> 6.5) railties (>= 6.1.0) fakeredis (0.1.4) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.1) + net-http (>= 0.5.0) ffaker (2.25.0) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-arm-linux-gnu) @@ -196,6 +202,7 @@ GEM rgeo-geojson (~> 2.1) zeitwerk (~> 2.5) hashdiff (1.1.2) + hashie (5.0.0) httparty (0.23.1) csv mini_mime (>= 1.0.0) @@ -256,6 +263,8 @@ GEM multi_json (1.15.0) multi_xml (0.7.1) bigdecimal (~> 3.1) + net-http (0.6.0) + uri net-imap (0.5.12) date net-protocol @@ -279,9 +288,36 @@ GEM racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) + oauth2 (2.0.17) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0, >= 2.0.3) + version_gem (~> 1.1, >= 1.1.9) oj (3.16.11) bigdecimal (>= 3.0) ostruct (>= 0.2) + omniauth (2.1.4) + hashie (>= 3.4.6) + logger + rack (>= 2.2.3) + rack-protection + omniauth-github (2.0.1) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.8) + omniauth-google-oauth2 (1.2.1) + jwt (>= 2.9.2) + oauth2 (~> 2.0) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.8) + omniauth-oauth2 (1.8.0) + oauth2 (>= 1.4, < 3) + omniauth (~> 2.0) + omniauth-rails_csrf_protection (1.0.2) + actionpack (>= 4.2) + omniauth (~> 2.0) optimist (3.2.1) orm_adapter (0.5.0) ostruct (0.6.1) @@ -321,6 +357,10 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.2.2) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -474,6 +514,9 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) + snaky_hash (2.0.3) + hashie (>= 0.1.0, < 6) + version_gem (>= 1.1.8, < 3) sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) @@ -491,14 +534,15 @@ GEM attr_extras (>= 6.2.4) diff-lcs patience_diff - tailwindcss-rails (4.3.0) + tailwindcss-rails (3.3.2) railties (>= 7.0.0) - tailwindcss-ruby (~> 4.0) - tailwindcss-ruby (4.1.13) - tailwindcss-ruby (4.1.13-aarch64-linux-gnu) - tailwindcss-ruby (4.1.13-arm64-darwin) - tailwindcss-ruby (4.1.13-x86_64-darwin) - tailwindcss-ruby (4.1.13-x86_64-linux-gnu) + tailwindcss-ruby (~> 3.0) + tailwindcss-ruby (3.4.17) + tailwindcss-ruby (3.4.17-aarch64-linux) + tailwindcss-ruby (3.4.17-arm-linux) + tailwindcss-ruby (3.4.17-arm64-darwin) + tailwindcss-ruby (3.4.17-x86_64-darwin) + tailwindcss-ruby (3.4.17-x86_64-linux) thor (1.4.0) timeout (0.4.3) tsort (0.2.0) @@ -513,6 +557,7 @@ GEM unicode-emoji (4.1.0) uri (1.0.3) useragent (0.16.11) + version_gem (1.1.9) warden (1.2.9) rack (>= 2.0.9) webmock (3.25.1) @@ -566,6 +611,9 @@ DEPENDENCIES kaminari lograge oj + omniauth-github (~> 2.0.0) + omniauth-google-oauth2 + omniauth-rails_csrf_protection parallel pg prometheus_exporter @@ -600,7 +648,7 @@ DEPENDENCIES stimulus-rails strong_migrations (>= 2.4.0) super_diff - tailwindcss-rails (>= 3.3.2) + tailwindcss-rails (= 3.3.2) turbo-rails (>= 2.0.17) tzinfo-data webmock diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb new file mode 100644 index 00000000..53da9f19 --- /dev/null +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController + def github + @user = User.from_omniauth(request.env['omniauth.auth']) + + if @user.persisted? + flash[:notice] = I18n.t 'devise.omniauth_callbacks.success', kind: 'GitHub' + sign_in_and_redirect @user, event: :authentication + else + session['devise.github_data'] = request.env['omniauth.auth'].except('extra') + redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\n") + end + end + + def failure + redirect_to root_path, alert: "Authentication failed: #{params[:message]}" + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 71269d64..737b509b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,7 +3,8 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength include UserFamily devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :validatable, :trackable + :recoverable, :rememberable, :validatable, :trackable, + :omniauthable, omniauth_providers: %i[github] has_many :points, dependent: :destroy has_many :imports, dependent: :destroy @@ -145,6 +146,19 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength points.where.not(city: [nil, '']).distinct.pluck(:city).compact end + def self.from_omniauth(access_token) + data = access_token.info + user = User.where(email: data['email']).first + + return user if user + + binding.pry + User.create( + email: data['email'], + password: Devise.friendly_token[0, 20] + ) + end + private def create_api_key diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index b471a5cf..0de3bae8 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -47,10 +47,10 @@
<%= f.submit (@invitation ? "Sign in & Accept Invitation" : "Log in"), class: 'btn btn-primary' %>
+ <% end %> - <% unless @invitation %> - <%= render "devise/shared/links" %> - <% end %> + <% unless @invitation %> + <%= render "devise/shared/links" %> <% end %> diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 7b207ed3..2c9e31c7 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -265,7 +265,7 @@ 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, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' + config.omniauth :github, ENV['GITHUB_OAUTH_CLIENT_ID'], ENV['GITHUB_OAUTH_CLIENT_SECRET'], scope: 'user' # ==> Warden configuration # If you want to use other strategies, that are not supported by Devise, or diff --git a/config/routes.rb b/config/routes.rb index d34aa775..16961f7e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -103,7 +103,8 @@ Rails.application.routes.draw do devise_for :users, controllers: { registrations: 'users/registrations', - sessions: 'users/sessions' + sessions: 'users/sessions', + omniauth_callbacks: 'users/omniauth_callbacks' } resources :metrics, only: [:index] From 44bbbd09b7d66d2f0e2e3fd7a057e77e253d0d23 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 26 Oct 2025 15:32:26 +0100 Subject: [PATCH 02/11] Fix omniauth GitHub scope to include user email access --- app/models/user.rb | 1 - app/views/devise/registrations/edit.html.erb | 4 ++-- app/views/devise/registrations/new.html.erb | 6 +++--- config/initializers/devise.rb | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 737b509b..9329f130 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -152,7 +152,6 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength return user if user - binding.pry User.create( email: data['email'], password: Devise.friendly_token[0, 20] diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index 257aba87..d5c4c1ce 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -62,10 +62,10 @@
<%= f.submit "Update", class: 'btn btn-primary' %>
- - <%= render "devise/shared/links" %> <% end %> + <%= render "devise/shared/links" %> +

Unhappy? <%= link_to "Cancel my account", registration_path(resource_name), data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: 'btn' %>

diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb index 707d9cee..54780f26 100644 --- a/app/views/devise/registrations/new.html.erb +++ b/app/views/devise/registrations/new.html.erb @@ -63,10 +63,10 @@ <%= f.submit (@invitation ? "Create Account & Join Family" : "Sign up"), class: 'btn btn-primary' %> + <% end %> - <% unless @invitation %> - <%= render "devise/shared/links" %> - <% end %> + <% unless @invitation %> + <%= render "devise/shared/links" %> <% end %> diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 2c9e31c7..30bb1a73 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -265,7 +265,7 @@ 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' + config.omniauth :github, ENV['GITHUB_OAUTH_CLIENT_ID'], ENV['GITHUB_OAUTH_CLIENT_SECRET'], scope: 'user:email' # ==> Warden configuration # If you want to use other strategies, that are not supported by Devise, or From af71661e2b8c5a158c6527dafe7f17ef5d0aa84e Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 26 Oct 2025 15:34:12 +0100 Subject: [PATCH 03/11] Remove margin-bottom --- app/views/devise/shared/_links.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb index c968119a..857c7d41 100644 --- a/app/views/devise/shared/_links.html.erb +++ b/app/views/devise/shared/_links.html.erb @@ -1,4 +1,4 @@ -

+
<% if !signed_in? %>
<%= link_to "Log in", new_session_path(resource_name) %> From 48e50c2ee81e99fd44a7db37ce372f0662db8aa8 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 26 Oct 2025 15:50:47 +0100 Subject: [PATCH 04/11] Implement Google OAuth2 authentication --- .../users/omniauth_callbacks_controller.rb | 13 +++++++++++-- app/models/user.rb | 2 +- config/initializers/devise.rb | 2 ++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 53da9f19..e2bbb362 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -2,13 +2,22 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController def github + handle_auth('GitHub') + end + + def google_oauth2 + handle_auth('Google') + end + + private + + def handle_auth(provider) @user = User.from_omniauth(request.env['omniauth.auth']) if @user.persisted? - flash[:notice] = I18n.t 'devise.omniauth_callbacks.success', kind: 'GitHub' + flash[:notice] = I18n.t 'devise.omniauth_callbacks.success', kind: provider sign_in_and_redirect @user, event: :authentication else - session['devise.github_data'] = request.env['omniauth.auth'].except('extra') redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\n") end end diff --git a/app/models/user.rb b/app/models/user.rb index 9329f130..56a754bb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,7 +4,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength include UserFamily devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :trackable, - :omniauthable, omniauth_providers: %i[github] + :omniauthable, omniauth_providers: %i[github google_oauth2] has_many :points, dependent: :destroy has_many :imports, dependent: :destroy diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 30bb1a73..19a30c97 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -266,6 +266,8 @@ Devise.setup do |config| # 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' # ==> Warden configuration # If you want to use other strategies, that are not supported by Devise, or From e6c8bd30dff547f99a07f714a29c8f6df97b239f Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 26 Oct 2025 18:10:48 +0100 Subject: [PATCH 05/11] Implement OIDC authentication for Dawarich using omniauth_openid_connect gem. --- .env.template | 50 ++++++ CHANGELOG.md | 5 + Gemfile | 1 + Gemfile.lock | 50 ++++++ README.md | 8 + .../users/omniauth_callbacks_controller.rb | 34 +++- app/models/user.rb | 2 +- config/initializers/devise.rb | 42 +++++ spec/models/user_spec.rb | 93 ++++++++++ .../requests/users/omniauth_callbacks_spec.rb | 167 ++++++++++++++++++ spec/support/omniauth.rb | 105 +++++++++++ 11 files changed, 552 insertions(+), 5 deletions(-) create mode 100644 spec/requests/users/omniauth_callbacks_spec.rb create mode 100644 spec/support/omniauth.rb diff --git a/.env.template b/.env.template index e69de29b..c53818ae 100644 --- a/.env.template +++ b/.env.template @@ -0,0 +1,50 @@ +# OAuth Configuration + +# GitHub OAuth +GITHUB_OAUTH_CLIENT_ID= +GITHUB_OAUTH_CLIENT_SECRET= + +# Google OAuth2 +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_CLIENT_SECRET= + +# Generic OpenID Connect (for Authelia, Authentik, Keycloak, etc.) +# Option 1: Using OIDC Discovery (Recommended) +# Set OIDC_ISSUER to your provider's issuer URL (e.g., https://auth.example.com) +# The provider must support OpenID Connect Discovery (.well-known/openid-configuration) +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_ISSUER= +OIDC_REDIRECT_URI= + +# Option 2: Manual Endpoint Configuration (if discovery is not supported) +# Use this if your provider doesn't support OIDC discovery +# OIDC_CLIENT_ID= +# OIDC_CLIENT_SECRET= +# OIDC_HOST=auth.example.com +# OIDC_SCHEME=https +# OIDC_PORT=443 +# OIDC_AUTHORIZATION_ENDPOINT=/authorize +# OIDC_TOKEN_ENDPOINT=/token +# OIDC_USERINFO_ENDPOINT=/userinfo +# OIDC_REDIRECT_URI=https://yourdomain.com/users/auth/openid_connect/callback + +# Example configurations: +# +# Authelia: +# OIDC_ISSUER=https://auth.example.com +# OIDC_CLIENT_ID=your-client-id +# OIDC_CLIENT_SECRET=your-client-secret +# OIDC_REDIRECT_URI=https://dawarich.example.com/users/auth/openid_connect/callback +# +# Authentik: +# OIDC_ISSUER=https://authentik.example.com/application/o/dawarich/ +# OIDC_CLIENT_ID=your-client-id +# OIDC_CLIENT_SECRET=your-client-secret +# OIDC_REDIRECT_URI=https://dawarich.example.com/users/auth/openid_connect/callback +# +# Keycloak: +# OIDC_ISSUER=https://keycloak.example.com/realms/your-realm +# OIDC_CLIENT_ID=dawarich +# OIDC_CLIENT_SECRET=your-client-secret +# OIDC_REDIRECT_URI=https://dawarich.example.com/users/auth/openid_connect/callback diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c4ca6bc..ecd214ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +# [UNRELEASED] + +- Implemented authentication via GitHub and Google for Dawarich Cloud. +- Implemented OpenID Connect authentication for self-hosted Dawarich instances. #66 + # [0.34.0] - 2025-10-10 ## The Family release diff --git a/Gemfile b/Gemfile index 8267c832..3a20abea 100644 --- a/Gemfile +++ b/Gemfile @@ -26,6 +26,7 @@ gem 'lograge' gem 'oj' gem 'omniauth-github', '~> 2.0.0' gem 'omniauth-google-oauth2' +gem 'omniauth_openid_connect' gem 'omniauth-rails_csrf_protection' gem 'parallel' gem 'pg' diff --git a/Gemfile.lock b/Gemfile.lock index f818ff05..f64a4477 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -86,8 +86,10 @@ GEM uri (>= 0.13.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + aes_key_wrap (1.1.0) ast (2.4.3) attr_extras (7.1.0) + attr_required (1.0.2) aws-eventstream (1.3.2) aws-partitions (1.1072.0) aws-sdk-core (3.215.1) @@ -108,6 +110,7 @@ GEM bcrypt (3.1.20) benchmark (0.4.1) bigdecimal (3.2.3) + bindata (2.5.1) bootsnap (1.18.6) msgpack (~> 1.2) brakeman (7.0.2) @@ -161,6 +164,8 @@ GEM dotenv (= 3.1.8) railties (>= 6.1) drb (2.2.3) + email_validator (2.2.4) + activemodel erb (5.0.2) erubi (1.13.1) et-orbi (1.4.0) @@ -175,6 +180,8 @@ GEM faraday-net_http (>= 2.0, < 3.5) json logger + faraday-follow_redirects (0.4.0) + faraday (>= 1, < 3) faraday-net_http (3.4.1) net-http (>= 0.5.0) ffaker (2.25.0) @@ -220,6 +227,13 @@ GEM reline (>= 0.4.2) jmespath (1.6.2) json (2.15.0) + json-jwt (1.17.0) + activesupport (>= 4.2) + aes_key_wrap + base64 + bindata + faraday (~> 2.0) + faraday-follow_redirects json-schema (5.0.1) addressable (~> 2.8) jwt (2.10.1) @@ -318,6 +332,22 @@ GEM omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) + omniauth_openid_connect (0.8.0) + omniauth (>= 1.9, < 3) + openid_connect (~> 2.2) + openid_connect (2.3.1) + activemodel + attr_required (>= 1.0.0) + email_validator + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + mail + rack-oauth2 (~> 2.2) + swd (~> 2.0) + tzinfo + validate_url + webfinger (~> 2.0) optimist (3.2.1) orm_adapter (0.5.0) ostruct (0.6.1) @@ -357,6 +387,13 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.2.2) + rack-oauth2 (2.2.1) + activesupport + attr_required + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.11.0) + rack (>= 2.1.0) rack-protection (4.2.1) base64 (>= 0.1.0) logger (>= 1.6.0) @@ -534,6 +571,11 @@ GEM attr_extras (>= 6.2.4) diff-lcs patience_diff + swd (2.0.3) + activesupport (>= 3) + attr_required (>= 0.0.5) + faraday (~> 2.0) + faraday-follow_redirects tailwindcss-rails (3.3.2) railties (>= 7.0.0) tailwindcss-ruby (~> 3.0) @@ -557,9 +599,16 @@ GEM unicode-emoji (4.1.0) uri (1.0.3) useragent (0.16.11) + validate_url (1.0.15) + activemodel (>= 3.0.0) + public_suffix version_gem (1.1.9) warden (1.2.9) rack (>= 2.0.9) + webfinger (2.1.3) + activesupport + faraday (~> 2.0) + faraday-follow_redirects webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -614,6 +663,7 @@ DEPENDENCIES omniauth-github (~> 2.0.0) omniauth-google-oauth2 omniauth-rails_csrf_protection + omniauth_openid_connect parallel pg prometheus_exporter diff --git a/README.md b/README.md index 797e2177..2efd0299 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,14 @@ 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/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index e2bbb362..10fd37ac 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -9,6 +9,36 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController handle_auth('Google') end + def openid_connect + handle_auth('OpenID Connect') + end + + def failure + error_type = request.env['omniauth.error.type'] + error = request.env['omniauth.error'] + + # Provide user-friendly error messages + error_message = + case error_type + when :invalid_credentials + 'Invalid credentials. Please check your username and password.' + when :timeout + 'Connection timeout. Please try again.' + when :csrf_detected + 'Security error detected. Please try again.' + else + if error&.message&.include?('Discovery') + 'Unable to connect to authentication provider. Please contact your administrator.' + elsif error&.message&.include?('Issuer mismatch') + 'Authentication provider configuration error. Please contact your administrator.' + else + "Authentication failed: #{params[:message] || error&.message || 'Unknown error'}" + end + end + + redirect_to root_path, alert: error_message + end + private def handle_auth(provider) @@ -21,8 +51,4 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\n") end end - - def failure - redirect_to root_path, alert: "Authentication failed: #{params[:message]}" - end end diff --git a/app/models/user.rb b/app/models/user.rb index 56a754bb..8740ef3d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,7 +4,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength include UserFamily devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :trackable, - :omniauthable, omniauth_providers: %i[github google_oauth2] + :omniauthable, omniauth_providers: %i[github google_oauth2 openid_connect] has_many :points, dependent: :destroy has_many :imports, dependent: :destroy diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 19a30c97..4fa03d98 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -269,6 +269,48 @@ Devise.setup do |config| config.omniauth :google_oauth2, ENV['GOOGLE_OAUTH_CLIENT_ID'], ENV['GOOGLE_OAUTH_CLIENT_SECRET'], scope: 'userinfo.email,userinfo.profile' + # 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? + oidc_config = { + name: :openid_connect, + scope: %i[openid email profile], + response_type: :code, + client_options: { + identifier: ENV['OIDC_CLIENT_ID'], + secret: ENV['OIDC_CLIENT_SECRET'], + redirect_uri: ENV.fetch('OIDC_REDIRECT_URI', "#{ENV.fetch('APPLICATION_URL', 'http://localhost:3000')}/users/auth/openid_connect/callback") + } + } + + # Use OIDC discovery if issuer is provided (recommended for Authelia, Authentik, Keycloak) + if ENV['OIDC_ISSUER'].present? + oidc_config[:issuer] = ENV['OIDC_ISSUER'] + oidc_config[:discovery] = true + Rails.logger.info "OIDC: Discovery mode enabled with issuer: #{ENV['OIDC_ISSUER']}" + # Otherwise use manual endpoint configuration + elsif ENV['OIDC_HOST'].present? + oidc_config[:client_options].merge!( + { + host: ENV['OIDC_HOST'], + scheme: ENV.fetch('OIDC_SCHEME', 'https'), + port: ENV.fetch('OIDC_PORT', 443).to_i, + authorization_endpoint: ENV.fetch('OIDC_AUTHORIZATION_ENDPOINT', '/authorize'), + token_endpoint: ENV.fetch('OIDC_TOKEN_ENDPOINT', '/token'), + userinfo_endpoint: ENV.fetch('OIDC_USERINFO_ENDPOINT', '/userinfo') + } + ) + Rails.logger.info "OIDC: Manual mode enabled with host: #{ENV['OIDC_SCHEME']}://#{ENV['OIDC_HOST']}:#{ENV.fetch( + 'OIDC_PORT', 443 + )}" + end + + Rails.logger.info "OIDC: Client ID: #{ENV['OIDC_CLIENT_ID']}, Redirect URI: #{oidc_config[:client_options][:redirect_uri]}" + config.omniauth :openid_connect, oidc_config + else + Rails.logger.warn 'OIDC: Not configured (missing OIDC_CLIENT_ID or OIDC_CLIENT_SECRET)' + end + # ==> Warden configuration # If you want to use other strategies, that are not supported by Devise, or # change the failure app, you can configure them inside the config.warden block. diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 928df596..25770617 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -336,4 +336,97 @@ RSpec.describe User, type: :model do end end end + + describe '.from_omniauth' do + let(:auth_hash) do + OmniAuth::AuthHash.new({ + provider: 'github', + uid: '123545', + info: { + email: email, + name: 'Test User' + } + }) + end + + context 'when user exists with the same email' do + let(:email) { 'existing@example.com' } + let!(:existing_user) { create(:user, email: email) } + + it 'returns the existing user' do + user = described_class.from_omniauth(auth_hash) + expect(user).to eq(existing_user) + expect(user.persisted?).to be true + end + + it 'does not create a new user' do + expect do + described_class.from_omniauth(auth_hash) + end.not_to change(User, :count) + end + end + + context 'when user does not exist' do + let(:email) { 'new@example.com' } + + it 'creates a new user with the OAuth email' do + expect do + described_class.from_omniauth(auth_hash) + end.to change(User, :count).by(1) + + user = User.last + expect(user.email).to eq(email) + end + + it 'generates a random password for the new user' do + user = described_class.from_omniauth(auth_hash) + expect(user.encrypted_password).to be_present + end + + it 'returns a persisted user' do + user = described_class.from_omniauth(auth_hash) + expect(user.persisted?).to be true + end + end + + context 'when OAuth provider is Google' do + let(:email) { 'google@example.com' } + let(:auth_hash) do + OmniAuth::AuthHash.new({ + provider: 'google_oauth2', + uid: '123545', + info: { + email: email, + name: 'Google User' + } + }) + end + + it 'creates a user from Google OAuth data' do + user = described_class.from_omniauth(auth_hash) + expect(user.email).to eq(email) + expect(user.persisted?).to be true + end + end + + context 'when email is nil' do + let(:email) { nil } + + it 'attempts to create a user but fails validation' do + user = described_class.from_omniauth(auth_hash) + expect(user.persisted?).to be false + expect(user.errors[:email]).to be_present + end + end + + context 'when email is blank' do + let(:email) { '' } + + it 'attempts to create a user but fails validation' do + user = described_class.from_omniauth(auth_hash) + expect(user.persisted?).to be false + expect(user.errors[:email]).to be_present + end + end + end end diff --git a/spec/requests/users/omniauth_callbacks_spec.rb b/spec/requests/users/omniauth_callbacks_spec.rb new file mode 100644 index 00000000..85d0d680 --- /dev/null +++ b/spec/requests/users/omniauth_callbacks_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Users::OmniauthCallbacks', type: :request do + let(:email) { 'oauth_user@example.com' } + + before do + Rails.application.env_config['devise.mapping'] = Devise.mappings[:user] + end + + shared_examples 'successful OAuth authentication' do |provider, provider_name| + context "when user doesn't exist" do + it 'creates a new user and signs them in' do + expect do + Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[provider] + get "/users/auth/#{provider}/callback" + end.to change(User, :count).by(1) + + expect(response).to redirect_to(root_path) + + user = User.find_by(email: email) + expect(user).to be_present + expect(user.encrypted_password).to be_present + end + end + + context 'when user already exists' do + let!(:existing_user) { create(:user, email: email) } + + it 'signs in the existing user without creating a new one' do + expect do + Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[provider] + get "/users/auth/#{provider}/callback" + end.not_to change(User, :count) + + expect(response).to redirect_to(root_path) + end + end + + context 'when user creation fails' do + before do + allow(User).to receive(:create).and_return( + User.new(email: email).tap do |u| + u.errors.add(:email, 'is invalid') + end + ) + end + + it 'redirects to registration with error message' do + Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[provider] + get "/users/auth/#{provider}/callback" + + expect(response).to redirect_to(new_user_registration_url) + end + 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 + + describe 'GET /users/auth/openid_connect/callback' do + before do + mock_openid_connect_auth(email: email) + end + + 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 + 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') + expect(response).to redirect_to(root_path) + end + end + 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) + + expect do + Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:openid_connect] + get '/users/auth/openid_connect/callback' + end.not_to raise_error + end + end +end diff --git a/spec/support/omniauth.rb b/spec/support/omniauth.rb new file mode 100644 index 00000000..8f809e62 --- /dev/null +++ b/spec/support/omniauth.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +OmniAuth.config.test_mode = true + +module OmniauthHelpers + def mock_github_auth(email: 'test@github.com') + OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new({ + provider: 'github', + uid: '123545', + info: { + email: email, + name: 'Test User', + image: 'https://avatars.githubusercontent.com/u/123545' + }, + credentials: { + token: 'mock_token', + expires_at: Time.now + 1.week + }, + extra: { + raw_info: { + login: 'testuser', + avatar_url: 'https://avatars.githubusercontent.com/u/123545', + name: 'Test User', + email: email + } + } + }) + end + + def mock_google_auth(email: 'test@gmail.com') + OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new({ + provider: 'google_oauth2', + uid: '123545', + info: { + email: email, + name: 'Test User', + image: 'https://lh3.googleusercontent.com/a/test' + }, + credentials: { + token: 'mock_token', + refresh_token: 'mock_refresh_token', + expires_at: Time.now + 1.hour + }, + extra: { + raw_info: { + email: email, + email_verified: true, + name: 'Test User', + given_name: 'Test', + family_name: 'User', + picture: 'https://lh3.googleusercontent.com/a/test' + } + } + }) + end + + def mock_openid_connect_auth(email: 'test@oidc.com', provider_name: 'Authelia') + OmniAuth.config.mock_auth[:openid_connect] = OmniAuth::AuthHash.new({ + provider: 'openid_connect', + uid: '123545', + info: { + email: email, + name: 'Test User', + image: 'https://example.com/avatar.jpg' + }, + credentials: { + token: 'mock_token', + refresh_token: 'mock_refresh_token', + expires_at: Time.now + 1.hour, + id_token: 'mock_id_token' + }, + extra: { + raw_info: { + sub: '123545', + email: email, + email_verified: true, + name: 'Test User', + preferred_username: 'testuser', + given_name: 'Test', + family_name: 'User', + picture: 'https://example.com/avatar.jpg' + } + } + }) + end + + def mock_oauth_failure(provider) + OmniAuth.config.mock_auth[provider] = :invalid_credentials + end +end + +RSpec.configure do |config| + config.include OmniauthHelpers, type: :request + config.include OmniauthHelpers, type: :system + + config.before do + OmniAuth.config.test_mode = true + end + + config.after do + OmniAuth.config.mock_auth[:github] = nil + OmniAuth.config.mock_auth[:google_oauth2] = nil + OmniAuth.config.mock_auth[:openid_connect] = nil + end +end From 7bc579e56395fb7ce881d51831d03abcbdd66f9e Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 29 Oct 2025 13:27:43 +0100 Subject: [PATCH 06/11] REVERT: Patreon account connection --- CHANGELOG.md | 7 ++ README.md | 8 -- app/controllers/settings_controller.rb | 8 ++ .../users/omniauth_callbacks_controller.rb | 4 + app/models/user.rb | 84 +++++++++++++++++-- app/views/settings/index.html.erb | 43 ++++++++++ config/initializers/01_constants.rb | 15 ++++ config/initializers/devise.rb | 26 +++++- config/routes.rb | 1 + db/schema.rb | 28 ++++--- .../requests/users/omniauth_callbacks_spec.rb | 77 +---------------- 11 files changed, 198 insertions(+), 103 deletions(-) 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) From 5a40f9fe901657869f195f2eb91c4dd4e07e8773 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 30 Oct 2025 19:18:08 +0100 Subject: [PATCH 07/11] Add patreon account linking and patron checking service --- app/services/patreon/patron_checker.rb | 131 ++++++++++++ config/initializers/omniauth.rb | 4 + .../20251028130433_add_omniauth_to_users.rb | 6 + ...251028160950_add_patreon_token_to_users.rb | 7 + docs/PATREON_INTEGRATION.md | 194 ++++++++++++++++++ lib/omniauth/strategies/patreon.rb | 46 +++++ 6 files changed, 388 insertions(+) create mode 100644 app/services/patreon/patron_checker.rb create mode 100644 config/initializers/omniauth.rb create mode 100644 db/migrate/20251028130433_add_omniauth_to_users.rb create mode 100644 db/migrate/20251028160950_add_patreon_token_to_users.rb create mode 100644 docs/PATREON_INTEGRATION.md create mode 100644 lib/omniauth/strategies/patreon.rb diff --git a/app/services/patreon/patron_checker.rb b/app/services/patreon/patron_checker.rb new file mode 100644 index 00000000..575f83f6 --- /dev/null +++ b/app/services/patreon/patron_checker.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module Patreon + # Service to check Patreon patron status + class PatronChecker + attr_reader :user + + def initialize(user) + @user = user + end + + # Check if user is a patron of a specific creator + # @param creator_id [String] The Patreon creator ID + # @return [Boolean] true if user is an active patron + def patron_of?(creator_id) + memberships.any? do |membership| + membership.dig('relationships', 'campaign', 'data', 'id') == creator_id.to_s && + membership.dig('attributes', 'patron_status') == 'active_patron' + end + end + + # Get all active memberships + # @return [Array] Array of membership data with campaign info + def memberships + @memberships ||= fetch_memberships + end + + # Get detailed membership info for a specific creator + # @param creator_id [String] The Patreon creator ID + # @return [Hash, nil] Membership details or nil if not a patron + def membership_for(creator_id) + memberships.find do |membership| + membership.dig('relationships', 'campaign', 'data', 'id') == creator_id.to_s + end + end + + private + + def fetch_memberships + return [] unless valid_token? + + response = make_api_request + return [] unless response + + extract_memberships(response) + rescue StandardError => e + Rails.logger.error("Failed to fetch Patreon memberships: #{e.message}") + [] + end + + def valid_token? + return false if user.patreon_access_token.blank? + + # Check if token is expired + if user.patreon_token_expires_at && user.patreon_token_expires_at < Time.current + refresh_token! + end + + user.patreon_access_token.present? + end + + def refresh_token! + return false if user.patreon_refresh_token.blank? + + conn = Faraday.new(url: 'https://www.patreon.com') do |f| + f.request :url_encoded + f.response :json + f.adapter Faraday.default_adapter + end + + response = conn.post('/api/oauth2/token') do |req| + req.body = { + grant_type: 'refresh_token', + refresh_token: user.patreon_refresh_token, + client_id: ENV['PATREON_CLIENT_ID'], + client_secret: ENV['PATREON_CLIENT_SECRET'] + } + end + + if response.success? + data = response.body + user.update( + patreon_access_token: data['access_token'], + patreon_refresh_token: data['refresh_token'], + patreon_token_expires_at: Time.current + data['expires_in'].seconds + ) + true + else + Rails.logger.error("Failed to refresh Patreon token: #{response.body}") + false + end + rescue StandardError => e + Rails.logger.error("Error refreshing Patreon token: #{e.message}") + false + end + + def make_api_request + conn = Faraday.new(url: 'https://www.patreon.com') do |f| + f.request :url_encoded + f.response :json + f.adapter Faraday.default_adapter + end + + response = conn.get('/api/oauth2/v2/identity') do |req| + req.headers['Authorization'] = "Bearer #{user.patreon_access_token}" + req.params = { + include: 'memberships,memberships.campaign', + 'fields[member]' => 'patron_status,pledge_relationship_start', + 'fields[campaign]' => 'vanity,url' + } + end + + response.success? ? response.body : nil + end + + def extract_memberships(response) + return [] unless response['included'] + + memberships = response['included'].select { |item| item['type'] == 'member' } + campaigns = response['included'].select { |item| item['type'] == 'campaign' } + + # Enrich memberships with campaign data + memberships.map do |membership| + campaign_id = membership.dig('relationships', 'campaign', 'data', 'id') + campaign = campaigns.find { |c| c['id'] == campaign_id } + + membership.merge('campaign' => campaign) if campaign + end.compact + end + end +end diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 00000000..ae8c8277 --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# Load custom OmniAuth strategies +require Rails.root.join('lib', 'omniauth', 'strategies', 'patreon') diff --git a/db/migrate/20251028130433_add_omniauth_to_users.rb b/db/migrate/20251028130433_add_omniauth_to_users.rb new file mode 100644 index 00000000..01c61b71 --- /dev/null +++ b/db/migrate/20251028130433_add_omniauth_to_users.rb @@ -0,0 +1,6 @@ +class AddOmniauthToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :provider, :string + add_column :users, :uid, :string + end +end diff --git a/db/migrate/20251028160950_add_patreon_token_to_users.rb b/db/migrate/20251028160950_add_patreon_token_to_users.rb new file mode 100644 index 00000000..77cd928f --- /dev/null +++ b/db/migrate/20251028160950_add_patreon_token_to_users.rb @@ -0,0 +1,7 @@ +class AddPatreonTokenToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :patreon_access_token, :text + add_column :users, :patreon_refresh_token, :text + add_column :users, :patreon_token_expires_at, :datetime + end +end diff --git a/docs/PATREON_INTEGRATION.md b/docs/PATREON_INTEGRATION.md new file mode 100644 index 00000000..dd35e938 --- /dev/null +++ b/docs/PATREON_INTEGRATION.md @@ -0,0 +1,194 @@ +# Patreon Integration + +Dawarich Cloud includes Patreon OAuth integration that allows users to connect their Patreon accounts. This enables checking if users are patrons of specific creators. + +## Features + +- **OAuth Authentication**: Users can connect their Patreon accounts via OAuth 2.0 +- **Patron Status Checking**: Check if a user is an active patron of specific creators +- **Membership Data**: Access detailed information about user's Patreon memberships +- **Token Management**: Automatic token refresh to maintain API access + +## Setup + +### Environment Variables + +Configure the following environment variables for Dawarich Cloud: + +```bash +PATREON_CLIENT_ID=your_patreon_client_id +PATREON_CLIENT_SECRET=your_patreon_client_secret +``` + +### Getting Patreon OAuth Credentials + +1. Go to [Patreon Developer Portal](https://www.patreon.com/portal/registration/register-clients) +2. Create a new OAuth client +3. Set the redirect URI to: `https://your-domain.com/users/auth/patreon/callback` +4. Copy the Client ID and Client Secret + +## Usage + +### User Connection + +Users can connect their Patreon account in the account settings: + +1. Navigate to Settings +2. Find the "Connected Accounts" section +3. Click "Connect" next to Patreon +4. Authorize the application on Patreon +5. Get redirected back to Dawarich + +### Checking Patron Status + +#### Check if User is a Patron of Specific Creator + +```ruby +# Get Dawarich creator's Patreon ID (find it in your Patreon campaign URL) +dawarich_creator_id = 'your_creator_id' + +# Check if current user is a patron +if current_user.patron_of?(dawarich_creator_id) + # User is an active patron! + # Grant special features, show badge, etc. +end +``` + +#### Get All Memberships + +```ruby +# Get all campaigns the user is supporting +memberships = current_user.patreon_memberships + +memberships.each do |membership| + campaign = membership['campaign'] + + puts "Supporting: #{campaign['attributes']['vanity']}" + puts "URL: #{campaign['attributes']['url']}" + puts "Status: #{membership['attributes']['patron_status']}" + puts "Since: #{membership['attributes']['pledge_relationship_start']}" +end +``` + +#### Get Specific Membership Details + +```ruby +creator_id = 'your_creator_id' + +membership = Patreon::PatronChecker.new(current_user).membership_for(creator_id) + +if membership + # User is a patron + status = membership.dig('attributes', 'patron_status') + started_at = membership.dig('attributes', 'pledge_relationship_start') + + # Access campaign details + campaign = membership['campaign'] + campaign_name = campaign.dig('attributes', 'vanity') +end +``` + +### Patron Status Values + +The `patron_status` field can have the following values: + +- `active_patron` - Currently an active patron +- `declined_patron` - Payment declined +- `former_patron` - Was a patron but not anymore + +### Example: Show Patron Badge + +```ruby +# In a view or helper +def show_patron_badge?(user) + dawarich_creator_id = ENV['DAWARICH_PATREON_CREATOR_ID'] + return false unless dawarich_creator_id.present? + + user.patron_of?(dawarich_creator_id) +end +``` + +```erb + +<% if show_patron_badge?(current_user) %> + + ❤️ Patreon Supporter + +<% end %> +``` + +### Example: Grant Premium Features + +```ruby +class User < ApplicationRecord + def premium_access? + return true if admin? + return true if active_subscription? # existing subscription logic + + # Check Patreon support + dawarich_creator_id = ENV['DAWARICH_PATREON_CREATOR_ID'] + return false unless dawarich_creator_id + + patron_of?(dawarich_creator_id) + end +end +``` + +## Token Management + +The integration automatically handles token refresh: + +- Access tokens are stored securely in the database +- Tokens are automatically refreshed when expired +- If refresh fails, the user needs to reconnect their account + +## Disconnecting Patreon + +Users can disconnect their Patreon account at any time: + +```ruby +current_user.disconnect_patreon! +``` + +This will: +- Remove the provider/uid linkage +- Clear all stored tokens +- Revoke API access + +## Security Considerations + +- Access tokens are stored in the database (consider encrypting at rest) +- Tokens are automatically refreshed to maintain access +- API requests are made server-side only +- Users can revoke access at any time from their Patreon settings + +## API Rate Limits + +Patreon API has rate limits. The service handles this by: +- Caching membership data when possible +- Using efficient API queries +- Handling API errors gracefully + +## Troubleshooting + +### Token Expired Errors + +If users see authentication errors: +1. Ask them to disconnect and reconnect their Patreon account +2. Check that the refresh token is still valid +3. Verify environment variables are set correctly + +### Creator ID Not Found + +To find a Patreon creator ID: +1. Go to the creator's Patreon page +2. Use the Patreon API: `GET https://www.patreon.com/api/oauth2/v2/campaigns` +3. The campaign ID is the creator ID you need + +## Future Enhancements + +Potential future features: +- Tier-based access (check pledge amount) +- Lifetime pledge amount tracking +- Patron anniversary badges +- Direct campaign data caching diff --git a/lib/omniauth/strategies/patreon.rb b/lib/omniauth/strategies/patreon.rb new file mode 100644 index 00000000..f6103e3e --- /dev/null +++ b/lib/omniauth/strategies/patreon.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'omniauth-oauth2' + +module OmniAuth + module Strategies + # OmniAuth strategy for Patreon OAuth2 + class Patreon < OmniAuth::Strategies::OAuth2 + option :name, 'patreon' + + option :client_options, + site: 'https://www.patreon.com', + authorize_url: 'https://www.patreon.com/oauth2/authorize', + token_url: 'https://www.patreon.com/api/oauth2/token' + + option :authorize_params, + scope: 'identity identity[email]' + + uid { raw_info['data']['id'] } + + info do + { + email: raw_info.dig('data', 'attributes', 'email'), + name: raw_info.dig('data', 'attributes', 'full_name'), + first_name: raw_info.dig('data', 'attributes', 'first_name'), + last_name: raw_info.dig('data', 'attributes', 'last_name'), + image: raw_info.dig('data', 'attributes', 'image_url') + } + end + + extra do + { + raw_info: raw_info + } + end + + def raw_info + @raw_info ||= access_token.get('/api/oauth2/v2/identity?include=memberships&fields[user]=email,first_name,full_name,last_name,image_url').parsed + end + + def callback_url + full_host + callback_path + end + end + end +end From a4dcd0387fe52b4de7fef4621c571299aa4e40d8 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 14 Nov 2025 18:39:15 +0100 Subject: [PATCH 08/11] Remove patreon OAuth integration --- app/controllers/settings_controller.rb | 8 - .../users/omniauth_callbacks_controller.rb | 4 - app/models/user.rb | 54 +---- app/services/patreon/patron_checker.rb | 131 ------------ app/views/settings/index.html.erb | 31 +-- config/initializers/01_constants.rb | 5 +- config/initializers/devise.rb | 6 - config/initializers/omniauth.rb | 2 +- config/routes.rb | 1 - ...251028160950_add_patreon_token_to_users.rb | 7 - docs/PATREON_INTEGRATION.md | 194 ------------------ lib/omniauth/strategies/patreon.rb | 46 ----- 12 files changed, 6 insertions(+), 483 deletions(-) delete mode 100644 app/services/patreon/patron_checker.rb delete mode 100644 db/migrate/20251028160950_add_patreon_token_to_users.rb delete mode 100644 docs/PATREON_INTEGRATION.md delete mode 100644 lib/omniauth/strategies/patreon.rb diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index d1f78c03..1a34fed4 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -28,14 +28,6 @@ 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 e56dfd5e..10fd37ac 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -13,10 +13,6 @@ 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 a4390ce2..8a44e379 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -156,10 +156,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength user = User.find_by(provider: provider, uid: uid) if user - # Update tokens for existing user - if provider == 'patreon' - user.update_patreon_tokens(access_token) - end + # User found by provider/uid return user end @@ -169,9 +166,6 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength 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 @@ -183,55 +177,9 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength 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 def create_api_key diff --git a/app/services/patreon/patron_checker.rb b/app/services/patreon/patron_checker.rb deleted file mode 100644 index 575f83f6..00000000 --- a/app/services/patreon/patron_checker.rb +++ /dev/null @@ -1,131 +0,0 @@ -# frozen_string_literal: true - -module Patreon - # Service to check Patreon patron status - class PatronChecker - attr_reader :user - - def initialize(user) - @user = user - end - - # Check if user is a patron of a specific creator - # @param creator_id [String] The Patreon creator ID - # @return [Boolean] true if user is an active patron - def patron_of?(creator_id) - memberships.any? do |membership| - membership.dig('relationships', 'campaign', 'data', 'id') == creator_id.to_s && - membership.dig('attributes', 'patron_status') == 'active_patron' - end - end - - # Get all active memberships - # @return [Array] Array of membership data with campaign info - def memberships - @memberships ||= fetch_memberships - end - - # Get detailed membership info for a specific creator - # @param creator_id [String] The Patreon creator ID - # @return [Hash, nil] Membership details or nil if not a patron - def membership_for(creator_id) - memberships.find do |membership| - membership.dig('relationships', 'campaign', 'data', 'id') == creator_id.to_s - end - end - - private - - def fetch_memberships - return [] unless valid_token? - - response = make_api_request - return [] unless response - - extract_memberships(response) - rescue StandardError => e - Rails.logger.error("Failed to fetch Patreon memberships: #{e.message}") - [] - end - - def valid_token? - return false if user.patreon_access_token.blank? - - # Check if token is expired - if user.patreon_token_expires_at && user.patreon_token_expires_at < Time.current - refresh_token! - end - - user.patreon_access_token.present? - end - - def refresh_token! - return false if user.patreon_refresh_token.blank? - - conn = Faraday.new(url: 'https://www.patreon.com') do |f| - f.request :url_encoded - f.response :json - f.adapter Faraday.default_adapter - end - - response = conn.post('/api/oauth2/token') do |req| - req.body = { - grant_type: 'refresh_token', - refresh_token: user.patreon_refresh_token, - client_id: ENV['PATREON_CLIENT_ID'], - client_secret: ENV['PATREON_CLIENT_SECRET'] - } - end - - if response.success? - data = response.body - user.update( - patreon_access_token: data['access_token'], - patreon_refresh_token: data['refresh_token'], - patreon_token_expires_at: Time.current + data['expires_in'].seconds - ) - true - else - Rails.logger.error("Failed to refresh Patreon token: #{response.body}") - false - end - rescue StandardError => e - Rails.logger.error("Error refreshing Patreon token: #{e.message}") - false - end - - def make_api_request - conn = Faraday.new(url: 'https://www.patreon.com') do |f| - f.request :url_encoded - f.response :json - f.adapter Faraday.default_adapter - end - - response = conn.get('/api/oauth2/v2/identity') do |req| - req.headers['Authorization'] = "Bearer #{user.patreon_access_token}" - req.params = { - include: 'memberships,memberships.campaign', - 'fields[member]' => 'patron_status,pledge_relationship_start', - 'fields[campaign]' => 'vanity,url' - } - end - - response.success? ? response.body : nil - end - - def extract_memberships(response) - return [] unless response['included'] - - memberships = response['included'].select { |item| item['type'] == 'member' } - campaigns = response['included'].select { |item| item['type'] == 'campaign' } - - # Enrich memberships with campaign data - memberships.map do |membership| - campaign_id = membership.dig('relationships', 'campaign', 'data', 'id') - campaign = campaigns.find { |c| c['id'] == campaign_id } - - membership.merge('campaign' => campaign) if campaign - end.compact - end - end -end diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb index 85ac469f..7b412fa8 100644 --- a/app/views/settings/index.html.erb +++ b/app/views/settings/index.html.erb @@ -78,35 +78,8 @@ 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. +

+ OAuth providers configured: <%= OMNIAUTH_PROVIDERS.map(&:to_s).join(', ').presence || 'None' %>

diff --git a/config/initializers/01_constants.rb b/config/initializers/01_constants.rb index 8bb2de8e..0215de24 100644 --- a/config/initializers/01_constants.rb +++ b/config/initializers/01_constants.rb @@ -38,16 +38,15 @@ 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 +# Self-hosted: only OpenID Connect, Cloud: only GitHub and Google 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 + # Cloud: only GitHub and Google 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 5fd04c74..2deaf387 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -278,12 +278,6 @@ Devise.setup do |config| 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) diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index ae8c8277..aa158eb6 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -1,4 +1,4 @@ # frozen_string_literal: true # Load custom OmniAuth strategies -require Rails.root.join('lib', 'omniauth', 'strategies', 'patreon') +# (none currently needed) diff --git a/config/routes.rb b/config/routes.rb index bd650203..16d2eacb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -50,7 +50,6 @@ 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/migrate/20251028160950_add_patreon_token_to_users.rb b/db/migrate/20251028160950_add_patreon_token_to_users.rb deleted file mode 100644 index 77cd928f..00000000 --- a/db/migrate/20251028160950_add_patreon_token_to_users.rb +++ /dev/null @@ -1,7 +0,0 @@ -class AddPatreonTokenToUsers < ActiveRecord::Migration[8.0] - def change - add_column :users, :patreon_access_token, :text - add_column :users, :patreon_refresh_token, :text - add_column :users, :patreon_token_expires_at, :datetime - end -end diff --git a/docs/PATREON_INTEGRATION.md b/docs/PATREON_INTEGRATION.md deleted file mode 100644 index dd35e938..00000000 --- a/docs/PATREON_INTEGRATION.md +++ /dev/null @@ -1,194 +0,0 @@ -# Patreon Integration - -Dawarich Cloud includes Patreon OAuth integration that allows users to connect their Patreon accounts. This enables checking if users are patrons of specific creators. - -## Features - -- **OAuth Authentication**: Users can connect their Patreon accounts via OAuth 2.0 -- **Patron Status Checking**: Check if a user is an active patron of specific creators -- **Membership Data**: Access detailed information about user's Patreon memberships -- **Token Management**: Automatic token refresh to maintain API access - -## Setup - -### Environment Variables - -Configure the following environment variables for Dawarich Cloud: - -```bash -PATREON_CLIENT_ID=your_patreon_client_id -PATREON_CLIENT_SECRET=your_patreon_client_secret -``` - -### Getting Patreon OAuth Credentials - -1. Go to [Patreon Developer Portal](https://www.patreon.com/portal/registration/register-clients) -2. Create a new OAuth client -3. Set the redirect URI to: `https://your-domain.com/users/auth/patreon/callback` -4. Copy the Client ID and Client Secret - -## Usage - -### User Connection - -Users can connect their Patreon account in the account settings: - -1. Navigate to Settings -2. Find the "Connected Accounts" section -3. Click "Connect" next to Patreon -4. Authorize the application on Patreon -5. Get redirected back to Dawarich - -### Checking Patron Status - -#### Check if User is a Patron of Specific Creator - -```ruby -# Get Dawarich creator's Patreon ID (find it in your Patreon campaign URL) -dawarich_creator_id = 'your_creator_id' - -# Check if current user is a patron -if current_user.patron_of?(dawarich_creator_id) - # User is an active patron! - # Grant special features, show badge, etc. -end -``` - -#### Get All Memberships - -```ruby -# Get all campaigns the user is supporting -memberships = current_user.patreon_memberships - -memberships.each do |membership| - campaign = membership['campaign'] - - puts "Supporting: #{campaign['attributes']['vanity']}" - puts "URL: #{campaign['attributes']['url']}" - puts "Status: #{membership['attributes']['patron_status']}" - puts "Since: #{membership['attributes']['pledge_relationship_start']}" -end -``` - -#### Get Specific Membership Details - -```ruby -creator_id = 'your_creator_id' - -membership = Patreon::PatronChecker.new(current_user).membership_for(creator_id) - -if membership - # User is a patron - status = membership.dig('attributes', 'patron_status') - started_at = membership.dig('attributes', 'pledge_relationship_start') - - # Access campaign details - campaign = membership['campaign'] - campaign_name = campaign.dig('attributes', 'vanity') -end -``` - -### Patron Status Values - -The `patron_status` field can have the following values: - -- `active_patron` - Currently an active patron -- `declined_patron` - Payment declined -- `former_patron` - Was a patron but not anymore - -### Example: Show Patron Badge - -```ruby -# In a view or helper -def show_patron_badge?(user) - dawarich_creator_id = ENV['DAWARICH_PATREON_CREATOR_ID'] - return false unless dawarich_creator_id.present? - - user.patron_of?(dawarich_creator_id) -end -``` - -```erb - -<% if show_patron_badge?(current_user) %> - - ❤️ Patreon Supporter - -<% end %> -``` - -### Example: Grant Premium Features - -```ruby -class User < ApplicationRecord - def premium_access? - return true if admin? - return true if active_subscription? # existing subscription logic - - # Check Patreon support - dawarich_creator_id = ENV['DAWARICH_PATREON_CREATOR_ID'] - return false unless dawarich_creator_id - - patron_of?(dawarich_creator_id) - end -end -``` - -## Token Management - -The integration automatically handles token refresh: - -- Access tokens are stored securely in the database -- Tokens are automatically refreshed when expired -- If refresh fails, the user needs to reconnect their account - -## Disconnecting Patreon - -Users can disconnect their Patreon account at any time: - -```ruby -current_user.disconnect_patreon! -``` - -This will: -- Remove the provider/uid linkage -- Clear all stored tokens -- Revoke API access - -## Security Considerations - -- Access tokens are stored in the database (consider encrypting at rest) -- Tokens are automatically refreshed to maintain access -- API requests are made server-side only -- Users can revoke access at any time from their Patreon settings - -## API Rate Limits - -Patreon API has rate limits. The service handles this by: -- Caching membership data when possible -- Using efficient API queries -- Handling API errors gracefully - -## Troubleshooting - -### Token Expired Errors - -If users see authentication errors: -1. Ask them to disconnect and reconnect their Patreon account -2. Check that the refresh token is still valid -3. Verify environment variables are set correctly - -### Creator ID Not Found - -To find a Patreon creator ID: -1. Go to the creator's Patreon page -2. Use the Patreon API: `GET https://www.patreon.com/api/oauth2/v2/campaigns` -3. The campaign ID is the creator ID you need - -## Future Enhancements - -Potential future features: -- Tier-based access (check pledge amount) -- Lifetime pledge amount tracking -- Patron anniversary badges -- Direct campaign data caching diff --git a/lib/omniauth/strategies/patreon.rb b/lib/omniauth/strategies/patreon.rb deleted file mode 100644 index f6103e3e..00000000 --- a/lib/omniauth/strategies/patreon.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -require 'omniauth-oauth2' - -module OmniAuth - module Strategies - # OmniAuth strategy for Patreon OAuth2 - class Patreon < OmniAuth::Strategies::OAuth2 - option :name, 'patreon' - - option :client_options, - site: 'https://www.patreon.com', - authorize_url: 'https://www.patreon.com/oauth2/authorize', - token_url: 'https://www.patreon.com/api/oauth2/token' - - option :authorize_params, - scope: 'identity identity[email]' - - uid { raw_info['data']['id'] } - - info do - { - email: raw_info.dig('data', 'attributes', 'email'), - name: raw_info.dig('data', 'attributes', 'full_name'), - first_name: raw_info.dig('data', 'attributes', 'first_name'), - last_name: raw_info.dig('data', 'attributes', 'last_name'), - image: raw_info.dig('data', 'attributes', 'image_url') - } - end - - extra do - { - raw_info: raw_info - } - end - - def raw_info - @raw_info ||= access_token.get('/api/oauth2/v2/identity?include=memberships&fields[user]=email,first_name,full_name,last_name,image_url').parsed - end - - def callback_url - full_host + callback_path - end - end - end -end From bedac898214eaf86e1ce0acc1b1f8587e0ac8438 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 14 Nov 2025 18:54:35 +0100 Subject: [PATCH 09/11] Move omniauthable to a concern --- .env.template | 50 ----------------------------- CHANGELOG.md | 1 - Gemfile | 2 +- Gemfile.lock | 21 +++++++++++- app/models/concerns/omniauthable.rb | 40 +++++++++++++++++++++++ app/models/user.rb | 34 +------------------- app/views/settings/index.html.erb | 5 +-- config/initializers/devise.rb | 4 +-- config/initializers/omniauth.rb | 4 --- docker/.env.example | 46 ++++++++++++++++++++++++++ 10 files changed, 111 insertions(+), 96 deletions(-) create mode 100644 app/models/concerns/omniauthable.rb delete mode 100644 config/initializers/omniauth.rb diff --git a/.env.template b/.env.template index c53818ae..e69de29b 100644 --- a/.env.template +++ b/.env.template @@ -1,50 +0,0 @@ -# OAuth Configuration - -# GitHub OAuth -GITHUB_OAUTH_CLIENT_ID= -GITHUB_OAUTH_CLIENT_SECRET= - -# Google OAuth2 -GOOGLE_OAUTH_CLIENT_ID= -GOOGLE_OAUTH_CLIENT_SECRET= - -# Generic OpenID Connect (for Authelia, Authentik, Keycloak, etc.) -# Option 1: Using OIDC Discovery (Recommended) -# Set OIDC_ISSUER to your provider's issuer URL (e.g., https://auth.example.com) -# The provider must support OpenID Connect Discovery (.well-known/openid-configuration) -OIDC_CLIENT_ID= -OIDC_CLIENT_SECRET= -OIDC_ISSUER= -OIDC_REDIRECT_URI= - -# Option 2: Manual Endpoint Configuration (if discovery is not supported) -# Use this if your provider doesn't support OIDC discovery -# OIDC_CLIENT_ID= -# OIDC_CLIENT_SECRET= -# OIDC_HOST=auth.example.com -# OIDC_SCHEME=https -# OIDC_PORT=443 -# OIDC_AUTHORIZATION_ENDPOINT=/authorize -# OIDC_TOKEN_ENDPOINT=/token -# OIDC_USERINFO_ENDPOINT=/userinfo -# OIDC_REDIRECT_URI=https://yourdomain.com/users/auth/openid_connect/callback - -# Example configurations: -# -# Authelia: -# OIDC_ISSUER=https://auth.example.com -# OIDC_CLIENT_ID=your-client-id -# OIDC_CLIENT_SECRET=your-client-secret -# OIDC_REDIRECT_URI=https://dawarich.example.com/users/auth/openid_connect/callback -# -# Authentik: -# OIDC_ISSUER=https://authentik.example.com/application/o/dawarich/ -# OIDC_CLIENT_ID=your-client-id -# OIDC_CLIENT_SECRET=your-client-secret -# OIDC_REDIRECT_URI=https://dawarich.example.com/users/auth/openid_connect/callback -# -# Keycloak: -# OIDC_ISSUER=https://keycloak.example.com/realms/your-realm -# OIDC_CLIENT_ID=dawarich -# OIDC_CLIENT_SECRET=your-client-secret -# OIDC_REDIRECT_URI=https://dawarich.example.com/users/auth/openid_connect/callback diff --git a/CHANGELOG.md b/CHANGELOG.md index d0c5e380..5e79c617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,6 @@ 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: diff --git a/Gemfile b/Gemfile index 219fa0e2..36cf0d9c 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby File.read('.ruby-version').strip -gem 'activerecord-postgis-adapter', '~> 11.0' +gem 'activerecord-postgis-adapter', '11.0' # https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40 gem 'aws-sdk-core', '~> 3.215.1', require: false gem 'aws-sdk-kms', '~> 1.96.0', require: false diff --git a/Gemfile.lock b/Gemfile.lock index dadaade0..a32eb801 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -110,6 +110,7 @@ GEM bcrypt (3.1.20) benchmark (0.4.1) bigdecimal (3.3.1) + bindata (2.5.1) bootsnap (1.18.6) msgpack (~> 1.2) brakeman (7.1.0) @@ -163,6 +164,8 @@ GEM dotenv (= 3.1.8) railties (>= 6.1) drb (2.2.3) + email_validator (2.2.4) + activemodel erb (5.1.3) erubi (1.13.1) et-orbi (1.4.0) @@ -384,6 +387,17 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.2.3) + rack-oauth2 (2.3.0) + activesupport + attr_required + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.11.0) + rack (>= 2.1.0) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -558,6 +572,11 @@ GEM attr_extras (>= 6.2.4) diff-lcs patience_diff + swd (2.0.3) + activesupport (>= 3) + attr_required (>= 0.0.5) + faraday (~> 2.0) + faraday-follow_redirects tailwindcss-rails (3.3.2) railties (>= 7.0.0) tailwindcss-ruby (~> 3.0) @@ -614,7 +633,7 @@ PLATFORMS x86_64-linux DEPENDENCIES - activerecord-postgis-adapter (~> 11.0) + activerecord-postgis-adapter (= 11.0) aws-sdk-core (~> 3.215.1) aws-sdk-kms (~> 1.96.0) aws-sdk-s3 (~> 1.177.0) diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb new file mode 100644 index 00000000..29e09c3d --- /dev/null +++ b/app/models/concerns/omniauthable.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Omniauthable + extend ActiveSupport::Concern + + class_methods do + def from_omniauth(access_token) + data = access_token.info + provider = access_token.provider + uid = access_token.uid + + # First, try to find user by provider and uid (for linked accounts) + user = find_by(provider: provider, uid: uid) + + if user + # User found by provider/uid + return user + end + + # If not found, try to find by email + user = find_by(email: data['email']) + + if user + # Update provider and uid for existing user (first-time linking) + user.update(provider: provider, uid: uid) + return user + end + + # Create new user if not found + user = create( + email: data['email'], + password: Devise.friendly_token[0, 20], + provider: provider, + uid: uid + ) + + user + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 8a44e379..d328cb20 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,6 +2,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength include UserFamily + include Omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :trackable, @@ -147,39 +148,6 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength points.where.not(city: [nil, '']).distinct.pluck(:city).compact end - def self.from_omniauth(access_token) - data = access_token.info - provider = access_token.provider - uid = access_token.uid - - # First, try to find user by provider and uid (for linked accounts) - user = User.find_by(provider: provider, uid: uid) - - if user - # User found by provider/uid - 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) - return user - end - - # Create new user if not found - user = User.create( - email: data['email'], - password: Devise.friendly_token[0, 20], - provider: provider, - uid: uid - ) - - user - end - private def create_api_key diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb index 7b412fa8..3653fe56 100644 --- a/app/views/settings/index.html.erb +++ b/app/views/settings/index.html.erb @@ -72,10 +72,7 @@ <% unless DawarichSettings.self_hosted? %>

- - - - Connected Accounts + <%= icon 'link', class: "h-6 w-6 text-info-content" %>Connected Accounts

diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 2deaf387..51e15216 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -266,8 +266,8 @@ Devise.setup do |config| # Add a new OmniAuth provider. Check the wiki for more information on setting # up on your models and hooks. - # Cloud version: only GitHub, Google, and Patreon OAuth (when env vars present) - unless SELF_HOSTED + # Cloud version: only GitHub, Google (when env vars present) + if !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' diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb deleted file mode 100644 index aa158eb6..00000000 --- a/config/initializers/omniauth.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -# Load custom OmniAuth strategies -# (none currently needed) diff --git a/docker/.env.example b/docker/.env.example index 008b2af6..afc8625c 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -139,3 +139,49 @@ APP_MEMORY_LIMIT=4G # SECRET_KEY_BASE=your-generated-secret-key # SELF_HOSTED=true # PROMETHEUS_EXPORTER_ENABLED=true + +# ============================================================================= +# Example of configuration for OpenID Connect (OIDC) authentication +# +# ============================================================================= + +# Generic OpenID Connect (for Authelia, Authentik, Keycloak, etc.) +# Option 1: Using OIDC Discovery (Recommended) +# Set OIDC_ISSUER to your provider's issuer URL (e.g., https://auth.example.com) +# The provider must support OpenID Connect Discovery (.well-known/openid-configuration) +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_ISSUER= +OIDC_REDIRECT_URI= + +# Option 2: Manual Endpoint Configuration (if discovery is not supported) +# Use this if your provider doesn't support OIDC discovery +# OIDC_CLIENT_ID= +# OIDC_CLIENT_SECRET= +# OIDC_HOST=auth.example.com +# OIDC_SCHEME=https +# OIDC_PORT=443 +# OIDC_AUTHORIZATION_ENDPOINT=/authorize +# OIDC_TOKEN_ENDPOINT=/token +# OIDC_USERINFO_ENDPOINT=/userinfo +# OIDC_REDIRECT_URI=https://yourdomain.com/users/auth/openid_connect/callback + +# Example configurations: +# +# Authelia: +# OIDC_ISSUER=https://auth.example.com +# OIDC_CLIENT_ID=your-client-id +# OIDC_CLIENT_SECRET=your-client-secret +# OIDC_REDIRECT_URI=https://dawarich.example.com/users/auth/openid_connect/callback +# +# Authentik: +# OIDC_ISSUER=https://authentik.example.com/application/o/dawarich/ +# OIDC_CLIENT_ID=your-client-id +# OIDC_CLIENT_SECRET=your-client-secret +# OIDC_REDIRECT_URI=https://dawarich.example.com/users/auth/openid_connect/callback +# +# Keycloak: +# OIDC_ISSUER=https://keycloak.example.com/realms/your-realm +# OIDC_CLIENT_ID=dawarich +# OIDC_CLIENT_SECRET=your-client-secret +# OIDC_REDIRECT_URI=https://dawarich.example.com/users/auth/openid_connect/callback From 8ecd75429b7780a536d3b3ef1ec3ec84a152810f Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 14 Nov 2025 19:06:16 +0100 Subject: [PATCH 10/11] Update an icon in integrations --- app/models/concerns/omniauthable.rb | 5 +---- app/views/settings/index.html.erb | 5 +++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb index 29e09c3d..b0fbb21b 100644 --- a/app/models/concerns/omniauthable.rb +++ b/app/models/concerns/omniauthable.rb @@ -12,10 +12,7 @@ module Omniauthable # First, try to find user by provider and uid (for linked accounts) user = find_by(provider: provider, uid: uid) - if user - # User found by provider/uid - return user - end + return user if user # If not found, try to find by email user = find_by(email: data['email']) diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb index 3653fe56..d33d86b1 100644 --- a/app/views/settings/index.html.erb +++ b/app/views/settings/index.html.erb @@ -72,11 +72,12 @@ <% unless DawarichSettings.self_hosted? %>

- <%= icon 'link', class: "h-6 w-6 text-info-content" %>Connected Accounts + <%= icon 'link', class: "text-primary mr-1" %> Connected Accounts

- OAuth providers configured: <%= OMNIAUTH_PROVIDERS.map(&:to_s).join(', ').presence || 'None' %> + You've connected your account using the following OAuth provider: + <%= current_user.provider.capitalize %>

From bb574f5aa33146ae3ef11232bd75a28a4eb40221 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 14 Nov 2025 19:09:57 +0100 Subject: [PATCH 11/11] Update changelog --- .env.template | 0 CHANGELOG.md | 20 +++++++++++--------- docker/.env.example | 8 ++++---- 3 files changed, 15 insertions(+), 13 deletions(-) delete mode 100644 .env.template diff --git a/.env.template b/.env.template deleted file mode 100644 index e69de29b..00000000 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e79c617..993af968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +# OIDC and KML support release + +To configure your OIDC provider, set the following environment variables: + +``` +OIDC_CLIENT_ID=client_id_example +OIDC_CLIENT_SECRET=client_secret_example +OIDC_ISSUER=https://authentik.yourdomain.com/application/o/dawarich/ +OIDC_REDIRECT_URI=https://your-dawarich-url.com/users/auth/openid_connect/callback +``` + ## Added - Support for KML file uploads. #350 @@ -15,21 +26,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - The map settings panel is now scrollable ---- - ## Changed - Internal redis settings updated to implement support for connecting to Redis via unix socket. #1706 - - Implemented authentication via GitHub and Google for Dawarich Cloud. - Implemented OpenID Connect authentication for self-hosted Dawarich instances. #66 -## 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.35.1] - 2025-11-09 diff --git a/docker/.env.example b/docker/.env.example index afc8625c..18efe182 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -149,10 +149,10 @@ APP_MEMORY_LIMIT=4G # Option 1: Using OIDC Discovery (Recommended) # Set OIDC_ISSUER to your provider's issuer URL (e.g., https://auth.example.com) # The provider must support OpenID Connect Discovery (.well-known/openid-configuration) -OIDC_CLIENT_ID= -OIDC_CLIENT_SECRET= -OIDC_ISSUER= -OIDC_REDIRECT_URI= +OIDC_CLIENT_ID=client_id_example +OIDC_CLIENT_SECRET=client_secret_example +OIDC_ISSUER=https://authentik.yourdomain.com/application/o/dawarich/ +OIDC_REDIRECT_URI=https://your-dawarich-url.com/users/auth/openid_connect/callback # Option 2: Manual Endpoint Configuration (if discovery is not supported) # Use this if your provider doesn't support OIDC discovery