diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index 4f79b4de..3975db17 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -385,7 +385,7 @@ class AdminController extends Controller { if(is_null($endpoint)) return Response::view('errors.404', [], 404); $user = $this->auth_service->getCurrentUser(); $selected_scopes = []; - $list = $endpoint->getScopes(); + $list = $endpoint->getScope(); foreach($list as $selected_scope){ $selected_scopes[] = $selected_scope->getId(); } diff --git a/app/Http/Controllers/Api/ClientApiController.php b/app/Http/Controllers/Api/ClientApiController.php index 45182d10..e21c0d81 100644 --- a/app/Http/Controllers/Api/ClientApiController.php +++ b/app/Http/Controllers/Api/ClientApiController.php @@ -675,6 +675,10 @@ final class ClientApiController extends APICRUDController 'id_token_encrypted_response_alg' => 'sometimes|required|encrypted_alg', 'id_token_encrypted_response_enc' => 'sometimes|required|encrypted_enc', 'admin_users' => 'nullable|int_array', + 'pkce_enabled' => 'sometimes|boolean', + 'otp_enabled' => 'sometimes|boolean', + 'otp_length' => 'sometimes|integer|min:4|max:8', + 'otp_lifetime' => 'sometimes|integer|min:60|max:600', ]; } diff --git a/app/Http/Controllers/OAuth2/OAuth2ProviderController.php b/app/Http/Controllers/OAuth2/OAuth2ProviderController.php index 8c9fd3fb..58f842b7 100644 --- a/app/Http/Controllers/OAuth2/OAuth2ProviderController.php +++ b/app/Http/Controllers/OAuth2/OAuth2ProviderController.php @@ -101,24 +101,32 @@ final class OAuth2ProviderController extends Controller return $response; } catch (OAuth2BaseException $ex1) { + $payload = [ + 'error' => $ex1->getError(), + 'error_description' => $ex1->getMessage() + ]; + if (request()->isJson()) { + return Response::json($payload, 400); + } return Response::view ( 'errors.400', - [ - 'error' => $ex1->getError(), - 'error_description' => $ex1->getMessage() - ], + $payload, 400 ); } catch (Exception $ex) { Log::error($ex); + $payload = [ + 'error' => "Bad Request", + 'error_description' => "Generic Error" + ]; + if (request()->isJson()) { + return Response::json($payload, 400); + } return Response::view ( 'errors.400', - [ - 'error' => "Bad Request", - 'error_description' => "Generic Error" - ], + $payload, 400 ); } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 0fc158ca..7f0f1d80 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -28,6 +28,10 @@ use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\View; use models\exceptions\EntityNotFoundException; use models\exceptions\ValidationException; +use Models\OAuth2\OAuth2OTP; +use OAuth2\Factories\OAuth2AuthorizationRequestFactory; +use OAuth2\OAuth2Message; +use OAuth2\OAuth2Protocol; use OAuth2\Repositories\IApiScopeRepository; use OAuth2\Repositories\IClientRepository; use OpenId\Services\IUserService; @@ -170,7 +174,9 @@ final class UserController extends OpenIdController $this->security_context_service = $security_context_service; $this->middleware(function ($request, $next) { - Log::debug(sprintf("UserController::middleware")); + + Log::debug(sprintf("UserController::middleware route %s %s", $request->getMethod(), $request->getRequestUri())); + if ($this->openid_memento_service->exists()) { //openid stuff Log::debug(sprintf("UserController::middleware OIDC")); @@ -232,16 +238,17 @@ final class UserController extends OpenIdController /** * @return \Illuminate\Http\JsonResponse|mixed */ - public function getAccount(){ + public function getAccount() + { try { $email = Request::input("email", ""); - if(empty($email)){ + if (empty($email)) { throw new ValidationException("empty email."); } $user = $this->auth_service->getUserByUsername($email); - if(is_null($user)) + if (is_null($user)) throw new EntityNotFoundException(); return $this->ok( @@ -250,16 +257,83 @@ final class UserController extends OpenIdController 'full_name' => $user->getFullName() ] ); - } - catch (ValidationException $ex){ + } catch (ValidationException $ex) { Log::warning($ex); return $this->error412($ex->getMessages()); - } - catch (EntityNotFoundException $ex){ + } catch (EntityNotFoundException $ex) { Log::warning($ex); return $this->error404(); + } catch (Exception $ex) { + Log::error($ex); + return $this->error500($ex); } - catch (Exception $ex){ + } + + /** + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function emitOTP() + { + try { + + $username = Request::input("username", ""); + $connection = Request::input("connection", ""); + $send = Request::input("send", ""); + + if (empty($username)) { + throw new ValidationException("empty username."); + } + + if (empty($connection)) { + throw new ValidationException("empty username."); + } + + if (empty($send)) { + throw new ValidationException("empty username."); + } + + $client = null; + + // check if we have a former oauth2 request + if ($this->oauth2_memento_service->exists()) { + + Log::debug("UserController::getOTP exist a oauth auth request on session"); + + $oauth_auth_request = OAuth2AuthorizationRequestFactory::getInstance()->build + ( + OAuth2Message::buildFromMemento($this->oauth2_memento_service->load()) + ); + + if ($oauth_auth_request->isValid()) { + + $client_id = $oauth_auth_request->getClientId(); + + $client = $this->client_repository->getClientById($client_id); + if (is_null($client)) + throw new ValidationException("client does not exists"); + + $this->oauth2_memento_service->serialize($oauth_auth_request->getMessage()->createMemento()); + } + } + + $otp = $this->token_service->createOTPFromPayload([ + OAuth2Protocol::OAuth2PasswordlessConnection => $connection, + OAuth2Protocol::OAuth2PasswordlessSend => $send, + OAuth2Protocol::OAuth2PasswordlessEmail => ($connection == OAuth2Protocol::OAuth2PasswordlessConnectionEmail) ? $username : null, + OAuth2Protocol::OAuth2PasswordlessPhoneNumber => ($connection == OAuth2Protocol::OAuth2PasswordlessConnectionSMS) ? $username : null + ], $client); + + return $this->created([ + 'otp_length' => $otp->getLength(), + 'otp_lifetime' => $otp->getLifetime(), + ]); + } catch (ValidationException $ex) { + Log::warning($ex); + return $this->error412($ex->getMessages()); + } catch (EntityNotFoundException $ex) { + Log::warning($ex); + return $this->error404(); + } catch (Exception $ex) { Log::error($ex); return $this->error500($ex); } @@ -271,6 +345,7 @@ final class UserController extends OpenIdController $login_attempts = 0; $username = ''; $user = null; + try { @@ -279,7 +354,7 @@ final class UserController extends OpenIdController if (isset($data['username'])) $data['username'] = trim($data['username']); - if(isset($data['password'])) + if (isset($data['password'])) $data['password'] = trim($data['password']); $login_attempts = intval(Request::input('login_attempts')); @@ -287,52 +362,90 @@ final class UserController extends OpenIdController $rules = [ 'username' => 'required|email', 'password' => 'required', + 'flow' => 'required|in:otp,password', + 'connection' => 'sometimes|string|in:sms,email', ]; - if ($login_attempts >= $max_login_attempts_2_show_captcha) - { + if ($login_attempts >= $max_login_attempts_2_show_captcha) { $rules['g-recaptcha-response'] = 'required|recaptcha'; } // Create a new validator instance. $validator = Validator::make($data, $rules); if ($validator->passes()) { + $username = $data['username']; $password = $data['password']; + $flow = $data['flow']; $remember = Request::input("remember"); $remember = !is_null($remember); + $connection = $data['connection'] ?? null; - if ($this->auth_service->login($username, $password, $remember)) - { - return $this->login_strategy->postLogin(); + try { + if ($flow == "password" && $this->auth_service->login($username, $password, $remember)) { + return $this->login_strategy->postLogin(); + } + + if ($flow == "otp") { + + $client = null; + + // check if we have a former oauth2 request + if ($this->oauth2_memento_service->exists()) { + + Log::debug("UserController::getOTP exist a oauth auth request on session"); + + $oauth_auth_request = OAuth2AuthorizationRequestFactory::getInstance()->build + ( + OAuth2Message::buildFromMemento($this->oauth2_memento_service->load()) + ); + + if ($oauth_auth_request->isValid()) { + + $client_id = $oauth_auth_request->getClientId(); + + $client = $this->client_repository->getClientById($client_id); + if (is_null($client)) + throw new ValidationException("client does not exists"); + + $this->oauth2_memento_service->serialize($oauth_auth_request->getMessage()->createMemento()); + } + } + + $otpClaim = OAuth2OTP::fromParams($username, $connection, $password); + $this->auth_service->loginWithOTP($otpClaim, $client); + return $this->login_strategy->postLogin(); + } + } catch (AuthenticationException $ex) { + // failed login attempt... + + $user = $this->auth_service->getUserByUsername($username); + if (!is_null($user)) { + $login_attempts = $user->getLoginFailedAttempt(); + } + + return $this->login_strategy->errorLogin + ( + [ + 'max_login_attempts_2_show_captcha' => $max_login_attempts_2_show_captcha, + 'login_attempts' => $login_attempts, + 'error_message' => $ex->getMessage(), + 'user_fullname' => !is_null($user) ? $user->getFullName() : "", + 'user_pic' => !is_null($user) ? $user->getPic(): "", + 'user_verified' => true, + 'username' => $username, + 'flow' => $flow + ] + ); } - //failed login attempt... - $user = $this->auth_service->getUserByUsername($username); - - if (!is_null($user)) { - $login_attempts = $user->getLoginFailedAttempt(); - } - - return $this->login_strategy->errorLogin - ( - [ - 'max_login_attempts_2_show_captcha' => $max_login_attempts_2_show_captcha, - 'login_attempts' => $login_attempts, - 'error_message' => "We are sorry, your username or password does not match an existing record.", - 'user_fullname' => $user->getFullName(), - 'user_pic' => $user->getPic(), - 'user_verified' => true, - 'username' => $username, - ] - ); } // validator errors $response_data = [ 'max_login_attempts_2_show_captcha' => $max_login_attempts_2_show_captcha, 'login_attempts' => $login_attempts, - 'validator' => $validator + 'validator' => $validator, ]; if(!is_null($user)){ @@ -376,9 +489,14 @@ final class UserController extends OpenIdController } } + /** + * @return \Illuminate\Http\Response|mixed + */ public function getConsent() { if (is_null($this->consent_strategy)) { + + Log::error(sprintf("UserController::getConsent consent strategy is null. request %s %s", Request::method(), Request::path())); return Response::view ( 'errors.400', @@ -389,10 +507,12 @@ final class UserController extends OpenIdController 400 ); } - return $this->consent_strategy->getConsent(); } + /** + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|mixed + */ public function postConsent() { try { @@ -405,7 +525,9 @@ final class UserController extends OpenIdController $validator = Validator::make($data, $rules); if ($validator->passes()) { if (is_null($this->consent_strategy)) { - Log::warning(sprintf("UserController::postConsent consent strategy is null")); + + Log::error(sprintf("UserController::postConsent consent strategy is null. request %s %s", Request::method(), Request::path())); + return Response::view ( 'errors.400', @@ -463,7 +585,6 @@ final class UserController extends OpenIdController $pic_url = str_contains($pic_url, 'http') ? $pic_url : $assets_url . $pic_url; $params = [ - 'show_fullname' => $user->getShowProfileFullName(), 'username' => $user->getFullName(), 'show_email' => $user->getShowProfileEmail(), diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index a23a8718..93da0db1 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -67,7 +67,7 @@ class Kernel extends HttpKernel 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'ssl' => \App\Http\Middleware\SSLMiddleware::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, - 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequestsWithRedis::class, 'csrf' => \App\Http\Middleware\VerifyCsrfToken::class, 'oauth2.endpoint' => \App\Http\Middleware\OAuth2BearerAccessTokenRequestValidator::class, 'oauth2.currentuser.serveradmin' => \App\Http\Middleware\CurrentUserIsOAuth2ServerAdmin::class, diff --git a/app/Mail/OAuth2PasswordlessOTPMail.php b/app/Mail/OAuth2PasswordlessOTPMail.php new file mode 100644 index 00000000..17ba34a9 --- /dev/null +++ b/app/Mail/OAuth2PasswordlessOTPMail.php @@ -0,0 +1,83 @@ +email = $to; + $this->otp = $otp; + $this->lifetime = $lifetime / 60; + } + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $this->subject = sprintf("[%s] Your Verification Code", Config::get('app.app_name')); + Log::debug(sprintf("OAuth2PasswordlessOTPMail::build to %s", $this->email)); + return $this->from(Config::get("mail.from")) + ->to($this->email) + ->subject($this->subject) + ->view('emails.oauth2_passwordless_otp'); + } +} \ No newline at end of file diff --git a/app/Mail/WelcomeNewUserEmail.php b/app/Mail/WelcomeNewUserEmail.php index b8c6d551..ff75d822 100644 --- a/app/Mail/WelcomeNewUserEmail.php +++ b/app/Mail/WelcomeNewUserEmail.php @@ -71,13 +71,13 @@ final class WelcomeNewUserEmail extends Mailable public function build() { - $subject = sprintf("%s email verification needed", Config::get('app.app_name')); + $subject = sprintf("Thank you for registering for an %s account", Config::get('app.app_name')); $view = 'emails.welcome_new_user_email'; if(Config::get("app.tenant_name") == 'FNTECH') { $view = 'emails.welcome_new_user_email_fn'; - $subject = sprintf("Thank you for registering for an %s account", Config::get('app.app_name')); } + Log::debug(sprintf("WelcomeNewUserEmail::build to %s", $this->user_email)); return $this->from(Config::get("mail.from")) ->to($this->user_email) diff --git a/app/Models/OAuth2/Client.php b/app/Models/OAuth2/Client.php index 0b5571f0..15d4b751 100644 --- a/app/Models/OAuth2/Client.php +++ b/app/Models/OAuth2/Client.php @@ -15,6 +15,7 @@ use App\libs\Utils\URLUtils; use Auth\User; use Doctrine\Common\Collections\Criteria; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; use jwa\cryptographic_algorithms\ContentEncryptionAlgorithms_Registry; use jwa\cryptographic_algorithms\DigitalSignatures_MACs_Registry; @@ -90,6 +91,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 +362,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 +383,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 +399,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 +425,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 = [ @@ -443,7 +470,9 @@ class Client extends BaseEntity implements IClient $this->getApplicationType() == IClient::ApplicationType_Native || $this->getApplicationType() == IClient::ApplicationType_Web_App || // PCKE - $this->pkce_enabled; + $this->pkce_enabled || + // Passwordless + $this->otp_enabled; } /** @@ -1603,4 +1632,107 @@ 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->otp_length = intval(Config::get("otp.length")); + $this->otp_lifetime = intval(Config::get("otp.lifetime")); + } + + public function disablePasswordless(): void + { + $this->otp_enabled = false; + } + + /** + * @return int + */ + public function getOtpLength(): int + { + $res = $this->otp_length; + if(is_null($res)){ + $res = intval(Config::get("otp.length")); + } + return $res; + } + + /** + * @param int $otp_length + */ + public function setOtpLength(int $otp_length): void + { + $this->otp_length = $otp_length; + } + + /** + * @return int + */ + public function getOtpLifetime(): int + { + $res = $this->otp_lifetime; + + if(is_null($res)){ + $res = intval(Config::get("otp.lifetime")); + } + return $res; + } + + /** + * @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/ClientFactory.php b/app/Models/OAuth2/Factories/ClientFactory.php index 9a54e2e0..ebac3df6 100644 --- a/app/Models/OAuth2/Factories/ClientFactory.php +++ b/app/Models/OAuth2/Factories/ClientFactory.php @@ -14,6 +14,7 @@ use App\libs\Utils\URLUtils; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Log; use Models\OAuth2\Client; use Models\OAuth2\ResourceServer; use OAuth2\Models\IClient; @@ -199,14 +200,32 @@ final class ClientFactory $client->disablePCKE(); } + if(isset($payload['otp_enabled'])) { + $otp_enabled = boolval($payload['otp_enabled']); + if($otp_enabled) + $client->enablePasswordless(); + else + $client->disablePasswordless(); + } + + if(isset($payload['otp_length'])){ + $client->setOtpLength(intval($payload['otp_length'])); + } + + if(isset($payload['otp_lifetime'])){ + $client->setOtpLifetime(intval($payload['otp_lifetime'])); + } + $scope_repository = App::make(IApiScopeRepository::class); //add default scopes foreach ($scope_repository->getDefaults() as $default_scope) { + Log::debug(sprintf("ClientFactory::populate processing scope %s", $default_scope->getName())); if ( $default_scope->getName() === OAuth2Protocol::OfflineAccess_Scope && !$client->canRequestRefreshTokens() ) { + Log::debug(sprintf("ClientFactory::populate skipping scope %s", $default_scope->getName())); continue; } $client->addScope($default_scope); diff --git a/app/Models/OAuth2/Factories/OTPFactory.php b/app/Models/OAuth2/Factories/OTPFactory.php new file mode 100644 index 00000000..6c962518 --- /dev/null +++ b/app/Models/OAuth2/Factories/OTPFactory.php @@ -0,0 +1,117 @@ +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->setScope($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 + * @param Client|null $client + * @return OAuth2OTP + */ + static public function buildFromPayload + ( + array $payload, + IdentifierGenerator $identifier_generator, + ?Client $client = null + ):OAuth2OTP{ + + $lifetime = Config::get("otp.lifetime", 120); + $length = Config::get("otp.length",6); + + if(!is_null($client)){ + $lifetime = $client->getOtpLifetime(); + $length = $client->getOtpLength(); + } + + $otp = new OAuth2OTP($length, $lifetime); + + $otp->setConnection($payload[OAuth2Protocol::OAuth2PasswordlessConnection]); + $otp->setSend($payload[OAuth2Protocol::OAuth2PasswordlessSend]); + $otp->setScope($payload[OAuth2Protocol::OAuth2Protocol_Scope] ?? null); + $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); + + 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; + } +} \ 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..da2dc7f9 --- /dev/null +++ b/app/Models/OAuth2/OAuth2OTP.php @@ -0,0 +1,467 @@ +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 getScope(): ?string + { + return $this->scope; + } + + /** + * @param string $scope + */ + public function setScope(?string $scope): void + { + $this->scope = $scope; + } + + /** + * @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->scope); + 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->scope = $request->getScopes(); + $instance->value = $request->getOTP(); + return $instance; + } + + /** + * @param string $user_name + * @param string $connection + * @param string $value + * @return OAuth2OTP|null + */ + public static function fromParams(string $user_name, string $connection, string $value):?OAuth2OTP{ + $instance = new self(strlen($value)); + $instance->connection = $connection; + if($connection == OAuth2Protocol::OAuth2PasswordlessConnectionEmail) + $instance->email = $user_name; + if($connection == OAuth2Protocol::OAuth2PasswordlessConnectionEmail) + $instance->phone_number = $user_name; + $instance->value = $value; + 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/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 16fe78b5..b81b2ffa 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -13,7 +13,12 @@ **/ use Illuminate\Routing\Router; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Route; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; + /** * Class RouteServiceProvider * @package App\Providers @@ -38,6 +43,28 @@ final class RouteServiceProvider extends ServiceProvider public function boot() { parent::boot(); + $this->configureRateLimiting(); + } + + /** + * Configure the rate limiters for the application. + * + * @return void + */ + protected function configureRateLimiting() + { + RateLimiter::for('account', function (Request $request) { + return Limit::perMinute(5)->by(optional($request->user())->id ?: $request->ip()); + }); + + RateLimiter::for('otp', function (Request $request) { + return Limit::perMinute(10)->by(optional($request->user())->id ?: $request->ip()); + }); + + RateLimiter::for('oauth2', function (Request $request) { + $maxAttempts = App::environment() == "testing" ? PHP_INT_MAX : 50; + return Limit::perMinute($maxAttempts)->by(optional($request->user())->id ?: $request->ip()); + }); } /** diff --git a/app/Repositories/DoctrineOAuth2OTPRepository.php b/app/Repositories/DoctrineOAuth2OTPRepository.php new file mode 100644 index 00000000..0d5152cd --- /dev/null +++ b/app/Repositories/DoctrineOAuth2OTPRepository.php @@ -0,0 +1,93 @@ +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(); + } + + /** + * @param string $user_name + * @param Client|null $client + * @return OAuth2OTP[] + */ + public function getByUserNameNotRedeemed + ( + string $user_name, + ?Client $client = null + ) + { + $query = $this->getEntityManager() + ->createQueryBuilder() + ->select("e") + ->from($this->getBaseEntity(), "e") + ->andWhere("(e.email = (:user_name) or e.phone_number = (:user_name))") + ->andWhere("e.redeemed_at is null") + ->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()->getResult(); + } +} \ 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/ClientService.php b/app/Services/OAuth2/ClientService.php index bae2834c..751eed8b 100644 --- a/app/Services/OAuth2/ClientService.php +++ b/app/Services/OAuth2/ClientService.php @@ -273,7 +273,7 @@ final class ClientService extends AbstractService implements IClientService return $this->tx_service->transaction(function () use ($id, $payload) { - $editing_user = $this->auth_service->getCurrentUser(); + $editing_user = $this->auth_service->getCurrentUser(); $client = $this->client_repository->getById($id); 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..bf5e1d30 100644 --- a/app/Services/OAuth2/TokenService.php +++ b/app/Services/OAuth2/TokenService.php @@ -14,7 +14,13 @@ 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\Exceptions\AuthenticationException; use Auth\User; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; @@ -23,6 +29,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 +57,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 +76,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 +110,10 @@ final class TokenService extends AbstractService implements ITokenService * @var IClientService */ private $client_service; + /** + * @var IUserService + */ + private $user_service; /** * @var ILockManagerService */ @@ -123,15 +137,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 +193,34 @@ 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 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,146 @@ final class TokenService extends AbstractService implements ITokenService return $this->getAccessToken($db_access_token->getValue(), true); } + private function postCreateOTP(OAuth2OTP $otp,?Client $client):OAuth2OTP{ + 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 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){ + + return $this->postCreateOTP + ( + OTPFactory::buildFromRequest($request, $this->identifier_generator, $client), + $client + ); + + }); + } + + /** + * @param array $payload + * @param Client|null $client + * @return OAuth2OTP + * @throws Exception + */ + public function createOTPFromPayload(array $payload, ?Client $client):OAuth2OTP{ + return $this->tx_service->transaction(function() use($payload, $client){ + + $otp = $this->postCreateOTP + ( + OTPFactory::buildFromPayload($payload, $this->identifier_generator, $client), + $client + ); + if(is_null($client)){ + foreach($this->otp_repository->getByUserNameNotRedeemed($otp->getUserName()) as $formerOTP){ + $this->otp_repository->delete($formerOTP); + } + $this->otp_repository->add($otp); + } + return $otp; + }); + } + + + /** + * @param OAuth2OTP $otp + * @param Client|null $client + * @return AccessToken + * @throws Exception + */ + public function createAccessTokenFromOTP(OAuth2OTP $otp, ?Client $client): AccessToken + { + + try { + $otp = $this->auth_service->loginWithOTP($otp, $client); + // build current audience ... + $audience = $this->scope_service->getStrAudienceByScopeNames + ( + explode + ( + OAuth2Protocol::OAuth2Protocol_Scope_Delimiter, + $otp->getScope() + ) + ); + + $access_token = $this->identifier_generator->generate + ( + AccessToken::createFromOTP + ( + $otp, + ! is_null($client) ? $client->getClientId() : null, + $audience, + $this->configuration_service->getConfigValue('OAuth2.AccessToken.Lifetime') + ) + ); + + return $this->tx_service->transaction(function() use($access_token, $client){ + // 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($this->auth_service->getCurrentUser()); + + $this->access_token_repository->add($access_token_db); + + //check if use refresh tokens... + + if + ( + $client->useRefreshToken() && + $client->isPasswordlessEnabled() && + str_contains($access_token->getScope(), 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; + }); + } + catch (AuthenticationException $ex){ + throw new InvalidOTPException($ex->getMessage()); + } + } } \ 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/Auth/AuthService.php b/app/libs/Auth/AuthService.php index 697ed024..16afdc0c 100644 --- a/app/libs/Auth/AuthService.php +++ b/app/libs/Auth/AuthService.php @@ -11,29 +11,38 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ + use App\libs\OAuth2\Exceptions\ReloadSessionException; +use App\libs\OAuth2\Repositories\IOAuth2OTPRepository; +use App\Services\AbstractService; +use Auth\Exceptions\AuthenticationException; use Auth\Repositories\IUserRepository; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Cookie; use Illuminate\Support\Facades\Session; +use Models\OAuth2\Client; +use Models\OAuth2\OAuth2OTP; +use OAuth2\Exceptions\InvalidOTPException; use OAuth2\Models\IClient; use OAuth2\Services\IPrincipalService; -use OpenId\Models\IOpenIdUser; use OpenId\Services\IUserService; +use App\Services\Auth\IUserService as IAuthUserService; use utils\Base64UrlRepresentation; +use Utils\Db\ITransactionService; use Utils\Services\IAuthService; use Utils\Services\ICacheService; use jwe\compression_algorithms\CompressionAlgorithms_Registry; use jwe\compression_algorithms\CompressionAlgorithmsNames; use Exception; use Illuminate\Support\Facades\Log; + /** * Class AuthService * @package Auth */ -final class AuthService implements IAuthService +final class AuthService extends AbstractService implements IAuthService { /** * @var IPrincipalService @@ -53,25 +62,44 @@ final class AuthService implements IAuthService */ private $user_repository; + /** + * @var IAuthUserService + */ + private $auth_user_service; + + /** + * @var IOAuth2OTPRepository + */ + private $otp_repository; + /** * AuthService constructor. * @param IUserRepository $user_repository + * @param IOAuth2OTPRepository $otp_repository * @param IPrincipalService $principal_service * @param IUserService $user_service * @param ICacheService $cache_service + * @param IAuthUserService $auth_user_service + * @param ITransactionService $tx_service */ public function __construct ( IUserRepository $user_repository, + IOAuth2OTPRepository $otp_repository, IPrincipalService $principal_service, IUserService $user_service, - ICacheService $cache_service + ICacheService $cache_service, + IAuthUserService $auth_user_service, + ITransactionService $tx_service ) { - $this->user_repository = $user_repository; + parent::__construct($tx_service); + $this->user_repository = $user_repository; $this->principal_service = $principal_service; - $this->user_service = $user_service; - $this->cache_service = $cache_service; + $this->user_service = $user_service; + $this->cache_service = $cache_service; + $this->auth_user_service = $auth_user_service; + $this->otp_repository = $otp_repository; } /** @@ -85,7 +113,7 @@ final class AuthService implements IAuthService /** * @return User|null */ - public function getCurrentUser():?User + public function getCurrentUser(): ?User { return Auth::user(); } @@ -94,25 +122,96 @@ final class AuthService implements IAuthService * @param string $username * @param string $password * @param bool $remember_me - * @return mixed + * @return bool + * @throws AuthenticationException */ - public function login($username, $password, $remember_me) + public function login(string $username, string $password, bool $remember_me): bool { Log::debug("AuthService::login"); - $res = Auth::attempt(['username' => $username, 'password' => $password], $remember_me); - if ($res) - { - Log::debug("AuthService::login: clearing principal"); - $this->principal_service->clear(); - $this->principal_service->register - ( - $this->getCurrentUser()->getId(), - time() - ); + $this->last_login_error = ""; + if (!Auth::attempt(['username' => $username, 'password' => $password], $remember_me)) { + throw new AuthenticationException("We are sorry, your username or password does not match an existing record."); } - return $res; + Log::debug("AuthService::login: clearing principal"); + $this->principal_service->clear(); + $this->principal_service->register + ( + $this->getCurrentUser()->getId(), + time() + ); + + return true; + } + + /** + * @param OAuth2OTP $otpClaim + * @param Client|null $client + * @return OAuth2OTP|null + * @throws AuthenticationException + */ + public function loginWithOTP(OAuth2OTP $otpClaim, ?Client $client = null): ?OAuth2OTP{ + + $otp = $this->tx_service->transaction(function() use($otpClaim, $client){ + // find first db OTP by connection , by username (email/phone) number and client not redeemed + $otp = $this->otp_repository->getByConnectionAndUserNameNotRedeemed + ( + $otpClaim->getConnection(), + $otpClaim->getUserName(), + $client + ); + if(is_null($otp)){ + // otp no emitted + throw new AuthenticationException("Non existent OTP."); + } + $otp->logRedeemAttempt(); + return $otp; + }); + + return $this->tx_service->transaction(function() use($otp, $otpClaim, $client){ + + if (!$otp->isAlive()) { + throw new AuthenticationException("OTP is expired."); + } + + if (!$otp->isValid()) { + throw new AuthenticationException( "OTP is not valid."); + } + + if ($otp->getValue() != $otpClaim->getValue()) { + throw new AuthenticationException("OTP mismatch."); + } + + if(!empty($otpClaim->getScope()) && !$otp->allowScope($otpClaim->getScope())) + throw new InvalidOTPException("OTP Requested scopes escalates former scopes."); + + if (($otp->hasClient() && is_null($client)) || + ($otp->hasClient() && !is_null($client) && $client->getClientId() != $otp->getClient()->getClientId())) { + throw new AuthenticationException("OTP audience mismatch."); + } + + $user = $this->getUserByUsername($otp->getUserName()); + + if (is_null($user)) { + // we need to create a new one ( auto register) + + Log::debug(sprintf("AuthService::loginWithOTP user %s does not exists ...", $otp->getUserName())); + + $user = $this->auth_user_service->registerUser([ + 'email' => $otp->getUserName(), + 'email_verified' => true, + ]); + } + + $otp->setAuthTime(time()); + $otp->setUserId($user->getId()); + $otp->redeem(); + + Auth::login($user, false); + + return $otp; + }); } public function logout() @@ -140,8 +239,7 @@ final class AuthService implements IAuthService */ public function getUserAuthorizationResponse() { - if (Session::has("openid.authorization.response")) - { + if (Session::has("openid.authorization.response")) { $value = Session::get("openid.authorization.response"); return $value; @@ -153,8 +251,7 @@ final class AuthService implements IAuthService public function clearUserAuthorizationResponse() { - if (Session::has("openid.authorization.response")) - { + if (Session::has("openid.authorization.response")) { Session::remove("openid.authorization.response"); Session::save(); } @@ -170,7 +267,7 @@ final class AuthService implements IAuthService * @param string $openid * @return User|null */ - public function getUserByOpenId(string $openid):?User + public function getUserByOpenId(string $openid): ?User { return $this->user_repository->getByIdentifier($openid); } @@ -179,7 +276,7 @@ final class AuthService implements IAuthService * @param string $username * @return null|User */ - public function getUserByUsername(string $username):?User + public function getUserByUsername(string $username): ?User { return $this->user_repository->getByEmailOrName($username); } @@ -188,7 +285,7 @@ final class AuthService implements IAuthService * @param int $id * @return null|User */ - public function getUserById(int $id):?User + public function getUserById(int $id): ?User { return $this->user_repository->getById($id); } @@ -212,26 +309,25 @@ final class AuthService implements IAuthService public function clearUserAuthenticationResponse() { - if (Session::has("openstackid.authentication.response")) - { + if (Session::has("openstackid.authentication.response")) { Session::remove("openstackid.authentication.response"); Session::save(); } } /** - * @param string $user_id + * @param string $user_id * @return string */ - public function unwrapUserId(string $user_id):string + public function unwrapUserId(string $user_id): string { $user = $this->getUserById(intval($user_id)); - if(!is_null($user)) + if (!is_null($user)) return $user_id; $unwrapped_name = $this->decrypt($user_id); - $parts = explode(':', $unwrapped_name); + $parts = explode(':', $unwrapped_name); return intval($parts[1]); } @@ -240,10 +336,10 @@ final class AuthService implements IAuthService * @param IClient $client * @return string */ - public function wrapUserId(int $user_id, IClient $client):string + public function wrapUserId(int $user_id, IClient $client): string { - if($client->getSubjectType() === IClient::SubjectType_Public) - return $user_id; + if ($client->getSubjectType() === IClient::SubjectType_Public) + return $user_id; $wrapped_name = sprintf('%s:%s', $client->getClientId(), $user_id); return $this->encrypt($wrapped_name); @@ -253,7 +349,7 @@ final class AuthService implements IAuthService * @param string $value * @return String */ - private function encrypt(string $value):string + private function encrypt(string $value): string { return base64_encode(Crypt::encrypt($value)); } @@ -262,7 +358,7 @@ final class AuthService implements IAuthService * @param string $value * @return String */ - private function decrypt(string $value):string + private function decrypt(string $value): string { $value = base64_decode($value); return Crypt::decrypt($value); @@ -271,16 +367,16 @@ final class AuthService implements IAuthService /** * @return string */ - public function getSessionId():string + public function getSessionId(): string { - return Session::getId(); + return Session::getId(); } /** * @param $client_id * @return void */ - public function registerRPLogin(string $client_id):void + public function registerRPLogin(string $client_id): void { try { @@ -292,15 +388,14 @@ final class AuthService implements IAuthService $rps = $zlib->uncompress($rps); $rps .= '|'; } - if(is_null($rps)) $rps = ""; + if (is_null($rps)) $rps = ""; if (!str_contains($rps, $client_id)) $rps .= $client_id; $rps = $zlib->compress($rps); $rps = $this->encrypt($rps); - } - catch(Exception $ex){ + } catch (Exception $ex) { Log::warning($ex); $rps = ""; } @@ -322,13 +417,12 @@ final class AuthService implements IAuthService /** * @return string[] */ - public function getLoggedRPs():array + public function getLoggedRPs(): array { - $rps = Cookie::get(IAuthService::LOGGED_RELAYING_PARTIES_COOKIE_NAME); + $rps = Cookie::get(IAuthService::LOGGED_RELAYING_PARTIES_COOKIE_NAME); $zlib = CompressionAlgorithms_Registry::getInstance()->get(CompressionAlgorithmsNames::ZLib); - if(!empty($rps)) - { + if (!empty($rps)) { $rps = $this->decrypt($rps); $rps = $zlib->uncompress($rps); return explode('|', $rps); @@ -340,29 +434,28 @@ final class AuthService implements IAuthService * @param string $jti * @throws Exception */ - public function reloadSession(string $jti):void + public function reloadSession(string $jti): void { - Log::debug(sprintf("AuthService::reloadSession jti %s", $jti )); + Log::debug(sprintf("AuthService::reloadSession jti %s", $jti)); $session_id = $this->cache_service->getSingleValue($jti); - Log::debug(sprintf("AuthService::reloadSession session_id %s", $session_id )); - if(empty($session_id)) + Log::debug(sprintf("AuthService::reloadSession session_id %s", $session_id)); + if (empty($session_id)) throw new ReloadSessionException('session not found!'); - if($this->cache_service->exists($session_id."invalid")){ + if ($this->cache_service->exists($session_id . "invalid")) { // session was marked as void, check if we are authenticated - if(!Auth::check()) + if (!Auth::check()) throw new ReloadSessionException('user not found!'); } Session::setId(Crypt::decrypt($session_id)); Session::start(); - if(!Auth::check()) - { - $user_id = $this->principal_service->get()->getUserId(); - Log::debug(sprintf("AuthService::reloadSession user_id %s", $user_id )); - $user = $this->getUserById($user_id); - if(is_null($user)) + if (!Auth::check()) { + $user_id = $this->principal_service->get()->getUserId(); + Log::debug(sprintf("AuthService::reloadSession user_id %s", $user_id)); + $user = $this->getUserById($user_id); + if (is_null($user)) throw new ReloadSessionException('user not found!'); Auth::login($user); } @@ -373,19 +466,21 @@ final class AuthService implements IAuthService * @param int $id_token_lifetime * @return string */ - public function generateJTI(string $client_id, int $id_token_lifetime):string { - $session_id = Crypt::encrypt(Session::getId()); - $encoder = new Base64UrlRepresentation(); - $jti = $encoder->encode(hash('sha512', $session_id.$client_id, true)); + public function generateJTI(string $client_id, int $id_token_lifetime): string + { + $session_id = Crypt::encrypt(Session::getId()); + $encoder = new Base64UrlRepresentation(); + $jti = $encoder->encode(hash('sha512', $session_id . $client_id, true)); $this->cache_service->addSingleValue($jti, $session_id, $id_token_lifetime); return $jti; } - - public function invalidateSession():void { - $session_id = Crypt::encrypt(Session::getId()); - $this->cache_service->addSingleValue($session_id."invalid", $session_id); + public function invalidateSession(): void + { + $session_id = Crypt::encrypt(Session::getId()); + $this->cache_service->addSingleValue($session_id . "invalid", $session_id); } + } \ No newline at end of file diff --git a/app/libs/Auth/Exceptions/AuthenticationException.php b/app/libs/Auth/Exceptions/AuthenticationException.php index 163b9fa2..6849e5cf 100644 --- a/app/libs/Auth/Exceptions/AuthenticationException.php +++ b/app/libs/Auth/Exceptions/AuthenticationException.php @@ -21,7 +21,6 @@ class AuthenticationException extends Exception public function __construct($message = "") { - $message = "Authentication Exception : " . $message; parent::__construct($message, 0, null); } } \ No newline at end of file diff --git a/app/libs/Auth/Models/User.php b/app/libs/Auth/Models/User.php index 7dd11856..812463ac 100644 --- a/app/libs/Auth/Models/User.php +++ b/app/libs/Auth/Models/User.php @@ -474,14 +474,18 @@ class User extends BaseEntity return $this->identifier; } - public function getEmail() + public function getEmail():string { return $this->email; } + /** + * @return string|null + */ public function getFullName(): ?string { - return $this->getFirstName() . " " . $this->getLastName(); + $full_name = $this->getFirstName() . " " . $this->getLastName(); + return !empty(trim($full_name)) ? $full_name : $this->email; } public function getFirstName() 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/AbstractGrantType.php b/app/libs/OAuth2/GrantTypes/AbstractGrantType.php index 0e96b892..a43077cc 100644 --- a/app/libs/OAuth2/GrantTypes/AbstractGrantType.php +++ b/app/libs/OAuth2/GrantTypes/AbstractGrantType.php @@ -90,10 +90,10 @@ abstract class AbstractGrantType implements IGrantType */ public function completeFlow(OAuth2Request $request) { - //get client credentials from request.. + // get client credentials from request.. $this->client_auth_context = $this->client_service->getCurrentClientAuthInfo(); - //retrieve client from storage.. + // retrieve client from storage.. $this->current_client = $this->client_repository->getClientById($this->client_auth_context->getId()); if (is_null($this->current_client)) diff --git a/app/libs/OAuth2/GrantTypes/PasswordlessGrantType.php b/app/libs/OAuth2/GrantTypes/PasswordlessGrantType.php new file mode 100644 index 00000000..f6e8d0bc --- /dev/null +++ b/app/libs/OAuth2/GrantTypes/PasswordlessGrantType.php @@ -0,0 +1,379 @@ +getResponseType(false), + OAuth2Protocol::OAuth2Protocol_GrantType_Passwordless + ) + ) { + return true; + } + // complete flow + $request = $this->buildTokenRequest($request); + if + ( + !is_null($request) && + $request instanceof OAuth2AccessTokenRequestPasswordless && + $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->getScope() + ); + } + + /** + * @param OAuth2Request $request + * @return OAuth2Response + * @throws \Exception + */ + public function handle(OAuth2Request $request) + { + try { + + if (!($request instanceof OAuth2PasswordlessAuthenticationRequest)) { + throw new InvalidOAuth2Request; + } + + if(!$request->isValid()){ + throw new InvalidOAuth2Request($request->getLastValidationError()); + } + + $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; + } + + if(!$request->isValid()){ + throw new InvalidOAuth2Request($request->getLastValidationError()); + } + + 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..577a5205 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->getScope(); + // 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..13edce25 --- /dev/null +++ b/app/libs/OAuth2/Repositories/IOAuth2OTPRepository.php @@ -0,0 +1,53 @@ + [ + 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..896b835c --- /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, + OAuth2Protocol::OAuth2Protocol_Nonce => [], + ]; + + /** + * @var array + */ + public static $optional_params = [ + 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())); @@ -51,6 +51,6 @@ final class ClientPKCEAuthContextValidator implements IClientAuthContextValidato Log::debug(sprintf("ClientPKCEAuthContextValidator::validate client id %s - provide client id %s", $client->getClientId(), $providedClientId)); - return $client->getClientId() === $providedClientId && $client->isPKCEEnabled(); + return $client->getClientId() === $providedClientId && ( $client->isPKCEEnabled() || $client->isPasswordlessEnabled()); } } \ No newline at end of file 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/IAuthService.php b/app/libs/Utils/Services/IAuthService.php index 37d7fcf5..eac987b7 100644 --- a/app/libs/Utils/Services/IAuthService.php +++ b/app/libs/Utils/Services/IAuthService.php @@ -12,7 +12,10 @@ * limitations under the License. **/ +use Auth\Exceptions\AuthenticationException; use Auth\User; +use Models\OAuth2\Client; +use Models\OAuth2\OAuth2OTP; use OAuth2\Models\IClient; use OpenId\Models\IOpenIdUser; /** @@ -33,6 +36,8 @@ interface IAuthService const AuthenticationResponse_None = "None"; const AuthenticationResponse_Cancel = "Cancel"; + const AuthenticationFlowPassword = "password"; + const AuthenticationFlowPasswordless = "otp"; /** * @return bool */ @@ -47,9 +52,19 @@ interface IAuthService * @param string $username * @param string $password * @param bool $remember_me - * @return mixed + * @return bool + * @throws AuthenticationException */ - public function login($username, $password, $remember_me); + public function login(string $username, string $password, bool $remember_me): bool; + + /** + * @param OAuth2OTP $otpClaim + * @param Client|null $client + * @return OAuth2OTP|null + * @throws AuthenticationException + */ + public function loginWithOTP(OAuth2OTP $otpClaim, ?Client $client = null): ?OAuth2OTP; + /** * @param string $username 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/Version20190604015804.php b/database/migrations/Version20190604015804.php index ca420ef2..9cc5e4d4 100644 --- a/database/migrations/Version20190604015804.php +++ b/database/migrations/Version20190604015804.php @@ -289,6 +289,9 @@ create table if not exists oauth2_client use_refresh_token tinyint(1) default '0' not null, rotate_refresh_token tinyint(1) default '0' not null, pkce_enabled tinyint(1) default '0' not null, + otp_length int default '6' not null, + otp_lifetime int default '120' not null, + max_refresh_token_issuance_qty int default '0' not null, resource_server_id bigint unsigned null, website text null, application_type enum('WEB_APPLICATION', 'JS_CLIENT', 'SERVICE', 'NATIVE') default 'WEB_APPLICATION' null, diff --git a/database/migrations/Version20210616123839.php b/database/migrations/Version20210616123839.php new file mode 100644 index 00000000..57d26adc --- /dev/null +++ b/database/migrations/Version20210616123839.php @@ -0,0 +1,93 @@ +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->text("scope")->setNotnull(false); + $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(true)->setDefault(6); + // seconds + $table->integer('lifetime')->setNotnull(true)->setDefault(60); + $table->dateTime('redeemed_at')->setNotnull(false); + // FK Optional + $table->unsignedBigInteger("oauth2_client_id", 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)->setDefault(120); + }); + } + } + + /** + * @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/public/assets/js/oauth2/profile/edit-client-security-main-settings.js b/public/assets/js/oauth2/profile/edit-client-security-main-settings.js index a4de7879..655715a0 100644 --- a/public/assets/js/oauth2/profile/edit-client-security-main-settings.js +++ b/public/assets/js/oauth2/profile/edit-client-security-main-settings.js @@ -24,6 +24,31 @@ jQuery(document).ready(function($){ } }); + if($("#otp_enabled") .is(":checked")){ + $(".otp_controls").removeClass("hidden"); + $("#otp_length").rules("add", {required:true, min:4, max:8}); + $("#otp_lifetime").rules("add", {required:true, min:60, max:600}); + } + else { + $(".otp_controls").addClass("hidden"); + $("#otp_length").rules("remove"); + $("#otp_lifetime").rules("remove"); + } + + + $("#otp_enabled").change(function() { + if(this.checked) { + $(".otp_controls").removeClass("hidden"); + $("#otp_length").rules("add", {required:true, min:4, max:8}); + $("#otp_lifetime").rules("add", {required:true, min:60, max:600}); + return true; + } + $(".otp_controls").addClass("hidden"); + $("#otp_length").rules("remove"); + $("#otp_lifetime").rules("remove"); + return true; + }); + $('#token_endpoint_auth_method').change(function() { var auth_method = $(this).val(); 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/js/base_actions.js b/resources/js/base_actions.js index e1cac25d..63e297ec 100644 --- a/resources/js/base_actions.js +++ b/resources/js/base_actions.js @@ -20,7 +20,7 @@ const xhrs = {}; const cancel = (key) => { if(xhrs[key]) { - xhrs[key].abort(); + xhrs[key].xhr.abort(); console.log(`aborted request ${key}`); delete xhrs[key]; } @@ -31,11 +31,15 @@ const schedule = (key, req) => { xhrs[key] = req; }; +const end = (key) => { + delete xhrs[key]; +} + const isObjectEmpty = (obj) => { return Object.keys(obj).length === 0 && obj.constructor === Object ; } -export const getRawRequest = (endpoint, errorHandler = null) => (params) => { +export const getRawRequest = (endpoint) => (params) => { let url = URI(endpoint); if(!isObjectEmpty(params)) @@ -45,26 +49,47 @@ export const getRawRequest = (endpoint, errorHandler = null) => (params) => { cancel(key); - return new Promise((resolve, reject) => { - let req = http.get(url.toString()) - .timeout({ - response: 60000, - deadline: 60000, - }) - .end( - (err, res) => { - if (err || !res.ok) { - if(errorHandler) { - errorHandler(err, res); - } - return reject({ err, res }) - } - let json = res.body; - return resolve({response: json}); - } - ) + let req = http.get(url.toString()); + schedule(key, req); + + return req.timeout({ + response: 60000, + deadline: 60000, + }).then((res) => { + let json = res.body; + end(key); + return Promise.resolve({response: json}); + }).catch((error) => { + end(key); + return Promise.reject(error); + }) +} + +export const postRawRequest = (endpoint) => (params, headers = {}) => { + let url = URI(endpoint); + + if(!isObjectEmpty(params)) + url = url.query(params); + + let key = url.toString(); + + cancel(key); + + let req = http.post(url.toString()); + + schedule(key, req); + + return req.set(headers).send(params).timeout({ + response: 60000, + deadline: 60000, + }).then((res) => { + let json = res.body; + end(key); + return Promise.resolve({response: json}); + }).catch((error) => { + end(key); + return Promise.reject(error); + }) - schedule(key, req); - }); } diff --git a/resources/js/login/actions.js b/resources/js/login/actions.js index e004915d..c7d6753c 100644 --- a/resources/js/login/actions.js +++ b/resources/js/login/actions.js @@ -1,8 +1,8 @@ -import {getRawRequest} from '../base_actions' - +import {getRawRequest, postRawRequest} from '../base_actions' export const verifyAccount = (email) => { + const params = { email: email }; @@ -10,3 +10,13 @@ export const verifyAccount = (email) => { return getRawRequest(window.VERIFY_ACCOUNT_ENDPOINT)(params); } + +export const emitOTP = (email, token, connection = 'email', send='code') => { + const params = { + username: email, + connection:connection, + send:send + } + + return postRawRequest(window.EMIT_OTP_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} diff --git a/resources/js/login/login.js b/resources/js/login/login.js index b3e3c9f8..9c57b195 100644 --- a/resources/js/login/login.js +++ b/resources/js/login/login.js @@ -14,7 +14,7 @@ import Container from '@material-ui/core/Container'; import Chip from '@material-ui/core/Chip'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import Checkbox from '@material-ui/core/Checkbox'; -import {verifyAccount} from './actions'; +import {verifyAccount, emitOTP} from './actions'; import {MuiThemeProvider, createMuiTheme} from '@material-ui/core/styles'; import DividerWithText from '../components/divider_with_text'; import Visibility from '@material-ui/icons/Visibility'; @@ -23,7 +23,7 @@ import InputAdornment from '@material-ui/core/InputAdornment'; import IconButton from '@material-ui/core/IconButton'; import {emailValidator} from '../validator'; import Grid from '@material-ui/core/Grid'; - +import Swal from 'sweetalert2' const EmailInputForm = ({onValidateEmail, onHandleUserNameChange, disableInput, emailError}) => { @@ -117,6 +117,7 @@ const PasswordInputForm = ({ /> + {shouldShowCaptcha() && { +const OTPInputForm = ({ + formAction, + onAuthenticate, + disableInput, + showPassword, + passwordValue, + passwordError, + onUserPasswordChange, + handleClickShowPassword, + handleMouseDownPassword, + userNameValue, + csrfToken, + shouldShowCaptcha, + captchaPublicKey, + onChangeRecaptcha + }) => { + return( +
+ + + {showPassword ? : } + + + ) + }} + + /> +

A Verification Code was just sent to your Email.

+ } + label="Remember me" + /> + + + + + {shouldShowCaptcha() && + + } + + + ); +} + +const HelpLinks = ({forgotPasswordAction, verifyEmailAction, helpAction, appName, emitOtpAction}) => { return ( <>
+ + Get A Login Code emailed to you + Forgot password? @@ -153,6 +235,17 @@ const HelpLinks = ({forgotPasswordAction, verifyEmailAction, helpAction, appName ); } +const OTPHelpLinks = ({emitOtpAction}) => { + return ( + <> +
+

Didn't receive it ?

+

Check your spam folder or resend email. +

+ + ); +} + const EmailErrorActions = ({createAccountAction, onValidateEmail, disableInput}) => { return( @@ -201,6 +294,7 @@ class LoginPage extends React.Component { constructor(props) { super(props); + this.state = { user_name: props.userName, user_password: '', @@ -214,7 +308,9 @@ class LoginPage extends React.Component { captcha_value: '', showPassword: false, disableInput: false, + authFlow: props.flow, } + this.onHandleUserNameChange = this.onHandleUserNameChange.bind(this); this.onValidateEmail = this.onValidateEmail.bind(this); this.handleDelete = this.handleDelete.bind(this); @@ -224,6 +320,29 @@ class LoginPage extends React.Component { this.shouldShowCaptcha = this.shouldShowCaptcha.bind(this); this.handleClickShowPassword = this.handleClickShowPassword.bind(this); this.handleMouseDownPassword = this.handleMouseDownPassword.bind(this); + this.handleEmitOtpAction = this.handleEmitOtpAction.bind(this); + } + + handleEmitOtpAction(ev){ + ev.preventDefault(); + let user_fullname = this.state.user_fullname ? this.state.user_fullname : this.state.user_name; + + emitOTP(this.state.user_name, this.props.token).then((payload) => { + let {response} = payload; + this.setState({...this.state, + authFlow:"otp", + errors: { + email: "", + password:"", + }, + user_verified: true, + user_fullname:user_fullname, + }); + }, (error) => { + let {response, status, message} = error; + Swal('Oops...', 'Something went wrong!', 'error') + }); + return false; } shouldShowCaptcha() { @@ -236,7 +355,11 @@ class LoginPage extends React.Component { onAuthenticate(ev) { if (this.state.user_password == '') { - this.setState({...this.state, errors: {...this.state.errors, password: 'Password is empty'}}); + let error = 'Password is empty'; + if(this.state.authFlow == 'OTP'){ + error = 'Verification Code is empty'; + } + this.setState({...this.state, errors: {...this.state.errors, password: error}}); ev.preventDefault(); return false; } @@ -276,9 +399,11 @@ class LoginPage extends React.Component { if (!emailValidator(this.state.user_name)) { return false; } + this.setState({...this.state, disableInput: true}); verifyAccount(this.state.user_name).then((payload) => { let {response} = payload; + this.setState({ ...this.state, user_pic: response.pic, @@ -291,10 +416,18 @@ class LoginPage extends React.Component { disableInput: false }) }, (error) => { - let {body} = error.res; - let newErrors = {} + + let {response, status, message} = error; + + let newErrors = {}; + newErrors['password'] = ''; newErrors['email'] = "We could not find an Account with that email Address"; + + if(status == 429){ + newErrors['email'] = "Too many requests. Try it later."; + } + this.setState({ ...this.state, user_pic: null, @@ -308,7 +441,10 @@ class LoginPage extends React.Component { } handleDelete() { - this.setState({...this.state, user_name: null, user_pic: null, user_fullname: null, user_verified: false}); + this.setState({...this.state, user_name: null, user_pic: null, user_fullname: null, user_verified: false, authFlow:"password", errors: { + email: "", + password:"", + },}); } handleClickShowPassword(ev) { @@ -365,12 +501,13 @@ class LoginPage extends React.Component { forgotPasswordAction={this.props.forgotPasswordAction} verifyEmailAction={this.props.verifyEmailAction} helpAction={this.props.helpAction} + emitOtpAction={this.handleEmitOtpAction} /> } } - {this.state.user_verified && + {this.state.user_verified && this.state.authFlow == 'password' && // proceed to ask for password ( 2nd step ) <> } + {this.state.user_verified && this.state.authFlow == 'otp' && + // proceed to ask for password ( 2nd step ) + <> + + + + } ); diff --git a/resources/js/login/login.module.scss b/resources/js/login/login.module.scss index 664c2219..468459c9 100644 --- a/resources/js/login/login.module.scss +++ b/resources/js/login/login.module.scss @@ -8,6 +8,7 @@ $base-color:#3fa2f7; color: $base-color; } + .inner_container { display: flex; margin-top: 64px; @@ -71,4 +72,9 @@ $base-color:#3fa2f7; .valid_user_name_chip{ float: right; +} + +.otp_p { + margin: 0; + padding: 0; } \ No newline at end of file diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 48d7b2c6..0b0c94fd 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -32,8 +32,10 @@ appLogo: '{{ Config::get("app.logo_url") }}', formAction: '{{ URL::action("UserController@postLogin") }}', accountVerifyAction : '{{URL::action("UserController@getAccount")}}', + emitOtpAction : '{{URL::action("UserController@emitOTP")}}', authError: authError, captchaPublicKey: '{{ Config::get("recaptcha.public_key") }}', + flow: 'password', thirdPartyProviders: [ @foreach($supported_providers as $provider => $label) {label: "{{$label}}", name:"{{$provider}}"}, @@ -63,9 +65,12 @@ @if(Session::has('user_verified')) config.user_verified = {{Session::get('user_verified')}}; @endif + @if(Session::has('flow')) + config.flow = '{{Session::get('flow')}}'; + @endif window.VERIFY_ACCOUNT_ENDPOINT = config.accountVerifyAction; - + window.EMIT_OTP_ENDPOINT = config.emitOtpAction; {!! HTML::script('assets/login.js') !!} @append \ No newline at end of file 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..48732d7c --- /dev/null +++ b/resources/views/emails/oauth2_passwordless_otp.blade.php @@ -0,0 +1,202 @@ + + + + + + {{$subject}} + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+
Hello,
+
+
Please use the verification code below on the {{Config::get('app.app_name')}} website:
+
+
{{$otp}}
+
+
Should be valid for {{$lifetime}} minutes.
+
+
If you didn't request this, you can ignore this email or let us know.
+
+
Thanks!
{{Config::get('app.tenant_name')}} Support Team
+
+
+ +
+
+ +
+ + + \ No newline at end of file diff --git a/resources/views/menu.blade.php b/resources/views/menu.blade.php index 5b190884..63f3af5e 100644 --- a/resources/views/menu.blade.php +++ b/resources/views/menu.blade.php @@ -9,23 +9,23 @@ - + diff --git a/resources/views/oauth2/profile/edit-client-security-main-settings.blade.php b/resources/views/oauth2/profile/edit-client-security-main-settings.blade.php index 55e1de4d..ab2fb865 100644 --- a/resources/views/oauth2/profile/edit-client-security-main-settings.blade.php +++ b/resources/views/oauth2/profile/edit-client-security-main-settings.blade.php @@ -10,11 +10,41 @@ id="pkce_enabled"> Use PCKE?   + aria-hidden="true" title="Use Proof Key for Code Exchange instead of a Client Secret (Public Clients)"> @endif +
+
+ +
+
+ +