diff --git a/composer.json b/composer.json index 70449dbeb..5146e035b 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,6 @@ "azure-oss/storage-blob-flysystem": "^1.2", "brianium/paratest": "^7.14", "carthage-software/mago": "1.0.0-beta.28", - "depotwarehouse/oauth2-twitch": "^1.3", "guzzlehttp/psr7": "^2.6.1", "league/flysystem-aws-s3-v3": "^3.25.1", "league/flysystem-ftp": "^3.25.1", @@ -88,6 +87,7 @@ "tempest/blade": "dev-main", "thenetworg/oauth2-azure": "^2.2", "twig/twig": "^3.16", + "vertisan/oauth2-twitch-helix": "^2.0", "wohali/oauth2-discord-new": "^1.2" }, "replace": { diff --git a/docs/2-features/17-oauth.md b/docs/2-features/17-oauth.md index 75ac6dc59..f2ca38203 100644 --- a/docs/2-features/17-oauth.md +++ b/docs/2-features/17-oauth.md @@ -220,6 +220,7 @@ Tempest provides a different configuration object for each OAuth provider. Below - **Microsoft** authentication using {b`Tempest\Auth\OAuth\Config\MicrosoftOAuthConfig`}, - **Slack** authentication using {b`Tempest\Auth\OAuth\Config\SlackOAuthConfig`}, - **Apple** authentication using {b`Tempest\Auth\OAuth\Config\AppleOAuthConfig`}, +- **Twitch** authentication using {b`Tempest\Auth\OAuth\Config\TwitchHelixOAuthConfig`}, - Any other OAuth platform using {b`Tempest\Auth\OAuth\Config\GenericOAuthConfig`}. ## Testing diff --git a/packages/auth/composer.json b/packages/auth/composer.json index 687412af4..4a83377c2 100644 --- a/packages/auth/composer.json +++ b/packages/auth/composer.json @@ -22,7 +22,7 @@ "adam-paterson/oauth2-slack": "^1.1", "wohali/oauth2-discord-new": "^1.2", "smolblog/oauth2-twitter": "^1.0", - "depotwarehouse/oauth2-twitch": "^1.3" + "vertisan/oauth2-twitch-helix": "^2.0" }, "autoload": { "psr-4": { diff --git a/packages/auth/src/Installer/AuthenticationInstaller.php b/packages/auth/src/Installer/AuthenticationInstaller.php index d80395421..efb237f1c 100644 --- a/packages/auth/src/Installer/AuthenticationInstaller.php +++ b/packages/auth/src/Installer/AuthenticationInstaller.php @@ -20,6 +20,8 @@ final class AuthenticationInstaller implements Installer { use PublishesFiles; + public bool $installOAuth = false; + private(set) string $name = 'auth'; public function __construct( @@ -31,17 +33,28 @@ public function __construct( public function install(): void { - $migration = $this->publish(__DIR__ . '/basic-user/CreateUsersTableMigration.stub.php', src_path('Authentication/CreateUsersTable.php')); - $this->publish(__DIR__ . '/basic-user/UserModel.stub.php', src_path('Authentication/User.php')); + // First question, ask whether to also install OAuth, as it changes the stubs to publish + $this->installOAuth = $this->shouldInstallOAuth(); + + // Get the appropriate stubs + $stubPath = $this->installOAuth ? 'oauth' : 'basic-user'; + + // Publish the stubs + $migration = $this->publish(__DIR__ . "/{$stubPath}/CreateUsersTableMigration.stub.php", src_path('Authentication/CreateUsersTable.php')); + $this->publish(__DIR__ . "/{$stubPath}/UserModel.stub.php", src_path('Authentication/User.php')); + $this->publish(__DIR__ . '/basic-user/MustBeAuthenticated.stub.php', src_path('Authentication/MustBeAuthenticated.php')); + $this->publish(__DIR__ . '/basic-user/LoginController.stub.php', src_path('Authentication/LoginController.php')); $this->publishImports(); + // Offer to migrate if ($migration && $this->shouldMigrate()) { $this->migrationManager->executeUp( migration: $this->container->get(to_fqcn($migration, root: root_path())), ); } - if ($this->shouldInstallOAuth()) { + // Run the OAuth installer now + if ($this->installOAuth) { $this->container->get(OAuthInstaller::class)->install(); } } diff --git a/packages/auth/src/Installer/OAuthInstaller.php b/packages/auth/src/Installer/OAuthInstaller.php index 0ee15ec40..1ff9ca0d4 100644 --- a/packages/auth/src/Installer/OAuthInstaller.php +++ b/packages/auth/src/Installer/OAuthInstaller.php @@ -115,14 +115,12 @@ private function publishController(SupportedOAuthProvider $provider): void 'redirect-route', 'callback-route', "'user-model-fqcn'", - 'provider_db_column', ], replace: [ "\\{$providerFqcn}::{$provider->name}", "/auth/{$name}", "/auth/{$name}/callback", "\\{$userModelFqcn}::class", - "{$name}_id", ], ), ); diff --git a/packages/auth/src/Installer/basic-user/LoginController.stub.php b/packages/auth/src/Installer/basic-user/LoginController.stub.php new file mode 100644 index 000000000..3eb2325af --- /dev/null +++ b/packages/auth/src/Installer/basic-user/LoginController.stub.php @@ -0,0 +1,66 @@ +select() + ->where('email', $request->email) + ->first(); + + if (! $user || ! $this->passwordHasher->verify($request->password, $user->password)) { + return new Redirect('/login')->flash('error', 'Invalid credentials'); + } + + $this->authenticator->authenticate($user); + + // Get the intended URL and redirect there, or default to home + // getIntended() automatically consumes/removes the stored URL + $intendedUrl = $this->previousUrl->getIntended('/dashboard'); + + return new Redirect($intendedUrl) + ->flash('success', 'Logged in successfully'); + } + + #[Post('/auth/logout')] + public function logout(): Redirect + { + $this->authenticator->deauthenticate(); + + return new Redirect('/') + ->flash('success', 'You have been logged out'); + } +} diff --git a/packages/auth/src/Installer/basic-user/MustBeAuthenticated.stub.php b/packages/auth/src/Installer/basic-user/MustBeAuthenticated.stub.php new file mode 100644 index 000000000..f4f70c50c --- /dev/null +++ b/packages/auth/src/Installer/basic-user/MustBeAuthenticated.stub.php @@ -0,0 +1,43 @@ +authenticator->current(); + + if ($user === null) { + // Store the intended URL + $this->previousUrl->setIntended($request->path); + + // Redirect to login if not authenticated + return new Redirect('/auth/login') + ->flash('error', 'You must be logged in to access this page'); + } + + // User is authenticated, continue with request + return $next($request); + } +} diff --git a/packages/auth/src/Installer/oauth/CreateUsersTableMigration.stub.php b/packages/auth/src/Installer/oauth/CreateUsersTableMigration.stub.php new file mode 100644 index 000000000..c5f5537d8 --- /dev/null +++ b/packages/auth/src/Installer/oauth/CreateUsersTableMigration.stub.php @@ -0,0 +1,28 @@ +primary() + ->string('email') + ->string('password', nullable: true) + ->string('name', nullable: true) + ->string('nickname', nullable: true) + ->string('avatar', nullable: true) + ->string('oauth_id', nullable: true) + ->string('oauth_raw', nullable: true) + ->string('oauth_provider', nullable: true); + } +} diff --git a/packages/auth/src/Installer/oauth/OAuthControllerStub.php b/packages/auth/src/Installer/oauth/OAuthControllerStub.php index 293ab874f..f9d9c7693 100644 --- a/packages/auth/src/Installer/oauth/OAuthControllerStub.php +++ b/packages/auth/src/Installer/oauth/OAuthControllerStub.php @@ -11,6 +11,7 @@ use Tempest\Discovery\SkipDiscovery; use Tempest\Http\Request; use Tempest\Http\Responses\Redirect; +use Tempest\Http\Session\PreviousUrl; use Tempest\Router\Get; use function Tempest\Database\query; @@ -21,6 +22,7 @@ public function __construct( #[Tag('tag_name')] private OAuthClient $oauth, + private PreviousUrl $previousUrl, ) {} #[Get('redirect-route')] @@ -32,19 +34,27 @@ public function redirect(): Redirect #[Get('callback-route')] public function callback(Request $request): Redirect { - // TODO: implement, the code below is an example + // TODO: implement, the code below is an example, customise to suit your application $this->oauth->authenticate( request: $request, map: fn (OAuthUser $user): Authenticatable => query('user-model-fqcn')->updateOrCreate([ - 'provider_db_column' => $user->id, + 'oauth_id' => $user->id, ], [ - 'provider_db_column' => $user->id, + 'oauth_id' => $user->id, 'username' => $user->nickname, 'email' => $user->email, + 'name' => $user->name, + 'nickname' => $user->nickname, + 'avatar' => $user->avatar, + 'oauth_raw' => $user->raw, + 'oauth_provider' => 'tag_name', ]), ); - return new Redirect('/'); + $intendedUrl = $this->previousUrl->getIntended('/dashboard'); + + return new Redirect($intendedUrl) + ->flash('success', 'Logged in successfully'); } } diff --git a/packages/auth/src/Installer/oauth/UserModel.stub.php b/packages/auth/src/Installer/oauth/UserModel.stub.php new file mode 100644 index 000000000..b8a28fceb --- /dev/null +++ b/packages/auth/src/Installer/oauth/UserModel.stub.php @@ -0,0 +1,27 @@ + $this->clientId, + 'clientSecret' => $this->clientSecret, + 'redirectUri' => $this->redirectTo, + ]); + } + + /** + * @param TwitchHelixResourceOwner $resourceOwner + */ + public function mapUser(ObjectFactory $factory, ResourceOwnerInterface $resourceOwner): OAuthUser + { + return $factory->withData([ + 'id' => (string) $resourceOwner->getId(), + 'email' => $resourceOwner->getEmail(), + 'name' => $resourceOwner->getDisplayName(), + 'nickname' => $resourceOwner->getDisplayName(), + 'avatar' => $resourceOwner->getProfileImageUrl(), + 'provider' => $this->provider, + 'raw' => $resourceOwner->toArray(), + ])->to(OAuthUser::class); + } +} diff --git a/packages/auth/src/OAuth/SupportedOAuthProvider.php b/packages/auth/src/OAuth/SupportedOAuthProvider.php index 1346a942d..94d260a95 100644 --- a/packages/auth/src/OAuth/SupportedOAuthProvider.php +++ b/packages/auth/src/OAuth/SupportedOAuthProvider.php @@ -11,6 +11,7 @@ use League\OAuth2\Client\Provider\Instagram; use League\OAuth2\Client\Provider\LinkedIn; use Stevenmaguire\OAuth2\Client\Provider\Microsoft; +use Vertisan\OAuth2\Client\Provider\TwitchHelix; use Wohali\OAuth2\Client\Provider\Discord; enum SupportedOAuthProvider: string @@ -25,6 +26,7 @@ enum SupportedOAuthProvider: string case LINKEDIN = LinkedIn::class; case MICROSOFT = Microsoft::class; case SLACK = Slack::class; + case TWITCHHELIX = TwitchHelix::class; public function composerPackage(): ?string { @@ -39,6 +41,7 @@ public function composerPackage(): ?string self::LINKEDIN => 'league/oauth2-linkedin', self::MICROSOFT => 'stevenmaguire/oauth2-microsoft', self::SLACK => 'adam-paterson/oauth2-slack', + self::TWITCHHELIX => 'vertisan/oauth2-twitch-helix', }; } } diff --git a/src/Tempest/Framework/Authentication/CreateUsersTable.php b/src/Tempest/Framework/Authentication/CreateUsersTable.php new file mode 100644 index 000000000..3d6823d11 --- /dev/null +++ b/src/Tempest/Framework/Authentication/CreateUsersTable.php @@ -0,0 +1,26 @@ +primary() + ->string('email') + ->string('password', nullable: true) + ->string('name', nullable: true) + ->string('nickname', nullable: true) + ->string('avatar', nullable: true) + ->string('oauth_id', nullable: true) + ->string('oauth_raw', nullable: true) + ->string('oauth_provider', nullable: true); + } +} diff --git a/tests/Integration/Auth/Installer/OAuthInstallerTest.php b/tests/Integration/Auth/Installer/OAuthInstallerTest.php index 22e754b60..7fd0a9b6a 100644 --- a/tests/Integration/Auth/Installer/OAuthInstallerTest.php +++ b/tests/Integration/Auth/Installer/OAuthInstallerTest.php @@ -121,6 +121,11 @@ public static function oauthProvider(): array 'expectedConfigPath' => 'App/Authentication/OAuth/slack.config.php', 'expectedControllerPath' => 'App/Authentication/OAuth/SlackController.php', ], + 'twitchhelix' => [ + 'provider' => SupportedOAuthProvider::TWITCHHELIX, + 'expectedConfigPath' => 'App/Authentication/OAuth/twitchhelix.config.php', + 'expectedControllerPath' => 'App/Authentication/OAuth/TwitchHelixController.php', + ], ]; } }