Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
AdminPasswordService
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
4 / 4
7
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sendResetLinkForInvestor
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 setPasswordForInvestor
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 resolveCustomer
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Auth\Service;
6
7use App\Domain\Audit\AuditAction;
8use App\Domain\Audit\AuditService;
9use App\Domain\Auth\Data\AdminActor;
10use App\Domain\Auth\Data\UserAuthData;
11use App\Domain\Auth\Repository\AuthRepository;
12use App\Domain\Exception\ForbiddenException;
13use App\Domain\Exception\NotFoundException;
14
15/**
16 * Admin-initiated password management for customer accounts (FSC-124).
17 *
18 * Two capabilities: email the customer a self-service reset link, or set a new
19 * password directly on their behalf. Both refuse to touch another staff
20 * account — an admin can never set or reset an admin/super_admin password,
21 * mirroring the target-role guard in ImpersonateUserAction — and both write an
22 * audit entry naming the acting admin and the affected customer.
23 */
24final readonly class AdminPasswordService
25{
26    public function __construct(
27        private AuthRepository $authRepository,
28        private PasswordService $passwordService,
29        private PasswordResetService $passwordResetService,
30        private AuditService $auditService,
31    ) {}
32
33    /**
34     * Email a single-use reset link to the customer behind $investorId.
35     *
36     * @param int $investorId
37     * @param string $appUrl  already-resolved base URL for the link
38     * @param AdminActor $actor
39     * @return UserAuthData the customer the link was sent to
40     */
41    public function sendResetLinkForInvestor(int $investorId, string $appUrl, AdminActor $actor): UserAuthData
42    {
43        $target = $this->resolveCustomer($investorId);
44
45        $this->passwordResetService->sendResetLink($target, $appUrl);
46
47        $this->auditService->log(
48            action: AuditAction::ADMIN_SENT_PASSWORD_RESET,
49            tableName: 'users',
50            recordId: $target->userId,
51            newValues: [
52                'target_user_id' => $target->userId,
53                'target_username' => $target->username,
54                'investor_id' => $investorId,
55            ],
56            userId: $actor->userId,
57            changedBy: $actor->username,
58            ipAddress: $actor->ipAddress,
59            userAgent: $actor->userAgent,
60            endpoint: "/api/admin/investors/{$investorId}/send-password-reset",
61            notes: "Admin '{$actor->username}' sent a password-reset link to '{$target->username}'",
62        );
63
64        return $target;
65    }
66
67    /**
68     * Set a new password directly on the customer's account.
69     *
70     * Validates strength, hashes, writes the new hash (which also clears any
71     * login lock, per AuthRepository::updatePassword), and burns any reset
72     * tokens still in flight so a stale link can't clobber this password.
73     *
74     * @param int $investorId
75     * @param string $newPassword
76     * @param AdminActor $actor
77     * @throws \App\Domain\Exception\ValidationException on a weak password
78     * @return UserAuthData the customer whose password was set
79     */
80    public function setPasswordForInvestor(int $investorId, string $newPassword, AdminActor $actor): UserAuthData
81    {
82        $target = $this->resolveCustomer($investorId);
83
84        $this->passwordService->validateStrength($newPassword);
85
86        $hash = $this->passwordService->hashPassword($newPassword);
87        $this->authRepository->updatePassword($target->userId, $hash);
88        $this->authRepository->invalidateResetTokensForUser($target->userId);
89
90        $this->auditService->log(
91            action: AuditAction::ADMIN_SET_USER_PASSWORD,
92            tableName: 'users',
93            recordId: $target->userId,
94            newValues: [
95                'target_user_id' => $target->userId,
96                'target_username' => $target->username,
97                'investor_id' => $investorId,
98            ],
99            userId: $actor->userId,
100            changedBy: $actor->username,
101            ipAddress: $actor->ipAddress,
102            userAgent: $actor->userAgent,
103            endpoint: "/api/admin/investors/{$investorId}/set-password",
104            notes: "Admin '{$actor->username}' set the password for '{$target->username}'",
105        );
106
107        return $target;
108    }
109
110    /**
111     * Resolve an investor to its login record, refusing staff accounts.
112     *
113     * @param int $investorId
114     * @throws NotFoundException when no user is linked to the investor
115     * @throws ForbiddenException when the target is an admin/super_admin
116     */
117    private function resolveCustomer(int $investorId): UserAuthData
118    {
119        $target = $this->authRepository->findUserByInvestorId($investorId);
120
121        if ($target === null) {
122            throw new NotFoundException('Investor account not found');
123        }
124
125        if ($target->role === 'admin' || $target->role === 'super_admin') {
126            throw new ForbiddenException('Cannot manage the password of a staff account');
127        }
128
129        return $target;
130    }
131}