zenstruck / signed-url-bundle
Requires
- php: >=7.4
- symfony/framework-bundle: ^4.4|^5.0|^6.0
- symfony/polyfill-php80: ^1.15
Requires (Dev)
- matthiasnoback/symfony-dependency-injection-test: ^4.3
- symfony/browser-kit: ^4.4|^5.0|^6.0
- symfony/phpunit-bridge: ^5.3
README
支持临时和单次使用的URL的签名和验证辅助工具。一些常见用例包括
use Zenstruck\SignedUrl\Generator; public function sendPasswordResetEmail(User $user, Generator $generator) { $resetUrl = $generator->build('password_reset_route', ['id' => $user->getId()]) ->expires('+1 day') ->singleUse($user->getPassword()) ; // create email with $resetUrl and send }
use Zenstruck\SignedUrl\Verifier; use Zenstruck\SignedUrl\Exception\UrlVerificationFailed; public function resetPasswordAction(User $user, Verifier $urlVerifier) { try { $urlVerifier->verifyCurrentRequest(singleUseToken: $user->getPassword()); } catch (UrlVerificationFailed $e) { $this->flashError($e->messageKey()); // safe reason to show user return $this->redirect(...); } // continue }
为什么使用此包?
Symfony 包含一个 UriSigner
(实际上,此包使用此功能),但它没有开箱即用的临时/单次使用URL支持。Symfony 5.2 引入了具有这些功能的 登录链接,但仅限于此类链接。
tilleuls/url-signer-bundle
是另一个提供过期签名URL但不是单次使用的包。
此外,此包还提供以下功能
安装
composer require zenstruck/signed-url-bundle
注意:如果未由 symfony/flex
自动添加,请启用 ZenstruckSignedUrlBundle
。
生成
Zenstruck\SignedUrl\Generator
是一个可自动装配的服务,用于为您 symfony 路由生成签名URL。默认情况下,所有生成的URL都是绝对的。
标准签名URL
Generator
服务是 Symfony\Component\Routing\Generator\UrlGeneratorInterface
的实例。调用 Generator::generate()
创建一个 标准 签名URL(无过期)。默认情况下,这些URL是绝对的。
use Zenstruck\SignedUrl\Generator; /** @var Generator $generator */ $generator->generate('route1'); // http://example.com/route1?_hash=... $generator->generate('route2', ['parameter1' => 'value']); // http://example.com/route2/value?_hash=... $generator->generate('route3', [], Generator::ABSOLUTE_PATH); // /route2/value?_hash=...
签名URL构建器
您可以使用 Generator::build()
创建一个签名、临时 和/或 单次使用 URL。
/** @var Zenstruck\SignedUrl\Generator $generator */ (string) $generator->build('reset_password', ['id' => $user->getId()]) ->expires('+1 hour') ->singleUse($user->getPassword()) ;
SignedUrl
对象
Generator::build()
创建一个签名URL构建器,在调用 create()
后返回一个包含URL上下文的 SignedUrl
对象
/** @var Zenstruck\SignedUrl\Generator $generator */ $signedUrl = $generator->build('reset_password', ['id' => $user->getId()]) ->expires('+1 hour') ->singleUse($user->getPassword()) ->create() ; /** @var Zenstruck\SignedUrl $signedUrl */ (string) $signedUrl; // the actual URL $signedUrl->expiresAt(); // \DateTimeImmutable $signedUrl->isTemporary(); // true $signedUrl->isSingleUse(); // true
临时URL
这些URL在指定时间后过期(无法验证)。它们也是经过签名的,因此无法被篡改。
/** @var Zenstruck\SignedUrl\Generator $generator */ (string) $generator->build('route1')->expires('+1 hour'); // http://example.com/route1?__expires=...&_hash=... (string) $generator->build('route2', ['parameter1' => 'value'])->expires('+1 hour'); // http://example.com/route2/value?__expires=...&_hash=... // use # of seconds (string) $generator->build('route1')->expires(3600); // http://example.com/route2/value?__expires=...&_hash=... // use an explicit \DateTime (string) $generator->build('route1')->expires(new \DateTime('+1 hour')); // http://example.com/route2/value?__expires=...&_hash=...
验证
Zenstruck\SignedUrl\Verifier
是一个可自动装配的服务,用于验证签名URL。
use Zenstruck\SignedUrl\Exception\UrlVerificationFailed; /** @var Zenstruck\SignedUrl\Verifier $verifier */ /** @var string $url */ /** @var Symfony\Component\HttpFoundation\Request $request */ // simple usage: return true if valid and non-expired (if applicable), false otherwise $verifier->isVerified($url); $verifier->isVerified($request); // can pass Symfony request object $verifier->isCurrentRequestVerified(); // verifies the current request (fetched from RequestStack) // try/catch usage: catch exceptions to provide better feedback to users try { $verifier->verify($url); $verifier->verify($request); // alternative $verifier->verifyCurrentRequest(); // alternative } catch (UrlVerificationFailed $e) { $e->url(); // the url used $e->getMessage(); // Internal message (ie for logging) $e->messageKey(); // Safe message with reason to show the user (or use with translator) }
注意:有关抛出异常的更多信息,请参阅验证异常。
单次使用URL
这些URL是由一个令牌生成的,一旦URL被使用,该令牌应该改变。
警告:确定此令牌取决于上下文,由您负责。令牌成功使用后,此值必须更改,否则它仍然有效。
一个很好的例子是密码重置。对于这些URL,令牌将是当前用户的密码。一旦他们成功更改密码,令牌将不匹配,因此URL将变得无效。
注意:首先使用此令牌对URL进行哈希处理,然后再使用应用程序级别的密钥进行哈希处理,以确保没有对其进行篡改。
/** @var Zenstruck\SignedUrl\Generator $generator */ // !! This will be the single-use token that changes once "used" !! $password = $user->getPassword(); $url = $generator->build('reset_password', ['id' => $user->getId()]) ->singleUse($password) ->create() ;
单次使用验证
为了验证单次使用URL,您需要将令牌传递给验证器的验证方法
use Zenstruck\SignedUrl\Exception\UrlVerificationFailed; /** @var Zenstruck\SignedUrl\Verifier $verifier */ /** @var string $url */ /** @var Symfony\Component\HttpFoundation\Request $request */ // !! This is the single-use token. If the url was generated with a different password verification will fail !! $password = $user->getPassword(); $verifier->isVerified($url, $password); $verifier->isVerified($request, $password); $verifier->isCurrentRequestVerified($password); // try/catch usage: catch exceptions to provide better feedback to users try { $verifier->verify($url, $password); $verifier->verify($request, $password); // alternative $verifier->verifyCurrentRequest($password); // alternative } catch (UrlVerificationFailed $e) { $e->messageKey(); // "URL has already been used." (if failed for this reason) }
令牌对象
单次使用令牌对于生成和验证URL都是必需的。这些可能在应用程序的不同部分完成。为了避免重复生成令牌,建议将逻辑封装到简单的令牌对象中,这些对象是\Stringable
final class ResetPasswordToken { public function __construct(private User $user) {} public function __toString(): string { return $this->user->getPassword(); } }
使用此令牌对象生成URL
/** @var Zenstruck\SignedUrl\Generator $generator */ $generator->build('reset_password', ['id' => $user->getId()])->singleUse(new ResetPasswordToken($user));
在验证时,也请在这里使用令牌对象
/** @var Zenstruck\SignedUrl\Verifier $verifier */ $verifier->isVerified($url, new ResetPasswordToken($user)); $verifier->verify($url, new ResetPasswordToken($user)); $verifier->isCurrentRequestVerified(new ResetPasswordToken($user)); $verifier->verifyCurrentRequest(new ResetPasswordToken($user));
自动验证路由
您可以使用路由选项或属性自动验证特定路由。在这些控制器被调用之前,一个事件监听器会验证路由,并在失败时抛出HttpException
(默认为403
)。您没有拦截并提供用户友好消息的选项。此外,无法进行单次使用URL验证。
此功能需要启用
# config/packages/zenstruck_signed_url.yaml zenstruck_signed_url: route_verification: true
将Zenstruck\SignedUrl\Attribute\Signed
属性添加到您希望自动验证的控制器中(可以添加到类中,以标记所有方法为已签名)
use Zenstruck\SignedUrl\Attribute\Signed; #[Signed] #[Route(...)] public function action1() {} // throws a 403 HttpException if verification fails #[Signed(status: 404)] #[Route(...)] public function action1() {} // throw a 404 exception instead
或者,可以在路由定义中添加一个signed
路由选项
# config/routes.yaml action1: path: /action1 options: { signed: true } # throws a 403 HttpException if verification fails action2: path: /action2 options: { signed: 404 } # throw a 404 exception instead
验证异常
验证可能会因以下原因失败(按此顺序)
- 签名缺失或无效(URL已被篡改)。
- 如果URL有到期时间并且已过期。
- 单次使用URL已被使用。
上述每个原因都有一个相应的异常,可以单独捕获(所有异常都是Zenstruck\SignedUrl\Exception\UrlVerificationFailed
的实例)
use Zenstruck\SignedUrl\Exception\UrlVerificationFailed; use Zenstruck\SignedUrl\Exception\UrlHasExpired; use Zenstruck\SignedUrl\Exception\UrlAlreadyUsed; /** @var Zenstruck\SignedUrl\Verifier $verifier */ try { $verifier->verifyCurrentRequest($user->getPassword()); } catch (UrlHasExpired $e) { // this exception makes the expiration available $e->expiredAt(); // \DateTimeImmutable $e->messageKey(); // "URL has expired." $e->url(); // the URL that failed verification } catch (UrlAlreadyUsed $e) { $e->messageKey(); // "URL has already been used." $e->url(); // the URL that failed verification } catch (UrlVerificationFailed $e) { // must be last as a "catch all" $e->messageKey(); // "URL Verification failed." $e->url(); // the URL that failed verification }
完整默认配置
zenstruck_signed_url: # The secret key to sign urls with secret: '%kernel.secret%' # Enable auto route verification (trigger with "signed" route option or "Zenstruck\SignedUrl\Attribute\Signed" attribute) route_verification: false
食谱
以下是一些伪代码食谱,用于可能的用例
无状态密码重置
生成一个有效期为1天的密码重置链接,当密码更改时被认为是已使用。
/** @var \Zenstruck\SignedUrl\Generator $generator */ /** @var \Zenstruck\SignedUrl\Verifier $verifier */ // REQUEST PASSWORD RESET ACTION (GENERATE URL) $url = $generator->build('reset_password', ['id' => $user->getId()]) ->expires('+1 day') ->singleUse($user->getPassword()) // current password is the token that changes once "used" ->create() ; // send email to user with $url // PASSWORD RESET ACTION (VERIFY URL) try { $verifier->verifyCurrentRequest($user->getPassword()); // current password as the token } catch (\Zenstruck\SignedUrl\Exception\UrlVerificationFailed $e) { $this->flashError($e->messageKey()); return $this->redirect(...); } // proceed with the reset, once a new password will be set/saved, this URL will become invalid
无状态电子邮件验证
用户注册后,发送验证电子邮件。这些电子邮件不会过期,但一旦$user->isVerified() === true
认为它们已被使用。由于这些链接不会过期,您可能需要一个cron作业来删除一段时间内未验证的用户。
final class VerifyToken { public function __construct(private User $user) {} public function __toString(): string { return $this->user->isVerified() ? 'verified' : 'unverified'; } } /** @var \Zenstruck\SignedUrl\Generator $generator */ /** @var \Zenstruck\SignedUrl\Verifier $verifier */ // REGISTRATION CONTROLLER ACTION (GENERATE URL) $url = $generator->build('verify_user', ['id' => $user->getId()]) ->singleUse(new VerifyToken($user)) // this token's value will be "unverified" ->create() ; // send email to user with $url // VERIFICATION ACTION (VERIFY URL) try { $verifier->verifyCurrentRequest(new VerifyToken($user)); // this token's value should be "unverified" but if not, it is invalid } catch (\Zenstruck\SignedUrl\Exception\UrlVerificationFailed $e) { $this->flashError($e->messageKey()); return $this->redirect(...); } $user->verify(); // marks the user as verified and invalidates the URL // save user & login user immediately or redirect to login page
无状态验证更改邮箱
如果您的应用程序需要所有用户都拥有已验证的邮箱,允许用户更改邮箱的系统也需要进行验证。您可以使用此包以无状态的方式启用此功能。首先,当用户请求更改邮箱时,将链接发送到新邮箱。此链接包含新邮箱信息,当他们点击时,应用程序知道要设置的新已验证邮箱。
/** @var \Zenstruck\SignedUrl\Generator $generator */ /** @var \Zenstruck\SignedUrl\Verifier $verifier */ // REQUEST EMAIL CHANGE ACTION (GENERATE URL) $url = $generator->build('reset_password', ['id' => $user->getId(), 'new-email' => $newEmailRequested]) ->expires('+1 day') ->singleUse($user->getEmail()) // the user's current email ->create() ; // send verification email to $newEmailRequested with $url // EMAIL CHANGE ACTION (VERIFY URL) try { $verifier->verify($request, $user->getEmail()); // the user's current email } catch (\Zenstruck\SignedUrl\Exception\UrlVerificationFailed $e) { $this->flashError($e->messageKey()); return $this->redirect(...); } $user->setEmail($request->query->get('new-email')); // changes the user email and invalidates the URL // save user
注意:由于新邮箱信息包含在查询字符串中,这可能会被视为PII泄露(因为它将出现在日志中)。避免这种情况的一个选择是对new-email
值进行加密/解密。