zenstruck/signed-url-bundle

该包已被废弃且不再维护。作者建议使用 zenstruck/uri 包。

支持临时和单次使用的URL的签名和验证辅助工具。

资助包维护!
kbond

安装: 595

依赖者: 0

建议者: 0

安全性: 0

星标: 6

关注者: 1

分支: 1

开放问题: 3

类型:symfony-bundle

v0.1.0 2021-12-22 15:10 UTC

This package is auto-updated.

Last update: 2022-12-14 21:10:32 UTC


README

CI Status codecov

支持临时和单次使用的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但不是单次使用的包。

此外,此包还提供以下功能

  1. SignedUrl 对象
  2. 显式异常,以便您确切知道验证失败的原因,并且可以选择将此信息传达给用户(例如,URL已被使用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

验证异常

验证可能会因以下原因失败(按此顺序)

  1. 签名缺失或无效(URL已被篡改)。
  2. 如果URL有到期时间并且已过期。
  3. 单次使用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值进行加密/解密。