ecourty/token-bundle
Symfony bundle to manage secure, typed, revocable tokens for any Doctrine entity (password resets, email verification, share links). Supports expiry, single-use/max-uses, JSON payloads, events, subject resolution, and a purge command.
## Getting Started
### Minimal Setup
1. **Install the package**:
```bash
composer require ecourty/token-bundle
Ensure TokenBundle is registered in config/bundles.php (Symfony Flex handles this automatically).
Create the database table:
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate
First use case: Password reset token
Implement TokenSubjectInterface on your User entity:
class User implements TokenSubjectInterface {
public function getTokenSubjectId(): string {
return (string) $this->id;
}
}
Generate and consume a token in a service:
$token = $tokenManager->create(
type: 'password_reset',
subject: $user,
expiresIn: '+1 hour',
singleUse: true
);
Creation:
$token = $tokenManager->create(
type: 'email_verification',
subject: $user,
expiresIn: '+24 hours',
payload: ['ip' => $request->getClientIp()]
);
payload for metadata (e.g., IP address, user agent).singleUse: true for one-time actions (e.g., password resets).Validation & Consumption:
try {
$token = $tokenManager->consume($tokenString, 'email_verification');
$user = $tokenManager->resolveSubject($token);
// Mark user as verified...
} catch (TokenExpiredException) {
// Redirect to resend flow
}
Revocation:
// Revoke a single token
$tokenManager->revoke($tokenString);
// Bulk revoke (e.g., on user logout)
$tokenManager->revokeAll($user, 'email_verification');
Email Verification:
$token = $tokenManager->create(
type: 'email_verification',
subject: $user,
expiresIn: '+7 days',
singleUse: true
);
// Store token in user's email template
Shareable Links:
$token = $tokenManager->create(
type: 'document_share',
subject: $document,
expiresIn: '+30 days',
maxUses: 5,
payload: ['permissions' => ['view']]
);
// Generate URL: `/documents/{id}?token={$token->getToken()}`
API Tokens:
#[RequiresToken(type: 'api_access', resolver: BearerTokenResolver::class)]
public function secureEndpoint(Request $request) {
$token = $request->attributes->get('_token');
$user = $tokenManager->resolveSubject($token);
}
Listen to token events for auditing or notifications:
#[AsEventListener]
public function onTokenConsumed(TokenConsumedEvent $event) {
if ($event->token->getType() === 'password_reset') {
$this->mailer->send(new UserPasswordResetSuccessEmail($event->token->getSubject()));
}
}
Race Conditions:
UPDATE queries on the uses column.$tokenManager->consume() instead of raw SQL.Subject Deletion:
TokenSubject entity is deleted, resolveSubject() returns null. Handle this gracefully:
$subject = $tokenManager->resolveSubject($token);
if (!$subject) {
throw new \RuntimeException('Subject no longer exists');
}
Token Length:
# config/packages/token.yaml
token:
token_length: 32 # Minimum: 16
QueryString Resolver:
HeaderTokenResolver for sensitive tokens (e.g., password resets).Bulk Revocation:
revokeAll() skips event dispatching for performance. Use revoke() for individual tokens if events are critical.Token Not Found:
type matches exactly (case-sensitive).try {
$token = $tokenManager->get($tokenString, 'type');
} catch (TokenRevokedException $e) {
// Log or notify user
}
Expired Tokens:
expiresIn uses valid DateInterval syntax (e.g., '+1 hour', '-5 minutes').TokenExpiredException in your error handling.Payload Data:
$data = json_decode($token->getPayload(), true);
Custom Token Generators: Override the default random token generator:
$tokenManager->setTokenGenerator(new CustomTokenGenerator());
Token Resolvers: Create custom resolvers for non-standard token sources (e.g., cookies):
class CookieTokenResolver implements TokenResolverInterface {
public function resolve(Request $request): ?string {
return $request->cookies->get('token');
}
}
Token Storage:
Extend the TokenRepository to add custom queries (e.g., find tokens by payload):
$tokens = $tokenRepository->findByPayload(['key' => 'value']);
Validation Logic:
Add pre-consumption checks via a custom TokenValidator:
$tokenManager->setValidator(new CustomTokenValidator());
Purge Command:
Run token:purge periodically (e.g., via cron) to clean expired/consumed tokens:
php bin/console token:purge --dry-run # Test first
Indexing:
Ensure the tokens table has indexes on:
subject_id + type (for findValid())token (for get()/consume())expires_at (for purging)Token Exposure: Avoid logging or storing tokens in plaintext. Use hashes for auditing:
$this->logger->info('Token used', ['token_hash' => hash('sha256', $tokenString)]);
Sensitive Payloads: Avoid storing PII in token payloads. Use encrypted payloads if needed:
$payload = $this->encoder->encode(['secret' => 'data']);
$tokenManager->create(..., payload: $payload);
CSRF Protection: Combine tokens with CSRF tokens for forms:
<form method="POST">
<input type="hidden" name="token" value="{{ token.getToken() }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('reset_password') }}">
</form>
```markdown
### Laravel-Specific Adaptations
While this bundle is Symfony-focused, Laravel developers can adapt it via:
1. **Symfony Bridge**:
Use `symfony/http-foundation` and `symfony/event-dispatcher` as Laravel packages:
```bash
composer require symfony/http-foundation symfony/event-dispatcher
Service Container:
Register the bundle’s services manually in config/app.php:
'bindings' => [
TokenManager::class => function ($app) {
return new TokenManager(
$app->make(TokenRepository::class),
$app->make(TokenGenerator::class),
$app->make(EventDispatcher::class)
);
},
],
Route Protection:
Replace #[RequiresToken] with a Laravel middleware:
class TokenMiddleware {
public function handle(Request $request, Closure $next) {
$tokenResolver = new HeaderTokenResolver();
$tokenString = $tokenResolver->resolve($request);
$tokenManager = app(TokenManager::class);
try {
$token = $tokenManager->consume($tokenString, 'api_access');
$request->attributes->add(['_token' => $token]);
return $next($request);
} catch (TokenAccessDeniedException $e) {
abort(403, 'Invalid token');
}
}
}
Event Listeners:
How can I help you explore Laravel packages today?