Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.83% covered (success)
95.83%
46 / 48
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserProfileService
95.74% covered (success)
95.74%
45 / 47
66.67% covered (warning)
66.67%
4 / 6
20
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getProfile
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 changePassword
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 updateAvatar
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 validatePasswordChange
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 validateNewPasswordPolicy
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\User\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\User\Domain\Model\User;
12use App\Modules\User\Domain\Repository\UserRepositoryInterface;
13use InvalidArgumentException;
14
15use App\Core\Security\Password\CompromisedPasswordValidatorInterface;
16use App\Core\Security\Password\HibpCompromisedPasswordValidator;
17
18/**
19 * User Profile Application Service.
20 *
21 * Provides operations for querying user profile and securely changing passwords.
22 *
23 * @package App\Modules\User\Application\Service
24 */
25final readonly class UserProfileService
26{
27    private const int MIN_PASSWORD_LENGTH = 8;
28    private CompromisedPasswordValidatorInterface $compromisedValidator;
29
30    /**
31     * UserProfileService constructor.
32     *
33     * @param UserRepositoryInterface                    $userRepository       User repository contract.
34     * @param CompromisedPasswordValidatorInterface|null $compromisedValidator Optional compromised password validator.
35     */
36    public function __construct(
37        private UserRepositoryInterface $userRepository,
38        ?CompromisedPasswordValidatorInterface $compromisedValidator = null
39    ) {
40        $this->compromisedValidator = $compromisedValidator ?? new HibpCompromisedPasswordValidator();
41    }
42
43    /**
44     * Fetches user profile data for the given user ID.
45     *
46     * @param int $userId Primary user ID.
47     * @return array<string, mixed> Profile details.
48     * @throws InvalidArgumentException When user is not found.
49     */
50    public function getProfile(int $userId): array
51    {
52        $profile = $this->userRepository->getUserProfile($userId);
53        if ($profile === null) {
54            throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
55        }
56
57        $firstName = (string)($profile['first_name'] ?? '');
58        $lastName = (string)($profile['last_name'] ?? '');
59        $fullName = trim($firstName . ' ' . $lastName);
60        if ($fullName === '') {
61            $fullName = (string)($profile['c_cn'] ?? $profile['username'] ?? 'User');
62        }
63
64        $profile['full_name'] = $fullName;
65        $profile['initial'] = mb_strtoupper(mb_substr($fullName, 0, 1, 'UTF-8'), 'UTF-8');
66
67        return $profile;
68    }
69
70    /**
71     * Verifies old password, validates new password, and updates password hash.
72     *
73     * @param int $userId Primary user ID.
74     * @param string $currentPassword Current plain password.
75     * @param string $newPassword New plain password.
76     * @param string $confirmPassword Repeated new plain password.
77     * @return array{success: bool, message: string} Result structure.
78     */
79    public function changePassword(
80        int $userId,
81        string $currentPassword,
82        string $newPassword,
83        string $confirmPassword
84    ): array {
85        $user = $this->userRepository->findById($userId);
86        if ($user === null) {
87            return ['success' => false, 'message' => 'User account not found.'];
88        }
89
90        $error = $this->validatePasswordChange($user, $currentPassword, $newPassword, $confirmPassword);
91        if ($error !== null) {
92            return ['success' => false, 'message' => $error];
93        }
94
95        $algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT;
96        $newHash = password_hash($newPassword, $algo);
97        $updated = $this->userRepository->updatePassword($userId, $newHash);
98
99        return [
100            'success' => $updated,
101            'message' => $updated ? 'Password updated successfully.' : 'Failed to update password in database.',
102        ];
103    }
104
105    /**
106     * Updates user profile avatar.
107     *
108     * @param int $userId Primary user ID.
109     * @param string $avatarUrl Avatar URL or asset path.
110     * @return array{success: bool, message: string} Result structure.
111     */
112    public function updateAvatar(int $userId, string $avatarUrl): array
113    {
114        $trimmedAvatar = trim($avatarUrl);
115        if ($trimmedAvatar === '') {
116            return ['success' => false, 'message' => 'Avatar URL cannot be empty.'];
117        }
118
119        if (!preg_match('#^/assets/images/avatars/avatar-(?:[1-9]|1[0-2])\.svg$#', $trimmedAvatar)) {
120            return ['success' => false, 'message' => 'Invalid avatar selection.'];
121        }
122
123        $updated = $this->userRepository->updateAvatar($userId, $trimmedAvatar);
124
125        return [
126            'success' => $updated,
127            'message' => $updated ? 'Avatar updated successfully.' : 'Failed to update avatar in database.',
128        ];
129    }
130
131    /**
132     * Validates current password and match with confirmation.
133     */
134    private function validatePasswordChange(
135        User $user,
136        string $currentPassword,
137        string $newPassword,
138        string $confirmPassword
139    ): ?string {
140        if (!$user->verifyPassword($currentPassword)) {
141            return 'Current password is incorrect.';
142        }
143        if ($newPassword !== $confirmPassword) {
144            return 'New password and confirmation do not match.';
145        }
146        return $this->validateNewPasswordPolicy($currentPassword, $newPassword);
147    }
148
149    /**
150     * Validates new password policy, difference from current password, and breach status.
151     */
152    private function validateNewPasswordPolicy(string $currentPassword, string $newPassword): ?string
153    {
154        if (mb_strlen($newPassword, 'UTF-8') < self::MIN_PASSWORD_LENGTH) {
155            return sprintf('New password must be at least %d characters long.', self::MIN_PASSWORD_LENGTH);
156        }
157        if ($newPassword === $currentPassword) {
158            return 'New password must be different from current password.';
159        }
160        if ($this->compromisedValidator->isCompromised($newPassword)) {
161            return 'The chosen password has appeared in a data breach. Please choose a different password.';
162        }
163        return null;
164    }
165}