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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,5 @@ L5_SWAGGER_OPEN_API_SPEC_VERSION=3.1.2
TWO_FACTOR_RECOVERY_ENROLLMENT_WINDOW=600
TWO_FACTOR_MAX_ENROLL_REQUESTS=5
TWO_FACTOR_ENROLL_WINDOW_MINUTES=15
TWO_FACTOR_MAX_PASSWORD_CONFIRM_ATTEMPTS=5
TWO_FACTOR_PASSWORD_CONFIRM_WINDOW_MINUTES=15
5 changes: 4 additions & 1 deletion app/Http/Controllers/AdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -660,8 +660,11 @@ public function editUser($user_id){
return View::make("admin.edit-user",
[
'user_id' => $user_id,
// a server admin manages their own 2FA from the profile (it asks for the
// password); UserService::update ignores the key when editing oneself
'is_own_account' => $this->auth_service->getCurrentUser()?->getId() == $user->getId(),
'user' => json_encode(SerializerRegistry::getInstance()
->getSerializer($user, SerializerRegistry::SerializerType_Private)->serialize('groups')),
->getSerializer($user, SerializerRegistry::SerializerType_Admin)->serialize('groups')),
'countries' => CountryList::getCountries(),
'languages' => $lang2Code,
]
Expand Down
5 changes: 5 additions & 0 deletions app/Http/Controllers/Api/OAuth2/OAuth2UserApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,11 @@ protected function curateUpdatePayload(array $payload): array
if (isset($payload['active']))
unset($payload['active']);

// 2FA is managed only from the admin user CRUD (server admin session) or the
// owner's profile, never by an OAuth2 client acting with the token owner's rights
if (array_key_exists('two_factor_enabled', $payload))
unset($payload['two_factor_enabled']);

return HTMLCleaner::cleanData($payload, [
'bio',
'statement_of_interest'
Expand Down
54 changes: 52 additions & 2 deletions app/Http/Controllers/Api/UserApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
use Exception;
use Illuminate\Http\Request as LaravelRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Validator;
Expand Down Expand Up @@ -158,7 +160,7 @@ public function lock($id)

protected function getAllSerializerType(): string
{
return SerializerRegistry::SerializerType_Private;
return SerializerRegistry::SerializerType_Admin;
}

/**
Expand Down Expand Up @@ -298,6 +300,54 @@ public function enableTwoFactor()
});
}

/**
* Disables 2FA for the current user (SDS idp-mfa.md §5.6). Deletes their recovery
* codes and revokes every trusted device, so the device trust cookie is expired too.
* Requires the current password, or for a user without one a session inside the
* recovery enrollment window (SDS rev 2.6 §4.10.3). Refused for group-enforced users.
*
* @return \Illuminate\Http\JsonResponse|mixed
*/
public function disableTwoFactor()
{
if (!Auth::check())
return $this->error403();

return $this->processRequest(function () {
$data = Request::all();
$validator = Validator::make($data, [
'current_password' => 'sometimes|nullable|string',
]);

if (!$validator->passes()) {
return $this->error412($validator->getMessageBag()->getMessages());
}

$user = Auth::user();

if ($user->isTwoFactorEnforcedByGroup()) {
return $this->error412(['two_factor' => ['Two-factor authentication is required for members of an administrator group and cannot be turned off.']]);
}

if (!$user->isTwoFactorEnabled()) {
return $this->error412(['two_factor' => ['Two-factor authentication is not enabled for this account.']]);
}

// the service enforces current_password / the enrollment window for the
// self-service actor and throws ValidationException (-> 412)
$this->recovery_code_service->disableTwoFactor($user, $data['current_password'] ?? null, $user);

// every trusted device was just revoked
Cookie::queue(Cookie::forget(
Config::get('two_factor.cookie_name', 'device_trust_token'),
Config::get('session.path'),
Config::get('session.domain')
));

return $this->ok(['two_factor_enabled' => false]);
});
}

/**
* Invalidates the current user's recovery codes and generates a fresh batch.
* Plaintext codes are returned once in the response and never persisted.
Expand Down Expand Up @@ -438,6 +488,6 @@ public function updatePic($id)

protected function serializerType(): string
{
return SerializerRegistry::SerializerType_Private;
return SerializerRegistry::SerializerType_Admin;
}
}
2 changes: 2 additions & 0 deletions app/Http/Controllers/Factories/UserValidationRulesFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ public static function build(array $data, $update = false, ?User $currentUser =
// admin fields
'email_verified' => 'nullable|boolean',
'active' => 'nullable|boolean',
// honoured only for a server admin editing ANOTHER user (UserService::update)
'two_factor_enabled' => 'nullable|boolean',
'groups' => 'sometimes|int_array',
'public_profile_show_photo' => 'sometimes|boolean',
'public_profile_show_fullname' => 'sometimes|boolean',
Expand Down
7 changes: 5 additions & 2 deletions app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1208,13 +1208,16 @@ public function getProfile()

return View::make("profile", array_merge([
'user' => json_encode(SerializerRegistry::getInstance()->getSerializer(
$user, SerializerRegistry::SerializerType_Private)->serialize()),
$user, SerializerRegistry::SerializerType_Admin)->serialize()),
"openid_url" => $this->server_configuration_service->getUserIdentityEndpointURL($user->getIdentifier()),
"sites" => $sites,
'actions' => $actions,
'countries' => CountryList::getCountries(),
'languages' => $lang2Code,
'two_factor_enabled' => $user->shouldRequire2FA(),
// the stored flag, not the effective requirement: the profile needs to tell a
// self-enrolled user (can turn it off) from one enforced by group (cannot)
'two_factor_enabled' => $user->isTwoFactorEnabled(),
'two_factor_enforced' => $user->isTwoFactorEnforcedByGroup(),
// The session completed a 2FA challenge within the enrollment window:
// a batch can be generated without the password.
'recovery_codes_window_open' => $window_open,
Expand Down
13 changes: 11 additions & 2 deletions app/Http/Middleware/TwoFactorRateLimitMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
*
* - every request reserves an attempt atomically before the controller runs.
* - verify / recovery: the attempt is refunded unless the response is a failure.
* - resend / otp: every request counts.
* - password_confirm: the attempt is refunded when the request succeeds (2xx),
* so only rejected password confirmations count.
* - resend / otp / enroll: every request counts.
*
* @package App\Http\Middleware
*/
Expand All @@ -54,7 +56,7 @@ public function __construct(private readonly ITwoFactorRateLimitService $rate_li
/**
* @param \Illuminate\Http\Request $request
* @param Closure $next
* @param string $action one of verify|recovery|resend|otp
* @param string $action one of verify|recovery|resend|otp|enroll|password_confirm
* @return mixed
*/
public function handle($request, Closure $next, string $action = ITwoFactorRateLimitService::ActionVerify)
Expand Down Expand Up @@ -99,6 +101,13 @@ public function handle($request, Closure $next, string $action = ITwoFactorRateL
$this->rate_limit_service->refund($action, $subject);
}

// password confirmation: the controller answers a wrong password with a plain
// 412 (no error_code), so any non-2xx response counts as a failed attempt
if ($action === ITwoFactorRateLimitService::ActionPasswordConfirm
&& method_exists($response, 'isSuccessful') && $response->isSuccessful()) {
$this->rate_limit_service->refund($action, $subject);
}

return $response;
}

Expand Down
80 changes: 80 additions & 0 deletions app/Mail/TwoFactorEnabledByAdminMail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php namespace App\Mail;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use Auth\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\URL;

/**
* Class TwoFactorEnabledByAdminMail
* Tells a user that an administrator turned two-factor authentication on for their
* account and that they should generate their recovery codes from the profile page.
* The mail never carries recovery codes.
* @package App\Mail
*/
class TwoFactorEnabledByAdminMail extends Mailable
{
use Queueable, SerializesModels;

public $tries = 2;

/**
* @var string
*/
public $user_email;

/**
* @var string
*/
public $user_fullname;

/**
* @var string
*/
public $profile_url;

/**
* The subject of the message.
*
* @var string
*/
public $subject;

/**
* @param User $user
*/
public function __construct(User $user)
{
$this->user_email = $user->getEmail();
$this->user_fullname = $user->getFullName();
$this->profile_url = URL::action('UserController@getProfile');
}

/**
* @return $this
*/
public function build()
{
$this->subject = sprintf('[%s] Two-factor authentication enabled', Config::get('app.app_name'));
Log::debug(sprintf("TwoFactorEnabledByAdminMail::build to %s", $this->user_email));
return $this->from(Config::get("mail.from"))
->to($this->user_email)
->subject($this->subject)
->view('emails.auth.two_factor_enabled_by_admin');
}
}
42 changes: 40 additions & 2 deletions app/ModelSerializers/Auth/UserSerializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ final class PublicUserSerializer extends BaseUserSerializer
* Class PrivateUserSerializer
* @package App\ModelSerializers\Auth
*/
final class PrivateUserSerializer extends BaseUserSerializer
class PrivateUserSerializer extends BaseUserSerializer
{

protected static $allowed_relations = [
Expand Down Expand Up @@ -130,4 +130,42 @@ public function serialize($expand = null, array $fields = [], array $relations =

return $values;
}
}
}

/**
* Class AdminUserSerializer
* The private user plus the 2FA state. For the user admin CRUD, the admin edit page and the
* user's own profile. Never for OAuth2 clients: they get the private serializer, whose
* shape does not change.
* @package App\ModelSerializers\Auth
*/
final class AdminUserSerializer extends PrivateUserSerializer
{
protected static $array_mappings = [
'TwoFactorEnabled' => 'two_factor_enabled:json_boolean',
'TwoFactorMethod' => 'two_factor_method:json_string',
'TwoFactorEnforcedAt' => 'two_factor_enforced_at:datetime_epoch',
];

/**
* @param null $expand
* @param array $fields
* @param array $relations
* @param array $params
* @return array
*/
public function serialize($expand = null, array $fields = [], array $relations = [], array $params = [])
{
$user = $this->object;
if (!$user instanceof User) return [];

$values = parent::serialize($expand, $fields, $relations, $params);

// group-derived, read only: not a stored column
if (empty($fields) || in_array('two_factor_enforced', $fields)) {
$values['two_factor_enforced'] = $user->isTwoFactorEnforcedByGroup();
}

return $values;
}
}
3 changes: 3 additions & 0 deletions app/ModelSerializers/SerializerRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
**/
use App\ModelSerializers\Auth\AdminUserSerializer;
use App\ModelSerializers\Auth\PrivateUserSerializer;
use App\ModelSerializers\Auth\PublicGroupSerializer;
use App\ModelSerializers\Auth\PublicUserSerializer;
Expand Down Expand Up @@ -43,6 +44,7 @@ final class SerializerRegistry

const SerializerType_Public = 'PUBLIC';
const SerializerType_Private = 'PRIVATE';
const SerializerType_Admin = 'ADMIN';

private function __clone(){}

Expand Down Expand Up @@ -79,6 +81,7 @@ private function __construct()
$this->registry["User"] = [
self::SerializerType_Public => PublicUserSerializer::class,
self::SerializerType_Private => PrivateUserSerializer::class,
self::SerializerType_Admin => AdminUserSerializer::class,
];

$this->registry["UserAction"] = UserActionSerializer::class;
Expand Down
15 changes: 15 additions & 0 deletions app/Services/Auth/IRecoveryCodeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,19 @@ public function getStatus(User $user): RecoveryCodesStatus;
* and the session is outside the enrollment window
*/
public function disableTwoFactor(User $user, ?string $currentPassword, ?User $actor): void;

/**
* Enables 2FA for a user on behalf of an administrator (support path). Unlike
* enableTwoFactorAndGenerateCodes() no recovery codes are generated: codes are shown
* once and only to their owner, who is told by email to generate them from their
* profile. Audited as enrollment_changed with {enabled: true, actor_id, reason: admin}.
* The email and the audit row are best-effort once 2FA is enabled.
*
* @param User $user
* @param string $method one of User::ValidMFAMethods
* @param User $actor the administrator performing the change
* @return void
* @throws ValidationException if $method is not valid or not available
*/
public function enableTwoFactorForUser(User $user, string $method, User $actor): void;
}
3 changes: 3 additions & 0 deletions app/Services/Auth/ITwoFactorRateLimitService.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ interface ITwoFactorRateLimitService
public const ActionResend = 'resend';
public const ActionOtp = 'otp';
public const ActionEnroll = 'enroll';
// password confirmation of the authenticated user's 2FA changes (disable 2FA,
// regenerate recovery codes): one budget shared by both routes
public const ActionPasswordConfirm = 'password_confirm';

public const RATE_LIMIT_ERROR_CODE = MFAConstants::ERROR_CODE_RATE_LIMIT;
public const RATE_LIMIT_MESSAGE = 'Too many attempts. Please try again later.';
Expand Down
Loading
Loading