Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Languages/en_US/Login.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

// Version: 3.0 Alpha 4; Login

// Login form.
$txt['login_alternatives'] = 'Or sign in with:';

// Registration agreement page.
$txt['agreement_agree'] = 'I accept the terms of the agreement.';
$txt['policy_agree'] = 'I accept the terms of the privacy policy.';
Expand Down Expand Up @@ -178,3 +181,10 @@
$txt['logout_confirm'] = 'Are you sure you want to log out?';
$txt['logout_notice'] = 'You are about to be logged out of the forum and continue browsing as a guest!';
$txt['logout_return'] = 'Stay logged in and return to browsing as a member.';

// External authentication.
$txt['authext_failed'] = 'That sign in could not be completed. Please try again.';
$txt['authext_declined'] = 'The sign in was cancelled.';
$txt['authext_provider_unavailable'] = 'That sign in method is not available right now.';
$txt['authext_no_account'] = 'There is no account here for that sign in, and this provider is not allowed to create one.';
$txt['authext_not_activated'] = 'That account is not activated yet.';
34 changes: 34 additions & 0 deletions Languages/en_US/ManageSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -487,3 +487,37 @@
$txt['export_min_diskspace_pct'] = 'Pause exports if free space on disk is less than';
$txt['export_rate'] = 'Rate at which to process posts & personal messages for export';
$txt['export_rate_desc'] = 'Higher values will compile exports more quickly, but could affect forum performance.';

// External authentication providers.
$txt['authentication_providers'] = 'Sign in providers';
$txt['authentication_providers_desc'] = 'Lets members sign in with an external account instead of a password. Each provider has to be registered with them first, which is where the client ID and secret come from.';
$txt['authentication_no_providers'] = 'No providers have been set up yet.';
$txt['authentication_add'] = 'Add a provider';
$txt['authentication_add_generic'] = 'Any OpenID Connect provider';
$txt['authentication_provider'] = 'Provider';
$txt['authentication_title'] = 'Name';
$txt['authentication_title_desc'] = 'What the button on the login page says.';
$txt['authentication_issuer'] = 'Issuer URL';
$txt['authentication_issuer_desc'] = 'The provider\'s base URL. Everything else is read from its discovery document.';
$txt['authentication_client_id'] = 'Client ID';
$txt['authentication_client_secret'] = 'Client secret';
$txt['authentication_client_secret_desc'] = 'Leave blank to keep the one already saved.';
$txt['authentication_scopes'] = 'Scopes';
$txt['authentication_scopes_desc'] = 'Space separated. Must include openid.';
$txt['authentication_redirect_uri'] = 'Redirect URI';
$txt['authentication_redirect_uri_desc'] = 'Give this to the provider when registering the forum. It has to match exactly.';
$txt['authentication_redirect_uri_pending'] = 'Available once this provider has been saved.';
$txt['authentication_enabled'] = 'Enabled';
$txt['authentication_order'] = 'Sort order';
$txt['authentication_policy'] = 'What a sign in may do';
$txt['authentication_allow_registration'] = 'Allow new accounts';
$txt['authentication_allow_registration_desc'] = 'Someone signing in with no account here is sent to the sign up form, still subject to the agreement, approval and age rules.';
$txt['authentication_link_by_email'] = 'Claim accounts by matching email';
$txt['authentication_link_by_email_desc'] = 'Only turn this on if you trust the provider to verify email addresses. Anyone who can get an address issued there could otherwise take over the account that uses it here.';
$txt['authentication_allow_private_host'] = 'Allow a provider on a private address';
$txt['authentication_allow_private_host_desc'] = 'Needed for a provider running on your own network. Leave off for anything on the internet.';
$txt['authentication_test'] = 'Test';
$txt['authentication_test_ok'] = 'The provider answered and its endpoints look usable.';
$txt['authentication_test_failed'] = 'Could not read anything usable from this provider.';
$txt['authentication_delete_confirm'] = 'Remove this provider? Anyone who signs in with it will have to use their password instead.';
$txt['authentication_needs_title_and_issuer'] = 'A provider needs at least a name and an issuer URL.';
15 changes: 15 additions & 0 deletions Languages/en_US/Profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -676,3 +676,18 @@
$txt['export_download_original'] = 'Download original';
$txt['export_view_source_button'] = 'Toggle source view';
$txt['export_open_in_browser'] = 'Please open this file in a web browser to see a human readable version.';

// Linked accounts.
$txt['linked_accounts'] = 'Linked accounts';
$txt['linked_accounts_desc'] = 'The external accounts you can sign in with.';
$txt['linked_accounts_none'] = 'You have not linked any accounts yet.';
$txt['linked_accounts_provider'] = 'Provider';
$txt['linked_accounts_added'] = 'Linked';
$txt['linked_accounts_last_used'] = 'Last used';
$txt['linked_accounts_unlink'] = 'Unlink';
$txt['linked_accounts_add'] = 'Link another account';
$txt['linked_accounts_linked'] = 'That account is now linked.';
$txt['linked_accounts_unlinked'] = 'That account is no longer linked.';
$txt['linked_accounts_last_one'] = 'That is the only way you can sign in, so it cannot be unlinked. Set a password first.';
$txt['linked_accounts_only_way_in'] = 'Your only way to sign in';
$txt['linked_accounts_unknown_provider'] = 'Provider no longer configured';
5 changes: 5 additions & 0 deletions Sources/Actions/Admin/ACP.php
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,11 @@ class ACP implements ActionInterface, Routable
],
],
],
'authentication' => [
'label' => 'authentication_providers',
'function' => __NAMESPACE__ . '\\Authentication::call',
'icon' => 'security',
],
'maintain' => [
'label' => 'maintain_title',
'function' => __NAMESPACE__ . '\\Maintenance::call',
Expand Down
223 changes: 223 additions & 0 deletions Sources/Actions/Admin/Authentication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<?php

/**
* Simple Machines Forum (SMF)
*
* @package SMF
* @author Simple Machines https://www.simplemachines.org
* @copyright 2026 Simple Machines and individual contributors
* @license https://www.simplemachines.org/about/smf/license.php BSD
*
* @version 3.0 Alpha 4
*/

declare(strict_types=1);

namespace SMF\Actions\Admin;

use SMF\ActionInterface;
use SMF\ActionTrait;
use SMF\Authentication\OidcClient;
use SMF\Authentication\Provider;
use SMF\ErrorHandler;
use SMF\Lang;
use SMF\Menu;
use SMF\SecurityToken;
use SMF\Theme;
use SMF\User;
use SMF\Utils;

/**
* Lets the admin set up the identity providers members can sign in with.
*/
class Authentication implements ActionInterface
{
use ActionTrait;

/*******************
* Public properties
*******************/

/**
* @var string
*
* The requested sub-action.
* This should be set by the constructor.
*/
public string $subaction = 'list';

/**************************
* Public static properties
**************************/

/**
* @var array
*
* Available sub-actions.
*/
public static array $subactions = [
'list' => 'providerList',
'edit' => 'edit',
'save' => 'save',
'delete' => 'delete',
'test' => 'test',
];

/****************
* Public methods
****************/

/**
* Dispatcher to whichever sub-action method is necessary.
*/
public function execute(): void
{
User::$me->isAllowedTo('admin_forum');

Theme::loadTemplate('Authentication');

Utils::$context['page_title'] = Lang::getTxt('authentication_providers', file: 'ManageSettings');

Menu::$loaded['admin']->tab_data = [
'title' => Lang::getTxt('authentication_providers', file: 'ManageSettings'),
'description' => Lang::getTxt('authentication_providers_desc', file: 'ManageSettings'),
];

$call = \is_string(self::$subactions[$this->subaction]) && method_exists($this, self::$subactions[$this->subaction]) ? [$this, self::$subactions[$this->subaction]] : Utils::getCallable(self::$subactions[$this->subaction]);

if (!empty($call)) {
\call_user_func($call);
}
}

/**
* Shows every configured provider.
*/
public function providerList(): void
{
Utils::$context['sub_template'] = 'authentication_list';
Utils::$context['providers'] = Provider::loadAll();
Utils::$context['presets'] = Provider::presets();
}

/**
* Shows the form for one provider.
*/
public function edit(): void
{
$provider = Provider::load((int) ($_REQUEST['provider'] ?? 0)) ?? new Provider();

// Starting from a preset just fills the form in; nothing is saved yet.
if ($provider->id === 0 && !empty($_REQUEST['preset'])) {
$preset = Provider::presets()[$_REQUEST['preset']] ?? [];

foreach ($preset as $field => $value) {
$provider->{$field} = $value;
}
}

Utils::$context['sub_template'] = 'authentication_edit';
Utils::$context['provider'] = $provider;
Utils::$context['redirect_uri'] = $provider->id === 0
? Lang::getTxt('authentication_redirect_uri_pending', file: 'ManageSettings')
: $provider->redirectUri();

SecurityToken::create('admin-authp');
}

/**
* Saves one provider.
*/
public function save(): void
{
User::$me->checkSession();
SecurityToken::validate('admin-authp');

$provider = Provider::load((int) ($_REQUEST['provider'] ?? 0)) ?? new Provider();

$provider->title = Utils::htmlTrim($_POST['title'] ?? '');
$provider->issuer = Utils::htmlTrim($_POST['issuer'] ?? '');
$provider->client_id = Utils::htmlTrim($_POST['client_id'] ?? '');
$provider->scopes = Utils::htmlTrim($_POST['scopes'] ?? 'openid email profile');
$provider->enabled = !empty($_POST['enabled']);
$provider->order = (int) ($_POST['provider_order'] ?? 0);

// An empty secret box means "leave it alone", so that editing a provider
// does not require retyping a secret the admin may not have to hand.
if (($_POST['client_secret'] ?? '') !== '') {
$provider->client_secret = $_POST['client_secret'];
}

$provider->settings['link_by_verified_email'] = !empty($_POST['link_by_verified_email']);
$provider->settings['allow_registration'] = !empty($_POST['allow_registration']);
$provider->settings['allow_private_host'] = !empty($_POST['allow_private_host']);

if ($provider->title === '' || $provider->issuer === '') {
ErrorHandler::fatalLang('authentication_needs_title_and_issuer', false);
}

// The issuer moved, so whatever we discovered about the old one is junk.
$provider->settings['discovery'] = [];
$provider->settings['discovered_at'] = 0;

$provider->save();

Utils::redirectexit('action=admin;area=authentication;saved');
}

/**
* Removes a provider, and every credential that came from it.
*/
public function delete(): void
{
User::$me->checkSession('get');

$provider = Provider::load((int) ($_REQUEST['provider'] ?? 0));

if ($provider !== null) {
$provider->delete();
}

Utils::redirectexit('action=admin;area=authentication;deleted');
}

/**
* Fetches the discovery document, so the admin can see it working.
*/
public function test(): void
{
User::$me->checkSession('get');

$provider = Provider::load((int) ($_REQUEST['provider'] ?? 0));

if ($provider === null) {
Utils::redirectexit('action=admin;area=authentication');
}

$client = new OidcClient($provider);
$document = $client->discover(true);

Utils::$context['sub_template'] = 'authentication_test';
Utils::$context['provider'] = $provider;
Utils::$context['test_error'] = $client->error;
Utils::$context['test_endpoints'] = $document === [] ? [] : [
'authorization_endpoint' => $document['authorization_endpoint'] ?? '',
'token_endpoint' => $document['token_endpoint'] ?? '',
'userinfo_endpoint' => $document['userinfo_endpoint'] ?? '',
];
}

/******************
* Internal methods
******************/

/**
* Constructor. Protected to force instantiation via self::load().
*/
protected function __construct()
{
if (!empty($_REQUEST['sa']) && isset(self::$subactions[$_REQUEST['sa']])) {
$this->subaction = $_REQUEST['sa'];
}
}
}
Loading