diff --git a/app/Mail/OAuth2PasswordlessOTPMail.php b/app/Mail/OAuth2PasswordlessOTPMail.php new file mode 100644 index 00000000..cde48891 --- /dev/null +++ b/app/Mail/OAuth2PasswordlessOTPMail.php @@ -0,0 +1,76 @@ +to = $to; + $this->otp = $otp; + $this->lifetime = $lifetime / 60; + } + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $subject = sprintf("[%s] Your Verification Code", Config::get('app.app_name')); + Log::debug(sprintf("OAuth2PasswordlessOTPMail::build to %s", $this->to)); + return $this->from(Config::get("mail.from")) + ->to($this->to) + ->subject($subject) + ->view('emails.oauth2_passwordless_otp'); + } +} \ No newline at end of file diff --git a/app/Models/OAuth2/Client.php b/app/Models/OAuth2/Client.php index 0b5571f0..ff1c315c 100644 --- a/app/Models/OAuth2/Client.php +++ b/app/Models/OAuth2/Client.php @@ -90,6 +90,24 @@ class Client extends BaseEntity implements IClient */ private $pkce_enabled; + /** + * @ORM\Column(name="otp_enabled", type="boolean") + * @var bool + */ + private $otp_enabled; + + /** + * @ORM\Column(name="otp_length", type="integer") + * @var int + */ + private $otp_length; + + /** + * @ORM\Column(name="otp_lifetime", type="integer") + * @var int + */ + private $otp_lifetime; + /** * @ORM\Column(name="locked", type="boolean") * @var bool @@ -343,13 +361,13 @@ class Client extends BaseEntity implements IClient private $admin_users; /** - * @ORM\OneToMany(targetEntity="Models\OAuth2\RefreshToken", mappedBy="client", cascade={"persist"}, orphanRemoval=true) + * @ORM\OneToMany(targetEntity="Models\OAuth2\RefreshToken", mappedBy="client", cascade={"persist", "remove"}, orphanRemoval=true) * @var ArrayCollection */ private $refresh_tokens; /** - * @ORM\OneToMany(targetEntity="Models\OAuth2\AccessToken", mappedBy="client", cascade={"persist"}, orphanRemoval=true) + * @ORM\OneToMany(targetEntity="Models\OAuth2\AccessToken", mappedBy="client", cascade={"persist", "remove"}, orphanRemoval=true) * @var ArrayCollection */ private $access_tokens; @@ -364,6 +382,12 @@ class Client extends BaseEntity implements IClient */ private $scopes; + /** + * @ORM\OneToMany(targetEntity="Models\OAuth2\OAuth2OTP", mappedBy="client", cascade={"persist", "remove"}, orphanRemoval=true) + * @var ArrayCollection + */ + private $otp_grants; + /** * Client constructor. */ @@ -374,6 +398,7 @@ class Client extends BaseEntity implements IClient $this->access_tokens = new ArrayCollection(); $this->refresh_tokens = new ArrayCollection(); $this->admin_users = new ArrayCollection(); + $this->otp_grants = new ArrayCollection(); $this->scopes = new ArrayCollection(); $this->locked = false; $this->active = false; @@ -399,6 +424,7 @@ class Client extends BaseEntity implements IClient $this->max_refresh_token_issuance_basis = 0; $this->max_refresh_token_issuance_qty = 0; $this->pkce_enabled = false; + $this->otp_enabled = false; } public static $valid_app_types = [ @@ -1603,4 +1629,97 @@ class Client extends BaseEntity implements IClient } $this->pkce_enabled = false; } + + /** + * @return bool + */ + public function isPasswordlessEnabled(): bool + { + return $this->otp_enabled; + } + + public function enablePasswordless(): void + { + $this->otp_enabled = true; + $this->token_endpoint_auth_method = OAuth2Protocol::TokenEndpoint_AuthMethod_None; + } + + public function disablePasswordless(): void + { + $this->otp_enabled = false; + } + + /** + * @return int + */ + public function getOtpLength(): int + { + return $this->otp_length; + } + + /** + * @param int $otp_length + */ + public function setOtpLength(int $otp_length): void + { + $this->otp_length = $otp_length; + } + + /** + * @return int + */ + public function getOtpLifetime(): int + { + return $this->otp_lifetime; + } + + /** + * @param int $otp_lifetime + */ + public function setOtpLifetime(int $otp_lifetime): void + { + $this->otp_lifetime = $otp_lifetime; + } + + public function getOTPGrantsByEmailNotRedeemed(string $email){ + $criteria = Criteria::create(); + $criteria->where(Criteria::expr()->eq('email', trim($email))); + $criteria->where(Criteria::expr()->isNull("redeemed_at")); + return $this->otp_grants->matching($criteria); + } + + public function getOTPGrantsByPhoneNumberNotRedeemed(string $phone_number){ + $criteria = Criteria::create(); + $criteria->where(Criteria::expr()->eq('phone_number', trim($phone_number))); + $criteria->where(Criteria::expr()->isNull("redeemed_at")); + return $this->otp_grants->matching($criteria); + } + + public function addOTPGrant(OAuth2OTP $otp){ + if($this->otp_grants->contains($otp)) return; + $this->otp_grants->add($otp); + $otp->setClient($this); + } + + public function removeOTPGrant(OAuth2OTP $otp){ + if(!$this->otp_grants->contains($otp)) return; + $this->otp_grants->removeElement($otp); + $otp->clearClient(); + } + + public function getOTPByValue(string $value):?OAuth2OTP{ + $criteria = Criteria::create(); + $criteria->where(Criteria::expr()->eq('value', trim($value))); + $res = $this->otp_grants->matching($criteria)->first(); + return !$res ? null : $res; + } + + /** + * @param string $value + * @return bool + */ + public function hasOTP(string $value):bool{ + return !is_null($this->getOTPByValue($value)); + } + } \ No newline at end of file diff --git a/app/Models/OAuth2/Factories/OTPFactory.php b/app/Models/OAuth2/Factories/OTPFactory.php new file mode 100644 index 00000000..096699ef --- /dev/null +++ b/app/Models/OAuth2/Factories/OTPFactory.php @@ -0,0 +1,95 @@ +getOtpLifetime(); + $length = $client->getOtpLength(); + } + + $otp = new OAuth2OTP($length, $lifetime); + $otp->setConnection($request->getConnection()); + $otp->setSend($request->getSend()); + $otp->setLifetime($lifetime); + $otp->setNonce($request->getNonce()); + $otp->setRedirectUrl($request->getRedirectUri()); + $otp->setScopes($request->getScope()); + $otp->setEmail($request->getEmail()); + $otp->setPhoneNumber($request->getPhoneNumber()); + $identifier_generator->generate($otp); + + if(!is_null($client)){ + // check that client does not has a value + while($client->hasOTP($otp->getValue())){ + $identifier_generator->generate($otp); + } + // then add it + $client->addOTPGrant($otp); + } + + return $otp; + } + + /** + * @param array $payload + * @param IdentifierGenerator $identifier_generator + * @return OAuth2OTP + */ + static public function buildFromPayload(array $payload, IdentifierGenerator $identifier_generator):OAuth2OTP{ + + $lifetime = Config::get("otp.lifetime", 120); + $length = Config::get("otp.length",6); + $otp = new OAuth2OTP($length, $lifetime); + $otp->setConnection($payload[OAuth2Protocol::OAuth2PasswordlessConnection]); + $otp->setSend($payload[OAuth2Protocol::OAuth2PasswordlessSend]); + $otp->setScopes($payload[OAuth2Protocol::OAuth2Protocol_Scope]); + $otp->setLifetime($lifetime); + $otp->setNonce($payload[OAuth2Protocol::OAuth2Protocol_Nonce] ?? null); + $otp->setRedirectUrl($payload[OAuth2Protocol::OAuth2Protocol_RedirectUri] ?? null); + $otp->setEmail($payload[OAuth2Protocol::OAuth2PasswordlessEmail] ?? null); + $otp->setPhoneNumber($payload[OAuth2Protocol::OAuth2PasswordlessPhoneNumber] ?? null); + $identifier_generator->generate($otp); + + return $otp; + } +} \ No newline at end of file diff --git a/app/Models/OAuth2/OAuth2OTP.php b/app/Models/OAuth2/OAuth2OTP.php new file mode 100644 index 00000000..bb824128 --- /dev/null +++ b/app/Models/OAuth2/OAuth2OTP.php @@ -0,0 +1,450 @@ +length = $length; + $this->lifetime = $lifetime; + $this->redeemed_at = null; + $this->redeemed_attempts = 0; + } + + /** + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * @return string + */ + public function getConnection(): string + { + return $this->connection; + } + + /** + * @param string $connection + */ + public function setConnection(string $connection): void + { + $this->connection = $connection; + } + + /** + * @return string + */ + public function getSend(): string + { + return $this->send; + } + + /** + * @param string $send + */ + public function setSend(string $send): void + { + $this->send = $send; + } + + /** + * @return string + */ + public function getScopes(): string + { + return $this->scopes; + } + + /** + * @param string $scopes + */ + public function setScopes(string $scopes): void + { + $this->scopes = $scopes; + } + + /** + * @return string + */ + public function getEmail(): ?string + { + return $this->email; + } + + /** + * @param string $email + */ + public function setEmail(?string $email): void + { + $this->email = $email; + } + + /** + * @return string + */ + public function getPhoneNumber(): ?string + { + return $this->phone_number; + } + + /** + * @param string $phone_number + */ + public function setPhoneNumber(?string $phone_number): void + { + $this->phone_number = $phone_number; + } + + /** + * @return string + */ + public function getNonce(): ?string + { + return $this->nonce; + } + + /** + * @param string $nonce + */ + public function setNonce(?string $nonce): void + { + $this->nonce = $nonce; + } + + /** + * @return string + */ + public function getRedirectUrl(): ?string + { + return $this->redirect_url; + } + + /** + * @param string $redirect_url + */ + public function setRedirectUrl(?string $redirect_url): void + { + $this->redirect_url = $redirect_url; + } + + /** + * @return \DateTime|null + */ + public function getRedeemedAt(): ?\DateTime + { + return $this->redeemed_at; + } + + public function isRedeemed():bool{ + return !is_null($this->redeemed_at); + } + + /** + * @throws ValidationException + */ + public function redeem(): void + { + if(!is_null($this->redeemed_at)) + throw new ValidationException("OTP is already redeemed."); + $this->redeemed_at = new \DateTime('now', new \DateTimeZone('UTC')); + $this->redeemed_from_ip = IPHelper::getUserIp(); + + Log::debug(sprintf("OAuth2OTP::redeem from ip %s", $this->redeemed_from_ip)); + } + + /** + * @return Client + */ + public function getClient(): Client + { + return $this->client; + } + + public function hasClient():bool{ + return !is_null($this->client); + } + + /** + * @param Client $client + */ + public function setClient(Client $client): void + { + $this->client = $client; + } + + /** + * @return int + */ + public function getLifetime(): int + { + return $this->lifetime; + } + + /** + * @param int $lifetime + */ + public function setLifetime(int $lifetime): void + { + $this->lifetime = $lifetime; + } + + public function getRemainingLifetime() + { + //check is refresh token is stills alive... (ZERO is infinite lifetime) + if (intval($this->lifetime) == 0) { + return 0; + } + $created_at = clone $this->created_at; + $created_at->add(new DateInterval('PT' . intval($this->lifetime) . 'S')); + $now = new DateTime(gmdate("Y-m-d H:i:s", time()), new DateTimeZone("UTC")); + //check validity... + if ($now > $created_at) { + return -1; + } + $seconds = abs($created_at->getTimestamp() - $now->getTimestamp());; + + return $seconds; + } + + public function isAlive():bool{ + return $this->getRemainingLifetime() >= 0; + } + + public function clearClient():void{ + $this->client = null; + } + + /** + * @return int + */ + public function getLength(): int + { + return $this->length; + } + + const MaxRedeemAttempts = 3; + + public function logRedeemAttempt():void{ + if($this->redeemed_attempts < self::MaxRedeemAttempts){ + $this->redeemed_attempts = $this->redeemed_attempts + 1; + Log::debug(sprintf("OAuth2OTP::logRedeemAttempt redeemed_attempts %s", $this->redeemed_attempts)); + } + } + + public function isValid():bool{ + return ($this->redeemed_attempts < self::MaxRedeemAttempts) && $this->isAlive(); + } + + public function getUserName():?string{ + return $this->connection == OAuth2Protocol::OAuth2PasswordlessEmail ? $this->email : $this->phone_number; + } + + /** + * @param string $scope + * @return bool + */ + public function allowScope(string $scope):bool{ + $s1 = explode(" ", $scope); + $s2 = explode(" ", $this->scopes); + return count(array_diff($s1, $s2)) == 0; + } + + public function setValue(string $value) + { + $this->value = $value; + } + + public function getType(): string + { + return "otp"; + } + + const VsChar = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + public function generateValue(): string + { + // calculate value + // entropy(SHANNON FANO Approx) len * log(count(VsChar))/log(2) = bits of entropy + $this->value = Rand::getString($this->length, self::VsChar); + return $this->value; + } + + /** + * @param OAuth2AccessTokenRequestPasswordless $request + * @param int $length + * @return OAuth2OTP + */ + public static function fromRequest(OAuth2AccessTokenRequestPasswordless $request, int $length):OAuth2OTP{ + $instance = new self($length); + $instance->connection = $request->getConnection(); + $instance->email = $request->getEmail(); + $instance->phone_number = $request->getPhoneNumber(); + $instance->scopes = $request->getScopes(); + $instance->value = $request->getOTP(); + return $instance; + } + + // non db fields + + private $auth_time; + + private $user_id; + + /** + * @param int $auth_time + */ + public function setAuthTime(int $auth_time): void + { + $this->auth_time = $auth_time; + } + + /** + * @param mixed $user_id + */ + public function setUserId($user_id): void + { + $this->user_id = $user_id; + } + + /** + * @return mixed + */ + public function getAuthTime() + { + return $this->auth_time; + } + + /** + * @return mixed + */ + public function getUserId() + { + return $this->user_id; + } + + +} \ No newline at end of file diff --git a/app/Models/OAuth2/OAuth2TrailException.php b/app/Models/OAuth2/OAuth2TrailException.php index 24711ca2..1cc7b870 100644 --- a/app/Models/OAuth2/OAuth2TrailException.php +++ b/app/Models/OAuth2/OAuth2TrailException.php @@ -12,7 +12,6 @@ * limitations under the License. **/ use App\Models\Utils\BaseEntity; -use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping AS ORM; /** * @ORM\Entity(repositoryClass="App\Repositories\DoctrineOAuth2TrailExceptionRepository") diff --git a/app/Repositories/DoctrineOAuth2OTPRepository.php b/app/Repositories/DoctrineOAuth2OTPRepository.php new file mode 100644 index 00000000..0c4f8649 --- /dev/null +++ b/app/Repositories/DoctrineOAuth2OTPRepository.php @@ -0,0 +1,66 @@ +findOneBy(['value' => trim($value)]); + } + + /** + * @param string $connection + * @param string $user_name + * @param Client|null $client + * @return OAuth2OTP|null + */ + public function getByConnectionAndUserNameNotRedeemed + ( + string $connection, + string $user_name, + ?Client $client + ):?OAuth2OTP + { + $query = $this->getEntityManager() + ->createQueryBuilder() + ->select("e") + ->from($this->getBaseEntity(), "e") + ->where("e.connection = (:connection)") + ->andWhere("(e.email = (:user_name) or e.phone_number = (:user_name))") + ->andWhere("e.redeemed_at is null") + ->setParameter("connection", $connection) + ->setParameter("user_name", $user_name); + // add client id condition + if(!is_null($client)){ + $query->join("e.client", "c")->andWhere("c.id = :client_id") + ->setParameter("client_id", $client->getId()); + } + $query->addOrderBy("e.id", "DESC"); + return $query->getQuery()->getOneOrNullResult(); + } +} \ No newline at end of file diff --git a/app/Repositories/RepositoriesProvider.php b/app/Repositories/RepositoriesProvider.php index ee6b5fa0..74d73e6c 100644 --- a/app/Repositories/RepositoriesProvider.php +++ b/app/Repositories/RepositoriesProvider.php @@ -11,6 +11,7 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ + use App\libs\Auth\Models\SpamEstimatorFeed; use App\libs\Auth\Models\UserRegistrationRequest; use App\libs\Auth\Repositories\IBannedIPRepository; @@ -20,6 +21,7 @@ use App\libs\Auth\Repositories\IUserExceptionTrailRepository; use App\libs\Auth\Repositories\IUserPasswordResetRequestRepository; use App\libs\Auth\Repositories\IUserRegistrationRequestRepository; use App\libs\Auth\Repositories\IWhiteListedIPRepository; +use App\libs\OAuth2\Repositories\IOAuth2OTPRepository; use App\libs\OAuth2\Repositories\IOAuth2TrailExceptionRepository; use App\Models\Repositories\IDisqusSSOProfileRepository; use App\Models\Repositories\IRocketChatSSOProfileRepository; @@ -43,6 +45,7 @@ use Models\OAuth2\ApiScope; use Models\OAuth2\ApiScopeGroup; use Models\OAuth2\Client; use Models\OAuth2\ClientPublicKey; +use Models\OAuth2\OAuth2OTP; use Models\OAuth2\OAuth2TrailException; use Models\OAuth2\RefreshToken; use Models\OAuth2\ResourceServer; @@ -67,43 +70,45 @@ use OAuth2\Repositories\IServerPrivateKeyRepository; use LaravelDoctrine\ORM\Facades\EntityManager; use OpenId\Repositories\IOpenIdAssociationRepository; use OpenId\Repositories\IOpenIdTrustedSiteRepository; + /** * Class RepositoriesProvider * @package Repositories */ final class RepositoriesProvider extends ServiceProvider implements DeferrableProvider { - public function boot(){ + public function boot() + { } - public function register(){ + public function register() + { App::singleton(IGroupRepository::class, - function(){ + function () { return EntityManager::getRepository(Group::class); } ); App::singleton(IUserPasswordResetRequestRepository::class, - function(){ + function () { return EntityManager::getRepository(UserPasswordResetRequest::class); - }) - ; + }); App::singleton(IServerExtensionRepository::class, - function(){ + function () { return EntityManager::getRepository(ServerExtension::class); } ); App::singleton(IOpenIdTrustedSiteRepository::class, - function(){ + function () { return EntityManager::getRepository(OpenIdTrustedSite::class); } ); App::singleton(IOpenIdAssociationRepository::class, - function(){ + function () { return EntityManager::getRepository(OpenIdAssociation::class); } ); @@ -111,171 +116,184 @@ final class RepositoriesProvider extends ServiceProvider implements DeferrablePr // doctrine repos App::singleton(IServerConfigurationRepository::class, - function(){ + function () { return EntityManager::getRepository(ServerConfiguration::class); } ); App::singleton(IUserExceptionTrailRepository::class, - function(){ + function () { return EntityManager::getRepository(UserExceptionTrail::class); } ); App::singleton(IBannedIPRepository::class, - function(){ + function () { return EntityManager::getRepository(BannedIP::class); } ); - App::singleton(IWhiteListedIPRepository::class, function (){ + App::singleton(IWhiteListedIPRepository::class, function () { return EntityManager::getRepository(WhiteListedIP::class); }); App::singleton(IUserRepository::class, - function(){ + function () { return EntityManager::getRepository(User::class); } ); App::singleton( IResourceServerRepository::class, - function(){ + function () { return EntityManager::getRepository(ResourceServer::class); } ); App::singleton( IApiRepository::class, - function(){ + function () { return EntityManager::getRepository(Api::class); } ); App::singleton( IApiEndpointRepository::class, - function(){ + function () { return EntityManager::getRepository(ApiEndpoint::class); } ); App::singleton( - IClientRepository::class, - function(){ + IClientRepository::class, + function () { return EntityManager::getRepository(Client::class); } ); App::singleton( IAccessTokenRepository::class, - function(){ + function () { return EntityManager::getRepository(AccessToken::class); } ); App::singleton( IRefreshTokenRepository::class, - function(){ + function () { return EntityManager::getRepository(RefreshToken::class); } ); App::singleton( IApiScopeRepository::class, - function(){ + function () { return EntityManager::getRepository(ApiScope::class); } ); App::singleton( IApiScopeGroupRepository::class, - function(){ + function () { return EntityManager::getRepository(ApiScopeGroup::class); } ); App::singleton( IOAuth2TrailExceptionRepository::class, - function(){ + function () { return EntityManager::getRepository(OAuth2TrailException::class); } ); App::singleton( IClientPublicKeyRepository::class, - function(){ + function () { return EntityManager::getRepository(ClientPublicKey::class); } ); App::singleton( IServerPrivateKeyRepository::class, - function(){ + function () { return EntityManager::getRepository(ServerPrivateKey::class); } ); App::singleton( IUserRegistrationRequestRepository::class, - function(){ + function () { return EntityManager::getRepository(UserRegistrationRequest::class); } ); App::singleton( ISpamEstimatorFeedRepository::class, - function(){ + function () { return EntityManager::getRepository(SpamEstimatorFeed::class); } ); App::singleton( IDisqusSSOProfileRepository::class, - function(){ + function () { return EntityManager::getRepository(DisqusSSOProfile::class); } ); App::singleton( IRocketChatSSOProfileRepository::class, - function(){ + function () { return EntityManager::getRepository(RocketChatSSOProfile::class); } ); App::singleton( IStreamChatSSOProfileRepository::class, - function(){ + function () { return EntityManager::getRepository(StreamChatSSOProfile::class); } ); + App::singleton( + IOAuth2OTPRepository::class, + function () { + return EntityManager::getRepository(OAuth2OTP::class); + } + ); + } public function provides() { return [ - IServerConfigurationRepository::class, IGroupRepository::class, - IOpenIdAssociationRepository::class, + IUserPasswordResetRequestRepository::class, + IServerExtensionRepository::class, IOpenIdTrustedSiteRepository::class, + IOpenIdAssociationRepository::class, + IServerConfigurationRepository::class, + IUserExceptionTrailRepository::class, + IBannedIPRepository::class, + IWhiteListedIPRepository::class, IUserRepository::class, + IResourceServerRepository::class, + IApiRepository::class, + IApiEndpointRepository::class, + IClientRepository::class, + IAccessTokenRepository::class, + IRefreshTokenRepository::class, + IApiScopeRepository::class, + IApiScopeGroupRepository::class, + IOAuth2TrailExceptionRepository::class, IClientPublicKeyRepository::class, IServerPrivateKeyRepository::class, - IClientRepository::class, - IApiScopeGroupRepository::class, - IApiEndpointRepository::class, - IRefreshTokenRepository::class, - IAccessTokenRepository::class, - IApiScopeRepository::class, - IApiRepository::class, - IResourceServerRepository::class, - IWhiteListedIPRepository::class, IUserRegistrationRequestRepository::class, ISpamEstimatorFeedRepository::class, IDisqusSSOProfileRepository::class, IRocketChatSSOProfileRepository::class, IStreamChatSSOProfileRepository::class, + IOAuth2OTPRepository::class, ]; } } \ No newline at end of file diff --git a/app/Services/Auth/UserService.php b/app/Services/Auth/UserService.php index fac59150..257b2e1b 100644 --- a/app/Services/Auth/UserService.php +++ b/app/Services/Auth/UserService.php @@ -128,6 +128,7 @@ final class UserService extends AbstractService implements IUserService if(count($default_groups) > 0){ $payload['groups'] = $default_groups; } + $user = UserFactory::build($payload); $this->user_repository->add($user); diff --git a/app/Services/OAuth2/OAuth2ServiceProvider.php b/app/Services/OAuth2/OAuth2ServiceProvider.php index 39861201..deeb1b22 100644 --- a/app/Services/OAuth2/OAuth2ServiceProvider.php +++ b/app/Services/OAuth2/OAuth2ServiceProvider.php @@ -13,13 +13,13 @@ **/ use App\Http\Utils\IUserIPHelperProvider; +use App\libs\OAuth2\Repositories\IOAuth2OTPRepository; +use App\Services\Auth\IUserService; use Illuminate\Contracts\Support\DeferrableProvider; use Illuminate\Support\ServiceProvider; -use OAuth2\Services\AccessTokenGenerator; -use OAuth2\Services\AuthorizationCodeGenerator; use OAuth2\Services\IApiScopeService; use OAuth2\Services\OAuth2ServiceCatalog; -use OAuth2\Services\RefreshTokenGenerator; +use Utils\Services\IdentifierGenerator; use Utils\Services\UtilsServiceCatalog; use Illuminate\Support\Facades\App; /** @@ -70,9 +70,7 @@ final class OAuth2ServiceProvider extends ServiceProvider implements DeferrableP App::make(UtilsServiceCatalog::CacheService), App::make(UtilsServiceCatalog::AuthenticationService), App::make(OAuth2ServiceCatalog::UserConsentService), - new AuthorizationCodeGenerator(App::make(UtilsServiceCatalog::CacheService)), - new AccessTokenGenerator(App::make(UtilsServiceCatalog::CacheService)), - new RefreshTokenGenerator(App::make(UtilsServiceCatalog::CacheService)), + App::make(IdentifierGenerator::class), App::make(\OAuth2\Repositories\IServerPrivateKeyRepository::class), new HttpIClientJWKSetReader, App::make(OAuth2ServiceCatalog::SecurityContextService), @@ -82,9 +80,11 @@ final class OAuth2ServiceProvider extends ServiceProvider implements DeferrableP App::make(\OAuth2\Repositories\IAccessTokenRepository::class), App::make(\OAuth2\Repositories\IRefreshTokenRepository::class), App::make(\OAuth2\Repositories\IResourceServerRepository::class), + App::make(IOAuth2OTPRepository::class), App::make(IUserIPHelperProvider::class), App::make(IApiScopeService::class), - App::make(UtilsServiceCatalog::TransactionService) + App::make(IUserService::class), + App::make(UtilsServiceCatalog::TransactionService), ); }); @@ -95,6 +95,7 @@ final class OAuth2ServiceProvider extends ServiceProvider implements DeferrableP public function provides() { return [ + IdentifierGenerator::class, \OAuth2\IResourceServerContext::class, OAuth2ServiceCatalog::ClientCredentialGenerator, OAuth2ServiceCatalog::ClientService, diff --git a/app/Services/OAuth2/TokenService.php b/app/Services/OAuth2/TokenService.php index 945c09a3..f8290f5a 100644 --- a/app/Services/OAuth2/TokenService.php +++ b/app/Services/OAuth2/TokenService.php @@ -14,7 +14,12 @@ use App\Http\Utils\IUserIPHelperProvider; use App\libs\Auth\Models\IGroupSlugs; +use App\libs\OAuth2\Repositories\IOAuth2OTPRepository; +use App\Models\OAuth2\Factories\OTPFactory; use App\Services\AbstractService; +use App\Services\Auth\IUserService; +use App\Strategies\OTP\OTPChannelStrategyFactory; +use App\Strategies\OTP\OTPTypeBuilderStrategyFactory; use Auth\User; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; @@ -23,6 +28,9 @@ use jwt\IBasicJWT; use jwt\impl\JWTClaimSet; use jwt\JWTClaim; use models\exceptions\ValidationException; +use Models\OAuth2\Client; +use Models\OAuth2\OAuth2OTP; +use OAuth2\Exceptions\InvalidOTPException; use OAuth2\Models\AccessToken; use Models\OAuth2\AccessToken as AccessTokenDB; use Models\OAuth2\RefreshToken as RefreshTokenDB; @@ -48,6 +56,7 @@ use OAuth2\Repositories\IRefreshTokenRepository; use OAuth2\Repositories\IResourceServerRepository; use OAuth2\Requests\OAuth2AuthenticationRequest; use OAuth2\Requests\OAuth2AuthorizationRequest; +use OAuth2\Requests\OAuth2PasswordlessAuthenticationRequest; use OAuth2\Services\IApiScopeService; use OAuth2\Services\ITokenService; use OAuth2\OAuth2Protocol; @@ -66,7 +75,7 @@ use Utils\Exceptions\UnacquiredLockException; use utils\json_types\JsonValue; use utils\json_types\NumericDate; use utils\json_types\StringOrURI; -use Utils\Model\Identifier; +use Utils\Model\AbstractIdentifier; use Utils\Services\IAuthService; use Utils\Services\ICacheService; use Utils\Services\IdentifierGenerator; @@ -100,6 +109,10 @@ final class TokenService extends AbstractService implements ITokenService * @var IClientService */ private $client_service; + /** + * @var IUserService + */ + private $user_service; /** * @var ILockManagerService */ @@ -123,15 +136,7 @@ final class TokenService extends AbstractService implements ITokenService /** * @var IdentifierGenerator */ - private $auth_code_generator; - /** - * @var IdentifierGenerator - */ - private $access_token_generator; - /** - * @var IdentifierGenerator - */ - private $refresh_token_generator; + private $identifier_generator; /** * @var IServerPrivateKeyRepository @@ -187,6 +192,35 @@ final class TokenService extends AbstractService implements ITokenService */ private $ip_helper; + /** + * @var IOAuth2OTPRepository + */ + private $otp_repository; + + /** + * TokenService constructor. + * @param IClientService $client_service + * @param ILockManagerService $lock_manager_service + * @param IServerConfigurationService $configuration_service + * @param ICacheService $cache_service + * @param IAuthService $auth_service + * @param IUserConsentService $user_consent_service + * @param IdentifierGenerator $identifier_generator + * @param IServerPrivateKeyRepository $server_private_key_repository + * @param IClientJWKSetReader $jwk_set_reader_service + * @param ISecurityContextService $security_context_service + * @param IPrincipalService $principal_service + * @param IdTokenBuilder $id_token_builder + * @param IClientRepository $client_repository + * @param IAccessTokenRepository $access_token_repository + * @param IRefreshTokenRepository $refresh_token_repository + * @param IResourceServerRepository $resource_server_repository + * @param IOAuth2OTPRepository $otp_repository + * @param IUserIPHelperProvider $ip_helper + * @param IApiScopeService $scope_service + * @param IUserService $user_service + * @param ITransactionService $tx_service + */ public function __construct ( IClientService $client_service, @@ -195,9 +229,7 @@ final class TokenService extends AbstractService implements ITokenService ICacheService $cache_service, IAuthService $auth_service, IUserConsentService $user_consent_service, - IdentifierGenerator $auth_code_generator, - IdentifierGenerator $access_token_generator, - IdentifierGenerator $refresh_token_generator, + IdentifierGenerator $identifier_generator, IServerPrivateKeyRepository $server_private_key_repository, IClientJWKSetReader $jwk_set_reader_service, ISecurityContextService $security_context_service, @@ -207,8 +239,10 @@ final class TokenService extends AbstractService implements ITokenService IAccessTokenRepository $access_token_repository, IRefreshTokenRepository $refresh_token_repository, IResourceServerRepository $resource_server_repository, + IOAuth2OTPRepository $otp_repository, IUserIPHelperProvider $ip_helper, IApiScopeService $scope_service, + IUserService $user_service, ITransactionService $tx_service ) { @@ -220,9 +254,7 @@ final class TokenService extends AbstractService implements ITokenService $this->cache_service = $cache_service; $this->auth_service = $auth_service; $this->user_consent_service = $user_consent_service; - $this->auth_code_generator = $auth_code_generator; - $this->access_token_generator = $access_token_generator; - $this->refresh_token_generator = $refresh_token_generator; + $this->identifier_generator = $identifier_generator; $this->server_private_key_repository = $server_private_key_repository; $this->jwk_set_reader_service = $jwk_set_reader_service; $this->security_context_service = $security_context_service; @@ -234,6 +266,8 @@ final class TokenService extends AbstractService implements ITokenService $this->resource_server_repository = $resource_server_repository; $this->ip_helper = $ip_helper; $this->scope_service = $scope_service; + $this->user_service = $user_service; + $this->otp_repository = $otp_repository; Event::listen('oauth2.client.delete', function ($client_id) { $this->revokeClientRelatedTokens($client_id); @@ -248,13 +282,13 @@ final class TokenService extends AbstractService implements ITokenService * Creates a brand new authorization code * @param OAuth2AuthorizationRequest $request * @param bool $has_previous_user_consent - * @return Identifier + * @return AbstractIdentifier */ public function createAuthorizationCode ( OAuth2AuthorizationRequest $request, bool $has_previous_user_consent = false - ): Identifier + ): AbstractIdentifier { $user = $this->auth_service->getCurrentUser(); @@ -276,7 +310,7 @@ final class TokenService extends AbstractService implements ITokenService $prompt = $request->getPrompt(true); } - $code = $this->auth_code_generator->generate + $code = $this->identifier_generator->generate ( AuthorizationCode::create ( @@ -354,7 +388,7 @@ final class TokenService extends AbstractService implements ITokenService public function createAccessToken(AuthorizationCode $auth_code, $redirect_uri = null) { - $access_token = $this->access_token_generator->generate + $access_token = $this->identifier_generator->generate ( AccessToken::create ( @@ -462,7 +496,7 @@ final class TokenService extends AbstractService implements ITokenService public function createAccessTokenFromParams($client_id, $scope, $audience, $user_id = null) { - $access_token = $this->access_token_generator->generate(AccessToken::createFromParams + $access_token = $this->identifier_generator->generate(AccessToken::createFromParams ( $scope, $client_id, @@ -559,7 +593,7 @@ final class TokenService extends AbstractService implements ITokenService } //create new access token - $access_token = $this->access_token_generator->generate + $access_token = $this->identifier_generator->generate ( AccessToken::createFromRefreshToken ( @@ -830,7 +864,7 @@ final class TokenService extends AbstractService implements ITokenService */ public function createRefreshToken(AccessToken &$access_token, $refresh_cache = false) { - $refresh_token = $this->refresh_token_generator->generate( + $refresh_token = $this->identifier_generator->generate( RefreshToken::create( $access_token, $this->configuration_service->getConfigValue('OAuth2.RefreshToken.Lifetime') @@ -1485,4 +1519,157 @@ final class TokenService extends AbstractService implements ITokenService return $this->getAccessToken($db_access_token->getValue(), true); } + /** + * @param OAuth2PasswordlessAuthenticationRequest $request + * @param Client|null $client + * @return OAuth2OTP + * @throws Exception + */ + public function createOTPFromRequest(OAuth2PasswordlessAuthenticationRequest $request, ?Client $client):OAuth2OTP{ + return $this->tx_service->transaction(function() use($request, $client){ + + $otp = OTPFactory::buildFromRequest($request, $this->identifier_generator, $client); + + if(!is_null($client)){ + // invalidate not redeemed former ones + $codes = $otp->getConnection() == OAuth2Protocol::OAuth2PasswordlessConnectionEmail ? + $client->getOTPGrantsByEmailNotRedeemed($otp->getUserName()): + $client->getOTPGrantsByPhoneNumberNotRedeemed($otp->getUserName()); + foreach ($codes as $code){ + if($code->getValue() == $otp->getValue()) continue; + $client->removeOTPGrant($code); + } + } + // create channel and value to send ( depending on connection and send params ) + OTPChannelStrategyFactory::build($otp->getConnection())->send + ( + OTPTypeBuilderStrategyFactory::build($otp->getSend()), + $otp + ); + return $otp; + }); + } + + /** + * @param OAuth2OTP $otp + * @param Client|null $client + * @return AccessToken + * @throws Exception + */ + public function createAccessTokenFromOTP(OAuth2OTP $otp, ?Client $client): AccessToken + { + // build current audience ... + $audience = $this->scope_service->getStrAudienceByScopeNames + ( + explode + ( + OAuth2Protocol::OAuth2Protocol_Scope_Delimiter, + $otp->getScopes() + ) + ); + + $access_token = $this->identifier_generator->generate + ( + AccessToken::createFromOTP + ( + $otp, + ! is_null($client) ? $client->getClientId() : null, + $audience, + $this->configuration_service->getConfigValue('OAuth2.AccessToken.Lifetime') + ) + ); + + $db_otp = $this->tx_service->transaction(function() use($otp, $client){ + // find first db OTP by connection , by username (email/phone) number and client not redeemed + $db_otp = $this->otp_repository->getByConnectionAndUserNameNotRedeemed + ( + $otp->getConnection(), + $otp->getUserName(), + $client + ); + if(is_null($db_otp)){ + // otp no emitted + throw new InvalidOTPException("Non existent OTP."); + } + $db_otp->logRedeemAttempt(); + return $db_otp; + }); + + return $this->tx_service->transaction(function() use($otp, $db_otp, $client, $access_token){ + + if( $db_otp->getValue() != $otp->getValue() || + $db_otp->getConnection() != $otp->getConnection() || + !$db_otp->isValid() || + ($db_otp->hasClient() && is_null($client)) || + ($db_otp->hasClient() && !is_null($client) && $client->getClientId() != $db_otp->getClient()->getClientId()) || + ($db_otp->getUserName() != $otp->getUserName()) || + (!$db_otp->allowScope($otp->getScopes())) + ){ + if(!$db_otp->isValid() || !$db_otp->allowScope($otp->getScopes())) + throw new InvalidOTPException("Invalidated OTP."); + + throw new InvalidOTPException("Non existent OTP."); + } + // we have a valid OTP + + $user = $this->auth_service->getUserByUsername($otp->getUserName()); + if(is_null($user))// we need to create a new one ( auto register) + { + Log::debug(sprintf("TokenService::createAccessTokenFromOTP user %s does not exists ...", $otp->getUserName())); + + $user = $this->user_service->registerUser([ + 'email' => $otp->getUserName(), + 'email_verified' => true, + ]); + } + + $otp->setAuthTime(time()); + $otp->setUserId($user->getId()); + $otp->setNonce($db_otp->getNonce()); + + $db_otp->redeem(); + + // TODO; move to a factory + $value = $access_token->getValue(); + $hashed_value = Hash::compute('sha256', $value); + + $access_token_db = new AccessTokenDB(); + $access_token_db->setValue($hashed_value); + $access_token_db->setFromIp($this->ip_helper->getCurrentUserIpAddress()); + $access_token_db->setLifetime($access_token->getLifetime()); + $access_token_db->setScope($access_token->getScope()); + $access_token_db->setAudience($access_token->getAudience()); + $access_token_db->setClient($client); + $access_token_db->setOwner($user); + + $this->access_token_repository->add($access_token_db); + + //check if use refresh tokens... + + if + ( + $client->useRefreshToken() && + $client->isPasswordlessEnabled() && + str_contains($otp->getScopes(), OAuth2Protocol::OfflineAccess_Scope) + ) { + Log::debug('TokenService::createAccessTokenFromOTP creating refresh token ...'); + $this->createRefreshToken($access_token); + } + + $this->storesAccessTokenOnCache($access_token); + // stores brand new access token hash value on a set by client id... + { + if (!is_null($client)) + $this->cache_service->addMemberSet($client->getClientId() . TokenService::ClientAccessTokenPrefixList, $hashed_value); + + $this->cache_service->incCounter + ( + $client->getClientId() . TokenService::ClientAccessTokensQty, + TokenService::ClientAccessTokensQtyLifetime + ); + } + + return $access_token; + }); + } } \ No newline at end of file diff --git a/app/Services/OpenId/OpenIdProvider.php b/app/Services/OpenId/OpenIdProvider.php index cf429f2f..2ba0874b 100644 --- a/app/Services/OpenId/OpenIdProvider.php +++ b/app/Services/OpenId/OpenIdProvider.php @@ -15,8 +15,8 @@ use Illuminate\Contracts\Support\DeferrableProvider; use Illuminate\Support\Facades\App; use Illuminate\Support\ServiceProvider; -use OpenId\Services\NonceUniqueIdentifierGenerator; use OpenId\Services\OpenIdServiceCatalog; +use Utils\Services\IdentifierGenerator; use Utils\Services\UtilsServiceCatalog; /** * Class OpenIdProvider @@ -44,7 +44,7 @@ final class OpenIdProvider extends ServiceProvider implements DeferrableProvider App::make(UtilsServiceCatalog::LockManagerService), App::make(UtilsServiceCatalog::CacheService), App::make(UtilsServiceCatalog::ServerConfigurationService), - new NonceUniqueIdentifierGenerator(App::make(UtilsServiceCatalog::CacheService)) + App::make(IdentifierGenerator::class), ); }); } diff --git a/app/Services/Utils/UtilsProvider.php b/app/Services/Utils/UtilsProvider.php index a5b79e5c..6248d688 100644 --- a/app/Services/Utils/UtilsProvider.php +++ b/app/Services/Utils/UtilsProvider.php @@ -15,6 +15,8 @@ use App\Models\Utils\BaseEntity; use App\Repositories\IServerConfigurationRepository; use App\Services\Utils\DoctrineTransactionService; use Illuminate\Contracts\Support\DeferrableProvider; +use Utils\Services\IdentifierGenerator; +use Utils\Services\UniqueIdentifierGenerator; use Utils\Services\UtilsServiceCatalog; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\App; @@ -31,6 +33,8 @@ final class UtilsProvider extends ServiceProvider implements DeferrableProvider */ public function register() { + App::singleton(IdentifierGenerator::class, UniqueIdentifierGenerator::class); + App::singleton(UtilsServiceCatalog::CacheService, RedisCacheService::class); App::singleton(UtilsServiceCatalog::TransactionService, function(){ return new DoctrineTransactionService(BaseEntity::EntityManager); @@ -55,12 +59,14 @@ final class UtilsProvider extends ServiceProvider implements DeferrableProvider return new ExternalUrlService(); }); + } public function provides() { return [ + IdentifierGenerator::class, UtilsServiceCatalog::CacheService, UtilsServiceCatalog::TransactionService, UtilsServiceCatalog::LogService, diff --git a/app/Strategies/OTP/IOTPChannelStrategy.php b/app/Strategies/OTP/IOTPChannelStrategy.php new file mode 100644 index 00000000..c66ad7fd --- /dev/null +++ b/app/Strategies/OTP/IOTPChannelStrategy.php @@ -0,0 +1,27 @@ +generate($otp); + // send email + try{ + Mail::queue + ( + new OAuth2PasswordlessOTPMail + ( + $otp->getUserName(), + $value, + $otp->getLifetime() + ) + ); + } + catch (\Exception $ex){ + Log::error($ex); + return false; + } + return true; + } +} \ No newline at end of file diff --git a/app/Strategies/OTP/OTPChannelStrategyFactory.php b/app/Strategies/OTP/OTPChannelStrategyFactory.php new file mode 100644 index 00000000..d248acbf --- /dev/null +++ b/app/Strategies/OTP/OTPChannelStrategyFactory.php @@ -0,0 +1,31 @@ +getValue(); + } +} \ No newline at end of file diff --git a/app/libs/OAuth2/Exceptions/InvalidOTPException.php b/app/libs/OAuth2/Exceptions/InvalidOTPException.php new file mode 100644 index 00000000..3373ae6f --- /dev/null +++ b/app/libs/OAuth2/Exceptions/InvalidOTPException.php @@ -0,0 +1,28 @@ +setValue(Rand::getString($identifier->getLenght(), OAuth2Protocol::VsChar, true)); + return OAuth2Protocol::OAuth2Protocol_Error_InvalidGrant; } } \ No newline at end of file diff --git a/app/libs/OAuth2/Factories/OAuth2AuthorizationRequestFactory.php b/app/libs/OAuth2/Factories/OAuth2AuthorizationRequestFactory.php index eaf645d1..5bbe66bf 100644 --- a/app/libs/OAuth2/Factories/OAuth2AuthorizationRequestFactory.php +++ b/app/libs/OAuth2/Factories/OAuth2AuthorizationRequestFactory.php @@ -11,6 +11,8 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ + +use OAuth2\Requests\OAuth2PasswordlessAuthenticationRequest; use OAuth2\Exceptions\InvalidAuthenticationRequestException; use OAuth2\Exceptions\InvalidAuthorizationRequestException; use OAuth2\OAuth2Protocol; @@ -33,8 +35,14 @@ final class OAuth2AuthorizationRequestFactory $auth_request = new OAuth2AuthorizationRequest($msg); $scope = $auth_request->getScope(); + $response_type = $auth_request->getResponseType(); + + if($response_type == OAuth2Protocol::OAuth2Protocol_ResponseType_OTP){ + return new OAuth2PasswordlessAuthenticationRequest($auth_request); + } + if(!is_null($scope) && str_contains($scope, OAuth2Protocol::OpenIdConnect_Scope) ) { - $auth_request = new OAuth2AuthenticationRequest($auth_request); + return new OAuth2AuthenticationRequest($auth_request); } return $auth_request; diff --git a/app/libs/OAuth2/GrantTypes/PasswordlessGrantType.php b/app/libs/OAuth2/GrantTypes/PasswordlessGrantType.php new file mode 100644 index 00000000..db90ea08 --- /dev/null +++ b/app/libs/OAuth2/GrantTypes/PasswordlessGrantType.php @@ -0,0 +1,373 @@ +isValid() && + OAuth2Protocol::responseTypeBelongsToFlow + ( + $request->getResponseType(false), + OAuth2Protocol::OAuth2Protocol_GrantType_Passwordless + ) + ) { + return true; + } + // complete flow + $request = $this->buildTokenRequest($request); + if + ( + !is_null($request) && + $request instanceof OAuth2AccessTokenRequestPasswordless && + $request->isValid() && + $request->getGrantType() == $this->getType() + ) { + return true; + } + + return false; + } + + public function getType() + { + return OAuth2Protocol::OAuth2Protocol_GrantType_Passwordless; + } + + public function getResponseType() + { + return OAuth2Protocol::getValidResponseTypes(OAuth2Protocol::OAuth2Protocol_GrantType_Passwordless); + } + + protected function checkClientTypeAccess(IClient $client) + { + if (!$client->isPasswordlessEnabled()) { + throw new InvalidApplicationType + ( + sprintf + ( + "client id %s must have Passwordless enabled", + $client->getClientId(), + ) + ); + } + } + + /** + * @param OAuth2AuthorizationRequest $request + * @param bool $has_former_consent + * @return OAuth2PasswordlessAuthenticationResponse|OAuth2Response + * @throws InvalidOAuth2Request + */ + protected function buildResponse(OAuth2AuthorizationRequest $request, $has_former_consent) + { + if (!($request instanceof OAuth2PasswordlessAuthenticationRequest)) { + throw new InvalidOAuth2Request; + } + + $otp = $this->token_service->createOTPFromRequest($request, $this->client); + + return new OAuth2PasswordlessAuthenticationResponse + ( + $otp->getLength(), + $otp->getRemainingLifetime(), + $otp->getScopes() + ); + } + + /** + * @param OAuth2Request $request + * @return OAuth2Response + * @throws \Exception + */ + public function handle(OAuth2Request $request) + { + try { + + if (!($request instanceof OAuth2PasswordlessAuthenticationRequest)) { + throw new InvalidOAuth2Request; + } + + $client_id = $request->getClientId(); + $this->client = $this->client_repository->getClientById($client_id); + + if (is_null($this->client)) { + throw new InvalidClientException + ( + sprintf + ( + "client_id %s does not exists!", + $client_id + ) + ); + } + + if (!$this->client->isActive() || $this->client->isLocked()) { + throw new LockedClientException + ( + sprintf + ( + 'client id %s is locked', + $client_id + ) + ); + } + + $this->checkClientTypeAccess($this->client); + + //check redirect uri + $redirect_uri = $request->getRedirectUri(); + + if (!empty($redirect_uri) && !$this->client->isUriAllowed($redirect_uri)) { + throw new UriNotAllowedException + ( + $redirect_uri + ); + } + + //check requested scope + $scope = $request->getScope(); + $this->log_service->debug_msg(sprintf("scope %s", $scope)); + if (empty($scope) || !$this->client->isScopeAllowed($scope)) { + throw new ScopeNotAllowedException($scope); + } + + $response = $this->buildResponse($request, false); + // clear save data ... + $this->auth_service->clearUserAuthorizationResponse(); + $this->memento_service->forget(); + return $response; + + } + catch(OAuth2BaseException $ex){ + $this->log_service->warning($ex); + // clear save data ... + $this->auth_service->clearUserAuthorizationResponse(); + $this->memento_service->forget(); + return new OAuth2DirectErrorResponse($ex->getError(), $ex->getMessage()); + } + catch (Exception $ex) { + $this->log_service->error($ex); + // clear save data ... + $this->auth_service->clearUserAuthorizationResponse(); + $this->memento_service->forget(); + throw $ex; + } + } + + /** + * Implements last request processing for Authorization code (Access Token Request processing) + * @see http://tools.ietf.org/html/rfc6749#section-4.1.3 and + * @see http://tools.ietf.org/html/rfc6749#section-4.1.4 + * @param OAuth2Request $request + * @return OAuth2AccessTokenResponse + * @throws \Exception + * @throws InvalidClientException + * @throws UnAuthorizedClientException + * @throws UriNotAllowedException + */ + public function completeFlow(OAuth2Request $request) + { + try { + + if (!($request instanceof OAuth2AccessTokenRequestPasswordless)) { + throw new InvalidOAuth2Request; + } + + parent::completeFlow($request); + + $this->client = $this->client_auth_context->getClient(); + $this->checkClientTypeAccess($this->client); + $otp = OAuth2OTP::fromRequest($request, $this->client->getOtpLength()); + + $access_token = $this->token_service->createAccessTokenFromOTP + ( + $otp, + $this->client + ); + + $this->principal_service->register + ( + $otp->getUserId(), + $otp->getAuthTime() + ); + + $id_token = $this->token_service->createIdToken + ( + $otp->getNonce(), + $this->client->getClientId(), + $access_token + ); + + $refresh_token = $access_token->getRefreshToken(); + + if (!is_null($access_token)) + $refresh_token = $access_token->getRefreshToken(); + + $response = new OAuth2IdTokenResponse + ( + is_null($access_token) ? null : $access_token->getValue(), + is_null($access_token) ? null : $access_token->getLifetime(), + is_null($id_token) ? null : $id_token->toCompactSerialization(), + is_null($refresh_token) ? null : $refresh_token->getValue() + ); + + $user = $this->auth_service->getUserByUsername($otp->getUserName()); + + // emmit login + Auth::login($user, false); + + $this->security_context_service->clear(); + + return $response; + } catch (InvalidOTPException $ex) { + $this->log_service->error($ex); + $this->security_context_service->clear(); + throw new InvalidRedeemOTPException + ( + $ex->getMessage() + ); + } + } + + + /** + * @param OAuth2Request $request + * @return OAuth2AccessTokenRequestPasswordless|OAuth2Response|null + */ + public function buildTokenRequest(OAuth2Request $request) + { + if ($request instanceof OAuth2TokenRequest) + { + if ($request->getGrantType() !== $this->getType()) + { + return null; + } + return new OAuth2AccessTokenRequestPasswordless($request->getMessage()); + } + return null; + } + +} \ No newline at end of file diff --git a/app/libs/OAuth2/Models/AccessToken.php b/app/libs/OAuth2/Models/AccessToken.php index b7d53b16..a368c76f 100644 --- a/app/libs/OAuth2/Models/AccessToken.php +++ b/app/libs/OAuth2/Models/AccessToken.php @@ -11,6 +11,9 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ +use Models\OAuth2\OAuth2OTP; +use OAuth2\OAuth2Protocol; +use Zend\Math\Rand; /** * Class AccessToken * @see http://tools.ietf.org/html/rfc6749#section-1.4 @@ -23,6 +26,11 @@ class AccessToken extends Token { */ private $auth_code; + /** + * @var OAuth2OTP + */ + private $otp; + /** * @var RefreshToken */ @@ -40,7 +48,7 @@ class AccessToken extends Token { * @param int $lifetime * @return AccessToken */ - public static function create(AuthorizationCode $auth_code, $lifetime = 3600){ + public static function create(AuthorizationCode $auth_code, $lifetime = 3600){ $instance = new self(); $instance->user_id = $auth_code->getUserId(); $instance->scope = $auth_code->getScope(); @@ -53,6 +61,25 @@ class AccessToken extends Token { return $instance; } + /** + * @param OAuth2OTP $otp + * @param string $client_id + * @param string $audience + * @param int $lifetime + * @return AccessToken + */ + public static function createFromOTP(OAuth2OTP $otp,string $client_id, string $audience, $lifetime = 3600){ + $instance = new self(); + $instance->otp = $otp; + $instance->scope = $otp->getScopes(); + // client id (oauth2) not client identifier + $instance->client_id = $client_id; + $instance->audience = $audience; + $instance->lifetime = intval($lifetime); + $instance->is_hashed = false; + return $instance; + } + public static function createFromParams($scope, $client_id, $audience,$user_id,$lifetime){ $instance = new self(); $instance->scope = $scope; @@ -130,7 +157,7 @@ class AccessToken extends Token { /** * @return string */ - public function getType() + public function getType():string { return 'access_token'; } @@ -142,4 +169,29 @@ class AccessToken extends Token { { return []; } + + /** + * @return OAuth2OTP + */ + public function getOtp(): ?OAuth2OTP + { + return $this->otp; + } + + /** + * @return int + */ + public function getUserId() + { + if(!is_null($this->otp)){ + $this->user_id = $this->otp->getUserId(); + } + return intval($this->user_id); + } + + public function generateValue(): string + { + $this->value = Rand::getString($this->len, OAuth2Protocol::VsChar); + return $this->value; + } } \ No newline at end of file diff --git a/app/libs/OAuth2/Models/AuthorizationCode.php b/app/libs/OAuth2/Models/AuthorizationCode.php index adc9a9a5..f3520dab 100644 --- a/app/libs/OAuth2/Models/AuthorizationCode.php +++ b/app/libs/OAuth2/Models/AuthorizationCode.php @@ -14,6 +14,7 @@ use Utils\IPHelper; use OAuth2\OAuth2Protocol; +use Zend\Math\Rand; /** * Class AuthorizationCode @@ -313,7 +314,7 @@ class AuthorizationCode extends Token /** * @return string */ - public function getType() + public function getType():string { return 'auth_code'; } @@ -384,4 +385,9 @@ class AuthorizationCode extends Token return $this->code_challenge_method; } + public function generateValue(): string + { + $this->value = Rand::getString($this->len, OAuth2Protocol::VsChar); + return $this->value; + } } \ No newline at end of file diff --git a/app/libs/OAuth2/Models/IClient.php b/app/libs/OAuth2/Models/IClient.php index 00bd0b45..e259fb8e 100644 --- a/app/libs/OAuth2/Models/IClient.php +++ b/app/libs/OAuth2/Models/IClient.php @@ -324,4 +324,9 @@ interface IClient extends IEntity * @return bool */ public function isPKCEEnabled():bool; + + /** + * @return bool + */ + public function isPasswordlessEnabled():bool; } \ No newline at end of file diff --git a/app/libs/OAuth2/Models/RefreshToken.php b/app/libs/OAuth2/Models/RefreshToken.php index acd1d304..6886a37b 100644 --- a/app/libs/OAuth2/Models/RefreshToken.php +++ b/app/libs/OAuth2/Models/RefreshToken.php @@ -11,7 +11,11 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ + +use OAuth2\OAuth2Protocol; use Utils\IPHelper; +use Zend\Math\Rand; + /** * Class RefreshToken * @see http://tools.ietf.org/html/rfc6749#section-1.5 @@ -81,7 +85,7 @@ class RefreshToken extends Token { /** * @return string */ - public function getType() + public function getType():string { return 'refresh_token'; } @@ -93,4 +97,10 @@ class RefreshToken extends Token { { return []; } + + public function generateValue(): string + { + $this->value = Rand::getString($this->len, OAuth2Protocol::VsChar); + return $this->value; + } } \ No newline at end of file diff --git a/app/libs/OAuth2/Models/Token.php b/app/libs/OAuth2/Models/Token.php index ab02b16c..7957a32c 100644 --- a/app/libs/OAuth2/Models/Token.php +++ b/app/libs/OAuth2/Models/Token.php @@ -15,13 +15,13 @@ use DateInterval; use DateTime; use DateTimeZone; use Utils\IPHelper; -use Utils\Model\Identifier; +use Utils\Model\AbstractIdentifier; /** * Class Token * Defines the common behavior for all emitted tokens * @package OAuth2\Models */ -abstract class Token extends Identifier +abstract class Token extends AbstractIdentifier { const DefaultByteLength = 32; @@ -54,6 +54,9 @@ abstract class Token extends Identifier * @var bool */ protected $is_hashed; + /** + * @var + */ protected $user_id; public function __construct($len = self::DefaultByteLength) @@ -141,4 +144,5 @@ abstract class Token extends Identifier public abstract function fromJSON($json); -} \ No newline at end of file + +} \ No newline at end of file diff --git a/app/libs/OAuth2/OAuth2Protocol.php b/app/libs/OAuth2/OAuth2Protocol.php index d5b5fd4e..5701ee17 100644 --- a/app/libs/OAuth2/OAuth2Protocol.php +++ b/app/libs/OAuth2/OAuth2Protocol.php @@ -11,6 +11,7 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ + use App\Http\Utils\UserIPHelperProvider; use Exception; use Illuminate\Support\Facades\Log; @@ -38,6 +39,7 @@ use OAuth2\GrantTypes\AuthorizationCodeGrantType; use OAuth2\GrantTypes\ClientCredentialsGrantType; use OAuth2\GrantTypes\HybridGrantType; use OAuth2\GrantTypes\ImplicitGrantType; +use OAuth2\GrantTypes\PasswordlessGrantType; use OAuth2\GrantTypes\RefreshBearerTokenGrantType; use OAuth2\Models\IClient; use OAuth2\Repositories\IClientRepository; @@ -64,6 +66,7 @@ use utils\factories\BasicJWTFactory; use Utils\Services\IAuthService; use Utils\Services\ICheckPointService; use Utils\Services\ILogService; + /** * Class OAuth2Protocol * Implementation of @see http://tools.ietf.org/html/rfc6749 @@ -76,21 +79,23 @@ final class OAuth2Protocol implements IOAuth2Protocol * @var OAuth2Request */ private $last_request = null; - const OAuth2Protocol_Scope_Delimiter = ' '; + const OAuth2Protocol_Scope_Delimiter = ' '; const OAuth2Protocol_ResponseType_Delimiter = ' '; - const OAuth2Protocol_GrantType_AuthCode = 'authorization_code'; - const OAuth2Protocol_GrantType_Implicit = 'implicit'; - const OAuth2Protocol_GrantType_Hybrid = 'hybrid'; + const OAuth2Protocol_GrantType_AuthCode = 'authorization_code'; + const OAuth2Protocol_GrantType_Passwordless = 'passwordless'; + const OAuth2Protocol_GrantType_Implicit = 'implicit'; + const OAuth2Protocol_GrantType_Hybrid = 'hybrid'; const OAuth2Protocol_GrantType_ResourceOwner_Password = 'password'; - const OAuth2Protocol_GrantType_ClientCredentials = 'client_credentials'; - const OAuth2Protocol_GrantType_RefreshToken = 'refresh_token'; + const OAuth2Protocol_GrantType_ClientCredentials = 'client_credentials'; + const OAuth2Protocol_GrantType_RefreshToken = 'refresh_token'; - const OAuth2Protocol_ResponseType_Code = 'code'; - const OAuth2Protocol_ResponseType_Token = 'token'; + const OAuth2Protocol_ResponseType_Code = 'code'; + const OAuth2Protocol_ResponseType_OTP = 'otp'; + const OAuth2Protocol_ResponseType_Token = 'token'; const OAuth2Protocol_ResponseType_IdToken = 'id_token'; - const OAuth2Protocol_ResponseType_None = 'none'; + const OAuth2Protocol_ResponseType_None = 'none'; /** * The OAuth 2.0 specification allows for registration of space-separated response_type parameter values. If a @@ -110,7 +115,7 @@ final class OAuth2Protocol implements IOAuth2Protocol * In this mode, Authorization Response parameters are encoded in the query string added to the redirect_uri when * redirecting back to the Client. */ - const OAuth2Protocol_ResponseMode_Query = 'query'; + const OAuth2Protocol_ResponseMode_Query = 'query'; /** * In this mode, Authorization Response parameters are encoded in the fragment added to the redirect_uri when @@ -127,9 +132,9 @@ final class OAuth2Protocol implements IOAuth2Protocol * is intended to be used only once, the Authorization Server MUST instruct the User Agent (and any intermediaries) * not to store or reuse the content of the response. */ - const OAuth2Protocol_ResponseMode_FormPost = 'form_post'; + const OAuth2Protocol_ResponseMode_FormPost = 'form_post'; - const OAuth2Protocol_ResponseMode_Direct = 'direct'; + const OAuth2Protocol_ResponseMode_Direct = 'direct'; static public $valid_response_modes = array @@ -155,21 +160,21 @@ final class OAuth2Protocol implements IOAuth2Protocol static public function getDefaultResponseMode(array $response_type) { - if(count(array_diff($response_type, array(self::OAuth2Protocol_ResponseType_Code))) === 0) + if (count(array_diff($response_type, array(self::OAuth2Protocol_ResponseType_Code))) === 0) return self::OAuth2Protocol_ResponseMode_Query; - if(count(array_diff($response_type, array(self::OAuth2Protocol_ResponseType_Token))) === 0) + if (count(array_diff($response_type, array(self::OAuth2Protocol_ResponseType_Token))) === 0) return self::OAuth2Protocol_ResponseMode_Fragment; // http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#Combinations - if(count(array_diff($response_type, array - ( - self::OAuth2Protocol_ResponseType_Code, - self::OAuth2Protocol_ResponseType_Token - ) + if (count(array_diff($response_type, array + ( + self::OAuth2Protocol_ResponseType_Code, + self::OAuth2Protocol_ResponseType_Token + ) )) === 0) - return self::OAuth2Protocol_ResponseMode_Fragment; + return self::OAuth2Protocol_ResponseMode_Fragment; - if(count(array_diff($response_type, array + if (count(array_diff($response_type, array ( self::OAuth2Protocol_ResponseType_Code, self::OAuth2Protocol_ResponseType_IdToken @@ -177,7 +182,7 @@ final class OAuth2Protocol implements IOAuth2Protocol )) === 0) return self::OAuth2Protocol_ResponseMode_Fragment; - if(count(array_diff($response_type, array + if (count(array_diff($response_type, array ( self::OAuth2Protocol_ResponseType_Token, self::OAuth2Protocol_ResponseType_IdToken @@ -185,7 +190,7 @@ final class OAuth2Protocol implements IOAuth2Protocol )) === 0) return self::OAuth2Protocol_ResponseMode_Fragment; - if(count(array_diff($response_type, array + if (count(array_diff($response_type, array ( self::OAuth2Protocol_ResponseType_Code, self::OAuth2Protocol_ResponseType_Token, @@ -197,21 +202,43 @@ final class OAuth2Protocol implements IOAuth2Protocol } - const OAuth2Protocol_ClientId = 'client_id'; - const OAuth2Protocol_UserId = 'user_id'; + const OAuth2Protocol_ClientId = 'client_id'; + const OAuth2Protocol_UserId = 'user_id'; const OAuth2Protocol_ClientSecret = 'client_secret'; - const OAuth2Protocol_Token = 'token'; - const OAuth2Protocol_TokenType = 'token_type'; + const OAuth2Protocol_Token = 'token'; + const OAuth2Protocol_TokenType = 'token_type'; // http://tools.ietf.org/html/rfc7009#section-2.1 - const OAuth2Protocol_TokenType_Hint = 'token_type_hint'; + const OAuth2Protocol_TokenType_Hint = 'token_type_hint'; const OAuth2Protocol_AccessToken_ExpiresIn = 'expires_in'; - const OAuth2Protocol_RefreshToken = 'refresh_token'; - const OAuth2Protocol_AccessToken = 'access_token'; - const OAuth2Protocol_RedirectUri = 'redirect_uri'; - const OAuth2Protocol_Scope = 'scope'; - const OAuth2Protocol_Audience = 'audience'; - const OAuth2Protocol_State = 'state'; + const OAuth2Protocol_RefreshToken = 'refresh_token'; + const OAuth2Protocol_AccessToken = 'access_token'; + const OAuth2Protocol_RedirectUri = 'redirect_uri'; + const OAuth2Protocol_Scope = 'scope'; + const OAuth2Protocol_Audience = 'audience'; + const OAuth2Protocol_State = 'state'; + + // passwordless + + const OAuth2PasswordlessConnection = 'connection'; + const OAuth2PasswordlessConnectionSMS = 'sms'; + const OAuth2PasswordlessConnectionEmail = 'email'; + const ValidOAuth2PasswordlessConnectionValues = [ + self::OAuth2PasswordlessConnectionSMS, + self::OAuth2PasswordlessConnectionEmail + ]; + + const OAuth2PasswordlessSend = 'send'; + const OAuth2PasswordlessSendCode = 'code'; + const OAuth2PasswordlessSendLink = 'link'; + + const ValidOAuth2PasswordlessSendValues = [ + self::OAuth2PasswordlessSendCode, + self::OAuth2PasswordlessSendLink, + ]; + + const OAuth2PasswordlessEmail = 'email'; + const OAuth2PasswordlessPhoneNumber = 'phone_number'; /** * @see http://openid.net/specs/openid-connect-session-1_0.html#CreatingUpdatingSessions @@ -223,13 +250,13 @@ final class OAuth2Protocol implements IOAuth2Protocol * JSON string that represents the End-User's login state at the OP. It MUST NOT contain the space (" ") character. * This value is opaque to the RP. This is REQUIRED if session management is supported. */ - const OAuth2Protocol_Session_State = 'session_state'; + const OAuth2Protocol_Session_State = 'session_state'; // http://openid.net/specs/openid-connect-core-1_0.html#TokenResponse // ID Token value associated with the authenticated session. - const OAuth2Protocol_IdToken = 'id_token'; + const OAuth2Protocol_IdToken = 'id_token'; // http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest - const OAuth2Protocol_Nonce = 'nonce'; + const OAuth2Protocol_Nonce = 'nonce'; /** * custom param - social login @@ -242,7 +269,7 @@ final class OAuth2Protocol implements IOAuth2Protocol * is requested as an Essential Claim, then this Claim is REQUIRED; otherwise, its inclusion is OPTIONAL. * (The auth_time Claim semantically corresponds to the OpenID 2.0 PAPE [OpenID.PAPE] auth_time response parameter.) */ - const OAuth2Protocol_AuthTime = 'auth_time'; + const OAuth2Protocol_AuthTime = 'auth_time'; /** * Access Token hash value. Its value is the base64url encoding of the left-most half of the hash of the octets of @@ -267,35 +294,35 @@ final class OAuth2Protocol implements IOAuth2Protocol * Specifies how the Authorization Server displays the authentication and consent user interface pages to * the End-User. */ - const OAuth2Protocol_Display ='display'; + const OAuth2Protocol_Display = 'display'; /** * The Authorization Server SHOULD display the authentication and consent UI consistent with a full User Agent page * view. If the display parameter is not specified, this is the default display mode. * The Authorization Server MAY also attempt to detect the capabilities of the User Agent and present an * appropriate display. */ - const OAuth2Protocol_Display_Page ='page'; + const OAuth2Protocol_Display_Page = 'page'; /** * The Authorization Server SHOULD display the authentication and consent UI consistent with a popup User Agent * window. The popup User Agent window should be of an appropriate size for a login-focused dialog and should not * obscure the entire window that it is popping up over. */ - const OAuth2Protocol_Display_PopUp ='popup'; + const OAuth2Protocol_Display_PopUp = 'popup'; /** * The Authorization Server SHOULD display the authentication and consent UI consistent with a device that leverages * a touch interface. */ - const OAuth2Protocol_Display_Touch ='touch'; + const OAuth2Protocol_Display_Touch = 'touch'; /** * The Authorization Server SHOULD display the authentication and consent UI consistent with a "feature phone" * type display. */ - const OAuth2Protocol_Display_Wap ='wap'; + const OAuth2Protocol_Display_Wap = 'wap'; /** * Extension: display the login/consent interaction like a json doc */ - const OAuth2Protocol_Display_Native ='native'; + const OAuth2Protocol_Display_Native = 'native'; /** * @var array @@ -363,14 +390,12 @@ final class OAuth2Protocol implements IOAuth2Protocol */ static public function getValidResponseTypes($flow = 'all') { - $code_flow = array - ( + $code_flow = [ //OAuth2 / OIDC - array - ( + [ self::OAuth2Protocol_ResponseType_Code - ) - ); + ] + ]; $implicit_flow = array ( @@ -386,17 +411,17 @@ final class OAuth2Protocol implements IOAuth2Protocol ), array ( - self::OAuth2Protocol_ResponseType_IdToken , + self::OAuth2Protocol_ResponseType_IdToken, self::OAuth2Protocol_ResponseType_Token ) ); - $hybrid_flow = array + $hybrid_flow = array ( array ( - self::OAuth2Protocol_ResponseType_Code, - self::OAuth2Protocol_ResponseType_IdToken + self::OAuth2Protocol_ResponseType_Code, + self::OAuth2Protocol_ResponseType_IdToken ), array ( @@ -405,13 +430,13 @@ final class OAuth2Protocol implements IOAuth2Protocol ), array ( - self::OAuth2Protocol_ResponseType_Code , + self::OAuth2Protocol_ResponseType_Code, self::OAuth2Protocol_ResponseType_IdToken, self::OAuth2Protocol_ResponseType_Token ) ); - if($flow === 'all') + if ($flow === 'all') return array_merge ( $code_flow, @@ -419,13 +444,20 @@ final class OAuth2Protocol implements IOAuth2Protocol $hybrid_flow ); - if($flow === OAuth2Protocol::OAuth2Protocol_GrantType_AuthCode) + if ($flow === OAuth2Protocol::OAuth2Protocol_GrantType_Passwordless) + return [ + [ + self::OAuth2Protocol_ResponseType_OTP + ] + ]; + + if ($flow === OAuth2Protocol::OAuth2Protocol_GrantType_AuthCode) return $code_flow; - if($flow === OAuth2Protocol::OAuth2Protocol_GrantType_Implicit) + if ($flow === OAuth2Protocol::OAuth2Protocol_GrantType_Implicit) return $implicit_flow; - if($flow === OAuth2Protocol::OAuth2Protocol_GrantType_Hybrid) + if ($flow === OAuth2Protocol::OAuth2Protocol_GrantType_Hybrid) return $hybrid_flow; return []; @@ -446,26 +478,25 @@ final class OAuth2Protocol implements IOAuth2Protocol { if ( - !in_array - ( - $flow, array - ( - OAuth2Protocol::OAuth2Protocol_GrantType_AuthCode, - OAuth2Protocol::OAuth2Protocol_GrantType_Implicit, - OAuth2Protocol::OAuth2Protocol_GrantType_Hybrid, - 'all' - ) - ) + !in_array + ( + $flow, [ + OAuth2Protocol::OAuth2Protocol_GrantType_AuthCode, + OAuth2Protocol::OAuth2Protocol_GrantType_Implicit, + OAuth2Protocol::OAuth2Protocol_GrantType_Hybrid, + OAuth2Protocol::OAuth2Protocol_GrantType_Passwordless, + 'all' + ] ) - return false; + ) + return false; $flow_response_types = self::getValidResponseTypes($flow); - foreach($flow_response_types as $rt) - { - if(count($rt) !== count($response_type)) continue; - $diff = array_diff($rt, $response_type); - if(count($diff) === 0) return true; + foreach ($flow_response_types as $rt) { + if (count($rt) !== count($response_type)) continue; + $diff = array_diff($rt, $response_type); + if (count($diff) === 0) return true; } return false; } @@ -534,9 +565,9 @@ final class OAuth2Protocol implements IOAuth2Protocol * through the sequence. If the value is force, then the user sees a consent page even if they * previously gave consent to your application for a given set of scopes. */ - const OAuth2Protocol_Approval_Prompt = 'approval_prompt'; + const OAuth2Protocol_Approval_Prompt = 'approval_prompt'; const OAuth2Protocol_Approval_Prompt_Force = 'force'; - const OAuth2Protocol_Approval_Prompt_Auto = 'auto'; + const OAuth2Protocol_Approval_Prompt_Auto = 'auto'; /** * Indicates whether your application needs to access an API when the user is not present at @@ -544,8 +575,8 @@ final class OAuth2Protocol implements IOAuth2Protocol * when the user is not present at the browser, then use offline. This will result in your application * obtaining a refresh token the first time your application exchanges an authorization code for a user. */ - const OAuth2Protocol_AccessType = 'access_type'; - const OAuth2Protocol_AccessType_Online = 'online'; + const OAuth2Protocol_AccessType = 'access_type'; + const OAuth2Protocol_AccessType_Online = 'online'; const OAuth2Protocol_AccessType_Offline = 'offline'; const OAuth2Protocol_GrantType = 'grant_type'; @@ -554,6 +585,7 @@ final class OAuth2Protocol implements IOAuth2Protocol const OAuth2Protocol_ErrorUri = 'error_uri'; const OAuth2Protocol_Error_InvalidRequest = 'invalid_request'; const OAuth2Protocol_Error_UnauthorizedClient = 'unauthorized_client'; + const OAuth2Protocol_Error_InvalidOTP = 'invalid_otp'; const OAuth2Protocol_Error_RedirectUriMisMatch = 'redirect_uri_mismatch'; const OAuth2Protocol_Error_AccessDenied = 'access_denied'; const OAuth2Protocol_Error_UnsupportedResponseType = 'unsupported_response_type'; @@ -627,26 +659,26 @@ final class OAuth2Protocol implements IOAuth2Protocol const OAuth2Protocol_Error_Invalid_Recipient_Keys = 'invalid_recipient_keys'; - const OAuth2Protocol_Error_Invalid_Server_Keys = 'invalid_server_keys'; - const OAuth2Protocol_Error_Not_Found_Server_Keys = 'not_found_server_keys'; + const OAuth2Protocol_Error_Invalid_Server_Keys = 'invalid_server_keys'; + const OAuth2Protocol_Error_Not_Found_Server_Keys = 'not_found_server_keys'; public static $valid_responses_types = array ( - self::OAuth2Protocol_ResponseType_Code => self::OAuth2Protocol_ResponseType_Code, + self::OAuth2Protocol_ResponseType_Code => self::OAuth2Protocol_ResponseType_Code, self::OAuth2Protocol_ResponseType_Token => self::OAuth2Protocol_ResponseType_Token ); // http://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication const TokenEndpoint_AuthMethod_ClientSecretBasic = 'client_secret_basic'; - const TokenEndpoint_AuthMethod_ClientSecretPost = 'client_secret_post'; - const TokenEndpoint_AuthMethod_ClientSecretJwt = 'client_secret_jwt'; - const TokenEndpoint_AuthMethod_PrivateKeyJwt = 'private_key_jwt'; - const TokenEndpoint_AuthMethod_None = 'none'; + const TokenEndpoint_AuthMethod_ClientSecretPost = 'client_secret_post'; + const TokenEndpoint_AuthMethod_ClientSecretJwt = 'client_secret_jwt'; + const TokenEndpoint_AuthMethod_PrivateKeyJwt = 'private_key_jwt'; + const TokenEndpoint_AuthMethod_None = 'none'; - const OAuth2Protocol_ClientAssertionType = 'client_assertion_type'; - const OAuth2Protocol_ClientAssertion = 'client_assertion'; + const OAuth2Protocol_ClientAssertionType = 'client_assertion_type'; + const OAuth2Protocol_ClientAssertion = 'client_assertion'; public static $token_endpoint_auth_methods = array ( @@ -721,8 +753,8 @@ final class OAuth2Protocol implements IOAuth2Protocol /** * PKCE - * @see https://tools.ietf.org/html/rfc7636 - **/ + * @see https://tools.ietf.org/html/rfc7636 + **/ // auth request new params @@ -835,31 +867,48 @@ final class OAuth2Protocol implements IOAuth2Protocol */ public function __construct ( - ILogService $log_service, + ILogService $log_service, IClientService $client_service, IClientRepository $client_repository, - ITokenService $token_service, - IAuthService $auth_service, + ITokenService $token_service, + IAuthService $auth_service, IOAuth2AuthenticationStrategy $auth_strategy, ICheckPointService $checkpoint_service, - IApiScopeService $scope_service, + IApiScopeService $scope_service, IUserConsentService $user_consent_service, IServerPrivateKeyRepository $server_private_keys_repository, IOpenIDProviderConfigurationService $oidc_provider_configuration_service, IMementoOAuth2SerializerService $memento_service, - ISecurityContextService $security_context_service, - IPrincipalService $principal_service, - IServerPrivateKeyRepository $server_private_key_repository, - IClientJWKSetReader $jwk_set_reader_service, - UserIPHelperProvider $ip_helper + ISecurityContextService $security_context_service, + IPrincipalService $principal_service, + IServerPrivateKeyRepository $server_private_key_repository, + IClientJWKSetReader $jwk_set_reader_service, + UserIPHelperProvider $ip_helper ) { - $this->server_private_keys_repository = $server_private_keys_repository; + $this->server_private_keys_repository = $server_private_keys_repository; $this->oidc_provider_configuration_service = $oidc_provider_configuration_service; - $this->memento_service = $memento_service; + $this->memento_service = $memento_service; - $authorization_code_grant_type = new AuthorizationCodeGrantType + $passwordless_grant_type = new PasswordlessGrantType + ( + $scope_service, + $client_service, + $client_repository, + $token_service, + $auth_service, + $auth_strategy, + $log_service, + $user_consent_service, + $this->memento_service, + $security_context_service, + $principal_service, + $server_private_key_repository, + $jwk_set_reader_service + ); + + $authorization_code_grant_type = new AuthorizationCodeGrantType ( $scope_service, $client_service, @@ -910,7 +959,7 @@ final class OAuth2Protocol implements IOAuth2Protocol $jwk_set_reader_service ); - $refresh_bearer_token_grant_type = new RefreshBearerTokenGrantType + $refresh_bearer_token_grant_type = new RefreshBearerTokenGrantType ( $client_service, $client_repository, @@ -918,7 +967,7 @@ final class OAuth2Protocol implements IOAuth2Protocol $log_service ); - $client_credential_grant_type = new ClientCredentialsGrantType + $client_credential_grant_type = new ClientCredentialsGrantType ( $scope_service, $client_service, @@ -927,24 +976,26 @@ final class OAuth2Protocol implements IOAuth2Protocol $log_service ); - $this->grant_types[$authorization_code_grant_type->getType()] = $authorization_code_grant_type; - $this->grant_types[$implicit_grant_type->getType()] = $implicit_grant_type; + // setting grants collection + $this->grant_types[$passwordless_grant_type->getType()] = $passwordless_grant_type; + $this->grant_types[$authorization_code_grant_type->getType()] = $authorization_code_grant_type; + $this->grant_types[$implicit_grant_type->getType()] = $implicit_grant_type; $this->grant_types[$refresh_bearer_token_grant_type->getType()] = $refresh_bearer_token_grant_type; - $this->grant_types[$client_credential_grant_type->getType()] = $client_credential_grant_type; - $this->grant_types[$hybrid_grant_type->getType()] = $hybrid_grant_type; + $this->grant_types[$client_credential_grant_type->getType()] = $client_credential_grant_type; + $this->grant_types[$hybrid_grant_type->getType()] = $hybrid_grant_type; - $this->log_service = $log_service; - $this->checkpoint_service = $checkpoint_service; - $this->client_service = $client_service; - $this->client_repository = $client_repository; - $this->auth_service = $auth_service; - $this->principal_service = $principal_service; - $this->token_service = $token_service; + $this->log_service = $log_service; + $this->checkpoint_service = $checkpoint_service; + $this->client_service = $client_service; + $this->client_repository = $client_repository; + $this->auth_service = $auth_service; + $this->principal_service = $principal_service; + $this->token_service = $token_service; - $this->authorize_endpoint = new AuthorizationEndpoint($this); - $this->token_endpoint = new TokenEndpoint($this); - $this->revoke_endpoint = new TokenRevocationEndpoint($this, $client_service, $client_repository, $token_service, $log_service); - $this->introspection_endpoint = new TokenIntrospectionEndpoint + $this->authorize_endpoint = new AuthorizationEndpoint($this); + $this->token_endpoint = new TokenEndpoint($this); + $this->revoke_endpoint = new TokenRevocationEndpoint($this, $client_service, $client_repository, $token_service, $log_service); + $this->introspection_endpoint = new TokenIntrospectionEndpoint ( $this, $client_service, @@ -964,14 +1015,12 @@ final class OAuth2Protocol implements IOAuth2Protocol */ public function authorize(OAuth2Request $request = null) { - try - { + try { $this->last_request = $request; if (is_null($this->last_request)) throw new InvalidOAuth2Request; - if(!$this->last_request->isValid()) - { + if (!$this->last_request->isValid()) { // then check if we have a memento .... if (!$this->memento_service->exists()) throw new InvalidOAuth2Request($this->last_request->getLastValidationError()); @@ -981,20 +1030,16 @@ final class OAuth2Protocol implements IOAuth2Protocol OAuth2Message::buildFromMemento($this->memento_service->load()) ); - if(!$this->last_request->isValid()) + if (!$this->last_request->isValid()) throw new InvalidOAuth2Request($this->last_request->getLastValidationError()); } return $this->authorize_endpoint->handle($this->last_request); - } - catch (UriNotAllowedException $ex1) - { + } catch (UriNotAllowedException $ex1) { $this->log_service->warning($ex1); $this->checkpoint_service->trackException($ex1); throw $ex1; - } - catch(OAuth2BaseException $ex2) - { + } catch (OAuth2BaseException $ex2) { $this->log_service->warning($ex2); $this->checkpoint_service->trackException($ex2); @@ -1010,8 +1055,7 @@ final class OAuth2Protocol implements IOAuth2Protocol $ex2->getMessage(), $redirect_uri ); - } - catch (AbsentClientException $ex3){ + } catch (AbsentClientException $ex3) { $this->log_service->warning($ex3); $this->checkpoint_service->trackException($ex3); @@ -1026,8 +1070,7 @@ final class OAuth2Protocol implements IOAuth2Protocol $ex3->getMessage(), $redirect_uri ); - } - catch (AbsentCurrentUserException $ex4){ + } catch (AbsentCurrentUserException $ex4) { $this->log_service->warning($ex4); $this->checkpoint_service->trackException($ex4); @@ -1042,9 +1085,7 @@ final class OAuth2Protocol implements IOAuth2Protocol $ex4->getMessage(), $redirect_uri ); - } - catch (Exception $ex) - { + } catch (Exception $ex) { $this->log_service->error($ex); $this->checkpoint_service->trackException($ex); @@ -1068,27 +1109,22 @@ final class OAuth2Protocol implements IOAuth2Protocol */ public function token(OAuth2Request $request = null) { - try - { + try { $this->last_request = $request; if (is_null($this->last_request)) throw new InvalidOAuth2Request; - if(!$this->last_request->isValid()) + if (!$this->last_request->isValid()) throw new InvalidOAuth2Request($this->last_request->getLastValidationError()); return $this->token_endpoint->handle($this->last_request); - } - catch(OAuth2BaseException $ex1) - { + } catch (OAuth2BaseException $ex1) { $this->log_service->warning($ex1); $this->checkpoint_service->trackException($ex1); return new OAuth2DirectErrorResponse($ex1->getError(), $ex1->getMessage()); - } - catch (Exception $ex) - { + } catch (Exception $ex) { $this->log_service->error($ex); $this->checkpoint_service->trackException($ex); @@ -1106,7 +1142,8 @@ final class OAuth2Protocol implements IOAuth2Protocol * @param OAuth2Request $request * @return OAuth2Response */ - public function revoke(OAuth2Request $request = null){ + public function revoke(OAuth2Request $request = null) + { try { $this->last_request = $request; @@ -1114,12 +1151,11 @@ final class OAuth2Protocol implements IOAuth2Protocol if (is_null($this->last_request)) throw new InvalidOAuth2Request; - if(!$this->last_request->isValid()) + if (!$this->last_request->isValid()) throw new InvalidOAuth2Request($this->last_request->getLastValidationError()); return $this->revoke_endpoint->handle($this->last_request); - } - catch (Exception $ex) { + } catch (Exception $ex) { $this->log_service->error($ex); $this->checkpoint_service->trackException($ex); //simple say "OK" and be on our way ... @@ -1135,31 +1171,24 @@ final class OAuth2Protocol implements IOAuth2Protocol */ public function introspection(OAuth2Request $request = null) { - try - { + try { $this->last_request = $request; if (is_null($this->last_request)) throw new InvalidOAuth2Request; - if(!$this->last_request->isValid()) + if (!$this->last_request->isValid()) throw new InvalidOAuth2Request($this->last_request->getLastValidationError()); return $this->introspection_endpoint->handle($this->last_request); - } - catch(ExpiredAccessTokenException $ex1) - { + } catch (ExpiredAccessTokenException $ex1) { $this->log_service->warning($ex1); return new OAuth2DirectErrorResponse($ex1->getError(), $ex1->getMessage()); - } - catch(OAuth2BaseException $ex2) - { + } catch (OAuth2BaseException $ex2) { $this->log_service->warning($ex2); $this->checkpoint_service->trackException($ex2); return new OAuth2DirectErrorResponse($ex2->getError(), $ex2->getMessage()); - } - catch (Exception $ex) - { + } catch (Exception $ex) { $this->log_service->error($ex); $this->checkpoint_service->trackException($ex); @@ -1183,13 +1212,12 @@ final class OAuth2Protocol implements IOAuth2Protocol static public function isClientAllowedToUseTokenEndpointAuth(IClient $client) { return $client->getClientType() === IClient::ClientType_Confidential || - $client->getApplicationType() === IClient::ApplicationType_Native; + $client->getApplicationType() === IClient::ApplicationType_Native; } static public function getTokenEndpointAuthMethodsPerClientType(IClient $client) { - if($client->getClientType() == IClient::ClientType_Public) - { + if ($client->getClientType() == IClient::ClientType_Public) { return ArrayUtils::convert2Assoc ( array @@ -1219,8 +1247,7 @@ final class OAuth2Protocol implements IOAuth2Protocol */ static public function getSigningAlgorithmsPerClientType(IClient $client) { - if($client->getClientType() == IClient::ClientType_Public) - { + if ($client->getClientType() == IClient::ClientType_Public) { return ArrayUtils::convert2Assoc ( array_merge @@ -1254,18 +1281,17 @@ final class OAuth2Protocol implements IOAuth2Protocol */ static public function getKeyManagementAlgorithmsPerClientType(IClient $client) { - if($client->getClientType() == IClient::ClientType_Public) - { + if ($client->getClientType() == IClient::ClientType_Public) { return ArrayUtils::convert2Assoc ( - array_diff - ( - self::$supported_key_management_algorithms, - array - ( - JSONWebSignatureAndEncryptionAlgorithms::Dir - ) - ) + array_diff + ( + self::$supported_key_management_algorithms, + array + ( + JSONWebSignatureAndEncryptionAlgorithms::Dir + ) + ) ); } return ArrayUtils::convert2Assoc @@ -1281,10 +1307,9 @@ final class OAuth2Protocol implements IOAuth2Protocol public function getJWKSDocument() { $keys = $this->server_private_keys_repository->getActives(); - $set = []; + $set = []; - foreach($keys as $private_key) - { + foreach ($keys as $private_key) { $jwk = RSAJWKFactory::build ( new RSAJWKPEMPrivateKeySpecification @@ -1419,8 +1444,7 @@ final class OAuth2Protocol implements IOAuth2Protocol */ public function endSession(OAuth2Request $request = null) { - try - { + try { $this->log_service->debug_msg("OAuth2Protocol::endSession"); $this->last_request = $request; @@ -1430,59 +1454,59 @@ final class OAuth2Protocol implements IOAuth2Protocol throw new InvalidOAuth2Request; } - if(!$this->last_request->isValid()) { + if (!$this->last_request->isValid()) { $this->log_service->debug_msg(sprintf("OAuth2Protocol::endSession last request is invalid error %s", $this->last_request->getLastValidationError())); throw new InvalidOAuth2Request($this->last_request->getLastValidationError()); } - if(!$this->last_request instanceof OAuth2LogoutRequest) throw new InvalidOAuth2Request; + if (!$this->last_request instanceof OAuth2LogoutRequest) throw new InvalidOAuth2Request; $id_token_hint = $this->last_request->getIdTokenHint(); - $client_id = null; - $user_id = null; - $user = null; + $client_id = null; + $user_id = null; + $user = null; - if(!empty($id_token_hint)){ + if (!empty($id_token_hint)) { $jwt = BasicJWTFactory::build($id_token_hint); - if((!$jwt instanceof IJWT)) { + if ((!$jwt instanceof IJWT)) { $this->log_service->debug_msg("OAuth2Protocol::endSession invalid id_token_hint!"); throw new InvalidOAuth2Request('invalid id_token_hint!'); } $client_id = $jwt->getClaimSet()->getAudience()->getString(); - $user_id = $jwt->getClaimSet()->getSubject(); + $user_id = $jwt->getClaimSet()->getSubject(); } - if(empty($client_id)){ + if (empty($client_id)) { $client_id = $this->last_request->getClientId(); } - if(is_null($client_id)) { + if (is_null($client_id)) { $this->log_service->debug_msg("OAuth2Protocol::endSession client_id can not be inferred."); throw new InvalidClientException('client_id can not be inferred.'); } $client = $this->client_repository->getClientById($client_id); - if(is_null($client)){ + if (is_null($client)) { $this->log_service->debug_msg("OAuth2Protocol::endSession client not found!"); throw new InvalidClientException('Client not found!'); } $redirect_logout_uri = $this->last_request->getPostLogoutRedirectUri(); - $state = $this->last_request->getState(); + $state = $this->last_request->getState(); - if(!empty($redirect_logout_uri) && !$client->isPostLogoutUriAllowed($redirect_logout_uri)) { + if (!empty($redirect_logout_uri) && !$client->isPostLogoutUriAllowed($redirect_logout_uri)) { $this->log_service->debug_msg("OAuth2Protocol::endSession post_logout_redirect_uri not allowed!"); throw new InvalidOAuth2Request('post_logout_redirect_uri not allowed!'); } - if(!is_null($user_id)){ + if (!is_null($user_id)) { // try to get the user from id token ( if its set ) $user_id = $this->auth_service->unwrapUserId(intval($user_id->getString())); - $user = $this->auth_service->getUserById($user_id); + $user = $this->auth_service->getUserById($user_id); - if(is_null($user)){ + if (is_null($user)) { $this->log_service->debug_msg("OAuth2Protocol::endSession user not found!"); throw new InvalidOAuth2Request('user not found!'); } @@ -1490,36 +1514,29 @@ final class OAuth2Protocol implements IOAuth2Protocol $logged_user = $this->auth_service->getCurrentUser(); - if(!is_null($logged_user) && !is_null($user) && $logged_user->getId() !== $user->getId()) { + if (!is_null($logged_user) && !is_null($user) && $logged_user->getId() !== $user->getId()) { Log::warning(sprintf("OAuth2Protocol::endSession user does not match with current session! logged user id %s - user id %s", $logged_user->getId(), $user->getId())); } - if(!is_null($logged_user)) + if (!is_null($logged_user)) $this->auth_service->logout(); - if(!empty($redirect_logout_uri)) - { + if (!empty($redirect_logout_uri)) { return new OAuth2LogoutResponse($redirect_logout_uri, $state); } return null; - } - catch (UriNotAllowedException $ex1) - { + } catch (UriNotAllowedException $ex1) { $this->log_service->warning($ex1); $this->checkpoint_service->trackException($ex1); return new OAuth2DirectErrorResponse(OAuth2Protocol::OAuth2Protocol_Error_UnauthorizedClient); - } - catch(OAuth2BaseException $ex2) - { + } catch (OAuth2BaseException $ex2) { $this->log_service->warning($ex2); $this->checkpoint_service->trackException($ex2); return new OAuth2DirectErrorResponse($ex2->getError(), $ex2->getMessage()); - } - catch (Exception $ex) - { + } catch (Exception $ex) { $this->log_service->error($ex); $this->checkpoint_service->trackException($ex); diff --git a/app/libs/OAuth2/Repositories/IOAuth2OTPRepository.php b/app/libs/OAuth2/Repositories/IOAuth2OTPRepository.php new file mode 100644 index 00000000..cbf230b3 --- /dev/null +++ b/app/libs/OAuth2/Repositories/IOAuth2OTPRepository.php @@ -0,0 +1,42 @@ + [ + OAuth2Protocol::OAuth2Protocol_GrantType_Passwordless + ], + OAuth2Protocol::OAuth2Protocol_ResponseType_OTP => [], + OAuth2Protocol::OAuth2PasswordlessConnection => [ + OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessConnectionSMS, + ] , + OAuth2Protocol::OAuth2Protocol_Scope => [] + ]; + + /** + * @var array + */ + public static $optional_params = [ + OAuth2Protocol::OAuth2PasswordlessEmail => [ + [ + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail + ] + ], + OAuth2Protocol::OAuth2PasswordlessPhoneNumber => [ + [ + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionSMS + ] + ], + ]; + + /** + * Validates current request + * @return bool + */ + public function isValid() + { + $this->last_validation_error = ''; + + // validate mandatory params + + foreach (self::$params as $mandatory_param => $values) { + $mandatory_val = $this->getParam($mandatory_param); + if (empty($mandatory_val)) { + $this->last_validation_error = sprintf("%s not set", $mandatory_param); + return false; + } + + if (count($values) > 0 && !in_array($mandatory_val, $values)) { + $this->last_validation_error = sprintf("%s has not a valid value (%s)", $mandatory_param, implode(",", $values)); + return false; + } + } + + // validate optional params + foreach (self::$optional_params as $optional_param => $rules) { + $optional_param_val = $this->getParam($optional_param); + if (empty($optional_param_val) && count($rules)) continue; + foreach ($rules as $dep_param => $dep_val) { + $dep_param_cur_val = $this->getParam($dep_param); + if ($dep_param_cur_val != $dep_val) continue; + if (empty($optional_param_val)) { + $this->last_validation_error = sprintf("%s not set.", $optional_param); + return false; + } + } + } + + return true; + } + + public function getConnection(): string + { + return $this->getParam(OAuth2Protocol::OAuth2PasswordlessConnection); + } + + public function getEmail(): ?string + { + return $this->getParam(OAuth2Protocol::OAuth2PasswordlessEmail); + } + + public function getPhoneNumber(): ?string + { + return $this->getParam(OAuth2Protocol::OAuth2PasswordlessPhoneNumber); + } + + public function getUserName(): ?string + { + return $this->getConnection() == OAuth2Protocol::OAuth2PasswordlessConnectionEmail ? $this->getEmail() : $this->getPhoneNumber(); + } + + public function getScopes():string{ + return $this->getParam(OAuth2Protocol::OAuth2Protocol_Scope); + } + + public function getOTP():string{ + return $this->getParam(OAuth2Protocol::OAuth2Protocol_ResponseType_OTP); + } +} \ No newline at end of file diff --git a/app/libs/OAuth2/Requests/OAuth2PasswordlessAuthenticationRequest.php b/app/libs/OAuth2/Requests/OAuth2PasswordlessAuthenticationRequest.php new file mode 100644 index 00000000..8c71393d --- /dev/null +++ b/app/libs/OAuth2/Requests/OAuth2PasswordlessAuthenticationRequest.php @@ -0,0 +1,127 @@ +getMessage()); + } + + public static $params = [ + OAuth2Protocol::OAuth2Protocol_ResponseType => [ + OAuth2Protocol::OAuth2Protocol_ResponseType_OTP + ], + OAuth2Protocol::OAuth2Protocol_ClientId => [], + OAuth2Protocol::OAuth2Protocol_Scope => [], + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::ValidOAuth2PasswordlessConnectionValues, + OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::ValidOAuth2PasswordlessSendValues, + ]; + + /** + * @var array + */ + public static $optional_params = [ + OAuth2Protocol::OAuth2Protocol_Nonce => [], + OAuth2Protocol::OAuth2Protocol_RedirectUri => [ + [ + OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::OAuth2PasswordlessSendLink + ] + ], + OAuth2Protocol::OAuth2PasswordlessEmail => [ + [ + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail + ] + ], + OAuth2Protocol::OAuth2PasswordlessPhoneNumber => [ + [ + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionSMS + ] + ], + ]; + + /** + * Validates current request + * @return bool + */ + public function isValid() + { + $this->last_validation_error = ''; + + // validate mandatory params + + foreach (self::$params as $mandatory_param => $values) { + $mandatory_val = $this->getParam($mandatory_param); + if (empty($mandatory_val)) { + $this->last_validation_error = sprintf("%s not set", $mandatory_param); + return false; + } + + if (count($values) > 0 && !in_array($mandatory_val, $values)) { + $this->last_validation_error = sprintf("%s has not a valid value (%s)", $mandatory_param, implode(",", $values)); + return false; + } + } + + // validate optional params + foreach (self::$optional_params as $optional_param => $rules) { + $optional_param_val = $this->getParam($optional_param); + if (empty($optional_param_val) && count($rules)) continue; + foreach ($rules as $dep_param => $dep_val) { + $dep_param_cur_val = $this->getParam($dep_param); + if ($dep_param_cur_val != $dep_val) continue; + if (empty($optional_param_val)) { + $this->last_validation_error = sprintf("%s not set.", $optional_param); + return false; + } + } + } + + return true; + } + + public function getConnection(): string + { + return $this->getParam(OAuth2Protocol::OAuth2PasswordlessConnection); + } + + public function getSend(): string + { + return $this->getParam(OAuth2Protocol::OAuth2PasswordlessSend); + } + + public function getEmail(): ?string + { + return $this->getParam(OAuth2Protocol::OAuth2PasswordlessEmail); + } + + public function getNonce(): ?string + { + return $this->getParam(OAuth2Protocol::OAuth2Protocol_Nonce); + } + + public function getPhoneNumber(): ?string + { + return $this->getParam(OAuth2Protocol::OAuth2PasswordlessPhoneNumber); + } +} \ No newline at end of file diff --git a/app/libs/OAuth2/Responses/OAuth2PasswordlessAuthenticationResponse.php b/app/libs/OAuth2/Responses/OAuth2PasswordlessAuthenticationResponse.php new file mode 100644 index 00000000..c9a1136c --- /dev/null +++ b/app/libs/OAuth2/Responses/OAuth2PasswordlessAuthenticationResponse.php @@ -0,0 +1,39 @@ +getTokenEndpointAuthInfo()->getAuthenticationMethod() !== $context->getAuthType()) - throw new InvalidClientCredentials(sprintf('invalid token endpoint auth method %s', $context->getAuthType())); + throw new InvalidClientCredentials(sprintf('invalid token endpoint auth method %s (%s)', $context->getAuthType(), $client->getTokenEndpointAuthInfo()->getAuthenticationMethod())); if ($client->getClientType() !== IClient::ClientType_Public) throw new InvalidClientCredentials(sprintf('invalid client type %s', $client->getClientType())); diff --git a/app/libs/OAuth2/Strategies/ClientPlainCredentialsAuthContextValidator.php b/app/libs/OAuth2/Strategies/ClientPlainCredentialsAuthContextValidator.php index 0bc665c1..ad8488d4 100644 --- a/app/libs/OAuth2/Strategies/ClientPlainCredentialsAuthContextValidator.php +++ b/app/libs/OAuth2/Strategies/ClientPlainCredentialsAuthContextValidator.php @@ -40,7 +40,7 @@ final class ClientPlainCredentialsAuthContextValidator implements IClientAuthCon throw new InvalidClientAuthenticationContextException('client not set!'); if($client->getTokenEndpointAuthInfo()->getAuthenticationMethod() !== $context->getAuthType()) - throw new InvalidClientCredentials(sprintf('invalid token endpoint auth method %s', $context->getAuthType())); + throw new InvalidClientCredentials(sprintf('invalid token endpoint auth method %s (%s)', $context->getAuthType(), $client->getTokenEndpointAuthInfo()->getAuthenticationMethod())); if($client->getClientType() !== IClient::ClientType_Confidential) throw new InvalidClientCredentials(sprintf('invalid client type %s', $client->getClientType())); diff --git a/app/libs/OpenId/Models/OpenIdNonce.php b/app/libs/OpenId/Models/OpenIdNonce.php index 2e4349a9..a8aa4893 100644 --- a/app/libs/OpenId/Models/OpenIdNonce.php +++ b/app/libs/OpenId/Models/OpenIdNonce.php @@ -13,12 +13,14 @@ **/ use OpenId\Exceptions\InvalidNonce; use OpenId\Helpers\OpenIdErrorMessages; -use Utils\Model\Identifier; +use Utils\Model\AbstractIdentifier; +use Zend\Math\Rand; + /** * Class OpenIdNonce * @package OpenId\Models */ -final class OpenIdNonce extends Identifier +final class OpenIdNonce extends AbstractIdentifier { const NonceRegexFormat = '/(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)Z(.*)/'; const NonceTimeFormat = '%Y-%m-%dT%H:%M:%SZ'; @@ -139,7 +141,7 @@ final class OpenIdNonce extends Identifier /** * @return string */ - public function getType() + public function getType():string { return 'nonce'; } @@ -151,4 +153,27 @@ final class OpenIdNonce extends Identifier { return []; } + + /* + * MAY contain additional ASCII characters in the range 33-126 inclusive (printable non-whitespace characters), as necessary to make each response unique + */ + const NoncePopulation = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + /** + * Nonce Salt Length + */ + const NonceSaltLength = 32; + + /** + * @return string + * @throws InvalidNonce + */ + public function generateValue(): string + { + $salt = Rand::getString(self::NonceSaltLength, self::NoncePopulation); + $date_part = false; + do{ $date_part = gmdate('Y-m-d\TH:i:s\Z'); } while($date_part === false); + $raw_nonce = $date_part. $salt; + $this->setValue($raw_nonce); + return $this->value; + } } \ No newline at end of file diff --git a/app/libs/OpenId/Services/NonceUniqueIdentifierGenerator.php b/app/libs/OpenId/Services/NonceUniqueIdentifierGenerator.php deleted file mode 100644 index a1ffd546..00000000 --- a/app/libs/OpenId/Services/NonceUniqueIdentifierGenerator.php +++ /dev/null @@ -1,47 +0,0 @@ -setValue($raw_nonce); - return $identifier; - } - -} \ No newline at end of file diff --git a/app/libs/Utils/Model/AbstractIdentifier.php b/app/libs/Utils/Model/AbstractIdentifier.php new file mode 100644 index 00000000..0f0764be --- /dev/null +++ b/app/libs/Utils/Model/AbstractIdentifier.php @@ -0,0 +1,84 @@ +lifetime = $lifetime; + $this->len = $len; + } + + /** + * @var int + */ + protected $len; + + /** + * @var int + */ + protected $lifetime; + /** + * @var string + */ + protected $value; + + /** + * @return int + */ + public function getLength():int + { + return $this->len; + } + + /** + * @return int + */ + public function getLifetime():int + { + return intval($this->lifetime); + } + + /** + * @return string + */ + public function getValue() + { + return $this->value; + } + + /** + * @param string $value + * @return $this + */ + public function setValue(string $value) + { + $this->value = $value; + return $this; + } + + /** + * @return array + */ + abstract public function toArray(): array; +} \ No newline at end of file diff --git a/app/libs/Utils/Model/Identifier.php b/app/libs/Utils/Model/Identifier.php index bc9d947e..2e4bb4b7 100644 --- a/app/libs/Utils/Model/Identifier.php +++ b/app/libs/Utils/Model/Identifier.php @@ -1,98 +1,41 @@ lifetime = $lifetime; - $this->len = $len; - } - - /** - * @var int - */ - protected $len; - - /** - * @var int - */ - protected $lifetime; - /** - * @var string - */ - protected $value; - - /** - * @param IdentifierGenerator $generator - * @return $this - */ - public function generate(IdentifierGenerator $generator) - { - return $generator->generate($this); - } - /** * @return int */ - public function getLenght() - { - return $this->len; - } - - /** - * @return int - */ - public function getLifetime() - { - return intval($this->lifetime); - } - - /** - * @return string - */ - public function getValue() - { - return $this->value; - } + public function getLength():int; /** * @param string $value * @return $this */ - public function setValue($value) - { - $this->value = $value; - return $this; - } + public function setValue(string $value); + + /** + * @return int + */ + public function getLifetime():int; /** * @return string */ - abstract public function getType(); + public function getType():string; /** - * @return array + * @return string */ - abstract public function toArray(): array; + public function generateValue():string; } \ No newline at end of file diff --git a/app/libs/Utils/Services/IdentifierGenerator.php b/app/libs/Utils/Services/IdentifierGenerator.php index d0073ac3..4c1bd537 100644 --- a/app/libs/Utils/Services/IdentifierGenerator.php +++ b/app/libs/Utils/Services/IdentifierGenerator.php @@ -22,5 +22,5 @@ interface IdentifierGenerator { * @param Identifier $identifier * @return Identifier */ - public function generate(Identifier $identifier); + public function generate(Identifier $identifier):Identifier; } \ No newline at end of file diff --git a/app/libs/Utils/Services/UniqueIdentifierGenerator.php b/app/libs/Utils/Services/UniqueIdentifierGenerator.php index 01e99ec5..a1b1a88b 100644 --- a/app/libs/Utils/Services/UniqueIdentifierGenerator.php +++ b/app/libs/Utils/Services/UniqueIdentifierGenerator.php @@ -17,7 +17,7 @@ use Zend\Crypt\Hash; * Class UniqueIdentifierGenerator * @package Utils\Services */ -abstract class UniqueIdentifierGenerator implements IdentifierGenerator +class UniqueIdentifierGenerator implements IdentifierGenerator { /** @@ -37,20 +37,13 @@ abstract class UniqueIdentifierGenerator implements IdentifierGenerator * @param Identifier $identifier * @return Identifier */ - public function generate(Identifier $identifier){ - + public function generate(Identifier $identifier):Identifier{ do { - $key = sprintf("%s.%s", $identifier->getType(), Hash::compute('sha256', $this->_generate($identifier)->getValue())); + $key = sprintf("%s.%s", $identifier->getType(), Hash::compute('sha256', $identifier->generateValue())); } while(!$this->cache_service->addSingleValue($key, $key)); return $identifier; } - /** - * @param Identifier $identifier - * @return Identifier - */ - abstract protected function _generate(Identifier $identifier); - } \ No newline at end of file diff --git a/config/otp.php b/config/otp.php new file mode 100644 index 00000000..ea406d66 --- /dev/null +++ b/config/otp.php @@ -0,0 +1,7 @@ + env("OTP_DEFAULT_LIFETIME", 120), + "length" => env("OTP_DEFAULT_LENGTH", 6) +]; \ No newline at end of file diff --git a/database/migrations/Version20210616123839.php b/database/migrations/Version20210616123839.php new file mode 100644 index 00000000..4c3493af --- /dev/null +++ b/database/migrations/Version20210616123839.php @@ -0,0 +1,94 @@ +hasTable("oauth2_otp")) { + $builder->create("oauth2_otp", function (Table $table) { + $table->bigInteger("id", true, false); + $table->primary("id"); + $table->timestamps(); + $table->string("value")->setLength(50)->setNotnull(true); + $table->string("connection")->setLength(10)->setNotnull(true);//sms|mail + $table->string("send")->setLength(10)->setNotnull(true);//code|link + $table->string("scopes")->setNotnull(true); + $table->string("email")->setLength(50)->setNotnull(false); + $table->string("phone_number")->setLength(50)->setNotnull(false); + $table->string("nonce")->setLength(50)->setNotnull(false); + $table->integer("redeemed_attempts")->setNotnull(true)->setDefault(0); + $table->string("redeemed_from_ip")->setNotnull(false); + $table->string("redirect_url")->setLength(255)->setNotnull(false); + $table->integer('length')->setNotnull(false)->setDefault(6); + // seconds + $table->integer('lifetime')->setNotnull(true); + $table->dateTime('redeemed_at')->setNotnull(false); + // FK Optional + $table->bigInteger("oauth2_client_id", false, false)->setNotnull(false)->setDefault('NULL'); + $table->index("oauth2_client_id", "oauth2_client_id"); + $table->foreign("oauth2_client", "oauth2_client_id", "id", ["onDelete" => "CASCADE"]); + + $table->unique(["oauth2_client_id", "value"]); + + }); + } + + if ($builder->hasTable("oauth2_client") && !$builder->hasColumn("oauth2_client","otp_enabled")) { + $builder->table('oauth2_client', function (Table $table) { + // + $table->boolean('otp_enabled')->setNotnull(false)->setDefault(0); + // characters + $table->integer('otp_length')->setNotnull(false)->setDefault(6); + // seconds + $table->integer('otp_lifetime')->setNotnull(false); + }); + } + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema): void + { + $builder = new Builder($schema); + + if ($builder->hasTable("oauth2_otp")) { + $builder->drop("oauth2_otp"); + } + + if ($builder->hasTable("oauth2_client") && $builder->hasColumn("oauth2_client","otp_enabled")) { + $builder->table('oauth2_client', function (Table $table) { + // + $table->dropColumn('otp_enabled'); + // characters + $table->dropColumn('otp_length'); + // seconds + $table->dropColumn('otp_lifetime'); + }); + } + } +} diff --git a/database/migrations/Version20210616123841.php b/database/migrations/Version20210616123841.php new file mode 100644 index 00000000..fd84a92b --- /dev/null +++ b/database/migrations/Version20210616123841.php @@ -0,0 +1,53 @@ +addSql($sql); + + $sql = <<addSql($sql); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema): void + { + + } +} diff --git a/readme.md b/readme.md index e8496ea4..782d0282 100644 --- a/readme.md +++ b/readme.md @@ -33,6 +33,10 @@ chmod 777 vendor/ezyang/htmlpurifier/library/HTMLPurifier/DefinitionCache/Serial Laravel may require some permissions to be configured: folders within storage and vendor require write access by the web server. +## validate schema + +php artisan doctrine:schema:validate + ## create schema php artisan doctrine:schema:create --sql --em=model > model.sql diff --git a/resources/views/emails/oauth2_passwordless_otp.blade.php b/resources/views/emails/oauth2_passwordless_otp.blade.php new file mode 100644 index 00000000..a7ccd4ce --- /dev/null +++ b/resources/views/emails/oauth2_passwordless_otp.blade.php @@ -0,0 +1,14 @@ + + + + + + +

Dear User

+

Here is your Verification code {{$otp}}.

+

Should be valid for {{$lifetime}} minutes.

+
+
+

Cheers,
Your {!! Config::get('app.tenant_name') !!} Support Team

+ +findAll(); + + + self::$client = $clients[0]; + + self::$client->enablePasswordless(); + self::$client->setOtpLifetime(60 * 3); + self::$client->setOtpLength(6); + self::$client->setTokenEndpointAuthMethod(OAuth2Protocol::TokenEndpoint_AuthMethod_ClientSecretBasic); + EntityManager::persist(self::$client); + } + + protected function tearDown():void + { + parent::tearDown(); + } + + /** + * @var string + */ + private $current_realm; + + protected function prepareForTests() + { + parent::prepareForTests(); + App::singleton(UtilsServiceCatalog::ServerConfigurationService, StubServerConfigurationService::class); + $this->current_realm = Config::get('app.url'); + Session::start(); + } + + public function testCodeEmailFlowErrorScopes(){ + $scope = sprintf('%s profile email', + OAuth2Protocol::OpenIdConnect_Scope, + ); + + $params = [ + 'client_id' => self::$client->getClientId(), + 'scope' => $scope, + OAuth2Protocol::OAuth2Protocol_Nonce => '123456', + OAuth2Protocol::OAuth2Protocol_ResponseType => OAuth2Protocol::OAuth2Protocol_ResponseType_OTP, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::OAuth2PasswordlessSendCode, + OAuth2Protocol::OAuth2PasswordlessEmail => "test@test.com", + ]; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", + $params, + [], + [], + []); + + $this->assertResponseStatus(200); + $otp = null; + Mail::assertNotQueued(OAuth2PasswordlessOTPMail::class, function(OAuth2PasswordlessOTPMail $email) use(&$otp){ + $otp = $email->otp; + }); + + $this->assertEquals('application/json;charset=UTF-8', $response->headers->get('Content-Type')); + + $content = $response->getContent(); + + $otp_response = json_decode($content); + + $this->assertTrue($otp_response->scope == $scope); + + // ask for wider scopes + $scope = sprintf('%s profile email address', + OAuth2Protocol::OpenIdConnect_Scope, + ); + + // exchange + $params = [ + 'grant_type' => OAuth2Protocol::OAuth2Protocol_GrantType_Passwordless, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessEmail =>"test@test.com", + OAuth2Protocol::OAuth2Protocol_ResponseType_OTP => $otp, + OAuth2Protocol::OAuth2Protocol_Scope => $scope + ]; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@token", + $params, + [], + [], + [], + // Symfony interally prefixes headers with "HTTP", so + array("HTTP_Authorization" => " Basic " . base64_encode(self::$client->getClientId() . ':' . self::$client->getClientSecret()))); + + $this->assertResponseStatus(400); + $content = $response->getContent(); + $response = json_decode($content); + $this->assertTrue(!empty($response->error)); + } + + public function testCodeEmailFlowError(){ + $scope = sprintf('%s profile email', + OAuth2Protocol::OpenIdConnect_Scope, + ); + + $params = [ + 'client_id' => self::$client->getClientId(), + 'scope' => $scope, + OAuth2Protocol::OAuth2Protocol_Nonce => '123456', + OAuth2Protocol::OAuth2Protocol_ResponseType => OAuth2Protocol::OAuth2Protocol_ResponseType_OTP, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::OAuth2PasswordlessSendLink, + OAuth2Protocol::OAuth2PasswordlessEmail => "test@test.com", + ]; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", + $params, + [], + [], + []); + + $this->assertResponseStatus(400); + } + + public function testCodeEmailFlowNoRefreshToken(){ + $scope = sprintf('%s profile email address', + OAuth2Protocol::OpenIdConnect_Scope, + ); + + $params = [ + 'client_id' => self::$client->getClientId(), + 'scope' => $scope, + OAuth2Protocol::OAuth2Protocol_Nonce => '123456', + OAuth2Protocol::OAuth2Protocol_ResponseType => OAuth2Protocol::OAuth2Protocol_ResponseType_OTP, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::OAuth2PasswordlessSendCode, + OAuth2Protocol::OAuth2PasswordlessEmail => "test@test.com", + ]; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", + $params, + [], + [], + []); + + $this->assertResponseStatus(200); + $otp = null; + Mail::assertNotQueued(OAuth2PasswordlessOTPMail::class, function(OAuth2PasswordlessOTPMail $email) use(&$otp){ + $otp = $email->otp; + }); + + $this->assertEquals('application/json;charset=UTF-8', $response->headers->get('Content-Type')); + + $content = $response->getContent(); + + $otp_response = json_decode($content); + + $this->assertTrue($otp_response->scope == $scope); + + // exchange + $params = [ + 'grant_type' => OAuth2Protocol::OAuth2Protocol_GrantType_Passwordless, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessEmail =>"test@test.com", + OAuth2Protocol::OAuth2Protocol_ResponseType_OTP => $otp, + OAuth2Protocol::OAuth2Protocol_Scope => $scope + ]; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@token", + $params, + [], + [], + [], + // Symfony interally prefixes headers with "HTTP", so + array("HTTP_Authorization" => " Basic " . base64_encode(self::$client->getClientId() . ':' . self::$client->getClientSecret()))); + + $this->assertResponseStatus(200); + + $this->assertEquals('application/json;charset=UTF-8', $response->headers->get('Content-Type')); + + $content = $response->getContent(); + + $response = json_decode($content); + $access_token = $response->access_token; + $id_token = $response->id_token; + + $this->assertTrue(!empty($access_token)); + $this->assertTrue(!property_exists($response, "refresh_token")); + $this->assertTrue(!empty($id_token)); + + } + + public function testCodeEmailFlowConsecutiveOTP(){ + $scope = sprintf('%s profile email address', + OAuth2Protocol::OpenIdConnect_Scope, + ); + + $params = [ + 'client_id' => self::$client->getClientId(), + 'scope' => $scope, + OAuth2Protocol::OAuth2Protocol_Nonce => '123456', + OAuth2Protocol::OAuth2Protocol_ResponseType => OAuth2Protocol::OAuth2Protocol_ResponseType_OTP, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::OAuth2PasswordlessSendCode, + OAuth2Protocol::OAuth2PasswordlessEmail => "test@test.com", + ]; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", + $params, + [], + [], + []); + + $this->assertResponseStatus(200); + $otp1 = null; + Mail::assertNotQueued(OAuth2PasswordlessOTPMail::class, function(OAuth2PasswordlessOTPMail $email) use(&$otp1){ + $otp1 = $email->otp; + }); + + $this->assertEquals('application/json;charset=UTF-8', $response->headers->get('Content-Type')); + + $content = $response->getContent(); + + $otp_response = json_decode($content); + + $this->assertTrue($otp_response->scope == $scope); + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", + $params, + [], + [], + []); + + $this->assertResponseStatus(200); + $otp2 = null; + Mail::assertNotQueued(OAuth2PasswordlessOTPMail::class, function(OAuth2PasswordlessOTPMail $email) use(&$otp2){ + $otp2 = $email->otp; + }); + + $this->assertEquals('application/json;charset=UTF-8', $response->headers->get('Content-Type')); + + $content = $response->getContent(); + + $otp_response = json_decode($content); + + $this->assertTrue($otp_response->scope == $scope); + + $repository = EntityManager::getRepository(OAuth2OTP::class); + + $otp1 = $repository->getByValue($otp1); + $this->assertTrue(is_null($otp1)); + $otp2 = $repository->getByValue($otp2); + $this->assertTrue(!is_null($otp2)); + } + + public function testCodeEmailFlowNarrowScopes(){ + $scope = sprintf('%s profile email address', + OAuth2Protocol::OpenIdConnect_Scope, + ); + + $params = [ + 'client_id' => self::$client->getClientId(), + 'scope' => $scope, + OAuth2Protocol::OAuth2Protocol_Nonce => '123456', + OAuth2Protocol::OAuth2Protocol_ResponseType => OAuth2Protocol::OAuth2Protocol_ResponseType_OTP, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::OAuth2PasswordlessSendCode, + OAuth2Protocol::OAuth2PasswordlessEmail => "test@test.com", + ]; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", + $params, + [], + [], + []); + + $this->assertResponseStatus(200); + $otp = null; + Mail::assertNotQueued(OAuth2PasswordlessOTPMail::class, function(OAuth2PasswordlessOTPMail $email) use(&$otp){ + $otp = $email->otp; + }); + + $this->assertEquals('application/json;charset=UTF-8', $response->headers->get('Content-Type')); + + $content = $response->getContent(); + + $otp_response = json_decode($content); + + $this->assertTrue($otp_response->scope == $scope); + + // ask for wider scopes + $scope = sprintf('%s profile email', + OAuth2Protocol::OpenIdConnect_Scope, + ); + + // exchange + $params = [ + 'grant_type' => OAuth2Protocol::OAuth2Protocol_GrantType_Passwordless, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessEmail =>"test@test.com", + OAuth2Protocol::OAuth2Protocol_ResponseType_OTP => $otp, + OAuth2Protocol::OAuth2Protocol_Scope => $scope + ]; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@token", + $params, + [], + [], + [], + // Symfony interally prefixes headers with "HTTP", so + array("HTTP_Authorization" => " Basic " . base64_encode(self::$client->getClientId() . ':' . self::$client->getClientSecret()))); + + $this->assertResponseStatus(200); + $content = $response->getContent(); + $response = json_decode($content); + $this->assertTrue(!empty($response->id_token)); + } + + public function testCodeEmailFlow() { + + $scope = sprintf('%s profile email address %s', + OAuth2Protocol::OpenIdConnect_Scope, + OAuth2Protocol::OfflineAccess_Scope + ); + + $params = [ + 'client_id' => self::$client->getClientId(), + 'scope' => $scope, + OAuth2Protocol::OAuth2Protocol_Nonce => '123456', + OAuth2Protocol::OAuth2Protocol_ResponseType => OAuth2Protocol::OAuth2Protocol_ResponseType_OTP, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::OAuth2PasswordlessSendCode, + OAuth2Protocol::OAuth2PasswordlessEmail => "test@test.com", + ]; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", + $params, + [], + [], + []); + + $this->assertResponseStatus(200); + $otp = null; + Mail::assertNotQueued(OAuth2PasswordlessOTPMail::class, function(OAuth2PasswordlessOTPMail $email) use(&$otp){ + $otp = $email->otp; + }); + + $this->assertEquals('application/json;charset=UTF-8', $response->headers->get('Content-Type')); + + $content = $response->getContent(); + + $otp_response = json_decode($content); + + $this->assertTrue($otp_response->scope == $scope); + + // exchange + $params = [ + 'grant_type' => OAuth2Protocol::OAuth2Protocol_GrantType_Passwordless, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessEmail =>"test@test.com", + OAuth2Protocol::OAuth2Protocol_ResponseType_OTP => $otp, + OAuth2Protocol::OAuth2Protocol_Scope => $scope + ]; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@token", + $params, + [], + [], + [], + // Symfony interally prefixes headers with "HTTP", so + array("HTTP_Authorization" => " Basic " . base64_encode(self::$client->getClientId() . ':' . self::$client->getClientSecret()))); + + $this->assertResponseStatus(200); + + $this->assertEquals('application/json;charset=UTF-8', $response->headers->get('Content-Type')); + + $content = $response->getContent(); + + $response = json_decode($content); + $access_token = $response->access_token; + $refresh_token = $response->refresh_token; + $id_token = $response->id_token; + + $this->assertTrue(!empty($access_token)); + $this->assertTrue(!empty($refresh_token)); + $this->assertTrue(!empty($id_token)); + + $jwt = BasicJWTFactory::build($id_token); + $use_enc = false; + if ($use_enc) { + $this->assertTrue($jwt instanceof IJWE); + + $recipient_key = RSAJWKFactory::build + ( + new RSAJWKPEMPrivateKeySpecification + ( + TestSeeder::$client_private_key_1, + RSAJWKPEMPrivateKeySpecification::WithoutPassword, + $jwt->getJOSEHeader()->getAlgorithm()->getString() + ) + ); + + $recipient_key->setKeyUse(JSONWebKeyPublicKeyUseValues::Encryption)->setId('recipient_public_key'); + + $jwt->setRecipientKey($recipient_key); + + $payload = $jwt->getPlainText(); + + $jwt = BasicJWTFactory::build($payload); + + $this->assertTrue($jwt instanceof IJWS); + } + + return $access_token; + } + + public function testInvalidRedeemCodeEmailFlow() { + + $scope = sprintf('%s profile email address %s', + OAuth2Protocol::OpenIdConnect_Scope, + OAuth2Protocol::OfflineAccess_Scope + ); + + $params = [ + 'client_id' => self::$client->getClientId(), + 'scope' => $scope, + OAuth2Protocol::OAuth2Protocol_Nonce => '123456', + OAuth2Protocol::OAuth2Protocol_ResponseType => OAuth2Protocol::OAuth2Protocol_ResponseType_OTP, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::OAuth2PasswordlessSendCode, + OAuth2Protocol::OAuth2PasswordlessEmail => "test@test.com", + ]; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@auth", + $params, + [], + [], + []); + + $this->assertResponseStatus(200); + $otp = null; + Mail::assertNotQueued(OAuth2PasswordlessOTPMail::class, function(OAuth2PasswordlessOTPMail $email) use(&$otp){ + $otp = $email->otp; + }); + + $this->assertEquals('application/json;charset=UTF-8', $response->headers->get('Content-Type')); + + $content = $response->getContent(); + + $otp_response = json_decode($content); + + $this->assertTrue($otp_response->scope == $scope); + + // exchange + $params = [ + 'grant_type' => OAuth2Protocol::OAuth2Protocol_GrantType_Passwordless, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessEmail =>"test@test.com", + OAuth2Protocol::OAuth2Protocol_ResponseType_OTP => $otp.'1', + OAuth2Protocol::OAuth2Protocol_Scope => $scope + ]; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@token", + $params, + [], + [], + [], + // Symfony interally prefixes headers with "HTTP", so + array("HTTP_Authorization" => " Basic " . base64_encode(self::$client->getClientId() . ':' . self::$client->getClientSecret()))); + + $this->assertResponseStatus(400); + + // exchange + $params = [ + 'grant_type' => OAuth2Protocol::OAuth2Protocol_GrantType_Passwordless, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessEmail =>"test@test.com", + OAuth2Protocol::OAuth2Protocol_ResponseType_OTP => $otp.'2', + OAuth2Protocol::OAuth2Protocol_Scope => $scope + ]; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@token", + $params, + [], + [], + [], + // Symfony interally prefixes headers with "HTTP", so + array("HTTP_Authorization" => " Basic " . base64_encode(self::$client->getClientId() . ':' . self::$client->getClientSecret()))); + + $this->assertResponseStatus(400); + + // exchange + $params = [ + 'grant_type' => OAuth2Protocol::OAuth2Protocol_GrantType_Passwordless, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessEmail =>"test@test.com", + OAuth2Protocol::OAuth2Protocol_ResponseType_OTP => $otp.'3', + OAuth2Protocol::OAuth2Protocol_Scope => $scope + ]; + + $response = $this->action("POST", "OAuth2\OAuth2ProviderController@token", + $params, + [], + [], + [], + // Symfony interally prefixes headers with "HTTP", so + array("HTTP_Authorization" => " Basic " . base64_encode(self::$client->getClientId() . ':' . self::$client->getClientSecret()))); + + $this->assertResponseStatus(400); + + $repository = EntityManager::getRepository(OAuth2OTP::class); + + $otp = $repository->getByValue($otp); + $this->assertTrue(!is_null($otp)); + $this->assertTrue(!$otp->isValid()); + } + +} \ No newline at end of file diff --git a/tests/OIDCProtocolTest.php b/tests/OIDCProtocolTest.php index 0fdfd3b6..76ccb56f 100644 --- a/tests/OIDCProtocolTest.php +++ b/tests/OIDCProtocolTest.php @@ -1,4 +1,4 @@ -current_realm = Config::get('app.url'); Session::start(); } diff --git a/tests/OTPModelTest.php b/tests/OTPModelTest.php new file mode 100644 index 00000000..113ca264 --- /dev/null +++ b/tests/OTPModelTest.php @@ -0,0 +1,101 @@ +findAll(); + + $this->assertTrue(count($clients) > 0); + + $client = $clients[0]; + + if(!$client instanceof Client) return; + + $client->enablePasswordless(); + $client->setOtpLifetime(60 * 3); + $client->setOtpLength(6); + + EntityManager::persist($client); + $values = + [ + OAuth2Protocol::OAuth2Protocol_ClientId => $client->getClientId(), + OAuth2Protocol::OAuth2Protocol_ResponseType => OAuth2Protocol::OAuth2Protocol_ResponseType_OTP, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::OAuth2PasswordlessSendCode, + OAuth2Protocol::OAuth2PasswordlessEmail => "test@test.com", + OAuth2Protocol::OAuth2Protocol_Scope => "test_scope" + ]; + + $request = OAuth2AuthorizationRequestFactory::getInstance()->build + ( + new OAuth2Message($values) + ); + + $this->assertTrue($request->isValid()); + $otp = OTPFactory::buildFromRequest($request, App::make(IdentifierGenerator::class), $client); + EntityManager::persist($client); + EntityManager::flush(); + $this->assertTrue($client->getOTPGrantsByEmailNotRedeemed("test@test.com")->count() > 0); + $this->assertTrue(strlen($otp->getValue()) == $client->getOtpLength()); + } + + public function testCreateFromPayloadNoClient(){ + $payload = + [ + OAuth2Protocol::OAuth2Protocol_ResponseType => OAuth2Protocol::OAuth2Protocol_ResponseType_OTP, + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::OAuth2PasswordlessSendCode, + OAuth2Protocol::OAuth2PasswordlessEmail => "test@test.com", + OAuth2Protocol::OAuth2Protocol_Scope => "test_scope" + ]; + + $otp = OTPFactory::buildFromPayload($payload, App::make(IdentifierGenerator::class)); + + EntityManager::persist($otp); + EntityManager::flush(); + $this->assertTrue($otp->getId() > 0); + } +} \ No newline at end of file diff --git a/tests/OpenIdProtocolTest.php b/tests/OpenIdProtocolTest.php index 844625b9..cb06ea5a 100644 --- a/tests/OpenIdProtocolTest.php +++ b/tests/OpenIdProtocolTest.php @@ -1,4 +1,4 @@ -