From df0d4d27facb548fb5eb789e06d6c81d62747bda Mon Sep 17 00:00:00 2001 From: romanilchyshyn Date: Wed, 13 Oct 2021 00:30:14 +0300 Subject: [PATCH] patreon auth --- README.md | 38 ++++++++++++------- backend/app/cmd/server.go | 8 ++++ backend/app/cmd/server_test.go | 5 ++- backend/app/rest/api/rest.go | 26 ++++++++++++- backend/app/rest/api/rest_test.go | 35 +++++++++++++++++ backend/app/rest/user.go | 2 + backend/app/store/user.go | 1 + compose-dev-backend.yml | 2 + frontend/app/common/types.ts | 2 +- .../auth/components/assets/patreon.svg | 1 + .../auth/components/oauth.consts.ts | 1 + .../docs/configuration/authorization/index.md | 6 +++ site/src/docs/contributing/api/index.md | 26 +++++++------ site/src/pages/index.md | 2 +- 14 files changed, 125 insertions(+), 30 deletions(-) create mode 100644 frontend/app/components/auth/components/assets/patreon.svg diff --git a/README.md b/README.md index c0b80ef1..f1a37af2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Remark42 is a self-hosted, lightweight and simple (yet functional) comment engine, which doesn't spy on users. It can be embedded into blogs, articles, or any other place where readers add comments. -* Social login via Google, Twitter, Facebook, Microsoft, GitHub, Yandex and Telegram +* Social login via Google, Twitter, Facebook, Microsoft, GitHub, Yandex, Patreon and Telegram * Login via email * Optional anonymous access * Multi-level nested comments with both tree and plain presentations @@ -53,6 +53,7 @@ For admin screenshots see [Admin UI wiki](https://github.com/umputun/remark42/wi - [Telegram Auth Provider](#telegram-auth-provider) - [Twitter Auth provider](#twitter-auth-provider) - [Yandex Auth provider](#yandex-auth-provider) + - [Patreon Auth provider](#patreon-auth-provider) - [Anonymous Auth provider](#anonymous-auth-provider) - [Initial import from Disqus](#initial-import-from-disqus) - [Initial import from WordPress](#initial-import-from-wordpress) @@ -150,6 +151,8 @@ _this is the recommended way to run Remark42_ | auth.github.csec | AUTH_GITHUB_CSEC | | GitHub OAuth client secret | | auth.twitter.cid | AUTH_TWITTER_CID | | Twitter Consumer API Key | | auth.twitter.csec | AUTH_TWITTER_CSEC | | Twitter Consumer API Secret key | +| auth.patreon.cid | AUTH_PATREON_CID | | Patreon OAuth Client ID | +| auth.patreon.csec | AUTH_PATREON_CSEC | | Patreon OAuth Client Secret | | auth.telegram | AUTH_TELEGRAM | | Enable Telegram auth (telegram.token must be present) | | auth.yandex.cid | AUTH_YANDEX_CID | | Yandex OAuth client ID | | auth.yandex.csec | AUTH_YANDEX_CSEC | | Yandex OAuth client secret | @@ -209,6 +212,7 @@ _this is the recommended way to run Remark42_ | port | REMARK_PORT | `8080` | web server port | | web-root | REMARK_WEB_ROOT | `./web` | web server root directory | | update-limit | UPDATE_LIMIT | `0.5` | updates/sec limit | +| subscribers-only | SUBSCRIBERS_ONLY | `false` | enable commenting only for Patreon subscribers | | admin-passwd | ADMIN_PASSWD | none (disabled) | password for `admin` basic auth | | dbg | DEBUG | `false` | debug mode | @@ -344,6 +348,12 @@ _instructions for Google OAuth2 setup borrowed from [oauth2_proxy](https://githu For more details refer to [Yandex OAuth](https://yandex.com/dev/oauth/doc/dg/concepts/about.html) and [Yandex.Passport](https://yandex.com/dev/passport/doc/dg/index.html) API documentation. +##### Patreon Auth provider +1. Create a new Patreon client https://www.patreon.com/portal/registration/register-clients +2. Fill **App Name**, **Description** +3. In the field **Redirect URIs** enter the correct URI constructed as domain + `/auth/patreon/callback`, i.e. `https://example.mysite.com/auth/patreon/callback` +4. Expand client details, take a note of the **Client ID** and **Client Secret**. Those will be used as `AUTH_PATREON_CID` and `AUTH_PATREON_CSEC` + ##### Anonymous Auth provider Optionally, anonymous access can be turned on. In this case, an extra `anonymous` provider will allow logins without any social login with any name satisfying 2 conditions: @@ -662,6 +672,7 @@ type User struct { Admin bool `json:"admin"` Blocked bool `json:"block"` Verified bool `json:"verified"` + PaidSub bool `json:"paid_sub"` // is paid Patreon subscriber } ``` @@ -760,18 +771,19 @@ type PostInfo struct { ```go type Config struct { - Version string `json:"version"` - EditDuration int `json:"edit_duration"` - MaxCommentSize int `json:"max_comment_size"` - Admins []string `json:"admins"` - AdminEmail string `json:"admin_email"` - Auth []string `json:"auth_providers"` - LowScore int `json:"low_score"` - CriticalScore int `json:"critical_score"` - PositiveScore bool `json:"positive_score"` - ReadOnlyAge int `json:"readonly_age"` - MaxImageSize int `json:"max_image_size"` - EmojiEnabled bool `json:"emoji_enabled"` + Version string `json:"version"` + EditDuration int `json:"edit_duration"` + MaxCommentSize int `json:"max_comment_size"` + Admins []string `json:"admins"` + AdminEmail string `json:"admin_email"` + Auth []string `json:"auth_providers"` + LowScore int `json:"low_score"` + CriticalScore int `json:"critical_score"` + PositiveScore bool `json:"positive_score"` + ReadOnlyAge int `json:"readonly_age"` + MaxImageSize int `json:"max_image_size"` + EmojiEnabled bool `json:"emoji_enabled"` + SubscribersOnly bool `json:"subscribers_only"` // enable commenting only for Patreon subscribers } ``` diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 525e0ae7..fc6f0359 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -80,6 +80,7 @@ type ServerCommand struct { SimpleView bool `long:"simpler-view" env:"SIMPLE_VIEW" description:"minimal comment editor mode"` ProxyCORS bool `long:"proxy-cors" env:"PROXY_CORS" description:"disable internal CORS and delegate it to proxy"` AllowedHosts []string `long:"allowed-hosts" env:"ALLOWED_HOSTS" description:"limit hosts/sources allowed to embed comments"` + SubscribersOnly bool `long:"subscribers-only" env:"SUBSCRIBERS_ONLY" description:"enable commenting only for Patreon subscribers"` Auth struct { TTL struct { @@ -96,6 +97,7 @@ type ServerCommand struct { Microsoft AuthGroup `group:"microsoft" namespace:"microsoft" env-namespace:"MICROSOFT" description:"Microsoft OAuth"` Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"` Twitter AuthGroup `group:"twitter" namespace:"twitter" env-namespace:"TWITTER" description:"Twitter OAuth"` + Patreon AuthGroup `group:"patreon" namespace:"patreon" env-namespace:"PATREON" description:"Patreon OAuth"` Telegram bool `long:"telegram" env:"TELEGRAM" description:"Enable Telegram auth (using token from telegram.token)"` Dev bool `long:"dev" env:"DEV" description:"enable dev (local) oauth2"` Anonymous bool `long:"anon" env:"ANON" description:"enable anonymous login"` @@ -291,6 +293,7 @@ func (s *ServerCommand) Execute(_ []string) error { "AUTH_MICROSOFT_CSEC", "AUTH_TWITTER_CSEC", "AUTH_YANDEX_CSEC", + "AUTH_PATREON_CSEC", "TELEGRAM_TOKEN", "SMTP_PASSWORD", "ADMIN_PASSWD", @@ -531,6 +534,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) { ProxyCORS: s.ProxyCORS, AllowedAncestors: s.AllowedHosts, SendJWTHeader: s.Auth.SendJWTHeader, + SubscribersOnly: s.SubscribersOnly, } srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore @@ -799,6 +803,10 @@ func (s *ServerCommand) addAuthProviders(ctx context.Context, authenticator *aut authenticator.AddProvider("twitter", s.Auth.Twitter.CID, s.Auth.Twitter.CSEC) providers++ } + if s.Auth.Patreon.CID != "" && s.Auth.Patreon.CSEC != "" { + authenticator.AddProvider("patreon", s.Auth.Patreon.CID, s.Auth.Patreon.CSEC) + providers++ + } if s.Auth.Telegram { telegram := &provider.TelegramHandler{ ProviderName: "telegram", diff --git a/backend/app/cmd/server_test.go b/backend/app/cmd/server_test.go index ea93d78a..a4fa1312 100644 --- a/backend/app/cmd/server_test.go +++ b/backend/app/cmd/server_test.go @@ -78,7 +78,7 @@ func TestServerApp_DevMode(t *testing.T) { waitForHTTPServerStart(port) providers := app.restSrv.Authenticator.Providers() - require.Equal(t, 8+1, len(providers), "extra auth provider") + require.Equal(t, 9+1, len(providers), "extra auth provider") assert.Equal(t, "dev", providers[len(providers)-2].Name(), "dev auth provider") // send ping resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port)) @@ -105,7 +105,7 @@ func TestServerApp_AnonMode(t *testing.T) { waitForHTTPServerStart(port) providers := app.restSrv.Authenticator.Providers() - require.Equal(t, 8+1, len(providers), "extra auth provider for anon") + require.Equal(t, 9+1, len(providers), "extra auth provider for anon") assert.Equal(t, "anonymous", providers[len(providers)-1].Name(), "anon auth provider") // send ping @@ -694,6 +694,7 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve cmd.Auth.Yandex.CSEC, cmd.Auth.Yandex.CID = "csec", "cid" cmd.Auth.Microsoft.CSEC, cmd.Auth.Microsoft.CID = "csec", "cid" cmd.Auth.Twitter.CSEC, cmd.Auth.Twitter.CID = "csec", "cid" + cmd.Auth.Patreon.CSEC, cmd.Auth.Patreon.CID = "csec", "cid" cmd.Auth.Telegram = true cmd.Telegram.Token = "token" cmd.Auth.Email.Enable = true diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index 1be3f8e5..2efbbb0d 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -63,6 +63,7 @@ type Rest struct { ProxyCORS bool SendJWTHeader bool AllowedAncestors []string // sets Content-Security-Policy "frame-ancestors ..." + SubscribersOnly bool SSLConfig SSLConfig httpsServer *http.Server @@ -312,7 +313,7 @@ func (s *Rest) routes() chi.Router { rapi.Group(func(rauth chi.Router) { rauth.Use(middleware.Timeout(10 * time.Second)) rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(s.updateLimiter(), nil))) - rauth.Use(authMiddleware.Auth, matchSiteID) + rauth.Use(authMiddleware.Auth, matchSiteID, subscribersOnly(s.SubscribersOnly)) rauth.Use(middleware.NoCache, logInfoWithBody) rauth.Put("/comment/{id}", s.privRest.updateCommentCtrl) @@ -429,6 +430,7 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) { EmojiEnabled bool `json:"emoji_enabled"` SimpleView bool `json:"simple_view"` SendJWTHeader bool `json:"send_jwt_header"` + SubscribersOnly bool `json:"subscribers_only"` }{ Version: s.Version, EditDuration: int(s.DataService.EditDuration.Seconds()), @@ -447,6 +449,7 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) { AnonVote: s.AnonVote, SimpleView: s.SimpleView, SendJWTHeader: s.SendJWTHeader, + SubscribersOnly: s.SubscribersOnly, } cnf.Auth = []string{} @@ -628,6 +631,27 @@ func frameAncestors(hosts []string) func(http.Handler) http.Handler { } } +// subscribersOnly is a middleware rejecting non-paid_sub users +func subscribersOnly(enable bool) func(http.Handler) http.Handler { + return func(h http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + if enable { + user, err := rest.GetUserInfo(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + if !user.PaidSub { + http.Error(w, "Access denied", http.StatusForbidden) + return + } + } + h.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) + } +} + func parseError(err error, defaultCode int) (code int) { code = defaultCode diff --git a/backend/app/rest/api/rest_test.go b/backend/app/rest/api/rest_test.go index c071aceb..ae138e7b 100644 --- a/backend/app/rest/api/rest_test.go +++ b/backend/app/rest/api/rest_test.go @@ -364,6 +364,41 @@ func TestRest_frameAncestors(t *testing.T) { } +func TestRest_subscribersOnly(t *testing.T) { + + paidSubUser := &token.User{} + paidSubUser.SetPaidSub(true) + + tbl := []struct { + subsOnly bool + user token.User + setUser bool + status int + }{ + {true, token.User{}, false, http.StatusUnauthorized}, + {true, token.User{}, true, http.StatusForbidden}, + {false, token.User{}, false, http.StatusOK}, + {false, token.User{}, true, http.StatusOK}, + {true, *paidSubUser, true, http.StatusOK}, + } + + for i, tt := range tbl { + tt := tt + t.Run(strconv.Itoa(i), func(t *testing.T) { + req := httptest.NewRequest("GET", "http://example.com", nil) + if tt.setUser { + req = token.SetUserInfo(req, tt.user) + } + w := httptest.NewRecorder() + h := subscribersOnly(tt.subsOnly)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + h.ServeHTTP(w, req) + resp := w.Result() + assert.Equal(t, tt.status, resp.StatusCode) + assert.NoError(t, resp.Body.Close()) + }) + } +} + // randomPath pick a file or folder name which is not in use for sure func randomPath(tempDir, basename, suffix string) (string, error) { for i := 0; i < 10; i++ { diff --git a/backend/app/rest/user.go b/backend/app/rest/user.go index d4842ba1..46f2cff0 100644 --- a/backend/app/rest/user.go +++ b/backend/app/rest/user.go @@ -36,6 +36,7 @@ func GetUserInfo(r *http.Request) (user store.User, err error) { Verified: u.BoolAttr("verified"), Blocked: u.BoolAttr("blocked"), SiteID: u.Audience, + PaidSub: u.IsPaidSub(), }, nil } @@ -62,6 +63,7 @@ func SetUserInfo(r *http.Request, user store.User) *http.Request { }, } u.SetAdmin(user.Admin) + u.SetPaidSub(user.PaidSub) return token.SetUserInfo(r, u) } diff --git a/backend/app/store/user.go b/backend/app/store/user.go index 6b109a28..24a66ef0 100644 --- a/backend/app/store/user.go +++ b/backend/app/store/user.go @@ -24,6 +24,7 @@ type User struct { Verified bool `json:"verified,omitempty"` EmailSubscription bool `json:"email_subscription,omitempty"` SiteID string `json:"site_id,omitempty"` + PaidSub bool `json:"paid_sub,omitempty"` } var reValidSha = regexp.MustCompile("^[a-fA-F0-9]{40}$") diff --git a/compose-dev-backend.yml b/compose-dev-backend.yml index fd32ef7e..b6f88f14 100644 --- a/compose-dev-backend.yml +++ b/compose-dev-backend.yml @@ -67,5 +67,7 @@ services: - AUTH_FACEBOOK_CSEC=1111 - AUTH_TWITTER_CID=1111 - AUTH_TWITTER_CSEC=1111 + - AUTH_PATREON_CID=1111 + - AUTH_PATREON_CSEC=1111 volumes: - ./var:/srv/var diff --git a/frontend/app/common/types.ts b/frontend/app/common/types.ts index 1cb4599b..c9e1ad58 100644 --- a/frontend/app/common/types.ts +++ b/frontend/app/common/types.ts @@ -95,7 +95,7 @@ export interface Tree { info: PostInfo; } -export type OAuthProvider = 'facebook' | 'twitter' | 'google' | 'yandex' | 'github' | 'microsoft' | 'dev'; +export type OAuthProvider = 'facebook' | 'twitter' | 'google' | 'yandex' | 'github' | 'microsoft' | 'patreon' | 'dev'; export type FormProvider = 'email' | 'anonymous'; export type Provider = OAuthProvider | FormProvider; diff --git a/frontend/app/components/auth/components/assets/patreon.svg b/frontend/app/components/auth/components/assets/patreon.svg new file mode 100644 index 00000000..5f17e028 --- /dev/null +++ b/frontend/app/components/auth/components/assets/patreon.svg @@ -0,0 +1 @@ + diff --git a/frontend/app/components/auth/components/oauth.consts.ts b/frontend/app/components/auth/components/oauth.consts.ts index 5631125a..ac39dff9 100644 --- a/frontend/app/components/auth/components/oauth.consts.ts +++ b/frontend/app/components/auth/components/oauth.consts.ts @@ -1,6 +1,7 @@ export const OAUTH_DATA = { facebook: require('./assets/facebook.svg').default as string, twitter: require('./assets/twitter.svg').default as string, + patreon: require('./assets/patreon.svg').default as string, google: require('./assets/google.svg').default as string, microsoft: require('./assets/microsoft.svg').default as string, yandex: require('./assets/yandex.svg').default as string, diff --git a/site/src/docs/configuration/authorization/index.md b/site/src/docs/configuration/authorization/index.md index 79d8f083..bf660bad 100644 --- a/site/src/docs/configuration/authorization/index.md +++ b/site/src/docs/configuration/authorization/index.md @@ -73,6 +73,12 @@ _instructions for Google OAuth2 setup borrowed from [oauth2_proxy](https://githu For more details refer to [Yandex OAuth](https://yandex.com/dev/oauth/doc/dg/concepts/about.html) and [Yandex.Passport](https://yandex.com/dev/passport/doc/dg/index.html) API documentation. +### Patreon Auth provider +1. Create a new Patreon client https://www.patreon.com/portal/registration/register-clients +2. Fill **App Name**, **Description** +3. In the field **Redirect URIs** enter the correct URI constructed as domain + `/auth/patreon/callback`, i.e. `https://example.mysite.com/auth/patreon/callback` +4. Expand client details, take a note of the **Client ID** and **Client Secret**. Those will be used as `AUTH_PATREON_CID` and `AUTH_PATREON_CSEC` + ## Anonymous Auth Provider Optionally, anonymous access can be turned on. In this case, an extra `anonymous` provider will allow logins without any social login with any name satisfying 2 conditions: diff --git a/site/src/docs/contributing/api/index.md b/site/src/docs/contributing/api/index.md index 03fb7f9f..8db9597a 100644 --- a/site/src/docs/contributing/api/index.md +++ b/site/src/docs/contributing/api/index.md @@ -15,6 +15,7 @@ type User struct { Admin bool `json:"admin"` Blocked bool `json:"block"` Verified bool `json:"verified"` + PaidSub bool `json:"paid_sub"` // is paid Patreon subscriber } ``` @@ -113,18 +114,19 @@ type PostInfo struct { ```go type Config struct { - Version string `json:"version"` - EditDuration int `json:"edit_duration"` - MaxCommentSize int `json:"max_comment_size"` - Admins []string `json:"admins"` - AdminEmail string `json:"admin_email"` - Auth []string `json:"auth_providers"` - LowScore int `json:"low_score"` - CriticalScore int `json:"critical_score"` - PositiveScore bool `json:"positive_score"` - ReadOnlyAge int `json:"readonly_age"` - MaxImageSize int `json:"max_image_size"` - EmojiEnabled bool `json:"emoji_enabled"` + Version string `json:"version"` + EditDuration int `json:"edit_duration"` + MaxCommentSize int `json:"max_comment_size"` + Admins []string `json:"admins"` + AdminEmail string `json:"admin_email"` + Auth []string `json:"auth_providers"` + LowScore int `json:"low_score"` + CriticalScore int `json:"critical_score"` + PositiveScore bool `json:"positive_score"` + ReadOnlyAge int `json:"readonly_age"` + MaxImageSize int `json:"max_image_size"` + EmojiEnabled bool `json:"emoji_enabled"` + SubscribersOnly bool `json:"subscribers_only"` // enable commenting only for Patreon subscribers } ``` diff --git a/site/src/pages/index.md b/site/src/pages/index.md index 8da944b6..471ca81c 100644 --- a/site/src/pages/index.md +++ b/site/src/pages/index.md @@ -8,7 +8,7 @@ title: Remark42 – Privacy focused lightweight commenting engine Remark42 gives you opportunity to have self-hosted, lightweight, and simple (yet functional) comment engine, which doesn't spy on users. It can be embedded into blogs, articles or any other place where readers add comments. -* Social login via Google, Twitter, Facebook, Microsoft, GitHub, Yandex and Telegram +* Social login via Google, Twitter, Facebook, Microsoft, GitHub, Yandex, Patreon and Telegram * Login via email * Optional anonymous access * Multi-level nested comments with both tree and plain presentations