Last active
April 14, 2026 14:47
-
-
Save Shahinyanm/47b2b813f28c839c6828a9ed0ff7d7d2 to your computer and use it in GitHub Desktop.
AuthProfile Module
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\OAuth\Grants; | |
| use DateInterval; | |
| use Ramsey\Uuid\Uuid; | |
| use Modules\OAuth\Utils\RequestMeta; | |
| use League\OAuth2\Server\RequestEvent; | |
| use Modules\OAuth\Dto\OAuthVerifyOtpDto; | |
| use Modules\OAuth\Services\OAuthService; | |
| use Illuminate\Support\Facades\Validator; | |
| use Psr\Http\Message\ServerRequestInterface; | |
| use Illuminate\Validation\ValidationException; | |
| use Modules\OAuth\Exceptions\InvalidOtpException; | |
| use Modules\OAuth\Exceptions\OAuthServerException; | |
| use League\OAuth2\Server\Entities\UserEntityInterface; | |
| use League\OAuth2\Server\Entities\ClientEntityInterface; | |
| use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; | |
| use Modules\OAuth\Exceptions\InvalidOtpRecoveryCodeException; | |
| use League\OAuth2\Server\Repositories\UserRepositoryInterface; | |
| use League\OAuth2\Server\Grant\AbstractGrant as LeagueAbstractGrant; | |
| use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; | |
| abstract class AbstractGrant extends LeagueAbstractGrant | |
| { | |
| public function __construct( | |
| UserRepositoryInterface $userRepository, | |
| RefreshTokenRepositoryInterface $refreshTokenRepository, | |
| protected readonly OAuthService $service, | |
| ) { | |
| $this->setUserRepository($userRepository); | |
| $this->setRefreshTokenRepository($refreshTokenRepository); | |
| $this->refreshTokenTTL = new DateInterval('P1M'); | |
| } | |
| abstract protected function getServerRequestValidationRules(ServerRequestInterface $request): array; | |
| protected function getServerRequestValidationMessages(ServerRequestInterface $request): array | |
| { | |
| return [ | |
| // | |
| ]; | |
| } | |
| abstract protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client): UserEntityInterface; | |
| abstract protected function shouldValidateOtp(): bool; | |
| /** | |
| * {@inheritDoc} | |
| * | |
| * @codeCoverageIgnore | |
| * @see \League\OAuth2\Server\Grant\PasswordGrant | |
| */ | |
| public function respondToAccessTokenRequest( | |
| ServerRequestInterface $request, | |
| ResponseTypeInterface $responseType, | |
| DateInterval $accessTokenTTL | |
| ) { | |
| $client = $this->validateClient($request); | |
| $scopes = $this->validateScopes($this->getRequestParameter('scope', $request, $this->defaultScope)); | |
| $user = $this->validateUser($request, $client); | |
| if ($this->shouldValidateOtp()) { | |
| $this->validateOtp($request, $user); | |
| } | |
| $finalizedScopes = $this->scopeRepository->finalizeScopes( | |
| $scopes, | |
| $this->getIdentifier(), | |
| $client, | |
| $user->getIdentifier() | |
| ); | |
| $accessToken = $this->issueAccessToken( | |
| $accessTokenTTL, | |
| $client, | |
| $user->getIdentifier(), | |
| $finalizedScopes, | |
| ); | |
| $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); | |
| $responseType->setAccessToken($accessToken); | |
| $refreshToken = $this->issueRefreshToken($accessToken); | |
| if ($refreshToken !== null) { | |
| $this->getEmitter()->emit(new RequestEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request)); | |
| $responseType->setRefreshToken($refreshToken); | |
| } | |
| return $responseType; | |
| } | |
| protected function validateClient(ServerRequestInterface $request) | |
| { | |
| $clientId = $this->getRequestParameter('client_id', $request); | |
| if (!is_string($clientId) || !Uuid::isValid($clientId)) { | |
| throw OAuthServerException::invalidClient($request); | |
| } | |
| return parent::validateClient($request); | |
| } | |
| protected function validateRequest(ServerRequestInterface $request): array | |
| { | |
| try { | |
| $rules = collect($this->getServerRequestValidationRules($request)); | |
| if ($this->shouldValidateOtp()) { | |
| $rules->merge($this->getServerRequestOTPValidationRules()); | |
| } | |
| $data = $rules | |
| ->keys() | |
| ->mapWithKeys(fn ($key) => [$key => $this->getRequestParameter($key, $request)]) | |
| ->toArray(); | |
| $validator = Validator::make( | |
| $data, | |
| $rules->all(), | |
| $this->getServerRequestValidationMessages($request), | |
| ); | |
| return $validator->validate(); | |
| } catch (ValidationException $e) { | |
| $errors = $e->errors(); | |
| $parameter = array_key_first($errors); | |
| $hint = reset($errors)[0]; | |
| throw OAuthServerException::invalidRequest($parameter, $hint); | |
| } | |
| } | |
| private function getServerRequestOTPValidationRules(): array | |
| { | |
| return [ | |
| 'otp' => [ | |
| 'nullable', | |
| 'string', | |
| 'size:6', | |
| ], | |
| 'otp_recovery_code' => [ | |
| 'nullable', | |
| 'string', | |
| 'max:255', | |
| ], | |
| 'trusted' => [ | |
| 'nullable', | |
| 'bool', | |
| ], | |
| ]; | |
| } | |
| private function validateOtp(ServerRequestInterface $request, UserEntityInterface $user): void | |
| { | |
| try { | |
| $this->service->verifyOtp(new OAuthVerifyOtpDto( | |
| $user->getIdentifier(), | |
| $this->getRequestParameter('otp', $request), | |
| $this->getRequestParameter('otp_recovery_code', $request), | |
| RequestMeta::getRemoteAddr($request), | |
| RequestMeta::getUserAgent($request), | |
| (bool) $this->getRequestParameter('trusted', $request), | |
| )); | |
| } catch (InvalidOtpException) { | |
| throw OAuthServerException::invalidOtp(); | |
| } catch (InvalidOtpRecoveryCodeException) { | |
| throw OAuthServerException::invalidOtpRecoveryCode(); | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\AuthProfile\Dto; | |
| final class AuthProfileChangeEmailDto | |
| { | |
| public function __construct( | |
| public readonly int $userId, | |
| public readonly string $newEmail, | |
| public readonly string $currentPassword, | |
| ) { | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\AuthProfile\Dto; | |
| final class AuthProfileChangePasswordDto | |
| { | |
| public function __construct( | |
| public readonly int $userId, | |
| public readonly string $currentPassword, | |
| public readonly string $newPassword, | |
| ) { | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\AuthProfile\Services; | |
| use Illuminate\Support\Facades\DB; | |
| use Illuminate\Support\Facades\Hash; | |
| use Infrastructure\Eloquent\Models\User; | |
| use Modules\AuthProfile\Dto\AuthProfileUpdateDto; | |
| use Modules\AuthProfile\Dto\AuthProfileChangeEmailDto; | |
| use Modules\AuthProfile\Dto\AuthProfileChangePasswordDto; | |
| use Modules\AuthProfile\Events\ProfileUpdatedEvent; | |
| use Modules\AuthProfile\Events\EmailChangeRequestedEvent; | |
| use Modules\AuthProfile\Events\PasswordChangedEvent; | |
| use Modules\AuthProfile\Exceptions\InvalidCurrentPasswordException; | |
| use Modules\AuthProfile\Exceptions\EmailAlreadyTakenException; | |
| use Modules\AuthProfile\Exceptions\EmailChangeThrottledException; | |
| use Infrastructure\Eloquent\Models\EmailChangeRequest; | |
| use Carbon\CarbonImmutable; | |
| final class AuthProfileCommandService | |
| { | |
| private const EMAIL_CHANGE_THROTTLE_MINUTES = 5; | |
| private const EMAIL_CHANGE_TOKEN_TTL_HOURS = 24; | |
| public function __construct( | |
| private readonly AuthProfileQueryService $queryService, | |
| ) { | |
| } | |
| public function update(AuthProfileUpdateDto $dto): void | |
| { | |
| $user = $this->queryService->findOrFail($dto->userId); | |
| $user->update([ | |
| 'first_name' => $dto->firstName, | |
| 'last_name' => $dto->lastName, | |
| ]); | |
| event(new ProfileUpdatedEvent($user)); | |
| } | |
| /** | |
| * Initiates email change flow with verification. | |
| * | |
| * Uses SELECT FOR UPDATE to prevent race conditions when the same user | |
| * submits multiple email change requests concurrently. Without the lock, | |
| * two concurrent requests could both pass the throttle check and create | |
| * duplicate pending requests. | |
| * | |
| * The uniqueness of the new email is checked within the same transaction | |
| * to avoid TOCTOU — between the check and the insert, another user could | |
| * claim the same email. | |
| */ | |
| public function requestEmailChange(AuthProfileChangeEmailDto $dto): void | |
| { | |
| DB::transaction(function () use ($dto) { | |
| /** @var User $user */ | |
| $user = User::query() | |
| ->lockForUpdate() | |
| ->findOrFail($dto->userId); | |
| if (!Hash::check($dto->currentPassword, $user->password)) { | |
| throw new InvalidCurrentPasswordException(); | |
| } | |
| $emailTaken = User::query() | |
| ->where('email', $dto->newEmail) | |
| ->where('id', '!=', $user->id) | |
| ->exists(); | |
| if ($emailTaken) { | |
| throw new EmailAlreadyTakenException(); | |
| } | |
| $this->assertEmailChangeNotThrottled($user->id); | |
| $pendingRequest = EmailChangeRequest::create([ | |
| 'user_id' => $user->id, | |
| 'new_email' => $dto->newEmail, | |
| 'token' => bin2hex(random_bytes(32)), | |
| 'expires_at' => CarbonImmutable::now()->addHours(self::EMAIL_CHANGE_TOKEN_TTL_HOURS), | |
| ]); | |
| event(new EmailChangeRequestedEvent($user, $pendingRequest)); | |
| }); | |
| } | |
| public function changePassword(AuthProfileChangePasswordDto $dto): void | |
| { | |
| DB::transaction(function () use ($dto) { | |
| /** @var User $user */ | |
| $user = User::query() | |
| ->lockForUpdate() | |
| ->findOrFail($dto->userId); | |
| if (!Hash::check($dto->currentPassword, $user->password)) { | |
| throw new InvalidCurrentPasswordException(); | |
| } | |
| $user->update([ | |
| 'password' => Hash::make($dto->newPassword), | |
| 'password_changed_at' => CarbonImmutable::now(), | |
| ]); | |
| event(new PasswordChangedEvent($user)); | |
| }); | |
| } | |
| /** | |
| * Confirms email change using the token from the verification email. | |
| * | |
| * Uses UPDATE ... WHERE with token + expires_at as a CAS guard: | |
| * only one request can successfully claim the token. If two concurrent | |
| * requests arrive with the same token, only the first UPDATE will match | |
| * a row — the second will see affected_rows = 0 and fail gracefully. | |
| */ | |
| public function confirmEmailChange(string $token): void | |
| { | |
| DB::transaction(function () use ($token) { | |
| $affected = EmailChangeRequest::query() | |
| ->where('token', $token) | |
| ->where('expires_at', '>', CarbonImmutable::now()) | |
| ->whereNull('confirmed_at') | |
| ->update(['confirmed_at' => CarbonImmutable::now()]); | |
| if ($affected === 0) { | |
| throw new \DomainException('Invalid or expired email change token.'); | |
| } | |
| $request = EmailChangeRequest::query() | |
| ->where('token', $token) | |
| ->firstOrFail(); | |
| $emailStillAvailable = !User::query() | |
| ->where('email', $request->new_email) | |
| ->where('id', '!=', $request->user_id) | |
| ->exists(); | |
| if (!$emailStillAvailable) { | |
| throw new EmailAlreadyTakenException(); | |
| } | |
| User::query() | |
| ->where('id', $request->user_id) | |
| ->update([ | |
| 'email' => $request->new_email, | |
| 'email_verified_at' => CarbonImmutable::now(), | |
| ]); | |
| }); | |
| } | |
| private function assertEmailChangeNotThrottled(int $userId): void | |
| { | |
| $recentRequest = EmailChangeRequest::query() | |
| ->where('user_id', $userId) | |
| ->where('created_at', '>', CarbonImmutable::now()->subMinutes(self::EMAIL_CHANGE_THROTTLE_MINUTES)) | |
| ->exists(); | |
| if ($recentRequest) { | |
| throw new EmailChangeThrottledException(self::EMAIL_CHANGE_THROTTLE_MINUTES); | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\AuthProfile\Http\Resources; | |
| use Infrastructure\Eloquent\Models\User; | |
| use Infrastructure\Http\Resources\JsonResource; | |
| use Modules\AuthProfile\Http\Schemas\AuthProfileSchema; | |
| use Infrastructure\Http\Resources\Traits\ConvertsSchemaToArray; | |
| /** | |
| * @property User $resource | |
| */ | |
| final class AuthProfileResource extends JsonResource | |
| { | |
| use ConvertsSchemaToArray; | |
| public function toSchema($request): AuthProfileSchema | |
| { | |
| return new AuthProfileSchema( | |
| $this->resource->id, | |
| $this->resource->first_name, | |
| $this->resource->last_name, | |
| $this->resource->email, | |
| (bool) $this->resource->password, | |
| (bool) $this->resource->google_id, | |
| (bool) $this->resource->google_2fa_secret, | |
| $this->resource->google_2fa_enabled, | |
| (bool) $this->resource->facebook_id, | |
| $this->resource->email_verified_at?->toRfc3339String(), | |
| $this->resource->password_changed_at?->toRfc3339String(), | |
| $this->resource->registered_at->toRfc3339String(), | |
| ); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\AuthProfile\Http\Schemas; | |
| use Infrastructure\Http\Schemas\AbstractSchema; | |
| /** | |
| * @OA\Schema(schema="AuthProfileSchema", type="object") | |
| */ | |
| final class AuthProfileSchema extends AbstractSchema | |
| { | |
| public function __construct( | |
| /** @OA\Property(minimum=1) */ | |
| public int $id, | |
| /** @OA\Property() */ | |
| public ?string $firstName, | |
| /** @OA\Property() */ | |
| public ?string $lastName, | |
| /** @OA\Property(format="email") */ | |
| public ?string $email, | |
| /** @OA\Property() */ | |
| public bool $hasPassword, | |
| /** @OA\Property() */ | |
| public bool $hasGoogle, | |
| /** @OA\Property() */ | |
| public bool $hasGoogle2FA, | |
| /** @OA\Property() */ | |
| public bool $hasGoogle2FAEnabled, | |
| /** @OA\Property() */ | |
| public bool $hasFacebook, | |
| /** @OA\Property(format="date-time") */ | |
| public ?string $emailVerifiedAt, | |
| /** @OA\Property(format="date-time") */ | |
| public ?string $passwordChangedAt, | |
| /** @OA\Property(format="date-time") */ | |
| public string $registeredAt, | |
| ) { | |
| // | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\AuthProfile\Http\Actions; | |
| use Illuminate\Http\JsonResponse; | |
| use Illuminate\Foundation\Auth\Access\AuthorizesRequests; | |
| use Modules\AuthProfile\Services\AuthProfileCommandService; | |
| use Modules\AuthProfile\Http\Requests\AuthProfileUpdateRequest; | |
| final class AuthProfileUpdateAction | |
| { | |
| use AuthorizesRequests; | |
| /** | |
| * @OA\Put( | |
| * path="/authProfile", | |
| * tags={"AuthProfile"}, | |
| * description="Update current authenticated user profile", | |
| * security={ | |
| * {"passport": {}}, | |
| * }, | |
| * @OA\RequestBody( | |
| * required=true, | |
| * @OA\JsonContent( | |
| * type="object", | |
| * @OA\Property(property="firstName", type="string", nullable=true), | |
| * @OA\Property(property="lastName", type="string", nullable=true), | |
| * ), | |
| * ), | |
| * @OA\Response( | |
| * response=200, | |
| * description="Successful", | |
| * @OA\JsonContent( | |
| * type="object", | |
| * ref="#/components/schemas/MessageSchema", | |
| * ), | |
| * ), | |
| * @OA\Response( | |
| * response=401, | |
| * description="Unauthenticated", | |
| * @OA\JsonContent( | |
| * type="object", | |
| * ref="#/components/schemas/ErrorMessageSchema", | |
| * ), | |
| * ), | |
| * ) | |
| */ | |
| public function __invoke(AuthProfileUpdateRequest $request, AuthProfileCommandService $service): JsonResponse | |
| { | |
| $dto = $request->toDto(); | |
| $this->authorize('auth:profile@update', [$dto]); | |
| $service->update($dto); | |
| return response()->message(trans('messages.auth_profile.updated')); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\AuthProfile\Dto; | |
| final class AuthProfileUpdateDto | |
| { | |
| public function __construct( | |
| public readonly int $userId, | |
| public readonly string $firstName, | |
| public readonly string $lastName, | |
| ) { | |
| // | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\AuthProfile\Http\Requests; | |
| use Illuminate\Support\Facades\Auth; | |
| use Illuminate\Foundation\Http\FormRequest; | |
| use Modules\AuthProfile\Dto\AuthProfileUpdateDto; | |
| final class AuthProfileUpdateRequest extends FormRequest | |
| { | |
| public function rules(): array | |
| { | |
| return [ | |
| 'firstName' => [ | |
| 'required', | |
| 'string', | |
| 'max:255', | |
| ], | |
| 'lastName' => [ | |
| 'required', | |
| 'string', | |
| 'max:255', | |
| ], | |
| ]; | |
| } | |
| public function toDto(): AuthProfileUpdateDto | |
| { | |
| return new AuthProfileUpdateDto( | |
| Auth::id(), | |
| $this->input('firstName'), | |
| $this->input('lastName'), | |
| ); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\AuthProfile\Http\Actions; | |
| use Illuminate\Http\Resources\Json\JsonResource; | |
| use Illuminate\Foundation\Auth\Access\AuthorizesRequests; | |
| use Modules\AuthProfile\Services\AuthProfileQueryService; | |
| use Modules\AuthProfile\Http\Resources\AuthProfileResource; | |
| use Modules\AuthProfile\Http\Requests\AuthProfileViewRequest; | |
| final class AuthProfileViewAction | |
| { | |
| use AuthorizesRequests; | |
| /** | |
| * @OA\Get( | |
| * path="/authProfile", | |
| * tags={"AuthProfile"}, | |
| * description="Get current authenticated user profile", | |
| * security={ | |
| * {"passport": {}}, | |
| * }, | |
| * @OA\Response( | |
| * response=200, | |
| * description="Successful", | |
| * @OA\JsonContent( | |
| * type="object", | |
| * @OA\Property(property="data", ref="#/components/schemas/AuthProfileSchema"), | |
| * ), | |
| * ), | |
| * @OA\Response( | |
| * response=401, | |
| * description="Unauthenticated", | |
| * @OA\JsonContent( | |
| * type="object", | |
| * ref="#/components/schemas/ErrorMessageSchema", | |
| * ), | |
| * ), | |
| * ) | |
| */ | |
| public function __invoke(AuthProfileViewRequest $request, AuthProfileQueryService $service): JsonResource | |
| { | |
| $dto = $request->toDto(); | |
| $this->authorize('auth:profile@view', [$dto]); | |
| $me = $service->view($dto); | |
| return new AuthProfileResource($me); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\AuthProfile\Dto; | |
| final class AuthProfileViewDto | |
| { | |
| public function __construct( | |
| public readonly int $userId, | |
| ) { | |
| // | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\OAuth\Grants; | |
| use League\OAuth2\Server\RequestEvent; | |
| use Modules\OAuth\Dto\OAuthFacebookDto; | |
| use Psr\Http\Message\ServerRequestInterface; | |
| use Modules\OAuth\Exceptions\OAuthServerException; | |
| use League\OAuth2\Server\Entities\UserEntityInterface; | |
| use League\OAuth2\Server\Entities\ClientEntityInterface; | |
| use Infrastructure\Validation\Rules\FacebookAccessTokenRule; | |
| final class FacebookGrant extends AbstractGrant | |
| { | |
| public function getIdentifier(): string | |
| { | |
| return 'facebook'; | |
| } | |
| protected function shouldValidateOtp(): bool | |
| { | |
| return false; | |
| } | |
| protected function getServerRequestValidationRules(ServerRequestInterface $request): array | |
| { | |
| return [ | |
| 'token' => [ | |
| 'required', | |
| 'string', | |
| 'max:255', | |
| new FacebookAccessTokenRule(), | |
| ], | |
| ]; | |
| } | |
| protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client): UserEntityInterface | |
| { | |
| $data = $this->validateRequest($request); | |
| $user = $this->service->facebook(new OAuthFacebookDto( | |
| $data['token'], | |
| )); | |
| if ($user instanceof UserEntityInterface === false) { | |
| $this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request)); | |
| throw OAuthServerException::invalidCredentials(); | |
| } | |
| return $user; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\OAuth\Grants; | |
| use Modules\OAuth\Dto\OAuthGoogleDto; | |
| use League\OAuth2\Server\RequestEvent; | |
| use Psr\Http\Message\ServerRequestInterface; | |
| use Modules\OAuth\Exceptions\OAuthServerException; | |
| use League\OAuth2\Server\Entities\UserEntityInterface; | |
| use League\OAuth2\Server\Entities\ClientEntityInterface; | |
| use Infrastructure\Validation\Rules\GoogleAccessTokenRule; | |
| final class GoogleGrant extends AbstractGrant | |
| { | |
| public function getIdentifier(): string | |
| { | |
| return 'google'; | |
| } | |
| protected function shouldValidateOtp(): bool | |
| { | |
| return false; | |
| } | |
| protected function getServerRequestValidationRules(ServerRequestInterface $request): array | |
| { | |
| return [ | |
| 'token' => [ | |
| 'required', | |
| 'string', | |
| 'max:255', | |
| new GoogleAccessTokenRule(), | |
| ], | |
| ]; | |
| } | |
| protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client): UserEntityInterface | |
| { | |
| $data = $this->validateRequest($request); | |
| $user = $this->service->google(new OAuthGoogleDto( | |
| $data['token'], | |
| )); | |
| if ($user instanceof UserEntityInterface === false) { | |
| $this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request)); | |
| throw OAuthServerException::invalidCredentials(); | |
| } | |
| return $user; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\OAuth\Grants; | |
| use League\OAuth2\Server\RequestEvent; | |
| use Modules\OAuth\Dto\OAuthPasswordDto; | |
| use Psr\Http\Message\ServerRequestInterface; | |
| use Modules\OAuth\Exceptions\OAuthServerException; | |
| use League\OAuth2\Server\Entities\UserEntityInterface; | |
| use League\OAuth2\Server\Entities\ClientEntityInterface; | |
| final class PasswordGrant extends AbstractGrant | |
| { | |
| public function getIdentifier(): string | |
| { | |
| return 'password'; | |
| } | |
| protected function shouldValidateOtp(): bool | |
| { | |
| return true; | |
| } | |
| protected function getServerRequestValidationRules(ServerRequestInterface $request): array | |
| { | |
| return [ | |
| 'username' => [ | |
| 'required', | |
| 'string', | |
| 'max:255', | |
| ], | |
| 'password' => [ | |
| 'required', | |
| 'string', | |
| 'max:255', | |
| ], | |
| ]; | |
| } | |
| protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client): UserEntityInterface | |
| { | |
| $data = $this->validateRequest($request); | |
| $user = $this->service->password(new OAuthPasswordDto( | |
| $data['username'], | |
| $data['password'], | |
| )); | |
| if ($user instanceof UserEntityInterface === false) { | |
| $this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request)); | |
| throw OAuthServerException::invalidCredentials(); | |
| } | |
| return $user; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Tests\Unit\Modules\OAuth\Grants; | |
| use DateInterval; | |
| use Mockery; | |
| use Mockery\MockInterface; | |
| use PHPUnit\Framework\TestCase; | |
| use PHPUnit\Framework\Attributes\Test; | |
| use PHPUnit\Framework\Attributes\DataProvider; | |
| use League\OAuth2\Server\CryptKey; | |
| use League\OAuth2\Server\RequestEvent; | |
| use League\OAuth2\Server\Entities\UserEntityInterface; | |
| use League\OAuth2\Server\Entities\ScopeEntityInterface; | |
| use League\OAuth2\Server\Entities\ClientEntityInterface; | |
| use League\OAuth2\Server\Entities\AccessTokenEntityInterface; | |
| use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; | |
| use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; | |
| use League\OAuth2\Server\Repositories\UserRepositoryInterface; | |
| use League\OAuth2\Server\Repositories\ClientRepositoryInterface; | |
| use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; | |
| use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; | |
| use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; | |
| use League\Event\EmitterInterface; | |
| use Psr\Http\Message\ServerRequestInterface; | |
| use Modules\OAuth\Grants\PasswordGrant; | |
| use Modules\OAuth\Services\OAuthService; | |
| use Modules\OAuth\Dto\OAuthPasswordDto; | |
| use Modules\OAuth\Dto\OAuthVerifyOtpDto; | |
| use Modules\OAuth\Exceptions\OAuthServerException; | |
| use Modules\OAuth\Exceptions\InvalidOtpException; | |
| final class PasswordGrantTest extends TestCase | |
| { | |
| private PasswordGrant $grant; | |
| private MockInterface&OAuthService $oauthService; | |
| private MockInterface&UserRepositoryInterface $userRepository; | |
| private MockInterface&RefreshTokenRepositoryInterface $refreshTokenRepository; | |
| private MockInterface&ClientRepositoryInterface $clientRepository; | |
| private MockInterface&ScopeRepositoryInterface $scopeRepository; | |
| private MockInterface&AccessTokenRepositoryInterface $accessTokenRepository; | |
| protected function setUp(): void | |
| { | |
| parent::setUp(); | |
| $this->oauthService = Mockery::mock(OAuthService::class); | |
| $this->userRepository = Mockery::mock(UserRepositoryInterface::class); | |
| $this->refreshTokenRepository = Mockery::mock(RefreshTokenRepositoryInterface::class); | |
| $this->clientRepository = Mockery::mock(ClientRepositoryInterface::class); | |
| $this->scopeRepository = Mockery::mock(ScopeRepositoryInterface::class); | |
| $this->accessTokenRepository = Mockery::mock(AccessTokenRepositoryInterface::class); | |
| $this->grant = new PasswordGrant( | |
| $this->userRepository, | |
| $this->refreshTokenRepository, | |
| $this->oauthService, | |
| ); | |
| $this->grant->setClientRepository($this->clientRepository); | |
| $this->grant->setScopeRepository($this->scopeRepository); | |
| $this->grant->setAccessTokenRepository($this->accessTokenRepository); | |
| $this->grant->setDefaultScope(''); | |
| $this->grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/stubs/private.key', null, false)); | |
| $this->grant->setEmitter(Mockery::mock(EmitterInterface::class)->shouldIgnoreMissing()); | |
| } | |
| protected function tearDown(): void | |
| { | |
| Mockery::close(); | |
| parent::tearDown(); | |
| } | |
| #[Test] | |
| public function it_issues_tokens_for_valid_credentials(): void | |
| { | |
| $request = $this->createRequest([ | |
| 'client_id' => 'f47ac10b-58cc-4372-a567-0e02b2c3d479', | |
| 'client_secret' => 'secret', | |
| 'username' => 'user@example.com', | |
| 'password' => 'correct-password', | |
| 'otp' => '123456', | |
| ]); | |
| $client = $this->mockValidClient(); | |
| $user = $this->mockValidUser(42); | |
| $this->oauthService | |
| ->shouldReceive('password') | |
| ->once() | |
| ->with(Mockery::on(fn (OAuthPasswordDto $dto) => | |
| $dto->username === 'user@example.com' | |
| && $dto->password === 'correct-password' | |
| )) | |
| ->andReturn($user); | |
| $this->oauthService | |
| ->shouldReceive('verifyOtp') | |
| ->once() | |
| ->with(Mockery::type(OAuthVerifyOtpDto::class)); | |
| $this->mockScopeFinalization($client, $user); | |
| $this->mockTokenIssuance($client, $user); | |
| $responseType = Mockery::mock(ResponseTypeInterface::class); | |
| $responseType->shouldReceive('setAccessToken')->once(); | |
| $responseType->shouldReceive('setRefreshToken')->once(); | |
| $result = $this->grant->respondToAccessTokenRequest( | |
| $request, | |
| $responseType, | |
| new DateInterval('PT1H'), | |
| ); | |
| $this->assertSame($responseType, $result); | |
| } | |
| #[Test] | |
| public function it_rejects_invalid_credentials(): void | |
| { | |
| $request = $this->createRequest([ | |
| 'client_id' => 'f47ac10b-58cc-4372-a567-0e02b2c3d479', | |
| 'client_secret' => 'secret', | |
| 'username' => 'user@example.com', | |
| 'password' => 'wrong-password', | |
| ]); | |
| $this->mockValidClient(); | |
| $this->oauthService | |
| ->shouldReceive('password') | |
| ->once() | |
| ->andReturnNull(); | |
| $this->expectException(OAuthServerException::class); | |
| $this->grant->respondToAccessTokenRequest( | |
| $request, | |
| Mockery::mock(ResponseTypeInterface::class), | |
| new DateInterval('PT1H'), | |
| ); | |
| } | |
| #[Test] | |
| public function it_rejects_invalid_otp(): void | |
| { | |
| $request = $this->createRequest([ | |
| 'client_id' => 'f47ac10b-58cc-4372-a567-0e02b2c3d479', | |
| 'client_secret' => 'secret', | |
| 'username' => 'user@example.com', | |
| 'password' => 'correct-password', | |
| 'otp' => '000000', | |
| ]); | |
| $this->mockValidClient(); | |
| $user = $this->mockValidUser(42); | |
| $this->oauthService | |
| ->shouldReceive('password') | |
| ->once() | |
| ->andReturn($user); | |
| $this->oauthService | |
| ->shouldReceive('verifyOtp') | |
| ->once() | |
| ->andThrow(new InvalidOtpException()); | |
| $this->expectException(OAuthServerException::class); | |
| $this->grant->respondToAccessTokenRequest( | |
| $request, | |
| Mockery::mock(ResponseTypeInterface::class), | |
| new DateInterval('PT1H'), | |
| ); | |
| } | |
| #[Test] | |
| #[DataProvider('missingRequiredFieldsProvider')] | |
| public function it_rejects_requests_with_missing_required_fields(array $params): void | |
| { | |
| $request = $this->createRequest(array_merge([ | |
| 'client_id' => 'f47ac10b-58cc-4372-a567-0e02b2c3d479', | |
| 'client_secret' => 'secret', | |
| ], $params)); | |
| $this->mockValidClient(); | |
| $this->expectException(OAuthServerException::class); | |
| $this->grant->respondToAccessTokenRequest( | |
| $request, | |
| Mockery::mock(ResponseTypeInterface::class), | |
| new DateInterval('PT1H'), | |
| ); | |
| } | |
| public static function missingRequiredFieldsProvider(): array | |
| { | |
| return [ | |
| 'missing username' => [['password' => 'pass']], | |
| 'missing password' => [['username' => 'user@example.com']], | |
| 'empty username' => [['username' => '', 'password' => 'pass']], | |
| 'empty password' => [['username' => 'user@example.com', 'password' => '']], | |
| ]; | |
| } | |
| #[Test] | |
| public function it_rejects_invalid_client_id_format(): void | |
| { | |
| $request = $this->createRequest([ | |
| 'client_id' => 'not-a-uuid', | |
| 'client_secret' => 'secret', | |
| 'username' => 'user@example.com', | |
| 'password' => 'password', | |
| ]); | |
| $this->expectException(OAuthServerException::class); | |
| $this->grant->respondToAccessTokenRequest( | |
| $request, | |
| Mockery::mock(ResponseTypeInterface::class), | |
| new DateInterval('PT1H'), | |
| ); | |
| } | |
| // ------------------------------------------------------------------ | |
| // Helpers | |
| // ------------------------------------------------------------------ | |
| private function createRequest(array $params): ServerRequestInterface | |
| { | |
| $request = Mockery::mock(ServerRequestInterface::class); | |
| $request->shouldReceive('getParsedBody')->andReturn($params); | |
| $request->shouldReceive('getServerParams')->andReturn([ | |
| 'REMOTE_ADDR' => '127.0.0.1', | |
| ]); | |
| $request->shouldReceive('getHeaderLine') | |
| ->with('User-Agent') | |
| ->andReturn('TestAgent/1.0'); | |
| return $request; | |
| } | |
| private function mockValidClient(): ClientEntityInterface | |
| { | |
| $client = Mockery::mock(ClientEntityInterface::class); | |
| $client->shouldReceive('getIdentifier')->andReturn('f47ac10b-58cc-4372-a567-0e02b2c3d479'); | |
| $client->shouldReceive('isConfidential')->andReturn(true); | |
| $client->shouldReceive('getRedirectUri')->andReturn(['https://example.com/callback']); | |
| $this->clientRepository | |
| ->shouldReceive('getClientEntity') | |
| ->andReturn($client); | |
| $this->clientRepository | |
| ->shouldReceive('validateClient') | |
| ->andReturn(true); | |
| return $client; | |
| } | |
| private function mockValidUser(int $id): UserEntityInterface | |
| { | |
| $user = Mockery::mock(UserEntityInterface::class); | |
| $user->shouldReceive('getIdentifier')->andReturn($id); | |
| return $user; | |
| } | |
| private function mockScopeFinalization(ClientEntityInterface $client, UserEntityInterface $user): void | |
| { | |
| $this->scopeRepository | |
| ->shouldReceive('getScopeEntityByIdentifier') | |
| ->andReturn(Mockery::mock(ScopeEntityInterface::class)); | |
| $this->scopeRepository | |
| ->shouldReceive('finalizeScopes') | |
| ->andReturn([]); | |
| } | |
| private function mockTokenIssuance(ClientEntityInterface $client, UserEntityInterface $user): void | |
| { | |
| $accessToken = Mockery::mock(AccessTokenEntityInterface::class); | |
| $accessToken->shouldReceive('getIdentifier')->andReturn('access-token-id'); | |
| $accessToken->shouldReceive('setPrivateKey'); | |
| $accessToken->shouldReceive('setExpiryDateTime'); | |
| $accessToken->shouldReceive('setUserIdentifier'); | |
| $accessToken->shouldReceive('setClient'); | |
| $accessToken->shouldReceive('addScope'); | |
| $this->accessTokenRepository | |
| ->shouldReceive('getNewToken') | |
| ->andReturn($accessToken); | |
| $this->accessTokenRepository | |
| ->shouldReceive('persistNewAccessToken'); | |
| $refreshToken = Mockery::mock(RefreshTokenEntityInterface::class); | |
| $refreshToken->shouldReceive('getIdentifier')->andReturn('refresh-token-id'); | |
| $refreshToken->shouldReceive('setIdentifier'); | |
| $refreshToken->shouldReceive('setExpiryDateTime'); | |
| $refreshToken->shouldReceive('setAccessToken'); | |
| $this->refreshTokenRepository | |
| ->shouldReceive('getNewRefreshToken') | |
| ->andReturn($refreshToken); | |
| $this->refreshTokenRepository | |
| ->shouldReceive('persistNewRefreshToken'); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Modules\OAuth\Grants; | |
| use Psr\Http\Message\ServerRequestInterface; | |
| use Modules\OAuth\Dto\OAuthPasswordSignupDto; | |
| use Infrastructure\Validation\Rules\PasswordRule; | |
| use League\OAuth2\Server\Entities\UserEntityInterface; | |
| use League\OAuth2\Server\Entities\ClientEntityInterface; | |
| final class PasswordSignupGrant extends AbstractGrant | |
| { | |
| public function getIdentifier(): string | |
| { | |
| return 'password_signup'; | |
| } | |
| protected function shouldValidateOtp(): bool | |
| { | |
| return false; | |
| } | |
| protected function getServerRequestValidationRules(ServerRequestInterface $request): array | |
| { | |
| return [ | |
| 'username' => [ | |
| 'required', | |
| 'string', | |
| 'max:255', | |
| 'email', | |
| 'unique:users,email', | |
| ], | |
| 'password' => [ | |
| 'required', | |
| 'string', | |
| 'max:255', | |
| ], | |
| 'first_name' => [ | |
| 'required', | |
| 'string', | |
| 'max:255', | |
| ], | |
| 'last_name' => [ | |
| 'required', | |
| 'string', | |
| 'max:255', | |
| ], | |
| 'company_name' => [ | |
| 'required', | |
| 'string', | |
| 'max:255', | |
| ], | |
| 'company_address' => [ | |
| 'required', | |
| 'string', | |
| 'max:255', | |
| ], | |
| 'company_type' => [ | |
| 'required', | |
| 'string', | |
| 'max:255', | |
| ], | |
| ]; | |
| } | |
| protected function getServerRequestValidationMessages(ServerRequestInterface $request): array | |
| { | |
| return [ | |
| 'username.unique' => trans('validation.unique', ['attribute' => 'email']), | |
| ]; | |
| } | |
| protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client): UserEntityInterface | |
| { | |
| $data = $this->validateRequest($request); | |
| return $this->service->passwordSignup(new OAuthPasswordSignupDto( | |
| $data['username'], | |
| $data['password'], | |
| $data['first_name'], | |
| $data['last_name'], | |
| $data['company_type'], | |
| $data['company_address'], | |
| $data['company_name'], | |
| )); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment