├── LICENSE.md ├── README.md ├── composer.json ├── extend.php ├── js ├── admin.js ├── dist │ ├── admin.js │ ├── admin.js.map │ ├── forum.js │ └── forum.js.map ├── forum.js ├── package.json ├── tsconfig.json ├── webpack.config.js └── yarn.lock ├── migrations └── 2020_06_07_000000_migrate_extension_settings.php ├── phpstan.neon ├── resources ├── less │ ├── admin.less │ └── forum.less └── locale │ └── en.yml ├── src ├── Api │ ├── AddForumAttributes.php │ ├── Controllers │ │ ├── DeleteProviderLinkController.php │ │ └── ListProvidersController.php │ ├── CurrentUserAttributes.php │ └── Serializers │ │ └── ProviderSerializer.php ├── Controller.php ├── Controllers │ ├── AuthController.php │ └── TwitterAuthController.php ├── Errors │ └── AuthenticationException.php ├── Events │ ├── SettingSuggestions.php │ └── UnlinkingFromProvider.php ├── Extend │ └── RegisterProvider.php ├── Jobs │ └── CheckAndUpdateUserEmail.php ├── Listeners │ ├── AssignGroupToUser.php │ ├── ClearOAuthCache.php │ ├── HandleLogout.php │ └── UpdateEmailFromProvider.php ├── LoginProviderStatus.php ├── Middleware │ ├── BindRequest.php │ └── ErrorHandler.php ├── OAuth2RoutePattern.php ├── OAuthServiceProvider.php ├── Provider.php ├── Providers │ ├── Custom │ │ └── LinkedIn │ │ │ ├── Provider │ │ │ ├── Exception │ │ │ │ └── LinkedInAccessDeniedException.php │ │ │ ├── LinkedIn.php │ │ │ └── LinkedInResourceOwner.php │ │ │ └── Token │ │ │ └── LinkedInAccessToken.php │ ├── Discord.php │ ├── Facebook.php │ ├── GitHub.php │ ├── GitLab.php │ ├── Google.php │ ├── LinkedIn.php │ └── Twitter.php └── Query │ └── SsoIdFilterGambit.php └── tests ├── integration ├── AuthenticationFlowTest.php ├── api │ ├── CurrentUserAttributesTest.php │ └── ForumSerializerTest.php └── setup.php ├── phpunit.integration.xml └── phpunit.unit.xml /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 FriendsOfFlarum 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OAuth by FriendsOfFlarum 2 | 3 | ![License](https://img.shields.io/badge/license-MIT-blue.svg) [![Latest Stable Version](https://img.shields.io/packagist/v/fof/oauth.svg)](https://packagist.org/packages/fof/oauth) [![Total Downloads](https://img.shields.io/packagist/dt/fof/oauth.svg)](https://packagist.org/packages/fof/oauthh) [![OpenCollective](https://img.shields.io/badge/opencollective-fof-blue.svg)](https://opencollective.com/fof/donate) 4 | 5 | 6 | A [Flarum](http://flarum.org) extension. Allow users to log in with various OAuth providers 7 | 8 | ### Bundled providers 9 | 10 | By default these providers are included: 11 | 12 | - Discord 13 | - Facebook 14 | - Github 15 | - Gitlab 16 | - Google 17 | - LinkedIn 18 | - Twitter 19 | 20 | ### Permissions 21 | 22 | This extension provides the ability to view the status of linked OAuth providers (intended for admin and/or moderator use). In order for this to function correctly, you must also set the permission `Moderate Access Tokens` to at least the same group as you require for `Moderate user's linked accounts`. 23 | 24 | ### Group Assignment 25 | 26 | You can configure each OAuth provider to automatically assign users to a specific group when they register. This is useful for tracking which provider users signed up with or for granting specific permissions based on the authentication method. 27 | 28 | To configure group assignment: 29 | 1. Go to the extension settings 30 | 2. Enable the desired OAuth provider 31 | 3. Click the settings icon for that provider 32 | 4. Select a group from the "Assign Group" dropdown 33 | 5. Save your changes 34 | 35 | Users who register through that provider will automatically be assigned to the selected group. 36 | 37 | ### Additional providers 38 | 39 | Additional OAuth providers are available for this extension. Here's a handy list of known extensions, let us know if you know of any more and we'll get them added! 40 | 41 | - [Amazon](https://extiverse.com/extension/ianm/oauth-amazon) 42 | - [Apple](https://extiverse.com/extension/blomstra/oauth-apple) 43 | - [Slack](https://extiverse.com/extension/blomstra/oauth-slack) 44 | - [Line](https://extiverse.com/extension/ianm/oauth-line) 45 | - [Microsoft](https://flarum.org/extension/xrh0905/oauth-microsoft) 46 | - [Twitch](https://github.com/imorland/flarum-ext-oauth-twitch) 47 | - [Auth0](https://extiverse.com/extension/lodge104/flarum-ext-oauth-auth0) 48 | 49 | ### Screenshots 50 | 51 | Default provider settings example 52 | ![provider setup example](https://user-images.githubusercontent.com/16573496/201470744-ca8be058-f79c-4fc4-8c19-3ac5af2bd44b.png) 53 | 54 | Login/signup example with `Github`, `Twitter`, `Twitch` and `Google` enabled. 55 | ![example login](https://user-images.githubusercontent.com/16573496/201470704-91874f67-284a-4fb2-967c-fd9d0eff2d9f.png) 56 | 57 | ### Installation 58 | 59 | ```sh 60 | composer require fof/oauth 61 | ``` 62 | 63 | ### Updating 64 | 65 | ```sh 66 | composer update fof/oauth 67 | php flarum cache:clear 68 | ``` 69 | 70 | 71 | ### Configuration 72 | 73 | #### Translation 74 | 75 | You can replace the text for the forum sign in buttons in two ways. 76 | - Use `fof-oauth.forum.providers.` to replace the name of the provider on the forum side 77 | - Use `fof-oauth.forum.log_in.with__button` to replace the entire button "Log In with " text 78 | 79 | ### Extending 80 | 81 | It is possible to add additional `Providers` using an extender. See [OAuth-Amazon](https://github.com/imorland/flarum-ext-oauth-amazon) for an example of how to accomplish this but basically: 82 | 83 | - In your new extension, require `fof/oauth` as a dependency 84 | - Define a new `Provider` which extends `FoF\OAuth\Provider` 85 | - From your new extensions `extend.php`, register the provider `(new FoF\OAuth\Extend\RegisterProvider(MyNewProvider::class))` 86 | - Provide the required translations under the `fof-oauth` namespace. See the linked example extension for details on which keys are required. 87 | - (optionally) Provide an admin panel link to `fof/oauth` for easy configuration. Again, see the linked example. 88 | - (optionally) Provide any CSS required to style your new login button. See the linked example. 89 | 90 | ### Links 91 | 92 | [![OpenCollective](https://img.shields.io/badge/donate-friendsofflarum-44AEE5?style=for-the-badge&logo=open-collective)](https://opencollective.com/fof/donate) 93 | 94 | - [Discuss](https://discuss.flarum.org/d/25182) 95 | - [Packagist](https://packagist.org/packages/fof/oauth) 96 | - [GitHub](https://github.com/FriendsOfFlarum/oauth) 97 | 98 | An extension by [FriendsOfFlarum](https://github.com/FriendsOfFlarum). 99 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fof/oauth", 3 | "description": "Allow users to log in with GitHub, Twitter, Facebook, Google, and more!", 4 | "keywords": [ 5 | "flarum", 6 | "friendsofflarum", 7 | "oauth", 8 | "facebook", 9 | "discord", 10 | "github", 11 | "gitlab", 12 | "twitter", 13 | "google", 14 | "linkedin" 15 | ], 16 | "type": "flarum-extension", 17 | "license": "MIT", 18 | "support": { 19 | "issues": "https://github.com/FriendsOfFlarum/oauth/issues", 20 | "source": "https://github.com/FriendsOfFlarum/oauth", 21 | "forum": "https://discuss.flarum.org/d/25182" 22 | }, 23 | "homepage": "https://friendsofflarum.org", 24 | "funding": [ 25 | { 26 | "type": "website", 27 | "url": "https://opencollective.com/fof/donate" 28 | } 29 | ], 30 | "require": { 31 | "php": "^7.4 || ^8.0", 32 | "flarum/core": "^1.8.1", 33 | "league/oauth1-client": "^1.10.1", 34 | "league/oauth2-facebook": "^2.2.0", 35 | "league/oauth2-github": "^3.1.0", 36 | "league/oauth2-google": "^4.0.1", 37 | "omines/oauth2-gitlab": "^3.3.0", 38 | "wohali/oauth2-discord-new": "^1.2.1", 39 | "fof/extend": "^1.3.3" 40 | }, 41 | "replace": { 42 | "flarum/auth-facebook": "*", 43 | "flarum/auth-github": "*", 44 | "flarum/auth-twitter": "*", 45 | "fof/auth-discord": "*", 46 | "fof/auth-gitlab": "*", 47 | "luuhai48/oauth-google": "*", 48 | "luuhai48/oauth-linkedin": "*" 49 | }, 50 | "authors": [ 51 | { 52 | "name": "David Sevilla Martin", 53 | "email": "me+fof@datitisev.me", 54 | "role": "Developer" 55 | }, 56 | { 57 | "name": "IanM", 58 | "email": "ian@flarum.org", 59 | "role": "Developer" 60 | } 61 | ], 62 | "autoload": { 63 | "psr-4": { 64 | "FoF\\OAuth\\": "src/" 65 | } 66 | }, 67 | "extra": { 68 | "flarum-extension": { 69 | "title": "FoF OAuth", 70 | "category": "feature", 71 | "icon": { 72 | "name": "fas fa-sign-in-alt", 73 | "backgroundColor": "#e74c3c", 74 | "color": "#fff" 75 | }, 76 | "optional-dependencies": [ 77 | "flarum/gdpr" 78 | ] 79 | }, 80 | "flagrow": { 81 | "discuss": "https://discuss.flarum.org/d/25182" 82 | }, 83 | "flarum-cli": { 84 | "modules": { 85 | "githubActions": true, 86 | "backendTesting": true 87 | } 88 | } 89 | }, 90 | "require-dev": { 91 | "flarum/phpstan": "*", 92 | "flarum/testing": "^1.0.0" 93 | }, 94 | "scripts": { 95 | "analyse:phpstan": "phpstan analyse", 96 | "clear-cache:phpstan": "phpstan clear-result-cache", 97 | "test": [ 98 | "@test:unit", 99 | "@test:integration" 100 | ], 101 | "test:unit": "phpunit -c tests/phpunit.unit.xml", 102 | "test:integration": "phpunit -c tests/phpunit.integration.xml", 103 | "test:setup": "@php tests/integration/setup.php" 104 | }, 105 | "scripts-descriptions": { 106 | "analyse:phpstan": "Run static analysis", 107 | "test": "Runs all tests.", 108 | "test:unit": "Runs all unit tests.", 109 | "test:integration": "Runs all integration tests.", 110 | "test:setup": "Sets up a database for use with integration tests. Execute this only once." 111 | }, 112 | "autoload-dev": { 113 | "psr-4": { 114 | "FoF\\OAuth\\Tests\\": "tests/" 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /extend.php: -------------------------------------------------------------------------------- 1 | js(__DIR__.'/js/dist/forum.js') 27 | ->css(__DIR__.'/resources/less/forum.less'), 28 | 29 | (new Extend\Frontend('admin')) 30 | ->js(__DIR__.'/js/dist/admin.js') 31 | ->css(__DIR__.'/resources/less/admin.less') 32 | ->content(function (Document $document) { 33 | $document->payload['fof-oauth'] = resolve('fof-oauth.providers.admin'); 34 | }), 35 | 36 | new Extend\Locales(__DIR__.'/resources/locale'), 37 | 38 | (new Extend\Middleware('forum')) 39 | ->add(Middleware\ErrorHandler::class) 40 | ->add(Middleware\BindRequest::class), 41 | 42 | (new Extend\Middleware('api')) 43 | ->add(Middleware\BindRequest::class), 44 | 45 | (new Extend\Routes('forum')) 46 | ->get('/auth/twitter', 'auth.twitter', Controllers\TwitterAuthController::class), 47 | 48 | (new Extend\Routes('api')) 49 | ->get('/users/{id}/linked-accounts', 'users.provider.list', Api\Controllers\ListProvidersController::class) 50 | ->get('/linked-accounts', 'user.provider.list', Api\Controllers\ListProvidersController::class) 51 | ->delete('/linked-accounts/{id}', 'users.provider.delete', Api\Controllers\DeleteProviderLinkController::class), 52 | 53 | (new Extend\ServiceProvider()) 54 | ->register(OAuthServiceProvider::class), 55 | 56 | (new Extend\ApiSerializer(ForumSerializer::class)) 57 | ->attributes(Api\AddForumAttributes::class), 58 | 59 | (new Extend\Settings()) 60 | ->default('fof-oauth.only_icons', false) 61 | ->default('fof-oauth.update_email_from_provider', true) 62 | ->serializeToForum('fof-oauth.only_icons', 'fof-oauth.only_icons', 'boolVal') 63 | ->default('fof-oauth.popupWidth', 580) 64 | ->default('fof-oauth.popupHeight', 400) 65 | ->default('fof-oauth.fullscreenPopup', true) 66 | ->serializeToForum('fof-oauth.popupWidth', 'fof-oauth.popupWidth', 'intval') 67 | ->serializeToForum('fof-oauth.popupHeight', 'fof-oauth.popupHeight', 'intval') 68 | ->serializeToForum('fof-oauth.fullscreenPopup', 'fof-oauth.fullscreenPopup', 'boolVal') 69 | ->default('fof-oauth.log-oauth-errors', false), 70 | 71 | (new Extend\Event()) 72 | ->listen(RegisteringFromProvider::class, Listeners\AssignGroupToUser::class) 73 | ->listen(OAuthLoginSuccessful::class, Listeners\UpdateEmailFromProvider::class) 74 | ->listen(LoggedOut::class, Listeners\HandleLogout::class) 75 | ->subscribe(Listeners\ClearOAuthCache::class), 76 | 77 | (new Extend\ApiSerializer(CurrentUserSerializer::class)) 78 | ->attributes(Api\CurrentUserAttributes::class), 79 | 80 | (new Extend\Filter(UserFilterer::class)) 81 | ->addFilter(Query\SsoIdFilterGambit::class), 82 | 83 | (new Extend\SimpleFlarumSearch(UserSearcher::class)) 84 | ->addGambit(Query\SsoIdFilterGambit::class), 85 | 86 | (new Extend\Conditional()) 87 | ->whenExtensionEnabled('flarum-gdpr', fn () => [ 88 | (new Extend\ApiSerializer(ForumSerializer::class)) 89 | ->attribute('passwordlessSignUp', function (ForumSerializer $serializer) { 90 | return !$serializer->getActor()->isGuest() && $serializer->getActor()->loginProviders()->count() > 0; 91 | }), 92 | ]), 93 | ]; 94 | -------------------------------------------------------------------------------- /js/admin.js: -------------------------------------------------------------------------------- 1 | export * from './src/admin'; 2 | -------------------------------------------------------------------------------- /js/dist/admin.js: -------------------------------------------------------------------------------- 1 | (()=>{var t={n:o=>{var n=o&&o.__esModule?()=>o.default:()=>o;return t.d(n,{a:n}),n},d:(o,n)=>{for(var e in n)t.o(n,e)&&!t.o(o,e)&&Object.defineProperty(o,e,{enumerable:!0,get:n[e]})},o:(t,o)=>Object.prototype.hasOwnProperty.call(t,o),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},o={};(()=>{"use strict";t.r(o),t.d(o,{AuthSettingsPage:()=>b,ConfigureWithOAuthButton:()=>y,ConfigureWithOAuthPage:()=>P});const n=flarum.core.compat["admin/app"];var e=t.n(n);function a(t,o){return a=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,o){return t.__proto__=o,t},a(t,o)}function r(t,o){t.prototype=Object.create(o.prototype),t.prototype.constructor=t,a(t,o)}const i=flarum.core.compat["common/components/Button"];var s=t.n(i);const l=flarum.core.compat["common/components/Dropdown"];var u=t.n(l);const f=flarum.core.compat["admin/components/ExtensionPage"];var p=t.n(f);const d=flarum.core.compat["common/helpers/icon"];var c=t.n(d);const h=flarum.core.compat["common/utils/ItemList"];var g=t.n(h),b=function(t){function o(){return t.apply(this,arguments)||this}r(o,t);var n=o.prototype;return n.oninit=function(o){t.prototype.oninit.call(this,o),this.showing=[]},n.content=function(){return[m("div",{className:"container"},m("div",{className:"AuthSettingsPage"},m("div",{className:"Form"},this.buildSettingComponent({type:"boolean",setting:"fof-oauth.only_icons",label:e().translator.trans("fof-oauth.admin.settings.only_icons_label")}),this.buildSettingComponent({type:"boolean",setting:"fof-oauth.update_email_from_provider",label:e().translator.trans("fof-oauth.admin.settings.update_email_from_provider_label"),help:e().translator.trans("fof-oauth.admin.settings.update_email_from_provider_help")}),this.buildSettingComponent({type:"boolean",setting:"fof-oauth.fullscreenPopup",label:e().translator.trans("fof-oauth.admin.settings.fullscreen_popup_label"),help:e().translator.trans("fof-oauth.admin.settings.fullscreen_popup_help")}),this.buildSettingComponent({type:"number",setting:"fof-oauth.popupWidth",label:e().translator.trans("fof-oauth.admin.settings.popup_width_label"),help:e().translator.trans("fof-oauth.admin.settings.popup_width_help"),placeholder:580,min:0}),this.buildSettingComponent({type:"number",setting:"fof-oauth.popupHeight",label:e().translator.trans("fof-oauth.admin.settings.popup_height_label"),help:e().translator.trans("fof-oauth.admin.settings.popup_height_help"),placeholder:400,min:0}),m("hr",null),this.providerSettingsItems().toArray(),m("hr",null),m("div",{className:"AuthSettingsPage--advanced"},m("h4",null,e().translator.trans("fof-oauth.admin.settings.advanced.heading")),this.buildSettingComponent({type:"boolean",setting:"fof-oauth.log-oauth-errors",label:e().translator.trans("fof-oauth.admin.settings.advanced.log-oauth-errors-label"),help:e().translator.trans("fof-oauth.admin.settings.advanced.log-oauth-errors-help")})),this.submitButton())))]},n.providerSettingsItems=function(){var t=this,o=new(g());return e().data["fof-oauth"].map((function(n){var a=n.name,r=!!Number(t.setting("fof-oauth."+a)()),i=!!t.showing[a],l=e().forum.attribute("baseUrl")+"/auth/"+a;o.add("fof-oauth."+a,m("div",{className:"Provider "+(r?"enabled":"disabled")+" "+(i&&"showing")},m("div",{className:"Provider--info Provider--"+a},t.buildSettingComponent({type:"boolean",setting:"fof-oauth."+a,label:m("div",null,c()(n.icon),m("span",null,e().translator.trans("fof-oauth.lib.providers."+a)))}),m(s(),{className:"Button Button--rounded "+(t.showing[a]&&"active"),onclick:function(){return t.showing[a]=!i},"aria-label":e().translator.trans("fof-oauth.admin.settings_accessibility_label",{name:a})},c()("fas fa-cog"))),m("div",{className:"Provider--settings"},m("div",null,m("p",null,e().translator.trans("fof-oauth.admin.settings.providers."+a+".description",{link:m("a",{href:n.link,target:"_blank"},n.link)})),m("p",null,e().translator.trans("fof-oauth.admin.settings.providers.callback_url_text",{url:m("a",{href:l,target:"_blank"},l)})),Object.keys(n.fields).map((function(o){var r;return t.buildSettingComponent({type:"string",setting:"fof-oauth."+a+"."+o,label:e().translator.trans("fof-oauth.admin.settings.providers."+a+"."+o+"_label"),required:(r={},r[i&&n.fields[o].includes("required")?"required":null]=!0,r)})})),t.customProviderSettings(a).toArray()))))})),o},n.getAvailableGroups=function(){return e().store.all("groups").filter((function(t){return!["2","3"].includes(t.id())}))},n.customProviderSettings=function(t){var o,n,a,r=this,i=new(g());return i.add("group",m("div",{className:"Form-group"},m("label",null,e().translator.trans("fof-oauth.admin.settings.providers.group_label")),m("div",{className:"helpText"},e().translator.trans("fof-oauth.admin.settings.providers.group_help")),(n=(o=r.setting("fof-oauth."+t+".group")())?e().store.getById("groups",o):null,a={1:"fas fa-check",3:"fas fa-user",4:"fas fa-map-pin"},m(u(),{label:n?[c()(n.icon()||a[n.id()]),"\t",n.namePlural()]:e().translator.trans("fof-oauth.admin.settings.providers.no_group_label"),buttonClassName:"Button",disabled:!r.setting("fof-oauth."+t)()},m(s(),{icon:"fas fa-times",onclick:function(){return r.setting("fof-oauth."+t+".group")("")},active:!o},e().translator.trans("fof-oauth.admin.settings.providers.no_group_label")),r.getAvailableGroups().map((function(n){return m(s(),{icon:n.icon()||a[n.id()],onclick:function(){return r.setting("fof-oauth."+t+".group")(n.id())},active:o===n.id(),key:n.id()},n.namePlural())})))))),i},o}(p());const v=flarum.core.compat["common/components/LinkButton"];var _=t.n(v),y=function(t){function o(){return t.apply(this,arguments)||this}return r(o,t),o.prototype.view=function(){return[m(_(),{className:"Button",href:e().route("extension",{id:"fof-oauth"})},e().translator.trans("fof-oauth.admin.configure_button_label"))]},o}(_()),P=function(t){function o(){return t.apply(this,arguments)||this}r(o,t);var n=o.prototype;return n.oninit=function(o){t.prototype.oninit.call(this,o)},n.content=function(){return[m("div",{className:"container"},m("div",{className:"OAuthSettingsPage"},m("br",null),m(y,null)))]},o}(p());e().initializers.add("fof/oauth",(function(){e().extensionData.for("fof-oauth").registerPage(b).registerPermission({icon:"fas fa-sign-in-alt",label:e().translator.trans("fof-oauth.admin.permissions.moderate_user_providers"),permission:"moderateUserProviders"},"moderate")}))})(),module.exports=o})(); 2 | //# sourceMappingURL=admin.js.map -------------------------------------------------------------------------------- /js/dist/admin.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"admin.js","mappings":"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","sources":["webpack://@fof/oauth/webpack/bootstrap","webpack://@fof/oauth/webpack/runtime/compat get default export","webpack://@fof/oauth/webpack/runtime/define property getters","webpack://@fof/oauth/webpack/runtime/hasOwnProperty shorthand","webpack://@fof/oauth/webpack/runtime/make namespace object","webpack://@fof/oauth/external root \"flarum.core.compat['admin/app']\"","webpack://@fof/oauth/./node_modules/@babel/runtime/helpers/esm/setPrototypeOf.js","webpack://@fof/oauth/./node_modules/@babel/runtime/helpers/esm/inheritsLoose.js","webpack://@fof/oauth/external root \"flarum.core.compat['common/components/Button']\"","webpack://@fof/oauth/external root \"flarum.core.compat['common/components/Dropdown']\"","webpack://@fof/oauth/external root \"flarum.core.compat['admin/components/ExtensionPage']\"","webpack://@fof/oauth/external root \"flarum.core.compat['common/helpers/icon']\"","webpack://@fof/oauth/external root \"flarum.core.compat['common/utils/ItemList']\"","webpack://@fof/oauth/./src/admin/components/AuthSettingsPage.js","webpack://@fof/oauth/external root \"flarum.core.compat['common/components/LinkButton']\"","webpack://@fof/oauth/./src/admin/components/ConfigureWithOAuthButton.js","webpack://@fof/oauth/./src/admin/components/ConfigureWithOAuthPage.js","webpack://@fof/oauth/./src/admin/index.js"],"sourcesContent":["// The require scope\nvar __webpack_require__ = {};\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['admin/app'];","function _setPrototypeOf(t, e) {\n return _setPrototypeOf = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function (t, e) {\n return t.__proto__ = e, t;\n }, _setPrototypeOf(t, e);\n}\nexport { _setPrototypeOf as default };","import setPrototypeOf from \"./setPrototypeOf.js\";\nfunction _inheritsLoose(t, o) {\n t.prototype = Object.create(o.prototype), t.prototype.constructor = t, setPrototypeOf(t, o);\n}\nexport { _inheritsLoose as default };","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/components/Button'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/components/Dropdown'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['admin/components/ExtensionPage'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/helpers/icon'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/utils/ItemList'];","import app from 'flarum/admin/app';\nimport Button from 'flarum/common/components/Button';\nimport Dropdown from 'flarum/common/components/Dropdown';\nimport ExtensionPage from 'flarum/admin/components/ExtensionPage';\nimport icon from 'flarum/common/helpers/icon';\nimport ItemList from 'flarum/common/utils/ItemList';\n\nexport default class AuthSettingsPage extends ExtensionPage {\n oninit(vnode) {\n super.oninit(vnode);\n\n this.showing = [];\n }\n\n content() {\n return [\n
\n
\n
\n {this.buildSettingComponent({\n type: 'boolean',\n setting: 'fof-oauth.only_icons',\n label: app.translator.trans(`fof-oauth.admin.settings.only_icons_label`),\n })}\n {this.buildSettingComponent({\n type: 'boolean',\n setting: 'fof-oauth.update_email_from_provider',\n label: app.translator.trans('fof-oauth.admin.settings.update_email_from_provider_label'),\n help: app.translator.trans('fof-oauth.admin.settings.update_email_from_provider_help'),\n })}\n {this.buildSettingComponent({\n type: 'boolean',\n setting: 'fof-oauth.fullscreenPopup',\n label: app.translator.trans('fof-oauth.admin.settings.fullscreen_popup_label'),\n help: app.translator.trans('fof-oauth.admin.settings.fullscreen_popup_help'),\n })}\n {this.buildSettingComponent({\n type: 'number',\n setting: 'fof-oauth.popupWidth',\n label: app.translator.trans('fof-oauth.admin.settings.popup_width_label'),\n help: app.translator.trans('fof-oauth.admin.settings.popup_width_help'),\n placeholder: 580,\n min: 0,\n })}\n {this.buildSettingComponent({\n type: 'number',\n setting: 'fof-oauth.popupHeight',\n label: app.translator.trans('fof-oauth.admin.settings.popup_height_label'),\n help: app.translator.trans('fof-oauth.admin.settings.popup_height_help'),\n placeholder: 400,\n min: 0,\n })}\n\n
\n\n {this.providerSettingsItems().toArray()}\n\n
\n\n
\n

{app.translator.trans('fof-oauth.admin.settings.advanced.heading')}

\n {this.buildSettingComponent({\n type: 'boolean',\n setting: 'fof-oauth.log-oauth-errors',\n label: app.translator.trans('fof-oauth.admin.settings.advanced.log-oauth-errors-label'),\n help: app.translator.trans('fof-oauth.admin.settings.advanced.log-oauth-errors-help'),\n })}\n
\n\n {this.submitButton()}\n
\n
\n
,\n ];\n }\n\n providerSettingsItems() {\n const items = new ItemList();\n\n app.data['fof-oauth'].map((provider) => {\n const { name } = provider;\n const enabled = !!Number(this.setting(`fof-oauth.${name}`)());\n const showSettings = !!this.showing[name];\n const callbackUrl = `${app.forum.attribute('baseUrl')}/auth/${name}`;\n\n items.add(\n `fof-oauth.${name}`,\n
\n
\n {this.buildSettingComponent({\n type: 'boolean',\n setting: `fof-oauth.${name}`,\n label: (\n
\n {icon(provider.icon)}\n {app.translator.trans(`fof-oauth.lib.providers.${name}`)}\n
\n ),\n })}\n\n {\n (this.showing[name] = !showSettings)}\n aria-label={app.translator.trans('fof-oauth.admin.settings_accessibility_label', {\n name,\n })}\n >\n {icon(`fas fa-cog`)}\n \n }\n
\n\n
\n
\n

\n {app.translator.trans(`fof-oauth.admin.settings.providers.${name}.description`, {\n link: (\n \n {provider.link}\n \n ),\n })}\n

\n

\n {app.translator.trans(`fof-oauth.admin.settings.providers.callback_url_text`, {\n url: (\n \n {callbackUrl}\n \n ),\n })}\n

\n\n {Object.keys(provider.fields).map((field) =>\n this.buildSettingComponent({\n type: 'string',\n setting: `fof-oauth.${name}.${field}`,\n label: app.translator.trans(`fof-oauth.admin.settings.providers.${name}.${field}_label`),\n required: {\n [showSettings && provider.fields[field].includes('required') ? 'required' : null]: true,\n },\n })\n )}\n\n {this.customProviderSettings(name).toArray()}\n
\n
\n
\n );\n });\n\n return items;\n }\n\n getAvailableGroups() {\n const groups = app.store.all('groups');\n return groups.filter((group) => !['2', '3'].includes(group.id())); // Exclude the \"Guests\" and \"Members\" groups\n }\n\n customProviderSettings(name) {\n const items = new ItemList();\n\n // Add group selection dropdown\n items.add(\n 'group',\n
\n \n
{app.translator.trans('fof-oauth.admin.settings.providers.group_help')}
\n\n {(() => {\n const groupId = this.setting(`fof-oauth.${name}.group`)();\n const selectedGroup = groupId ? app.store.getById('groups', groupId) : null;\n const icons = {\n 1: 'fas fa-check', // Admins\n 3: 'fas fa-user', // Members\n 4: 'fas fa-map-pin', // Mods\n };\n\n return (\n \n \n\n {this.getAvailableGroups().map((group) => (\n this.setting(`fof-oauth.${name}.group`)(group.id())}\n active={groupId === group.id()}\n key={group.id()}\n >\n {group.namePlural()}\n \n ))}\n \n );\n })()}\n
\n );\n\n return items;\n }\n}\n","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/components/LinkButton'];","import app from 'flarum/admin/app';\nimport LinkButton from 'flarum/common/components/LinkButton';\n\nexport default class ConfigureWithOAuthButton extends LinkButton {\n view() {\n return [\n \n {app.translator.trans('fof-oauth.admin.configure_button_label')}\n ,\n ];\n }\n}\n","import ExtensionPage from 'flarum/admin/components/ExtensionPage';\nimport ConfigureWithOAuthButton from './ConfigureWithOAuthButton';\n\n/**\n * The `ConfigureWithOAuthPage` component is meant for 3rd party extensions to provide a handy link to `fof/oauth` settings.\n * It is not used directly by `fof/oauth` itself.\n */\nexport default class ConfigureWithOAuthPage extends ExtensionPage {\n oninit(vnode) {\n super.oninit(vnode);\n }\n\n content() {\n return [\n
\n
\n
\n \n
\n
,\n ];\n }\n}\n","import app from 'flarum/admin/app';\nimport AuthSettingsPage from './components/AuthSettingsPage';\nimport ConfigureWithOAuthPage from './components/ConfigureWithOAuthPage';\nimport ConfigureWithOAuthButton from './components/ConfigureWithOAuthButton';\n\napp.initializers.add('fof/oauth', () => {\n app.extensionData\n .for('fof-oauth')\n .registerPage(AuthSettingsPage)\n .registerPermission(\n {\n icon: 'fas fa-sign-in-alt',\n label: app.translator.trans('fof-oauth.admin.permissions.moderate_user_providers'),\n permission: 'moderateUserProviders',\n },\n 'moderate'\n );\n});\n\nexport { AuthSettingsPage, ConfigureWithOAuthPage, ConfigureWithOAuthButton };\n"],"names":["__webpack_require__","module","getter","__esModule","d","a","exports","definition","key","o","Object","defineProperty","enumerable","get","obj","prop","prototype","hasOwnProperty","call","Symbol","toStringTag","value","flarum","core","compat","_setPrototypeOf","t","e","setPrototypeOf","bind","__proto__","_inheritsLoose","create","constructor","AuthSettingsPage","_ExtensionPage","apply","arguments","_proto","oninit","vnode","this","showing","content","m","className","buildSettingComponent","type","setting","label","app","trans","help","placeholder","min","providerSettingsItems","toArray","submitButton","_this","items","ItemList","map","provider","name","enabled","Number","showSettings","callbackUrl","attribute","add","icon","Button","onclick","link","href","target","url","keys","fields","field","_required","required","includes","customProviderSettings","getAvailableGroups","all","filter","group","id","groupId","selectedGroup","icons","_this2","getById","Dropdown","namePlural","buttonClassName","disabled","active","ExtensionPage","ConfigureWithOAuthButton","_LinkButton","view","LinkButton","ConfigureWithOAuthPage","registerPage","registerPermission","permission"],"sourceRoot":""} -------------------------------------------------------------------------------- /js/dist/forum.js: -------------------------------------------------------------------------------- 1 | /*! For license information please see forum.js.LICENSE.txt */ 2 | (()=>{var t={24:(t,r,e)=>{var n=e(735).default;function o(){"use strict";t.exports=o=function(){return e},t.exports.__esModule=!0,t.exports.default=t.exports;var r,e={},i=Object.prototype,a=i.hasOwnProperty,s=Object.defineProperty||function(t,r,e){t[r]=e.value},u="function"==typeof Symbol?Symbol:{},c=u.iterator||"@@iterator",l=u.asyncIterator||"@@asyncIterator",f=u.toStringTag||"@@toStringTag";function p(t,r,e){return Object.defineProperty(t,r,{value:e,enumerable:!0,configurable:!0,writable:!0}),t[r]}try{p({},"")}catch(r){p=function(t,r,e){return t[r]=e}}function d(t,r,e,n){var o=r&&r.prototype instanceof w?r:w,i=Object.create(o.prototype),a=new B(n||[]);return s(i,"_invoke",{value:N(t,e,a)}),i}function m(t,r,e){try{return{type:"normal",arg:t.call(r,e)}}catch(t){return{type:"throw",arg:t}}}e.wrap=d;var h="suspendedStart",v="suspendedYield",g="executing",y="completed",b={};function w(){}function L(){}function x(){}var k={};p(k,c,(function(){return this}));var _=Object.getPrototypeOf,I=_&&_(_(F([])));I&&I!==i&&a.call(I,c)&&(k=I);var P=x.prototype=w.prototype=Object.create(k);function A(t){["next","throw","return"].forEach((function(r){p(t,r,(function(t){return this._invoke(r,t)}))}))}function O(t,r){function e(o,i,s,u){var c=m(t[o],t,i);if("throw"!==c.type){var l=c.arg,f=l.value;return f&&"object"==n(f)&&a.call(f,"__await")?r.resolve(f.__await).then((function(t){e("next",t,s,u)}),(function(t){e("throw",t,s,u)})):r.resolve(f).then((function(t){l.value=t,s(l)}),(function(t){return e("throw",t,s,u)}))}u(c.arg)}var o;s(this,"_invoke",{value:function(t,n){function i(){return new r((function(r,o){e(t,n,r,o)}))}return o=o?o.then(i,i):i()}})}function N(t,e,n){var o=h;return function(i,a){if(o===g)throw Error("Generator is already running");if(o===y){if("throw"===i)throw a;return{value:r,done:!0}}for(n.method=i,n.arg=a;;){var s=n.delegate;if(s){var u=E(s,n);if(u){if(u===b)continue;return u}}if("next"===n.method)n.sent=n._sent=n.arg;else if("throw"===n.method){if(o===h)throw o=y,n.arg;n.dispatchException(n.arg)}else"return"===n.method&&n.abrupt("return",n.arg);o=g;var c=m(t,e,n);if("normal"===c.type){if(o=n.done?y:v,c.arg===b)continue;return{value:c.arg,done:n.done}}"throw"===c.type&&(o=y,n.method="throw",n.arg=c.arg)}}}function E(t,e){var n=e.method,o=t.iterator[n];if(o===r)return e.delegate=null,"throw"===n&&t.iterator.return&&(e.method="return",e.arg=r,E(t,e),"throw"===e.method)||"return"!==n&&(e.method="throw",e.arg=new TypeError("The iterator does not provide a '"+n+"' method")),b;var i=m(o,t.iterator,e.arg);if("throw"===i.type)return e.method="throw",e.arg=i.arg,e.delegate=null,b;var a=i.arg;return a?a.done?(e[t.resultName]=a.value,e.next=t.nextLoc,"return"!==e.method&&(e.method="next",e.arg=r),e.delegate=null,b):a:(e.method="throw",e.arg=new TypeError("iterator result is not an object"),e.delegate=null,b)}function j(t){var r={tryLoc:t[0]};1 in t&&(r.catchLoc=t[1]),2 in t&&(r.finallyLoc=t[2],r.afterLoc=t[3]),this.tryEntries.push(r)}function S(t){var r=t.completion||{};r.type="normal",delete r.arg,t.completion=r}function B(t){this.tryEntries=[{tryLoc:"root"}],t.forEach(j,this),this.reset(!0)}function F(t){if(t||""===t){var e=t[c];if(e)return e.call(t);if("function"==typeof t.next)return t;if(!isNaN(t.length)){var o=-1,i=function e(){for(;++o=0;--o){var i=this.tryEntries[o],s=i.completion;if("root"===i.tryLoc)return n("end");if(i.tryLoc<=this.prev){var u=a.call(i,"catchLoc"),c=a.call(i,"finallyLoc");if(u&&c){if(this.prev=0;--e){var n=this.tryEntries[e];if(n.tryLoc<=this.prev&&a.call(n,"finallyLoc")&&this.prev=0;--r){var e=this.tryEntries[r];if(e.finallyLoc===t)return this.complete(e.completion,e.afterLoc),S(e),b}},catch:function(t){for(var r=this.tryEntries.length-1;r>=0;--r){var e=this.tryEntries[r];if(e.tryLoc===t){var n=e.completion;if("throw"===n.type){var o=n.arg;S(e)}return o}}throw Error("illegal catch attempt")},delegateYield:function(t,e,n){return this.delegate={iterator:F(t),resultName:e,nextLoc:n},"next"===this.method&&(this.arg=r),b}},e}t.exports=o,t.exports.__esModule=!0,t.exports.default=t.exports},735:t=>{function r(e){return t.exports=r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},t.exports.__esModule=!0,t.exports.default=t.exports,r(e)}t.exports=r,t.exports.__esModule=!0,t.exports.default=t.exports},183:(t,r,e)=>{var n=e(24)();t.exports=n;try{regeneratorRuntime=n}catch(t){"object"==typeof globalThis?globalThis.regeneratorRuntime=n:Function("r","regeneratorRuntime = r")(n)}}},r={};function e(n){var o=r[n];if(void 0!==o)return o.exports;var i=r[n]={exports:{}};return t[n](i,i.exports,e),i.exports}e.n=t=>{var r=t&&t.__esModule?()=>t.default:()=>t;return e.d(r,{a:r}),r},e.d=(t,r)=>{for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},e.o=(t,r)=>Object.prototype.hasOwnProperty.call(t,r),e.r=t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})};var n={};(()=>{"use strict";e.r(n),e.d(n,{components:()=>st,extend:()=>at,utils:()=>ut});const t=flarum.core.compat["forum/app"];var r=e.n(t);const o=flarum.core.compat["common/extend"],i=flarum.core.compat["forum/components/UserSecurityPage"];var a=e.n(i);function s(t,r,e,n,o,i,a){try{var s=t[i](a),u=s.value}catch(t){return void e(t)}s.done?r(u):Promise.resolve(u).then(n,o)}function u(t){return function(){var r=this,e=arguments;return new Promise((function(n,o){var i=t.apply(r,e);function a(t){s(i,n,o,a,u,"next",t)}function u(t){s(i,n,o,a,u,"throw",t)}a(void 0)}))}}function c(t,r){return c=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,r){return t.__proto__=r,t},c(t,r)}function l(t,r){t.prototype=Object.create(r.prototype),t.prototype.constructor=t,c(t,r)}var f=e(183),p=e.n(f);const d=flarum.core.compat["common/Component"];var h=e.n(d);const v=flarum.core.compat["common/components/FieldSet"];var g=e.n(v);const y=flarum.core.compat["common/helpers/listItems"];var b=e.n(y);const w=flarum.core.compat["common/utils/ItemList"];var L=e.n(w);const x=flarum.core.compat["common/components/LoadingIndicator"];var k=e.n(x);const _=flarum.core.compat["common/helpers/icon"];var I=e.n(_);const P=flarum.core.compat["common/components/Button"];var A=e.n(P);const O=flarum.core.compat["common/components/Link"];var N=e.n(O);const E=flarum.core.compat["common/helpers/humanTime"];var j=e.n(E);const S=flarum.core.compat["common/components/LabelValue"];var B=e.n(S),F=function(t){function e(){return t.apply(this,arguments)||this}l(e,t);var n=e.prototype;return n.view=function(t){var e=this.attrs.provider;return e.orphaned()?m("div",null,m("p",{className:"LinkedAccountsList-item-title"},e.name()),m("p",{className:"helpText"},r().translator.trans("fof-oauth.forum.user.settings.linked-account.orphaned-account")),m("div",{className:"LinkedAccountsList"},this.providerInfoItems(e).toArray())):e.linked()?m("div",null,m("p",{className:"LinkedAccountsList-item-title"},r().translator.trans("fof-oauth.forum.providers."+e.name())),m("div",{className:"LinkedAccountsList"},this.providerInfoItems(e).toArray())):m("div",null,m("p",{className:"LinkedAccountsList-item-title"},r().translator.trans("fof-oauth.forum.providers."+e.name())))},n.providerInfoItems=function(t){var e=new(L());return e.add("firstLogin",m(B(),{label:r().translator.trans("fof-oauth.forum.user.settings.linked-account.link-created-label"),value:j()(t.firstLogin())}),100),e.add("lastLogin",m(B(),{label:r().translator.trans("fof-oauth.forum.user.settings.linked-account.last-used-label"),value:j()(t.lastLogin())}),90),e.add("identification",m(B(),{label:r().translator.trans("fof-oauth.forum.user.settings.linked-account.identification-label",{provider:r().translator.trans("fof-oauth.forum.providers."+t.name())}),value:t.providerIdentifier()}),80),e},e}(h());const T=flarum.core.compat["common/utils/extractText"];var M=e.n(T),G=function(t){function e(){for(var r,e=arguments.length,n=new Array(e),o=0;o function (Builder $schema) { 17 | /** 18 | * @var $settings SettingsRepositoryInterface 19 | */ 20 | $settings = resolve(SettingsRepositoryInterface::class); 21 | $connection = $schema->getConnection(); 22 | $rows = $connection->table('settings') 23 | ->where('key', 'LIKE', 'flarum-auth-%') 24 | ->orWhere('key', 'LIKE', 'fof-oauth-%') 25 | ->get(); 26 | 27 | foreach ($rows as $item) { 28 | $key = preg_replace('/(?:flarum|fof)-auth-(\w+?)\.(\w+)/', 'fof-oauth.$1.$2', $item->key); 29 | 30 | $settings->set($key, $item->value); 31 | $settings->delete($item->key); 32 | } 33 | }, 34 | 'down' => function (Builder $schema) { 35 | $schema->getConnection()->table('settings') 36 | ->where('key', 'LIKE', 'fof-oauth.%') 37 | ->delete(); 38 | }, 39 | ]; 40 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/flarum/phpstan/extension.neon 3 | 4 | parameters: 5 | # The level will be increased in Flarum 2.0 6 | level: 5 7 | paths: 8 | - extend.php 9 | - src 10 | excludePaths: 11 | - *.blade.php 12 | checkMissingIterableValueType: false 13 | databaseMigrationsPath: ['migrations'] 14 | -------------------------------------------------------------------------------- /resources/less/admin.less: -------------------------------------------------------------------------------- 1 | .AuthSettingsPage { 2 | margin-top: 20px; 3 | // Same max width as basics/mail page 4 | // Without this the provider settings icon ends up very far from the label on wide screens 5 | max-width: 600px; 6 | 7 | .Provider--info { 8 | display: flex; 9 | 10 | label { 11 | font-size: 16px; 12 | font-weight: normal; 13 | 14 | span { 15 | margin-left: 10px; 16 | line-height: 1; 17 | } 18 | 19 | span, .icon { 20 | vertical-align: text-top; 21 | } 22 | 23 | .icon { 24 | font-weight: normal; 25 | } 26 | } 27 | 28 | .Form-group { 29 | flex-grow: 1; 30 | margin-bottom: 10px; 31 | } 32 | 33 | .Button--rounded { 34 | height: 37px; 35 | } 36 | } 37 | 38 | .Provider--settings { 39 | max-height: 0; 40 | margin-bottom: 0; 41 | transition: 42 | max-height 0.5s, 43 | margin-bottom 0.25s; 44 | overflow-y: hidden; 45 | overflow-x: visible; 46 | } 47 | 48 | .Provider { 49 | &.disabled .Button--rounded { 50 | display: none; 51 | 52 | &:before { 53 | content: ''; 54 | height: 37px; 55 | } 56 | } 57 | 58 | &.showing .Provider--settings { 59 | max-height: 2000px; 60 | margin-bottom: 25px; 61 | overflow-y: visible; 62 | padding: 0 5px 5px; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /resources/less/forum.less: -------------------------------------------------------------------------------- 1 | .LogInButtons { 2 | .LogInButtonContainer { 3 | margin-bottom: 5px; 4 | 5 | &:last-child { 6 | margin-bottom: 20px; 7 | } 8 | 9 | .LogInButton { 10 | margin-bottom: 0; 11 | } 12 | } 13 | } 14 | 15 | .FoFLogInButton { 16 | .Button-label { 17 | // Margin that meets Google requirements 18 | // We use it everywhere for consistency 19 | margin-left: 24px; 20 | } 21 | 22 | &.LogInButton--discord { 23 | .Button--color(#fff, #5865f2); 24 | } 25 | 26 | &.LogInButton--facebook { 27 | .Button--color(#fff, #1877f2); 28 | } 29 | 30 | &.LogInButton--github { 31 | .Button--color(#333, #ccc); 32 | } 33 | 34 | &.LogInButton--twitter { 35 | .Button--color(#fff, #1d9bf0); 36 | } 37 | 38 | &.LogInButton--gitlab { 39 | .Button--color(#fff, #D06503); 40 | } 41 | 42 | &.LogInButton--linkedin { 43 | .Button--color(#fff, #2867b2); 44 | } 45 | 46 | // Custom CSS that meets the bare minimum of https://developers.google.com/identity/branding-guidelines 47 | &.LogInButton--google { 48 | .Button--color(#757575, #fff); 49 | 50 | .Button-icon { 51 | &::before { 52 | content: ""; 53 | display: inline-block; 54 | width: 18px; // Same size as other icons, which happen to match Google minimums 55 | height: 18px; 56 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAMAAABHPGVmAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAC3UExURUdwTP84AEyxTQ550wx40xp7zv9eAA1505SJXiV8zLiVIv88AP89AP85AE6xTF2yRWSyQf86AP5jAFKxSv89AFyxRlexSU6xTP83AP86AP/DAFCxSw150v/DAP84AP7AAP8/AP/CAAx40w950P89AFCxS1CxS//DAP/CAAt40/nCAhF50B6IsTKah//BAA140rm8G0qxTgl41P/DAP83AP+EAIu3MebBBj+maf9aAP+kAP+2AGS0Q8y4bCgAAAAxdFJOUwDp8hDyNCLTBBUKVobW4C8YvxN+bUdaz/Oo7bik2Ps2RVnEYDWap72m4YVG/f1tfvn45KBTAAAEEklEQVRo3t2a61biMBSF0wI10qb3q4VaEVAHFQt1xrm8/3NNuBdI2iQNzFqz1/If+HH2zjknFQFgU18b60biIKRgIeQkhp9rfSBPUMsNBylZWWb4Z6csRo6hywFBzU9QVlZ+e1WZ4vjjthxXT5SyThiuJLrWAqH5TlayCBmiGM1AJbMUIYzrcyDEMDBnNKqSToZ0riPgGkrJryxOOIrJnVJQSIeMVvlKKazYcNmsysoWylgs05KypZxxI8MpW8txL89Q9IZheHnGNeoAbnJ5BjQuzwB+xjSh4jjOhBl5U5/HCC92PR+Pc903EqQIMNz60Fdr1q3MJeiO/dOl2cioDwQZeJFDeL7+qwuhkVFrFvIp4whC13fYGTVmxfUbz93O7GYG/WRlKK/fEBCMHTaGRt3nLIPbNWIGBnj//NlmBfV1BoY2XP4iUWKfdZkyvOZ9uVz+/jzPg5XBtHCfliudFWNIZICH5Ua/fx4va1ciAz4vd6pahjSJjFXsex0si3OZjL1bG8s+LxFI1a2KZXLNOnLrkL8BLufWzjLJhYBvy3N9Si6k/0yA3N3XvKNzy64OXK86d0iAPNU8zXQGNxwaYPXA/R0B8l5XyM2CU4+E3Jvc4oYMiLkPXamQESDl/gylQm7AEwHyDUiFdMGQM3dpkAe5kAUJUnu4hCB3/w1k+I8gVwlePuQqzXiFsfJCHJBPsgfk+zVGPXFpPUiFzM9vRE2hdEZduoi5P4I+6Xh9WDWXwR5dM1KVLz3SlejH98ITu/rMSKWMOoTLHWYUkS104x2Qc8fX1JPkP/4UWKEIpNclX1bASSirMlYSKYVcyCoScNQpO0YhkgoxkcVgfVIrnbKxai3T4mXcjhY0t6p+fRQVpZyGwfmC7tb+fB2s2hrG96z12F3Q3do9Bv34c8woVK4TNnshz5RZ9RHl45SBKQHH6aVMtFUnbnR/d2rVNnxmSm9EvdEfnraIDExhdGxGm8yj28OLLJMMKVSP5cudx5dFcyEAeAVN08Z+sb036k7sVM+4HVEpZlhbDAzSovh6Jd8gZsevDVUqpUgDKgZak/UbyZTBSaf1p3RIoaYhsf37wWT/2V7pzc6Q/QYTeYF9/Llsy0ur5b8R1u6Z6gzbcKZeaFk2lmWF3iQ6ff1bg1lre72iWapqRpFpkj/PF7VFKvWnRUtV4z8PZBtLJI9CCoQlfCZtKd05vbOC9pRN/PO6XSSJUsvAQ6I95et13rBTYfv0GfYDtFqe5Ihp09kTtQUjZbxKwVA4GNVjvUhBYcuigOca1ffMS5ax737uZKYB/1/eYTDlwOC9JvYvRewYYcSmaTyG3jQnQct/jLLD8x14RJiGNpAgO/BS0jJUzdQL8eaX9U0LxKBJhYS38NQLLEaX/gIGHNymY/90kQAAAABJRU5ErkJggg=="); 57 | background-position: center; 58 | background-repeat: no-repeat; 59 | background-size: 18px 18px; 60 | margin-bottom: -2px; // -2 px is so the button still has the same height as the other ones 61 | } 62 | } 63 | } 64 | } 65 | 66 | // Optional icon-only layout 67 | 68 | .FoFLogInButtons--icons { 69 | width: 100%; 70 | display: flex; 71 | column-gap: 10px; 72 | row-gap: 15px; 73 | justify-content: space-around; 74 | flex-flow: row wrap; 75 | margin-bottom: 10px; 76 | 77 | .FoFLogInButton:not(.LogInButton--google) { 78 | width: 50px; 79 | height: 50px; 80 | padding: 0; 81 | border-radius: 30px; 82 | 83 | .Button-icon { 84 | font-size: 30px; 85 | margin-right: 0; 86 | } 87 | } 88 | 89 | .LogInButtonContainer { 90 | height: 50px; 91 | margin-bottom: 0; 92 | } 93 | 94 | .LogInButtonContainer--google { 95 | flex-basis: 100%; 96 | 97 | .FoFLogInButton { 98 | width: min-content; 99 | margin: 0 auto; 100 | } 101 | } 102 | } 103 | 104 | .SettingsPage { 105 | .item-account { 106 | .item-linkedAccounts { 107 | display: block; 108 | } 109 | } 110 | } 111 | 112 | .LinkedAccounts-List { 113 | display: flex; 114 | flex-direction: column; 115 | gap: 16px; 116 | margin: 0; 117 | padding: 0; 118 | list-style: none; 119 | } 120 | 121 | .LinkedAccounts-Account { 122 | display: flex; 123 | padding: 16px 16px 16px 0; 124 | background-color: var(--control-bg); 125 | color: var(--control-color); 126 | border-radius: @border-radius; 127 | 128 | .Provider-Icon { 129 | --font-size: 1.6rem; 130 | font-size: var(--font-size); 131 | width: calc(~"var(--font-size) + 4rem"); 132 | display: flex; 133 | align-items: center; 134 | justify-content: center; 135 | } 136 | 137 | legend { 138 | font-weight: bold; 139 | } 140 | 141 | .Provider-Info { 142 | display: flex; 143 | align-items: center; 144 | gap: 8px; 145 | } 146 | 147 | .FoFLogInButton { 148 | .Button-label { 149 | margin-left: 4px; 150 | } 151 | } 152 | } 153 | 154 | .LinkedAccountsList { 155 | display: flex; 156 | flex-direction: column; 157 | border-radius: var(--border-radius); 158 | overflow: hidden; 159 | 160 | &-item { 161 | display: flex; 162 | padding: 16px 16px 16px 0; 163 | background-color: var(--control-bg); 164 | color: var(--control-color); 165 | 166 | &-icon { 167 | --font-size: 1.6rem; 168 | font-size: var(--font-size); 169 | width: calc(~"var(--font-size) + 4rem"); 170 | display: flex; 171 | align-items: center; 172 | justify-content: center; 173 | } 174 | 175 | &-title { 176 | font-weight: bold; 177 | 178 | &-sub { 179 | font-style: italic; 180 | } 181 | } 182 | 183 | &-value { 184 | font-weight: normal; 185 | } 186 | 187 | &-actions { 188 | display: flex; 189 | align-items: center; 190 | margin-left: auto; 191 | 192 | > *:not(:first-child) { 193 | margin-left: 8px; 194 | } 195 | } 196 | 197 | &--active &-title-sub { 198 | color: var(--alert-success-color); 199 | } 200 | } 201 | 202 | &--empty { 203 | color: var(--control-color); 204 | } 205 | } 206 | 207 | @media @phone { 208 | .LinkedAccountsList { 209 | &-item { 210 | flex-wrap: wrap; 211 | padding: 16px; 212 | 213 | &-icon { 214 | justify-content: start; 215 | padding: 8px; 216 | width: auto; 217 | min-width: calc(~"var(--font-size) + 4rem"); 218 | } 219 | 220 | &-actions { 221 | width: auto; 222 | } 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /resources/locale/en.yml: -------------------------------------------------------------------------------- 1 | fof-oauth: 2 | admin: 3 | # This is used by extensions which extend fof/oauth to add additional providers 4 | configure_button_label: Configure with FoF OAuth 5 | settings_accessibility_label: "{name} settings" 6 | 7 | permissions: 8 | moderate_user_providers: Moderate user's linked accounts 9 | 10 | settings: 11 | advanced: 12 | heading: Advanced 13 | log-oauth-errors-label: Log OAuth errors 14 | log-oauth-errors-help: If enabled, OAuth errors will be logged to the Flarum log. This may help with debugging OAuth issues, but may also contain sensitive information. 15 | only_icons_label: Only show the Log In Button icons (alternative layout) 16 | update_email_from_provider_label: Update email address from provider 17 | update_email_from_provider_help: If enabled, the user's email address will be updated to match the one provided by the OAuth provider on each login to the forum. Not all providers provide the updated email, in which case this setting will not have any effect with those providers. 18 | fullscreen_popup_label: Use Fullscreen Popup for OAuth 19 | fullscreen_popup_help: When enabled, the OAuth login will open in a fullscreen popup. If this is enabled, the width and height settings will be ignored. 20 | popup_width_label: OAuth Popup Width 21 | popup_width_help: Set the width of the OAuth popup window. This setting will be ignored if "Use Fullscreen Popup for OAuth" is enabled. 22 | popup_height_label: OAuth Popup Height 23 | popup_height_help: Set the height of the OAuth popup window. This setting will be ignored if "Use Fullscreen Popup for OAuth" is enabled. 24 | 25 | providers: 26 | callback_url_text: If necessary, set the callback URL to {url}. 27 | group_label: Assign Group 28 | group_help: Select a group to automatically assign to users who register using this provider. 29 | no_group_label: No group assignment 30 | 31 | discord: 32 | description: Create an app at {link}. Add the redirect URL in the OAuth2 tab. 33 | 34 | client_id_label: => fof-oauth.ref.settings.client_id 35 | client_secret_label: => fof-oauth.ref.settings.client_secret 36 | 37 | facebook: 38 | description: Create an app at {link}. 39 | 40 | app_id_label: => fof-oauth.ref.settings.app_id 41 | app_secret_label: => fof-oauth.ref.settings.app_secret 42 | 43 | github: 44 | description: Create an OAuth app at {link}. 45 | 46 | client_id_label: => fof-oauth.ref.settings.client_id 47 | client_secret_label: => fof-oauth.ref.settings.client_secret 48 | 49 | gitlab: 50 | description: Create an application at {link}. Give the application the read_user scope. 51 | 52 | client_id_label: => fof-oauth.ref.settings.app_id 53 | client_secret_label: => fof-oauth.ref.settings.app_secret 54 | domain_label: GitLab Domain 55 | 56 | twitter: 57 | description: Create an app at {link}. You will need to set a Terms of Service URL and a Privacy Policy URL. Make sure to then add the request email permission. 58 | 59 | api_key_label: API Key 60 | api_secret_label: API Secret 61 | 62 | google: 63 | description: Create an application at {link}. 64 | 65 | client_id_label: => fof-oauth.ref.settings.client_id 66 | client_secret_label: => fof-oauth.ref.settings.client_secret 67 | hosted_domain_label: Hosted Domain (G suite/Google Apps for Business, optional) 68 | 69 | linkedin: 70 | description: Create an application at {link}. 71 | 72 | client_id_label: => fof-oauth.ref.settings.client_id 73 | client_secret_label: => fof-oauth.ref.settings.client_secret 74 | 75 | forum: 76 | log_in: 77 | with_button: Log In with {provider} 78 | 79 | # Modify these if your provider requires specific wording. 80 | # You may use {provider} in the translation. 81 | with_discord_button: '=> fof-oauth.forum.log_in.with_button' 82 | with_facebook_button: '=> fof-oauth.forum.log_in.with_button' 83 | with_github_button: '=> fof-oauth.forum.log_in.with_button' 84 | with_gitlab_button: '=> fof-oauth.forum.log_in.with_button' 85 | with_google_button: '=> fof-oauth.forum.log_in.with_button' 86 | with_linkedin_button: '=> fof-oauth.forum.log_in.with_button' 87 | with_twitter_button: '=> fof-oauth.forum.log_in.with_button' 88 | 89 | user: 90 | settings: 91 | linked-account: 92 | label: Linked accounts 93 | last-used-label: Last used 94 | link-created-label: Link created 95 | identification-label: "{provider} ID" 96 | orphaned-account: You have signed in through this provider previously, but this forum has disabled sign-in with this method since. 97 | help: These linked accounts allow you to sign into the forum using other providers. 98 | identifier-label: "{provider} ID" 99 | unlink-confirm: Are you sure you want to remove the link with {provider}? You will no longer be able to sign in to this forum unless you re-authenticate in the future. 100 | # Modify these if you need to change how the provider name 101 | # appears in the log in button. 102 | providers: 103 | discord: '=> fof-oauth.lib.providers.discord' 104 | facebook: '=> fof-oauth.lib.providers.facebook' 105 | github: '=> fof-oauth.lib.providers.github' 106 | gitlab: '=> fof-oauth.lib.providers.gitlab' 107 | google: '=> fof-oauth.lib.providers.google' 108 | linkedin: '=> fof-oauth.lib.providers.linkedin' 109 | twitter: '=> fof-oauth.lib.providers.twitter' 110 | 111 | signup: 112 | username_help: Please choose a username to be known by here 113 | 114 | unlink: Remove link 115 | 116 | error: 117 | bad_verification_code: Invalid or expired verification token. Please try again. 118 | invalid_state: Invalid state. Please try again. 119 | 120 | lib: 121 | providers: 122 | discord: Discord 123 | facebook: Facebook 124 | github: GitHub 125 | gitlab: GitLab 126 | twitter: Twitter 127 | google: Google 128 | linkedin: LinkedIn 129 | 130 | ref: 131 | settings: 132 | app_id: App ID 133 | app_secret: App Secret 134 | 135 | client_id: Client ID 136 | client_secret: Client Secret 137 | 138 | -------------------------------------------------------------------------------- /src/Api/AddForumAttributes.php: -------------------------------------------------------------------------------- 1 | getActor()->isGuest()) { 21 | $attributes['fof-oauth'] = resolve('fof-oauth.providers.forum'); 22 | } else { 23 | $attributes['fofOauthModerate'] = $serializer->getActor()->can('moderateUserProviders'); 24 | } 25 | 26 | return $attributes; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Api/Controllers/DeleteProviderLinkController.php: -------------------------------------------------------------------------------- 1 | events = $events; 39 | $this->users = $users; 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | */ 45 | protected function delete(ServerRequestInterface $request) 46 | { 47 | $actor = RequestUtil::getActor($request); 48 | $id = Arr::get($request->getQueryParams(), 'id'); 49 | 50 | $actor->assertRegistered(); 51 | 52 | $provider = LoginProvider::findOrFail($id); 53 | 54 | $user = $this->users->findOrFail($provider->user_id); 55 | 56 | if ($user->id !== $actor->id) { 57 | $actor->assertCan('moderateUserProviders'); 58 | } 59 | 60 | $this->events->dispatch(new UnlinkingFromProvider($user, $provider, $actor)); 61 | 62 | $provider->delete(); 63 | 64 | return new EmptyResponse(204); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Api/Controllers/ListProvidersController.php: -------------------------------------------------------------------------------- 1 | users = $users; 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | protected function data(ServerRequestInterface $request, Document $document) 47 | { 48 | $actor = RequestUtil::getActor($request); 49 | $actor->assertRegistered(); 50 | 51 | // If no id is provided, we're looking at the current user. 52 | $user = $this->users->findOrFail(Arr::get($request->getQueryParams(), 'id', $actor->id)); 53 | 54 | if ($actor->id !== $user->id) { 55 | $actor->assertCan('moderateUserProviders'); 56 | } 57 | 58 | $providers = $this->getProviders(); 59 | 60 | $loginProviders = $this->getUserProviders($user, $providers); 61 | 62 | $data = new Collection(); 63 | 64 | $providers->each(function (array $provider) use ($loginProviders, &$data, $user) { 65 | $loginProvider = $loginProviders->where('provider', Arr::get($provider, 'name'))->first(); 66 | $data->add(LoginProviderStatus::build( 67 | Arr::get($provider, 'name'), 68 | Arr::get($provider, 'icon'), 69 | Arr::get($provider, 'priority'), 70 | $user, 71 | $loginProvider 72 | )); 73 | }); 74 | 75 | $this->getOrphanedUserProviders($user, $providers)->each(function (LoginProvider $loginProvider) use (&$data) { 76 | $data->add(LoginProviderStatus::build( 77 | $loginProvider->provider, 78 | 'fas fa-question', 79 | -100, 80 | $loginProvider->user, 81 | $loginProvider, 82 | true 83 | )); 84 | }); 85 | 86 | return $data; 87 | } 88 | 89 | private function getUserProviders(User $user, Collection $providers): Collection 90 | { 91 | return LoginProvider::query() 92 | ->whereIn('provider', $this->getProviderKeys($providers)) 93 | ->where('user_id', $user->id) 94 | ->get(); 95 | } 96 | 97 | private function getOrphanedUserProviders(User $user, Collection $providers): Collection 98 | { 99 | return LoginProvider::query() 100 | ->where('user_id', $user->id) 101 | ->whereNotIn('provider', $this->getProviderKeys($providers)) 102 | ->get(); 103 | } 104 | 105 | private function getProviders(): Collection 106 | { 107 | /** @var Collection $providers */ 108 | $providers = collect(resolve('fof-oauth.providers.forum'))->reject(function ($provider) { 109 | return $provider === null; 110 | }); 111 | 112 | return $providers; 113 | } 114 | 115 | private function getProviderKeys(Collection $providers): array 116 | { 117 | return $providers->map(function ($provider) { 118 | return Arr::get($provider, 'name'); 119 | })->toArray(); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Api/CurrentUserAttributes.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 30 | } 31 | 32 | public function __invoke(CurrentUserSerializer $serializer, User $user, array $attributes): array 33 | { 34 | $session = $serializer->getRequest()->getAttribute('session'); 35 | 36 | if ($session !== null) { 37 | $loginProvider = $this->cache->get(AbstractOAuthController::SESSION_OAUTH2PROVIDER.'_'.$session->getId()); 38 | 39 | if ($loginProvider === null) { 40 | // This solution is not optimal, if someone uses multiple login providers at the same time, this could be lead to wrong results 41 | $loginProvider = LoginProvider::query() 42 | ->where('user_id', $user->id) 43 | ->orderBy('last_login_at', 'desc') 44 | ->value('provider'); 45 | 46 | $loginProvider = $loginProvider ?: false; 47 | $this->cache->forever(AbstractOAuthController::SESSION_OAUTH2PROVIDER.'_'.$session->getId(), $loginProvider); 48 | } 49 | 50 | // We don't want return false when the provider is not set, map it back to null 51 | $attributes['loginProvider'] = $loginProvider ?: null; 52 | } 53 | 54 | return $attributes; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Api/Serializers/ProviderSerializer.php: -------------------------------------------------------------------------------- 1 | $provider->name, 32 | 'icon' => $provider->icon, 33 | 'priority' => $provider->priority, 34 | 'orphaned' => $provider->orphaned, 35 | 'linked' => $provider->linked, 36 | 'identifier' => $provider->identifier, 37 | 'providerIdentifier' => $provider->providerIdentifier, 38 | 'firstLogin' => $this->formatDate($provider->createdAt), 39 | 'lastLogin' => $this->formatDate($provider->lastLogin), 40 | ]; 41 | } 42 | 43 | public function getId($provider): string 44 | { 45 | return $provider->identifier ?? "$provider->userId-$provider->name"; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Controller.php: -------------------------------------------------------------------------------- 1 | settings = $settings; 45 | } 46 | 47 | protected function getRouteName(): string 48 | { 49 | return 'auth.'.$this->getProviderName(); 50 | } 51 | 52 | protected function getIdentifier($user): string 53 | { 54 | return $user->getId(); 55 | } 56 | 57 | /** 58 | * @param ServerRequestInterface $request 59 | * 60 | * @throws Exception 61 | * 62 | * @return ResponseInterface 63 | */ 64 | public function handle(ServerRequestInterface $request): ResponseInterface 65 | { 66 | if (!(bool) (int) $this->settings->get('fof-oauth.'.$this->getProviderName())) { 67 | throw new RouteNotFoundException(); 68 | } 69 | 70 | try { 71 | return parent::handle($request); 72 | } catch (Exception $e) { 73 | if ((bool) $this->settings->get('fof-oauth.log-oauth-errors')) { 74 | /** @var LoggerInterface $logger */ 75 | $logger = resolve('log'); 76 | $detail = json_encode([ 77 | 'server_params' => $request->getServerParams(), 78 | 'request_attrs' => $request->getAttributes(), 79 | 'cookie_params' => $request->getCookieParams(), 80 | 'query_params' => $request->getQueryParams(), 81 | 'parsed_body' => $request->getParsedBody(), 82 | 'code' => $e->getCode(), 83 | 'trace' => $e->getTraceAsString(), 84 | ], JSON_PRETTY_PRINT); 85 | 86 | $logger->error("[OAuth][{$this->getProviderName()}] {$e->getMessage()}: {$detail}"); 87 | } 88 | 89 | if ($e->getMessage() === 'Invalid state' || $e instanceof IdentityProviderException) { 90 | throw new AuthenticationException($e->getMessage()); 91 | } 92 | 93 | throw $e; 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Controllers/AuthController.php: -------------------------------------------------------------------------------- 1 | getQueryParams(), 'provider'); 34 | $providers = resolve('container')->tagged('fof-oauth.providers'); 35 | 36 | foreach ($providers as $provider) { 37 | if ($provider->name() === $name) { 38 | if ($provider->enabled()) { 39 | $this->provider = $provider; 40 | } 41 | 42 | break; 43 | } 44 | } 45 | 46 | if (!$this->provider) { 47 | throw new RouteNotFoundException(); 48 | } 49 | 50 | return parent::handle($request); 51 | } 52 | 53 | protected function getRouteName(): string 54 | { 55 | // Errors are thrown if we return 'fof-oauth' because no options are passed. 56 | return 'auth.twitter'; 57 | } 58 | 59 | protected function getProvider(string $redirectUri): AbstractProvider 60 | { 61 | return $this->provider->provider( 62 | $this->url->to('forum')->route( 63 | 'fof-oauth', 64 | ['provider' => $this->getProviderName()] 65 | ) 66 | ); 67 | } 68 | 69 | protected function getProviderName(): string 70 | { 71 | return $this->provider->name(); 72 | } 73 | 74 | protected function getAuthorizationUrlOptions(): array 75 | { 76 | return $this->provider->options(); 77 | } 78 | 79 | protected function setSuggestions(Registration $registration, $user, string $token) 80 | { 81 | $this->provider->suggestions($registration, $user, $token); 82 | 83 | $this->events->dispatch( 84 | new SettingSuggestions($this->getProviderName(), $registration, $user, $token) 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Controllers/TwitterAuthController.php: -------------------------------------------------------------------------------- 1 | response = $response; 59 | $this->settings = $settings; 60 | $this->url = $url; 61 | } 62 | 63 | public function handle(ServerRequestInterface $request): ResponseInterface 64 | { 65 | try { 66 | return $this->work($request); 67 | } catch (CredentialsException $e) { 68 | $originalMessage = $e->getMessage(); 69 | $error = preg_replace('/Received HTTP status code \[400\] with message "(.+)" when getting temporary credentials\./m', '$1', $originalMessage); 70 | $details = Arr::get(json_decode($error, true), 'errors.0'); 71 | 72 | throw new AuthenticationException(Arr::get($details, 'message', $originalMessage), Arr::get($details, 'code', 0), $e); 73 | } 74 | } 75 | 76 | public function work(ServerRequestInterface $request): ResponseInterface 77 | { 78 | $redirectUri = $this->url->to('forum')->route('auth.twitter'); 79 | 80 | $server = new Twitter([ 81 | 'identifier' => $this->getSetting('api_key'), 82 | 'secret' => $this->getSetting('api_secret'), 83 | 'callback_uri' => $redirectUri, 84 | ]); 85 | 86 | /** @var Store $session */ 87 | $session = $request->getAttribute('session'); 88 | 89 | $queryParams = $request->getQueryParams(); 90 | $oAuthToken = Arr::get($queryParams, 'oauth_token'); 91 | $oAuthVerifier = Arr::get($queryParams, 'oauth_verifier'); 92 | 93 | if ($requestLinkTo = Arr::pull($queryParams, 'linkTo')) { 94 | $session->put('linkTo', $requestLinkTo); 95 | } 96 | 97 | if (!$oAuthToken || !$oAuthVerifier) { 98 | $temporaryCredentials = $server->getTemporaryCredentials(); 99 | 100 | $session->put('temporary_credentials', serialize($temporaryCredentials)); 101 | 102 | $authUrl = $server->getAuthorizationUrl($temporaryCredentials); 103 | 104 | return new RedirectResponse($authUrl); 105 | } 106 | 107 | $temporaryCredentials = unserialize($session->get('temporary_credentials')); 108 | 109 | $tokenCredentials = $server->getTokenCredentials($temporaryCredentials, $oAuthToken, $oAuthVerifier); 110 | 111 | $user = $server->getUserDetails($tokenCredentials); 112 | 113 | if ($shouldLink = $session->remove('linkTo')) { 114 | // Don't register a new user, just link to the existing account, else continue with registration. 115 | $actor = RequestUtil::getActor($request); 116 | 117 | if ($actor->exists) { 118 | $actor->assertRegistered(); 119 | 120 | if ($actor->id !== (int) $shouldLink) { 121 | throw new ValidationException(['linkAccount' => 'User data mismatch']); 122 | } 123 | 124 | return $this->link($actor, $user); 125 | } 126 | } 127 | 128 | return $this->response->make( 129 | 'twitter', 130 | $user->uid, 131 | function (Registration $registration) use ($user) { 132 | $this->setSuggestions($registration, $user); 133 | } 134 | ); 135 | } 136 | 137 | protected function setSuggestions(Registration $registration, User $user) 138 | { 139 | $email = $user->email; 140 | 141 | if (empty($email)) { 142 | throw new AuthenticationException('invalid_email'); 143 | } 144 | 145 | $registration 146 | ->provideTrustedEmail($email) 147 | ->suggestUsername($user->nickname ?: '') 148 | ->provideAvatar(str_replace('_normal', '', $user->imageUrl)) 149 | ->setPayload(get_object_vars($user)); 150 | } 151 | 152 | protected function getSetting($key): ?string 153 | { 154 | return $this->settings->get("fof-oauth.twitter.{$key}"); 155 | } 156 | 157 | /** 158 | * Link the currently authenticated user to the OAuth account. 159 | * 160 | * @param FlarumUser $user 161 | * @param User $resourceOwner 162 | * 163 | * @return HtmlResponse 164 | */ 165 | protected function link(FlarumUser $user, User $resourceOwner): HtmlResponse 166 | { 167 | if (LoginProvider::where('identifier', $resourceOwner->uid)->where('provider', 'twitter')->exists()) { 168 | throw new ValidationException(['linkAccount' => 'Account already linked to another user']); 169 | } 170 | 171 | $user->loginProviders()->firstOrCreate([ 172 | 'provider' => 'twitter', 173 | 'identifier' => $resourceOwner->uid, 174 | ])->touch(); 175 | 176 | $content = ''; 177 | 178 | return new HtmlResponse($content); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Errors/AuthenticationException.php: -------------------------------------------------------------------------------- 1 | [ 22 | 'OAuthException: This authorization code has expired.', 23 | 'Received HTTP status code [401] with message "This feature is temporarily unavailable" when getting token credentials.', 24 | ], 25 | 26 | 'invalid_state' => [ 27 | 'Invalid state', 28 | ], 29 | ]; 30 | 31 | public function getShortCode(): string 32 | { 33 | $message = $this->getMessage(); 34 | 35 | if (!Arr::has(self::MESSAGE_TYPES, $message)) { 36 | foreach (self::MESSAGE_TYPES as $type => $messages) { 37 | if (in_array($message, $messages)) { 38 | return $type; 39 | } 40 | } 41 | } 42 | 43 | return $message; 44 | } 45 | 46 | public function getType(): string 47 | { 48 | return 'authentication_error'; 49 | } 50 | 51 | public function shouldBeReported() 52 | { 53 | $code = $this->getShortCode(); 54 | 55 | return $code !== 'invalid_state' && $code !== 'bad_verification_code'; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Events/SettingSuggestions.php: -------------------------------------------------------------------------------- 1 | provider = $provider; 42 | $this->registration = $registration; 43 | $this->resourceOwner = $resourceOwner; 44 | $this->token = $token; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Events/UnlinkingFromProvider.php: -------------------------------------------------------------------------------- 1 | user = $user; 41 | $this->provider = $provider; 42 | $this->actor = $actor; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Extend/RegisterProvider.php: -------------------------------------------------------------------------------- 1 | provider = $provider; 27 | } 28 | 29 | public function extend(Container $container, ?Extension $extension = null) 30 | { 31 | $provider = $container->make($this->provider); 32 | 33 | if ($provider instanceof Provider) { 34 | $container->tag([ 35 | $this->provider, 36 | ], 'fof-oauth.providers'); 37 | } else { 38 | throw new InvalidArgumentException("{$this->provider} has to extend ".Provider::class); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Jobs/CheckAndUpdateUserEmail.php: -------------------------------------------------------------------------------- 1 | providerName = $providerName; 51 | $this->identifier = $identifier; 52 | $this->providedEmail = $providedEmail; 53 | } 54 | 55 | public function handle(UserValidator $validator, Dispatcher $events) 56 | { 57 | $provider = LoginProvider::where('provider', $this->providerName)->where('identifier', $this->identifier)->first(); 58 | 59 | if (!$provider) { 60 | return; 61 | } 62 | 63 | /** @var User|null $user */ 64 | $user = User::find($provider->user_id); 65 | 66 | if ($user === null) { 67 | return; 68 | } 69 | 70 | if (!empty($this->providedEmail) && $user->email !== $this->providedEmail) { 71 | $validator->setUser($user); 72 | 73 | $validator->assertValid([ 74 | 'email' => $this->providedEmail, 75 | ]); 76 | 77 | $user->changeEmail($this->providedEmail); 78 | 79 | $user->save(); 80 | foreach ($user->releaseEvents() as $event) { 81 | $events->dispatch($event); 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Listeners/AssignGroupToUser.php: -------------------------------------------------------------------------------- 1 | settings = $settings; 32 | } 33 | 34 | /** 35 | * @param RegisteringFromProvider $event 36 | */ 37 | public function handle(RegisteringFromProvider $event) 38 | { 39 | $provider = $event->provider; 40 | $user = $event->user; 41 | 42 | // Get the group ID for this provider 43 | $groupId = $this->settings->get("fof-oauth.{$provider}.group"); 44 | 45 | // If a group is specified, assign it to the user 46 | if ($groupId && is_numeric($groupId)) { 47 | $user->afterSave(function (User $user) use ($groupId) { 48 | // Attach the group to the user 49 | $user->groups()->attach($groupId); 50 | }); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Listeners/ClearOAuthCache.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 28 | } 29 | 30 | public function subscribe(Dispatcher $events) 31 | { 32 | $events->listen(Saving::class, [$this, 'settingsSaved']); 33 | $events->listen([Enabling::class, Disabling::class], [$this, 'clearOauthCache']); 34 | } 35 | 36 | public function clearOauthCache() 37 | { 38 | $this->cache->forget('fof-oauth.providers.forum'); 39 | $this->cache->forget('fof-oauth.providers.admin'); 40 | } 41 | 42 | public function settingsSaved(Saving $event) 43 | { 44 | foreach (array_keys($event->settings) as $key) { 45 | if (Str::startsWith($key, 'fof-oauth')) { 46 | $this->clearOauthCache(); 47 | break; // Exit the loop once the cache is cleared 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Listeners/HandleLogout.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 31 | } 32 | 33 | public function handle(LoggedOut $event) 34 | { 35 | $user = $event->user; 36 | 37 | /** @var ServerRequestInterface|null $request */ 38 | $request = resolve('fof-oauth-request'); 39 | 40 | if ($request) { 41 | $requestUser = RequestUtil::getActor($request); 42 | 43 | if ($requestUser->id === $user->id) { 44 | /** @var Session $session */ 45 | $session = $request->getAttribute('session'); 46 | 47 | $this->cache->forget(AbstractOAuthController::SESSION_OAUTH2PROVIDER.'_'.$session->getId()); 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Listeners/UpdateEmailFromProvider.php: -------------------------------------------------------------------------------- 1 | settings = $settings; 34 | $this->bus = $bus; 35 | } 36 | 37 | public function handle(OAuthLoginSuccessful $event) 38 | { 39 | if ((bool) $this->settings->get('fof-oauth.update_email_from_provider') && method_exists($event->userResource, 'getEmail')) { 40 | $this->bus->dispatch(new CheckAndUpdateUserEmail( 41 | $event->providerName, 42 | $event->userResource->getId(), 43 | $event->userResource->getEmail() 44 | )); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/LoginProviderStatus.php: -------------------------------------------------------------------------------- 1 | name = $name; 74 | $status->icon = $icon; 75 | $status->priority = $priority; 76 | $status->orphaned = $orphaned; 77 | $status->linked = (bool) $provider; 78 | $status->userId = $user->id; 79 | 80 | if ($provider) { 81 | $status->identifier = $provider->id; 82 | $status->providerIdentifier = $provider->identifier; 83 | $status->createdAt = $provider->created_at; 84 | $status->lastLogin = $provider->last_login_at; 85 | } 86 | 87 | return $status; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Middleware/BindRequest.php: -------------------------------------------------------------------------------- 1 | container = $container; 27 | } 28 | 29 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 30 | { 31 | $this->container->instance('fof-oauth-request', $request); 32 | 33 | return $handler->handle($request); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Middleware/ErrorHandler.php: -------------------------------------------------------------------------------- 1 | view = $view; 46 | $this->translator = $translator; 47 | $this->debugMode = Arr::get($config, 'debug', true); 48 | $this->reporters = $container->tagged(Reporter::class); 49 | } 50 | 51 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 52 | { 53 | if ($this->debugMode) { 54 | return $handler->handle($request); 55 | } 56 | 57 | try { 58 | return $handler->handle($request); 59 | } catch (AuthenticationException $exception) { 60 | $view = $this->view->make('flarum.forum::error.default') 61 | ->with('message', $this->getMessage($exception)); 62 | 63 | $this->report($exception); 64 | 65 | return new HtmlResponse($view->render(), 401); 66 | } 67 | } 68 | 69 | protected function getMessage(AuthenticationException $exception) 70 | { 71 | $code = $exception->getShortCode(); 72 | $key = "fof-oauth.forum.error.$code"; 73 | $translation = $this->translator->trans($key); 74 | 75 | return $key === $translation 76 | ? $exception->getMessage() 77 | : $translation; 78 | } 79 | 80 | protected function report(AuthenticationException $e) 81 | { 82 | if ($e->shouldBeReported()) { 83 | foreach ($this->reporters as $reporter) { 84 | $reporter->report($e); 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/OAuth2RoutePattern.php: -------------------------------------------------------------------------------- 1 | tagged('fof-oauth.providers'); 27 | 28 | $providerNames = []; 29 | 30 | foreach ($providers as $provider) { 31 | // Skip disabled providers, this increases compatibility with other oauth extensions which might offer the same providers 32 | // Skip providers that want to provider their own route (ie in extend.php) 33 | if (!$provider->enabled() || $provider->excludeFromRoutePattern()) { 34 | continue; 35 | } 36 | 37 | $providerNames[] = $provider->name(); 38 | } 39 | 40 | return '/auth/{provider:'.implode('|', $providerNames).'}'; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/OAuthServiceProvider.php: -------------------------------------------------------------------------------- 1 | container->tag([ 26 | Providers\Discord::class, 27 | Providers\Facebook::class, 28 | Providers\GitHub::class, 29 | Providers\GitLab::class, 30 | Providers\Twitter::class, 31 | Providers\Google::class, 32 | Providers\LinkedIn::class, 33 | ], 'fof-oauth.providers'); 34 | 35 | // Add OAuth provider routes 36 | $this->container->resolving('flarum.forum.routes', function (RouteCollection $collection, Container $container) { 37 | /** @var RouteHandlerFactory $factory */ 38 | $factory = $container->make(RouteHandlerFactory::class); 39 | 40 | $collection->addRoute('GET', new OAuth2RoutePattern(), 'fof-oauth', $factory->toController(Controllers\AuthController::class)); 41 | }); 42 | } 43 | 44 | public function boot() 45 | { 46 | /** @var Cache $cache */ 47 | $cache = $this->container->make(Cache::class); 48 | /** @var Config $config */ 49 | $config = $this->container->make(Config::class); 50 | 51 | $this->container->singleton('fof-oauth.providers.forum', function () use ($cache, $config) { 52 | // If we're in debug mode, don't cache the providers, but directly return them. 53 | if ($config->inDebugMode()) { 54 | return $this->mapProviders(); 55 | } 56 | 57 | $cacheKey = 'fof-oauth.providers.forum'; 58 | 59 | $data = $cache->get($cacheKey); 60 | if ($data === null) { 61 | $data = $this->mapProviders(); 62 | $cache->forever($cacheKey, $data); 63 | } 64 | 65 | return $data; 66 | }); 67 | 68 | $this->container->singleton('fof-oauth.providers.admin', function () use ($cache, $config) { 69 | // If we're in debug mode, don't cache the providers, but directly return them. 70 | if ($config->inDebugMode()) { 71 | return $this->mapProviders(true); 72 | } 73 | 74 | $cacheKey = 'fof-oauth.providers.admin'; 75 | 76 | $data = $cache->get($cacheKey); 77 | if ($data === null) { 78 | $data = $this->mapProviders(true); 79 | $cache->forever($cacheKey, $data); 80 | } 81 | 82 | return $data; 83 | }); 84 | } 85 | 86 | protected function mapProviders(bool $admin = false): array 87 | { 88 | $providers = $this->container->tagged('fof-oauth.providers'); 89 | 90 | if ($admin) { 91 | return array_map(function (Provider $provider) { 92 | return [ 93 | 'name' => $provider->name(), 94 | 'icon' => $provider->icon(), 95 | 'link' => $provider->link(), 96 | 'fields' => $provider->fields(), 97 | ]; 98 | }, iterator_to_array($providers)); 99 | } 100 | 101 | return array_map(function (Provider $provider) { 102 | if (!$provider->enabled()) { 103 | return null; 104 | } 105 | 106 | return [ 107 | 'name' => $provider->name(), 108 | 'icon' => $provider->icon(), 109 | 'priority' => $provider->priority(), 110 | ]; 111 | }, iterator_to_array($providers)); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Provider.php: -------------------------------------------------------------------------------- 1 | settings = $settings; 29 | } 30 | 31 | // Provider data 32 | 33 | abstract public function name(): string; 34 | 35 | abstract public function link(): string; 36 | 37 | abstract public function fields(): array; 38 | 39 | public function icon(): string 40 | { 41 | return "fab fa-{$this->name()}"; 42 | } 43 | 44 | public function priority(): int 45 | { 46 | return 0; 47 | } 48 | 49 | // Controller options 50 | 51 | abstract public function provider(string $redirectUri): ?AbstractProvider; 52 | 53 | public function options(): array 54 | { 55 | return []; 56 | } 57 | 58 | public function suggestions(Registration $registration, $user, string $token) 59 | { 60 | // 61 | } 62 | 63 | // Helpers 64 | 65 | public function enabled() 66 | { 67 | return $this->settings->get("fof-oauth.{$this->name()}"); 68 | } 69 | 70 | protected function getSetting($key): string 71 | { 72 | return $this->settings->get("fof-oauth.{$this->name()}.{$key}") ?? ''; 73 | } 74 | 75 | protected function verifyEmail(?string $email) 76 | { 77 | if ($email === null || empty($email)) { 78 | throw new AuthenticationException('invalid_email'); 79 | } 80 | } 81 | 82 | // Set this value to `true` in your provider class if you wish to provide your own 83 | // route or controller. 84 | public function excludeFromRoutePattern(): bool 85 | { 86 | return false; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Providers/Custom/LinkedIn/Provider/Exception/LinkedInAccessDeniedException.php: -------------------------------------------------------------------------------- 1 | "Bearer {$token}", 86 | ]; 87 | } 88 | 89 | /** 90 | * Get the string used to separate scopes. 91 | * 92 | * @return string 93 | */ 94 | protected function getScopeSeparator() 95 | { 96 | return ' '; 97 | } 98 | 99 | /** 100 | * Get authorization url to begin OAuth flow. 101 | * 102 | * @return string 103 | */ 104 | public function getBaseAuthorizationUrl() 105 | { 106 | return 'https://www.linkedin.com/oauth/v2/authorization'; 107 | } 108 | 109 | /** 110 | * Get access token url to retrieve token. 111 | * 112 | * @return string 113 | */ 114 | public function getBaseAccessTokenUrl(array $params) 115 | { 116 | return 'https://www.linkedin.com/oauth/v2/accessToken'; 117 | } 118 | 119 | /** 120 | * Get provider url to fetch user details. 121 | * 122 | * @param AccessToken $token 123 | * 124 | * @return string 125 | */ 126 | public function getResourceOwnerDetailsUrl(AccessToken $token) 127 | { 128 | return 'https://api.linkedin.com/v2/userinfo'; 129 | } 130 | 131 | /** 132 | * Get the default scopes used by this provider. 133 | * 134 | * This should not be a complete list of all scopes, but the minimum 135 | * required for the provider user interface! 136 | * 137 | * @return array 138 | */ 139 | protected function getDefaultScopes() 140 | { 141 | return $this->defaultScopes; 142 | } 143 | 144 | /** 145 | * Check a provider response for errors. 146 | * 147 | * @param ResponseInterface $response 148 | * @param array $data Parsed response data 149 | * 150 | * @throws IdentityProviderException 151 | * 152 | * @return void 153 | * 154 | * @see https://developer.linkedin.com/docs/guide/v2/error-handling 155 | */ 156 | protected function checkResponse(ResponseInterface $response, $data) 157 | { 158 | $this->checkResponseUnauthorized($response, $data); 159 | 160 | if ($response->getStatusCode() >= 400) { 161 | throw new IdentityProviderException( 162 | isset($data['message']) ? $data['message'] : $response->getReasonPhrase(), 163 | isset($data['status']) ? $data['status'] : $response->getStatusCode(), 164 | $response 165 | ); 166 | } 167 | } 168 | 169 | /** 170 | * Check a provider response for unauthorized errors. 171 | * 172 | * @param ResponseInterface $response 173 | * @param array $data Parsed response data 174 | * 175 | * @throws LinkedInAccessDeniedException 176 | * 177 | * @return void 178 | * 179 | * @see https://developer.linkedin.com/docs/guide/v2/error-handling 180 | */ 181 | protected function checkResponseUnauthorized(ResponseInterface $response, $data) 182 | { 183 | if (isset($data['status']) && $data['status'] === 403) { 184 | throw new LinkedInAccessDeniedException( 185 | isset($data['message']) ? $data['message'] : $response->getReasonPhrase(), 186 | Arr::get($data, 'status', $response->getStatusCode()), 187 | $response 188 | ); 189 | } 190 | } 191 | 192 | /** 193 | * Generate a user object from a successful user details request. 194 | * 195 | * @param array $response 196 | * @param AccessToken $token 197 | * 198 | * @return LinkedInResourceOwner 199 | */ 200 | protected function createResourceOwner(array $response, AccessToken $token) 201 | { 202 | return new LinkedInResourceOwner($response); 203 | } 204 | 205 | /** 206 | * Returns the requested fields in scope. 207 | * 208 | * @return array 209 | */ 210 | public function getFields() 211 | { 212 | return $this->fields; 213 | } 214 | 215 | /** 216 | * Updates the requested fields in scope. 217 | * 218 | * @param array $fields 219 | * 220 | * @return LinkedIn 221 | */ 222 | public function withFields(array $fields) 223 | { 224 | $this->fields = $fields; 225 | 226 | return $this; 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/Providers/Custom/LinkedIn/Provider/LinkedInResourceOwner.php: -------------------------------------------------------------------------------- 1 | response = $response; 51 | $this->setSortedProfilePictures(); 52 | } 53 | 54 | /** 55 | * Gets resource owner attribute by key. The key supports dot notation. 56 | * 57 | * @return mixed 58 | */ 59 | public function getAttribute($key) 60 | { 61 | return $this->getValueByKey($this->response, (string) $key); 62 | } 63 | 64 | /** 65 | * Get user first name. 66 | * 67 | * @return string|null 68 | */ 69 | public function getFirstName() 70 | { 71 | return $this->getAttribute('given_name'); 72 | } 73 | 74 | /** 75 | * Get user user id. 76 | * 77 | * @return string|null 78 | */ 79 | public function getId() 80 | { 81 | return $this->getAttribute('sub'); 82 | } 83 | 84 | /** 85 | * Get specific image by size. 86 | * 87 | * @param int $size 88 | * 89 | * @return array|null 90 | */ 91 | public function getImageBySize($size) 92 | { 93 | $pictures = array_filter($this->sortedProfilePictures, function ($picture) use ($size) { 94 | return isset($picture['width']) && $picture['width'] == $size; 95 | }); 96 | 97 | return count($pictures) ? $pictures[0] : null; 98 | } 99 | 100 | /** 101 | * Get available user image sizes. 102 | * 103 | * @return array 104 | */ 105 | public function getImageSizes() 106 | { 107 | return array_map(function ($picture) { 108 | return $this->getValueByKey($picture, 'width'); 109 | }, $this->sortedProfilePictures); 110 | } 111 | 112 | /** 113 | * Get user image url. 114 | * 115 | * @return string|null 116 | */ 117 | public function getImageUrl() 118 | { 119 | $pictures = $this->getSortedProfilePictures(); 120 | $picture = array_pop($pictures); 121 | 122 | return $picture ? $this->getValueByKey($picture, 'url') : null; 123 | } 124 | 125 | /** 126 | * Get user last name. 127 | * 128 | * @return string|null 129 | */ 130 | public function getLastName() 131 | { 132 | return $this->getAttribute('family_name'); 133 | } 134 | 135 | /** 136 | * Returns the sorted collection of profile pictures. 137 | * 138 | * @return array 139 | */ 140 | public function getSortedProfilePictures() 141 | { 142 | return $this->sortedProfilePictures; 143 | } 144 | 145 | /** 146 | * Get user url. 147 | * 148 | * @return string|null 149 | */ 150 | public function getUrl() 151 | { 152 | $vanityName = $this->getAttribute('vanityName'); 153 | 154 | return $vanityName ? sprintf('https://www.linkedin.com/in/%s', $vanityName) : null; 155 | } 156 | 157 | /** 158 | * Get user email, if available. 159 | * 160 | * @return string|null 161 | */ 162 | public function getEmail() 163 | { 164 | return $this->getAttribute('email'); 165 | } 166 | 167 | /** 168 | * Attempts to sort the collection of profile pictures included in the profile 169 | * before caching them in the resource owner instance. 170 | * 171 | * @return void 172 | */ 173 | private function setSortedProfilePictures() 174 | { 175 | $pictures = $this->getAttribute('profilePicture.displayImage~.elements'); 176 | if (is_array($pictures)) { 177 | $pictures = array_filter($pictures, function ($element) { 178 | // filter to public images only 179 | return 180 | isset($element['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']) 181 | && strtoupper($element['authorizationMethod']) === 'PUBLIC' 182 | && isset($element['identifiers'][0]['identifier']); 183 | }); 184 | // order images by width, LinkedIn profile pictures are always squares, so that should be good enough 185 | usort($pictures, function ($elementA, $elementB) { 186 | $wA = $elementA['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width']; 187 | $wB = $elementB['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width']; 188 | 189 | return $wA - $wB; 190 | }); 191 | $pictures = array_map(function ($element) { 192 | // this is an URL, no idea how many of identifiers there can be, so take the first one. 193 | $url = $element['identifiers'][0]['identifier']; 194 | $type = $element['identifiers'][0]['mediaType']; 195 | $width = $element['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width']; 196 | 197 | return [ 198 | 'width' => $width, 199 | 'url' => $url, 200 | 'contentType' => $type, 201 | ]; 202 | }, $pictures); 203 | } else { 204 | $pictures = []; 205 | } 206 | 207 | $this->sortedProfilePictures = $pictures; 208 | } 209 | 210 | /** 211 | * Return all of the owner details available as an array. 212 | * 213 | * @return array 214 | */ 215 | public function toArray() 216 | { 217 | return $this->response; 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/Providers/Custom/LinkedIn/Token/LinkedInAccessToken.php: -------------------------------------------------------------------------------- 1 | isExpirationTimestamp($expires)) { 38 | $expires += time(); 39 | } 40 | $this->refreshTokenExpires = $expires; 41 | } 42 | } 43 | 44 | /** 45 | * Returns the refresh token expiration timestamp, if defined. 46 | * 47 | * @return int|null 48 | */ 49 | public function getRefreshTokenExpires() 50 | { 51 | return $this->refreshTokenExpires; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Providers/Discord.php: -------------------------------------------------------------------------------- 1 | 'required', 35 | 'client_secret' => 'required', 36 | ]; 37 | } 38 | 39 | public function provider(string $redirectUri): AbstractProvider 40 | { 41 | return new DiscordProvider([ 42 | 'clientId' => $this->getSetting('client_id'), 43 | 'clientSecret' => $this->getSetting('client_secret'), 44 | 'redirectUri' => $redirectUri, 45 | ]); 46 | } 47 | 48 | public function options(): array 49 | { 50 | return ['scope' => ['identify', 'email']]; 51 | } 52 | 53 | public function suggestions(Registration $registration, $user, string $token) 54 | { 55 | $this->verifyEmail($email = $user->getEmail()); 56 | 57 | $hash = $user->getAvatarHash(); 58 | $file = $hash ? 59 | "https://cdn.discordapp.com/avatars/{$user->getId()}/{$user->getAvatarHash()}.png" 60 | : 'https://cdn.discordapp.com/embed/avatars/0.png'; 61 | 62 | $registration 63 | ->provideTrustedEmail($email) 64 | ->provideAvatar($file) 65 | ->suggestUsername($user->getUsername() ?: '') 66 | ->setPayload($user->toArray()); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Providers/Facebook.php: -------------------------------------------------------------------------------- 1 | 'required', 35 | 'app_secret' => 'required', 36 | ]; 37 | } 38 | 39 | public function provider(string $redirectUri): AbstractProvider 40 | { 41 | return new FacebookProvider([ 42 | 'clientId' => $this->getSetting('app_id'), 43 | 'clientSecret' => $this->getSetting('app_secret'), 44 | 'redirectUri' => $redirectUri, 45 | 'graphApiVersion' => 'v3.0', 46 | ]); 47 | } 48 | 49 | public function suggestions(Registration $registration, $user, string $token) 50 | { 51 | $this->verifyEmail($email = $user->getEmail()); 52 | 53 | $registration 54 | ->provideTrustedEmail($email) 55 | ->provideAvatar($user->getPictureUrl() ?: '') 56 | ->suggestUsername($user->getName() ?: '') 57 | ->setPayload($user->toArray()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Providers/GitHub.php: -------------------------------------------------------------------------------- 1 | 'required', 41 | 'client_secret' => 'required', 42 | ]; 43 | } 44 | 45 | public function provider(string $redirectUri): AbstractProvider 46 | { 47 | return $this->provider = new GitHubProvider([ 48 | 'clientId' => $this->getSetting('client_id'), 49 | 'clientSecret' => $this->getSetting('client_secret'), 50 | 'redirectUri' => $redirectUri, 51 | ]); 52 | } 53 | 54 | public function options(): array 55 | { 56 | return ['scope' => ['user:email']]; 57 | } 58 | 59 | public function suggestions(Registration $registration, $user, string $token) 60 | { 61 | $this->verifyEmail($email = $user->getEmail() ?: $this->getEmailFromApi($token)); 62 | 63 | $registration 64 | ->provideTrustedEmail($email) 65 | ->suggestUsername($user->getNickname() ?: '') 66 | ->provideAvatar(Arr::get($user->toArray(), 'avatar_url', '')) 67 | ->setPayload($user->toArray()); 68 | } 69 | 70 | private function getEmailFromApi(string $token) 71 | { 72 | $url = $this->provider->apiDomain.'/user/emails'; 73 | 74 | $response = $this->provider->getResponse( 75 | $this->provider->getAuthenticatedRequest('GET', $url, $token) 76 | ); 77 | 78 | $emails = json_decode($response->getBody(), true); 79 | 80 | foreach ($emails as $email) { 81 | if ($email['primary'] && $email['verified']) { 82 | return $email['email']; 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Providers/GitLab.php: -------------------------------------------------------------------------------- 1 | 'required', 35 | 'client_secret' => 'required', 36 | 'domain' => '', 37 | ]; 38 | } 39 | 40 | public function provider(string $redirectUri): AbstractProvider 41 | { 42 | $options = [ 43 | 'clientId' => $this->getSetting('client_id'), 44 | 'clientSecret' => $this->getSetting('client_secret'), 45 | 'redirectUri' => $redirectUri, 46 | ]; 47 | $domain = $this->getSetting('domain'); 48 | 49 | if ($domain) { 50 | $options['domain'] = $domain; 51 | } 52 | 53 | return new GitlabProvider($options); 54 | } 55 | 56 | public function options(): array 57 | { 58 | return ['scope' => 'read_user']; 59 | } 60 | 61 | public function suggestions(Registration $registration, $user, string $token) 62 | { 63 | $this->verifyEmail($email = $user->getEmail()); 64 | 65 | $registration 66 | ->provideTrustedEmail($email) 67 | ->provideAvatar($user->getAvatarUrl() ?: '') 68 | ->suggestUsername($user->getUsername() ?: '') 69 | ->setPayload($user->toArray()); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Providers/Google.php: -------------------------------------------------------------------------------- 1 | 'required', 35 | 'client_secret' => 'required', 36 | 'hosted_domain' => '', 37 | ]; 38 | } 39 | 40 | public function provider(string $redirectUri): AbstractProvider 41 | { 42 | return new GoogleProvider([ 43 | 'clientId' => $this->getSetting('client_id'), 44 | 'clientSecret' => $this->getSetting('client_secret'), 45 | 'redirectUri' => $redirectUri, 46 | 'approvalPrompt' => 'force', 47 | 'hostedDomain' => $this->getHostedDomain(), 48 | 'accessType' => 'offline', 49 | ]); 50 | } 51 | 52 | /** 53 | * @return string|null 54 | */ 55 | protected function getHostedDomain() 56 | { 57 | $hostedDomain = $this->getSetting('hosted_domain'); 58 | 59 | // Return null if $hostedDomain is an empty string 60 | return $hostedDomain !== '' ? $hostedDomain : null; 61 | } 62 | 63 | public function suggestions(Registration $registration, $user, string $token) 64 | { 65 | $this->verifyEmail($email = $user->getEmail()); 66 | 67 | $registration 68 | ->provideTrustedEmail($email) 69 | ->suggestUsername($user->getName()) 70 | ->provideAvatar($user->getAvatar()) 71 | ->setPayload($user->toArray()); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Providers/LinkedIn.php: -------------------------------------------------------------------------------- 1 | settings = $settings; 30 | } 31 | 32 | /** 33 | * @var LinkedInProvider 34 | */ 35 | protected $provider; 36 | 37 | public function name(): string 38 | { 39 | return 'linkedin'; 40 | } 41 | 42 | public function link(): string 43 | { 44 | return 'https://linkedin.com/developers/apps/new'; 45 | } 46 | 47 | public function fields(): array 48 | { 49 | return [ 50 | 'client_id' => 'required', 51 | 'client_secret' => 'required', 52 | ]; 53 | } 54 | 55 | public function provider(string $redirectUri): AbstractProvider 56 | { 57 | return $this->provider = new LinkedInProvider([ 58 | 'clientId' => $this->getSetting('client_id'), 59 | 'clientSecret' => $this->getSetting('client_secret'), 60 | 'redirectUri' => $redirectUri, 61 | ]); 62 | } 63 | 64 | public function suggestions(Registration $registration, $user, string $token) 65 | { 66 | $this->verifyEmail($email = $user->getEmail()); 67 | 68 | $registration 69 | ->provideTrustedEmail($email) 70 | ->suggestUsername($user->getFirstName()) 71 | ->setPayload($user->toArray()); 72 | 73 | $avatar = $user->getImageUrl(); 74 | if ($avatar) { 75 | $registration->provideAvatar($avatar); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Providers/Twitter.php: -------------------------------------------------------------------------------- 1 | 'required', 33 | 'api_secret' => 'required', 34 | ]; 35 | } 36 | 37 | public function excludeFromRoutePattern(): bool 38 | { 39 | return true; 40 | } 41 | 42 | public function provider(string $redirectUri): ?AbstractProvider 43 | { 44 | return null; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Query/SsoIdFilterGambit.php: -------------------------------------------------------------------------------- 1 | getActor()->hasPermission('fof-oauth.admin.permissions.moderate_user_providers')) { 28 | return false; 29 | } 30 | 31 | return parent::apply($search, $bit); 32 | } 33 | 34 | public function getGambitPattern() 35 | { 36 | return 'sso:(.+)'; 37 | } 38 | 39 | protected function conditions(SearchState $search, array $matches, $negate) 40 | { 41 | $this->constrain($search->getQuery(), $matches[1], $negate); 42 | } 43 | 44 | public function getFilterKey(): string 45 | { 46 | return 'sso'; 47 | } 48 | 49 | public function filter(FilterState $filterState, $filterValue, bool $negate) 50 | { 51 | if (!$filterState->getActor()->hasPermission('fof-oauth.admin.permissions.moderate_user_providers')) { 52 | return; 53 | } 54 | 55 | $this->constrain($filterState->getQuery(), $filterValue, $negate); 56 | } 57 | 58 | protected function constrain(Builder $query, $rawSso, bool $negate) 59 | { 60 | $sso = $this->asString($rawSso); 61 | 62 | $query->whereIn('id', function ($query) use ($sso) { 63 | $query->select('user_id') 64 | ->from('login_providers') 65 | ->where('identifier', $sso); 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/integration/AuthenticationFlowTest.php: -------------------------------------------------------------------------------- 1 | extension('fof-oauth'); 32 | 33 | $this->prepareDatabase([ 34 | 'users' => [ 35 | $this->normalUser(), 36 | [ 37 | 'id' => 3, 'username' => 'Seboubeach', 38 | 'is_email_confirmed' => 1, 'email' => 'Seboubeach1@machine.local', 39 | 'joined_at' => '2021-01-01 00:00:00', 40 | ], 41 | [ 42 | 'id' => 4, 'username' => 'Hephoica', 43 | 'is_email_confirmed' => 1, 'email' => 'Hephoica@machine.local', 44 | 'joined_at' => '2021-01-01 00:00:00', 45 | ], 46 | ], 47 | 'login_providers' => [ 48 | ['id' => 1, 'user_id' => 3, 'provider' => 'gitlab', 'identifier' => '123456'], 49 | ], 50 | 'group_permission' => [ 51 | ['permission' => 'user.editOwnNickname', 'group_id' => 4], 52 | ], 53 | ]); 54 | 55 | $this->setting('fof-oauth.gitlab.client_id', 'test'); 56 | $this->setting('fof-oauth.gitlab.client_secret', 'test'); 57 | $this->setting('fof-oauth.gitlab', 1); 58 | } 59 | 60 | public function test_loginProvider_is_set_with_correct_value_after_oauth_login(): void 61 | { 62 | $this->mockProvider('123456', 'Seboubeach1@machine.local'); 63 | 64 | $response = $this->send($this->request('GET', '/auth/gitlab')); 65 | 66 | // get query params from location url in the header 67 | $location = $response->getHeaderLine('location'); 68 | parse_str(parse_url($location, PHP_URL_QUERY), $query); 69 | 70 | $request = $this->request('GET', '/auth/gitlab') 71 | ->withQueryParams([ 72 | 'code' => 'code:123456', 73 | 'state' => $query['state'], 74 | ]) 75 | ->withCookieParams($this->toRequestCookies($response)); 76 | 77 | $response = $this->send($request); 78 | $content = $response->getBody()->getContents(); 79 | 80 | // check if the content contains is_loggedIn 81 | $this->assertStringContainsString( 82 | 'window.opener.app.authenticationComplete(', 83 | $content 84 | ); 85 | 86 | preg_match('/window.opener.app.authenticationComplete\((.*)\)/', $content, $matches); 87 | $json = json_decode($matches[1], true); 88 | 89 | $this->assertArrayHasKey('loggedIn', $json); 90 | 91 | $response = $this->send($this->request('GET', '/')->withCookieParams($this->toRequestCookies($response))); 92 | $this->assertEquals(200, $response->getStatusCode()); 93 | 94 | $this->checkOauthProviderIsSerialized($this->toRequestCookies($response), 'gitlab'); 95 | } 96 | 97 | protected function checkOauthProviderIsSerialized(array $cookies, ?string $value = null): void 98 | { 99 | $response = $this->send( 100 | $this->request('GET', '/api')->withCookieParams($cookies) 101 | ); 102 | 103 | $this->assertEquals(200, $response->getStatusCode()); 104 | 105 | $body = json_decode($response->getBody()->getContents(), true); 106 | 107 | // get user from included 108 | $user = array_filter($body['included'], function ($item) { 109 | return $item['type'] === 'users'; 110 | }); 111 | 112 | $user = array_values($user)[0]; 113 | $this->assertArrayHasKey('loginProvider', $user['attributes']); 114 | $this->assertEquals($value, $user['attributes']['loginProvider']); 115 | } 116 | 117 | private function mockProvider(string $identifier, string $email): void 118 | { 119 | $container = $this->app()->getContainer(); 120 | 121 | $mockProvider = $this->getMockBuilder(Gitlab::class) 122 | ->setConstructorArgs([ 123 | 'options' => [ 124 | 'clientId' => 'test', 125 | 'clientSecret' => 'test', 126 | 'redirectUri' => 'http://localhost/auth/gitlab', 127 | ], 128 | ]) 129 | ->onlyMethods(['getAccessToken', 'getResourceOwner']) 130 | ->getMock(); 131 | 132 | $accessToken = new AccessToken(['access_token' => '123456', 'expires' => time() + 3600]); 133 | $mockProvider->method('getAccessToken')->willReturn( 134 | $accessToken 135 | ); 136 | $mockProvider->method('getResourceOwner')->willReturn( 137 | new GitlabResourceOwner(['id' => $identifier, 'email' => $email], $accessToken) 138 | ); 139 | 140 | $mockFofProvider = $this->getMockBuilder(\FoF\OAuth\Providers\GitLab::class) 141 | ->setConstructorArgs([ 142 | 'settings' => $container->make(SettingsRepositoryInterface::class), 143 | ]) 144 | ->onlyMethods(['provider']) 145 | ->getMock(); 146 | $mockFofProvider->method('provider')->willReturn($mockProvider); 147 | 148 | $this->app()->getContainer()->instance(\FoF\OAuth\Providers\GitLab::class, $mockFofProvider); 149 | } 150 | 151 | protected function toRequestCookies(ResponseInterface $response): array 152 | { 153 | $responseCookies = []; 154 | foreach (SetCookies::fromResponse($response)->getAll() as $cookie) { 155 | $responseCookies[$cookie->getName()] = $cookie->getValue(); 156 | } 157 | 158 | return $responseCookies; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /tests/integration/api/CurrentUserAttributesTest.php: -------------------------------------------------------------------------------- 1 | extend( 29 | (new Extend\Csrf())->exemptRoute('login') 30 | ); 31 | 32 | $this->extension('fof-oauth'); 33 | 34 | $this->prepareDatabase([ 35 | 'users' => [ 36 | $this->normalUser(), 37 | [ 38 | 'id' => 3, 'username' => 'oauth_user', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 39 | 'is_email_confirmed' => 1, 'email' => 'oauth_user@example.com', 40 | 'joined_at' => '2021-01-01 00:00:00', 41 | ], 42 | ], 43 | 'login_providers' => [ 44 | [ 45 | 'id' => 1, 46 | 'user_id' => 3, 47 | 'provider' => 'gitlab', 48 | 'identifier' => '123456', 49 | 'last_login_at' => '2023-01-01 00:00:00', 50 | ], 51 | [ 52 | 'id' => 2, 53 | 'user_id' => 3, 54 | 'provider' => 'github', 55 | 'identifier' => '654321', 56 | 'last_login_at' => '2023-01-02 00:00:00', 57 | ], 58 | ], 59 | ]); 60 | } 61 | 62 | /** 63 | * @test 64 | */ 65 | public function it_includes_login_provider_in_current_user_attributes() 66 | { 67 | // Log in as the user with OAuth providers 68 | $response = $this->send( 69 | $this->request('POST', '/login', [ 70 | 'json' => [ 71 | 'identification' => 'oauth_user', 72 | 'password' => 'too-obscure', 73 | ], 74 | ]) 75 | ); 76 | 77 | $this->assertEquals(200, $response->getStatusCode()); 78 | 79 | // Get the current user data 80 | $response = $this->send( 81 | $this->request('GET', '/api/users/3', ['cookiesFrom' => $response]) 82 | ); 83 | 84 | $this->assertEquals(200, $response->getStatusCode()); 85 | 86 | $body = json_decode($response->getBody()->getContents(), true); 87 | 88 | // Check that the loginProvider attribute is present and has the most recent provider 89 | $this->assertArrayHasKey('loginProvider', $body['data']['attributes']); 90 | $this->assertEquals('github', $body['data']['attributes']['loginProvider']); 91 | } 92 | 93 | /** 94 | * @test 95 | */ 96 | public function it_returns_null_for_users_without_login_providers() 97 | { 98 | // Log in as a normal user without OAuth providers 99 | $response = $this->send( 100 | $this->request('POST', '/login', [ 101 | 'json' => [ 102 | 'identification' => 'normal', 103 | 'password' => 'too-obscure', 104 | ], 105 | ]) 106 | ); 107 | 108 | $this->assertEquals(200, $response->getStatusCode()); 109 | 110 | // Get the current user data 111 | $response = $this->send( 112 | $this->request('GET', '/api/users/2', ['cookiesFrom' => $response]) 113 | ); 114 | 115 | $this->assertEquals(200, $response->getStatusCode()); 116 | 117 | $body = json_decode($response->getBody()->getContents(), true); 118 | 119 | // Check that the loginProvider attribute is present but null 120 | $this->assertArrayHasKey('loginProvider', $body['data']['attributes']); 121 | $this->assertNull($body['data']['attributes']['loginProvider']); 122 | } 123 | 124 | /** 125 | * @test 126 | */ 127 | public function it_uses_cached_provider_when_available() 128 | { 129 | // Log in as the user with OAuth providers 130 | $response = $this->send( 131 | $this->request('POST', '/login', [ 132 | 'json' => [ 133 | 'identification' => 'oauth_user', 134 | 'password' => 'too-obscure', 135 | ], 136 | ]) 137 | ); 138 | 139 | $this->assertEquals(200, $response->getStatusCode()); 140 | 141 | // Get the session ID from cookies 142 | $cookies = []; 143 | foreach ($response->getHeaders()['Set-Cookie'] as $cookie) { 144 | if (strpos($cookie, 'flarum_session=') === 0) { 145 | preg_match('/flarum_session=([^;]+)/', $cookie, $matches); 146 | $sessionId = $matches[1]; 147 | break; 148 | } 149 | } 150 | 151 | // Manually set a cached value 152 | $cache = $this->app()->getContainer()->make(Cache::class); 153 | $cache->forever(AbstractOAuthController::SESSION_OAUTH2PROVIDER.'_'.$sessionId, 'discord'); 154 | 155 | // Get the current user data 156 | $response = $this->send( 157 | $this->request('GET', '/api/users/3', ['cookiesFrom' => $response]) 158 | ); 159 | 160 | $this->assertEquals(200, $response->getStatusCode()); 161 | 162 | $body = json_decode($response->getBody()->getContents(), true); 163 | 164 | // Check that the loginProvider attribute uses the cached value 165 | $this->assertArrayHasKey('loginProvider', $body['data']['attributes']); 166 | $this->assertEquals('discord', $body['data']['attributes']['loginProvider']); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /tests/integration/api/ForumSerializerTest.php: -------------------------------------------------------------------------------- 1 | extension('fof-oauth'); 27 | 28 | $this->extend( 29 | (new Extend\Csrf())->exemptRoute('login') 30 | ); 31 | 32 | $this->prepareDatabase([ 33 | 'users' => [ 34 | $this->normalUser(), 35 | ['id' => 3, 'username' => 'moderator', 'is_email_confirmed' => true], 36 | ], 37 | 'group_user' => [ 38 | ['user_id' => 3, 'group_id' => 4], 39 | ], 40 | 'group_permission' => [ 41 | ['permission' => 'moderateUserProviders', 'group_id' => 4], 42 | ], 43 | ]); 44 | } 45 | 46 | public function authorizedUserProvider() 47 | { 48 | return [ 49 | [1], 50 | [3], 51 | ]; 52 | } 53 | 54 | /** 55 | * @test 56 | */ 57 | public function it_includes_providers_in_forum_attributes_for_guests() 58 | { 59 | $response = $this->send( 60 | $this->request('GET', '/api') 61 | ); 62 | 63 | $this->assertEquals(200, $response->getStatusCode()); 64 | 65 | $body = json_decode($response->getBody(), true); 66 | 67 | $this->assertArrayHasKey('fof-oauth', $body['data']['attributes']); 68 | $this->assertArrayNotHasKey('fofOauthModerate', $body['data']['attributes']); 69 | } 70 | 71 | /** 72 | * @dataProvider authorizedUserProvider 73 | * 74 | * @test 75 | */ 76 | public function it_does_not_include_providers_in_forum_attributes_for_logged_in_users(int $userId) 77 | { 78 | $response = $this->send( 79 | $this->request('GET', '/api', ['authenticatedAs' => $userId]) 80 | ); 81 | 82 | $this->assertEquals(200, $response->getStatusCode()); 83 | 84 | $body = json_decode($response->getBody()->getContents(), true); 85 | 86 | $this->assertArrayNotHasKey('fof-oauth', $body['data']['attributes']); 87 | $this->assertArrayHasKey('fofOauthModerate', $body['data']['attributes']); 88 | $this->assertTrue($body['data']['attributes']['fofOauthModerate']); 89 | } 90 | 91 | /** 92 | * @test 93 | */ 94 | public function normal_user_does_not_have_moderate_flag() 95 | { 96 | $response = $this->send( 97 | $this->request('GET', '/api', ['authenticatedAs' => 2]) 98 | ); 99 | 100 | $this->assertEquals(200, $response->getStatusCode()); 101 | 102 | $body = json_decode($response->getBody()->getContents(), true); 103 | 104 | $this->assertArrayNotHasKey('fof-oauth', $body['data']['attributes']); 105 | $this->assertArrayHasKey('fofOauthModerate', $body['data']['attributes']); 106 | $this->assertFalse($body['data']['attributes']['fofOauthModerate']); 107 | } 108 | 109 | /** 110 | * @test 111 | */ 112 | public function admin_panel_is_available() 113 | { 114 | $login = $this->send( 115 | $this->request('POST', '/login', [ 116 | 'json' => [ 117 | 'identification' => 'admin', 118 | 'password' => 'password', 119 | ], 120 | ]) 121 | ); 122 | 123 | $this->assertEquals(200, $login->getStatusCode()); 124 | 125 | $response = $this->send( 126 | $this->request('GET', '/admin', ['cookiesFrom' => $login]) 127 | ); 128 | 129 | $this->assertEquals(200, $response->getStatusCode()); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /tests/integration/setup.php: -------------------------------------------------------------------------------- 1 | run(); 19 | -------------------------------------------------------------------------------- /tests/phpunit.integration.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ../src/ 17 | 18 | 19 | 20 | 21 | ./integration 22 | ./integration/tmp 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/phpunit.unit.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ../src/ 17 | 18 | 19 | 20 | 21 | ./unit 22 | 23 | 24 | 25 | 26 | 27 | 28 | --------------------------------------------------------------------------------