diff --git a/.env.example b/.env.example index a551b36af..a27414961 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,8 @@ OAUTH_KEYCLOAK_SECRET= OAUTH_KEYCLOAK_URI= OAUTH_KEYCLOAK_REALM= OAUTH_KEYCLOAK_VERSION= +OAUTH_SIMPLELOGIN_ID= +OAUTH_SIMPLELOGIN_SECRET= OAUTH_ZITADEL_ID= OAUTH_ZITADEL_SECRET= OAUTH_ZITADEL_BASE_URL= diff --git a/.env.example_docker b/.env.example_docker index 337c8a451..635c94f81 100644 --- a/.env.example_docker +++ b/.env.example_docker @@ -72,6 +72,8 @@ OAUTH_KEYCLOAK_SECRET= OAUTH_KEYCLOAK_URI= OAUTH_KEYCLOAK_REALM= OAUTH_KEYCLOAK_VERSION= +OAUTH_SIMPLELOGIN_ID= +OAUTH_SIMPLELOGIN_SECRET= OAUTH_ZITADEL_ID= OAUTH_ZITADEL_SECRET= OAUTH_ZITADEL_BASE_URL= diff --git a/config/kbin_routes/security.yaml b/config/kbin_routes/security.yaml index 11661e39a..897886ff1 100644 --- a/config/kbin_routes/security.yaml +++ b/config/kbin_routes/security.yaml @@ -93,6 +93,16 @@ oauth_keycloak_verify: path: /oauth/keycloak/verify methods: [ GET ] +oauth_simplelogin_connect: + controller: App\Controller\Security\SimpleLoginController::connect + path: /oauth/simplelogin/connect + methods: [ GET ] + +oauth_simplelogin_verify: + controller: App\Controller\Security\SimpleLoginController::verify + path: /oauth/simplelogin/verify + methods: [ GET ] + oauth_zitadel_connect: controller: App\Controller\Security\ZitadelController::connect path: /oauth/zitadel/connect diff --git a/config/packages/knpu_oauth2_client.yaml b/config/packages/knpu_oauth2_client.yaml index d329673bf..bb547e394 100644 --- a/config/packages/knpu_oauth2_client.yaml +++ b/config/packages/knpu_oauth2_client.yaml @@ -35,6 +35,13 @@ knpu_oauth2_client: version: '%oauth_keycloak_version%' redirect_route: oauth_keycloak_verify redirect_params: { } + simplelogin: + type: generic + client_id: '%oauth_simplelogin_id%' + client_secret: '%oauth_simplelogin_secret%' + redirect_route: oauth_simplelogin_verify + redirect_params: { } + provider_class: 'App\Provider\SimpleLogin' zitadel: type: generic client_id: '%oauth_zitadel_id%' diff --git a/config/packages/security.yaml b/config/packages/security.yaml index b11113027..d77cda718 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -38,6 +38,7 @@ security: - App\Security\GoogleAuthenticator - App\Security\GithubAuthenticator - App\Security\KeycloakAuthenticator + - App\Security\SimpleLoginAuthenticator - App\Security\ZitadelAuthenticator logout: enable_csrf: true diff --git a/config/services.yaml b/config/services.yaml index 35a7c8ea1..f610510b3 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -53,6 +53,9 @@ parameters: oauth_keycloak_realm: "%env(OAUTH_KEYCLOAK_REALM)%" oauth_keycloak_version: "%env(OAUTH_KEYCLOAK_VERSION)%" + oauth_simplelogin_id: "%env(default::OAUTH_SIMPLELOGIN_ID)%" + oauth_simplelogin_secret: "%env(OAUTH_SIMPLELOGIN_SECRET)%" + oauth_zitadel_id: "%env(default::OAUTH_ZITADEL_ID)%" oauth_zitadel_secret: "%env(OAUTH_ZITADEL_SECRET)%" oauth_zitadel_base_url: "%env(OAUTH_ZITADEL_BASE_URL)%" diff --git a/migrations/Version20240503224350.php b/migrations/Version20240503224350.php new file mode 100644 index 000000000..9e2404ea0 --- /dev/null +++ b/migrations/Version20240503224350.php @@ -0,0 +1,29 @@ +addSql('ALTER TABLE "user" ADD oauth_simple_login_id VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE "user" DROP oauth_simple_login_id'); + } +} diff --git a/src/Controller/Security/SimpleLoginController.php b/src/Controller/Security/SimpleLoginController.php new file mode 100644 index 000000000..94fb9ae65 --- /dev/null +++ b/src/Controller/Security/SimpleLoginController.php @@ -0,0 +1,28 @@ +getClient('simplelogin') + ->redirect([ + 'openid', + 'email', + 'profile', + ]); + } + + public function verify(Request $request, ClientRegistry $client) + { + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 2d8c7674e..71dddb727 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -119,6 +119,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil #[Column(type: 'string', nullable: true)] public ?string $oauthKeycloakId = null; #[Column(type: 'string', nullable: true)] + public ?string $oauthSimpleLoginId = null; + #[Column(type: 'string', nullable: true)] public ?string $oauthZitadelId = null; #[Column(type: 'boolean', nullable: false, options: ['default' => true])] public bool $hideAdult = true; @@ -755,7 +757,7 @@ public function removeOAuth2UserConsent(OAuth2UserConsent $oAuth2UserConsent): s public function isSsoControlled(): bool { - return $this->oauthAzureId || $this->oauthGithubId || $this->oauthGoogleId || $this->oauthFacebookId || $this->oauthKeycloakId || $this->oauthZitadelId; + return $this->oauthAzureId || $this->oauthGithubId || $this->oauthGoogleId || $this->oauthFacebookId || $this->oauthKeycloakId || $this->oauthSimpleLoginId || $this->oauthZitadelId; } public function getCustomCss(): ?string diff --git a/src/Provider/SimpleLogin.php b/src/Provider/SimpleLogin.php new file mode 100644 index 000000000..09145c51a --- /dev/null +++ b/src/Provider/SimpleLogin.php @@ -0,0 +1,72 @@ +baseUrl, '/').'/'; + } + + protected function getAuthorizationHeaders($token = null) + { + return ['Authorization' => 'Bearer '.$token]; + } + + public function getBaseAuthorizationUrl() + { + return $this->getBaseUrl().'oauth2/authorize'; + } + + public function getBaseAccessTokenUrl(array $params) + { + return $this->getBaseUrl().'oauth2/token'; + } + + public function getResourceOwnerDetailsUrl(AccessToken $token) + { + return $this->getBaseUrl().'oauth2/userinfo'; + } + + protected function getDefaultScopes() + { + return ['openid', 'profile', 'email']; + } + + protected function checkResponse(ResponseInterface $response, $data) + { + if (!empty($data['error'])) { + $error = htmlentities($data['error'], ENT_QUOTES, 'UTF-8'); + $message = htmlentities($data['error_description'], ENT_QUOTES, 'UTF-8'); + throw new IdentityProviderException($message, $response->getStatusCode(), $response); + } + } + + protected function createResourceOwner(array $response, AccessToken $token) + { + return new SimpleLoginResourceOwner($response); + } + + protected function getScopeSeparator() + { + return ' '; + } +} diff --git a/src/Provider/SimpleLoginResourceOwner.php b/src/Provider/SimpleLoginResourceOwner.php new file mode 100644 index 000000000..a899ed6a0 --- /dev/null +++ b/src/Provider/SimpleLoginResourceOwner.php @@ -0,0 +1,58 @@ +response = $response; + } + + public function getId() + { + return $this->getResponseValue('sub'); + } + + public function getName() + { + return $this->getResponseValue('name'); + } + + public function getEmail() + { + return $this->getResponseValue('email'); + } + + public function getPictureUrl() + { + return $this->getResponseValue('avatar_url'); + } + + public function toArray() + { + return $this->response; + } + + protected function getResponseValue($key) + { + $keys = explode('.', $key); + $value = $this->response; + + foreach ($keys as $k) { + if (isset($value[$k])) { + $value = $value[$k]; + } else { + return null; + } + } + + return $value; + } +} diff --git a/src/Security/SimpleLoginAuthenticator.php b/src/Security/SimpleLoginAuthenticator.php new file mode 100644 index 000000000..32229ac79 --- /dev/null +++ b/src/Security/SimpleLoginAuthenticator.php @@ -0,0 +1,185 @@ +attributes->get('_route'); + } + + public function authenticate(Request $request): Passport + { + $client = $this->clientRegistry->getClient('simplelogin'); + $slugger = $this->slugger; + + $provider = $client->getOAuth2Provider(); + + $accessToken = $provider->getAccessToken('authorization_code', [ + 'code' => $request->query->get('code'), + ]); + + $rememberBadge = new RememberMeBadge(); + $rememberBadge = $rememberBadge->enable(); + + return new SelfValidatingPassport( + new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger) { + /** @var SimpleLoginResourceOwner $simpleloginUser */ + $simpleloginUser = $client->fetchUserFromToken($accessToken); + + $existingUser = $this->entityManager->getRepository(User::class)->findOneBy( + ['oauthSimpleLoginId' => $simpleloginUser->getId()] + ); + + if ($existingUser) { + return $existingUser; + } + + $user = $this->userRepository->findOneBy(['email' => $simpleloginUser->getEmail()]); + + if ($user) { + $user->oauthSimpleLoginId = $simpleloginUser->getId(); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } + + if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) { + throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED'); + } + + $name = $simpleloginUser->getName(); + $name = preg_replace('/\s+/', '', $name); // remove all whitespace + $name = preg_replace('#[[:punct:]]#', '', $name); // remove all punctuation + + $username = $slugger->slug($name); + + $usernameTaken = $this->entityManager->getRepository(User::class)->findOneBy( + ['username' => $username] + ); + + if ($usernameTaken) { + $username = $username.rand(1, 999); + } + + $dto = (new UserDto())->create( + $username, + $simpleloginUser->getEmail() + ); + + $avatar = $this->getAvatar($simpleloginUser->getPictureUrl()); + + if ($avatar) { + $dto->avatar = $this->imageFactory->createDto($avatar); + } + + $dto->plainPassword = bin2hex(random_bytes(20)); + $dto->ip = $this->ipResolver->resolve(); + + $user = $this->userManager->create($dto, false); + $user->oauthSimpleLoginId = $simpleloginUser->getId(); + $user->avatar = $this->getAvatar($simpleloginUser->getPictureUrl()); + $user->isVerified = true; + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + }), + [ + $rememberBadge, + ] + ); + } + + private function getAvatar(?string $pictureUrl): ?Image + { + if (!$pictureUrl) { + return null; + } + + try { + $tempFile = $this->imageManager->download($pictureUrl); + } catch (\Exception $e) { + $tempFile = null; + } + + if ($tempFile) { + $image = $this->imageRepository->findOrCreateFromPath($tempFile); + if ($image) { + $this->entityManager->persist($image); + $this->entityManager->flush(); + } + } + + return $image ?? null; + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + $targetUrl = $this->router->generate('user_settings_profile'); + + return new RedirectResponse($targetUrl); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $message = strtr($exception->getMessageKey(), $exception->getMessageData()); + + if ('MBIN_SSO_REGISTRATIONS_ENABLED' === $message) { + $session = $request->getSession(); + $session->getFlashBag()->add('error', 'sso_registrations_enabled.error'); + + return new RedirectResponse($this->router->generate('app_login')); + } + + return new Response($message, Response::HTTP_FORBIDDEN); + } +} diff --git a/src/Twig/Components/LoginSocialsComponent.php b/src/Twig/Components/LoginSocialsComponent.php index d9772fc04..9f1979b83 100644 --- a/src/Twig/Components/LoginSocialsComponent.php +++ b/src/Twig/Components/LoginSocialsComponent.php @@ -19,6 +19,8 @@ public function __construct( private readonly ?string $oauthGithubId, #[Autowire('%oauth_keycloak_id%')] private readonly ?string $oauthKeycloakId, + #[Autowire('%oauth_simplelogin_id%')] + private readonly ?string $oauthSimpleLoginId, #[Autowire('%oauth_zitadel_id%')] private readonly ?string $oauthZitadelId, #[Autowire('%oauth_azure_id%')] @@ -46,6 +48,11 @@ public function keycloakEnabled(): bool return !empty($this->oauthKeycloakId); } + public function simpleloginEnabled(): bool + { + return !empty($this->oauthSimpleLoginId); + } + public function zitadelEnabled(): bool { return !empty($this->oauthZitadelId); diff --git a/templates/components/login_socials.html.twig b/templates/components/login_socials.html.twig index 117a89758..116ea6578 100644 --- a/templates/components/login_socials.html.twig +++ b/templates/components/login_socials.html.twig @@ -1,5 +1,5 @@ {# @var this App\Twig\Components\LoginSocialsComponent #} -{%- set HAS_ANY_SOCIAL = this.googleEnabled or this.facebookEnabled or this.githubEnabled or this.keycloakEnabled or this.zitadelEnabled or this.azureEnabled -%} +{%- set HAS_ANY_SOCIAL = this.googleEnabled or this.facebookEnabled or this.githubEnabled or this.keycloakEnabled or this.simpleloginEnabled or this.zitadelEnabled or this.azureEnabled -%} {% if HAS_ANY_SOCIAL %} {% if not mbin_sso_only_mode() and not mbin_sso_show_first() %}
@@ -21,6 +21,10 @@ {{ 'continue_with'|trans }} Keycloak {% endif %} + {% if this.simpleloginEnabled %} + + {{ 'continue_with'|trans }} SimpleLogin + {% endif %} {% if this.zitadelEnabled %} {{ 'continue_with'|trans }} Zitadel