我在尝试在 Symfony 中实现登录过程的自定义身份验证时遇到身份验证问题。尽管将 security.yml 文件配置为允许某些路由(尤其是登录路由)无需身份验证即可访问,但我始终收到错误消息:
“令牌无效或被篡改:HMAC 验证失败(401 未经授权)”
这是我的 security.yml 配置的片段:
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
stateless: true
custom_authenticators:
- App\Security\CustomAuthenticator
provider: users_in_memory
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/api/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: '^/', roles: IS_AUTHENTICATED_FULLY }
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon
这是我的自定义身份验证类:
<?php
namespace App\Security;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use App\Entity\User; // Assuming your User entity is in the App\Entity namespace
class CustomAuthenticator implements AuthenticatorInterface
{
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
public function supports(Request $request): ?bool
{
return true;
}
public function authenticate(Request $request): Passport
{
$requestData = json_decode($request->getContent(), true);
if (!isset($requestData['name'])) {
throw new UnauthorizedHttpException('Invalid credentials', 'Required field "name" is missing');
}
$name = $requestData['name'];
try {
$decryptedName = $this->decrypt($name);
} catch (Exception $e) {
throw new UnauthorizedHttpException('Invalid credentials', 'Invalid or tampered token'.$e->getMessage());
}
$user = $this->loadUser($decryptedName);
$userBadge = new UserBadge($decryptedName, function ($name) {
return $this->loadUser($name);
});
return new Passport($userBadge);
}
private function loadUser(string $name): ?UserInterface
{
return $this->entityManager->getRepository(User::class)->findOneBy(['name' => $name]);
}
private function decrypt($string): string
{
// Retrieve the secret key
$secretKey = $_ENV['APP_SECRET'];
if (!$secretKey) {
throw new \RuntimeException('APP_SECRET environment variable is not set.');
}
// Derive the encryption key from the secret key
$key = openssl_digest($secretKey, 'SHA256', TRUE);
// Extract the IV, HMAC, and ciphertext from the encoded string
$ciphertext = base64_decode($string);
$ivLength = openssl_cipher_iv_length($cipher = "AES-128-CBC");
$iv = substr($ciphertext, 0, $ivLength);
$hmac = substr($ciphertext, $ivLength, $sha2len = 32);
$ciphertextRaw = substr($ciphertext, $ivLength + $sha2len);
// Ensure the IV is exactly 16 bytes long
$iv = str_pad($iv, $ivLength, "\0");
// Verify the HMAC to ensure integrity
$calculatedHmac = hash_hmac('sha256', $ciphertextRaw, $key, true);
if (!hash_equals($hmac, $calculatedHmac)) {
throw new \RuntimeException('HMAC validation failed.');
}
// Decrypt the ciphertext to obtain the original plaintext
$originalPlaintext = openssl_decrypt($ciphertextRaw, $cipher, $key, OPENSSL_RAW_DATA, $iv);
if ($originalPlaintext === false) {
throw new \RuntimeException('Decryption failed.');
}
return $originalPlaintext;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new Response('Authenticated Successfully', Response::HTTP_OK);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new Response('Unauthorized', Response::HTTP_UNAUTHORIZED);
}
public function createToken(Passport $passport, string $firewallName): TokenInterface
{
throw new \LogicException('This method should not be called for stateless authentication.');
}
}
这是我的用户控制器类:
<?php
namespace App\Controller;
use App\Entity\Company;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class UserController extends AbstractController
{
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
#[Route('/api/login', name: 'login', methods: ['POST'])]
public function login(Request $request): JsonResponse
{
// Retrieve the request data
$requestData = json_decode($request->getContent(), true);
// Ensure the required fields are provided
if (!isset($requestData['name'])) {
return new JsonResponse(['error' => 'Name field is required'], Response::HTTP_BAD_REQUEST);
}
$name = $requestData['name'];
// Encrypt the name
$encryptedName = $this->encrypt($name);
// Generate a token based on the encrypted name
$token = $this->generateToken($encryptedName);
// Return the token to the client
return new JsonResponse(['token' => $token]);
}
private function encrypt(string $name): string
{
$secretKey = $_ENV['APP_SECRET'];
$key = openssl_digest($secretKey, 'SHA256', TRUE);
$ivlen = openssl_cipher_iv_length($cipher = "AES-128-CBC");
$iv = openssl_random_pseudo_bytes($ivlen);
$ciphertextRaw = openssl_encrypt($name, $cipher, $key, OPENSSL_RAW_DATA, $iv);
$hmac = hash_hmac('sha256', $ciphertextRaw, $key, true);
$output = base64_encode($iv . $hmac . $ciphertextRaw);
return $output;
}
private function generateToken(string $encryptedName): string
{
// Generate a token based on the encrypted name
return hash('sha256', $encryptedName);
}
#[Route('/api/users', name: 'user_index', methods: ['GET'])]
public function index(): Response
{
$currentUser = $this->getUser();
if (!$currentUser) {
return $this->json(['error' => 'User not authenticated'], Response::HTTP_UNAUTHORIZED);
}
$userRepository = $this->entityManager->getRepository(User::class);
if (in_array('ROLE_SUPER_ADMIN', $currentUser->getRoles())) {
$users = $userRepository->findAll();
} elseif (in_array('ROLE_COMPANY_ADMIN', $currentUser->getRoles())) {
$users = $userRepository->findBy(['company' => $currentUser->getCompany()]);
} else {
$users = [$currentUser];
}
return $this->json($users);
}
#[Route('/api/users/{id}', name: 'user_show', methods: ['GET'])]
public function show(User $user): Response
{
$currentUser = $this->getUser();
if (!$currentUser) {
return $this->json(['error' => 'User not authenticated'], Response::HTTP_UNAUTHORIZED);
}
if (!in_array('ROLE_SUPER_ADMIN', $currentUser->getRoles()) &&
($user->getCompany() !== $currentUser->getCompany() || !in_array('ROLE_COMPANY_ADMIN', $currentUser->getRoles()))) {
return $this->json(['error' => 'Access denied'], Response::HTTP_FORBIDDEN);
}
return $this->json($user);
}
#[Route('/api/users', name: 'user_new', methods: ['POST'])]
public function new(Request $request, ValidatorInterface $validator): Response
{
$currentUser = $this->getUser();
if (!$currentUser) {
return $this->json(['error' => 'User not authenticated'], Response::HTTP_UNAUTHORIZED);
}
if (!in_array('ROLE_SUPER_ADMIN', $currentUser->getRoles()) && !in_array('ROLE_COMPANY_ADMIN', $currentUser->getRoles())) {
return $this->json(['error' => 'Access denied'], Response::HTTP_FORBIDDEN);
}
// Check if Content-Type is application/json
if ($request->headers->get('Content-Type') !== 'application/json') {
return $this->json(['error' => 'Request must be JSON'], Response::HTTP_UNSUPPORTED_MEDIA_TYPE);
}
// Decode JSON content
$data = json_decode($request->getContent(), true);
// Check if decoding was successful
if ($data === null && json_last_error() !== JSON_ERROR_NONE) {
return $this->json(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST);
}
// Check if required fields are present
if (!isset($data['name']) || !isset($data['role'])) {
return $this->json(['error' => 'Name and role are required'], Response::HTTP_BAD_REQUEST);
}
// Create new user
$user = new User();
$user->setName($data['name']);
$user->setRole($data['role']);
// Validate the user entity
$errors = $validator->validate($user);
// Check if there are any validation errors
if (count($errors) > 0) {
$errorMessages = [];
foreach ($errors as $error) {
$errorMessages[] = $error->getMessage();
}
return $this->json(['errors' => $errorMessages], Response::HTTP_BAD_REQUEST);
}
// If the role is super admin, set company_id to null
if ($data['role'] === 'ROLE_SUPER_ADMIN') {
$user->setCompany(null);
} else {
// Check if company_id is provided and set it if present
if (isset($data['company_id'])) {
// Fetch company entity based on the provided company_id
$company = $this->entityManager->getRepository(Company::class)->find($data['company_id']);
if (!$company) {
return $this->json(['error' => 'Invalid company_id'], Response::HTTP_BAD_REQUEST);
}
// Set the company for the user
$user->setCompany($company);
}
}
// Persist user and associated entities to the database
$this->entityManager->persist($user);
$this->entityManager->flush();
// Return created user
return $this->json($user, Response::HTTP_CREATED);
}
#[Route('/api/users/{id}', name: 'user_delete', methods: ['DELETE'])]
public function delete(User $user): Response
{
$currentUser = $this->getUser();
if (!$currentUser) {
return $this->json(['error' => 'User not authenticated'], Response::HTTP_UNAUTHORIZED);
}
if (!in_array('ROLE_SUPER_ADMIN', $currentUser->getRoles())) {
return $this->json(['error' => 'Access denied'], Response::HTTP_FORBIDDEN);
}
// Delete the user
$this->entityManager->remove($user);
$this->entityManager->flush();
return $this->json(['message' => 'User deleted'], Response::HTTP_OK);
}
}
尽管指定 /login 路由应该无需身份验证即可访问,但我仍然遇到此错误。值得一提的是,我的身份验证逻辑在 Laravel 中运行成功,但在 Symfony 中似乎遇到了这个问题。在我的 Symfony 应用程序中,我使用类似的方法,通过用户名对用户进行身份验证,并利用 .env 文件中存储的 app_secret 进行解密。
有人可以提供有关可能导致此错误的原因以及如何解决该错误的指导吗?我对 Symfony 的身份验证逻辑感到困惑,并且希望能得到任何有关故障排除的见解或建议。谢谢你。
我设法解决了这个问题。这是 userCotnroller 的更新登录方法:
#[Route('/api/login', name: 'login', methods: ['POST'])]
public function login(Request $request): JsonResponse
{
// Retrieve the request data
$requestData = json_decode($request->getContent(), true);
// Ensure the required fields are provided
if (!isset($requestData['name'])) {
return new JsonResponse(['error' => 'Name field is required'], Response::HTTP_BAD_REQUEST);
}
$name = $requestData['name'];
// Check if a user with the provided name exists
$user = $this->entityManager->getRepository(User::class)->findOneBy(['name' => $name]);
if (!$user) {
return new JsonResponse(['error' => 'User not found'], Response::HTTP_NOT_FOUND);
}
// Generate a token
$token = $this->generateToken($name);
// Return the token to the client
return new JsonResponse(['token' => $token]);
}
// Generate a token based on the user's name
private function generateToken(string $name): string
{
// You can use any method to generate a token. Here, I'm using a simple JWT for demonstration purposes.
$payload = [
'name' => $name,
'exp' => time() + 3600 // Token expiration time (1 hour)
];
// Retrieve the secret key from the APP_SECRET environment variable
$secretKey = $_ENV['JWT_SECRET'];
if (!$secretKey) {
throw new \Exception('JWT_SECRET environment variable not found.');
}
// Replace 'your_secret_key' with your actual secret key
return JWT::encode($payload, $secretKey, 'HS256');
}
这是 CustomAuthenticaor:
<?php
namespace App\Security;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class CustomAuthenticator implements AuthenticatorInterface
{
private EntityManagerInterface $entityManager;
private UserRepository $userRepository;
public function __construct(EntityManagerInterface $entityManager, UserRepository $userRepository)
{
$this->entityManager = $entityManager;
$this->userRepository = $userRepository;
}
public function supports(Request $request): ?bool
{
if ($request->attributes->get('_route') === 'login' && $request->isMethod('POST')) {
return false; // Return false for the login route
}
// Return true for other routes where you want this authenticator to handle authentication
return true;
}
public function authenticate(Request $request): SelfValidatingPassport
{
try {
$authorizationHeader = $request->headers->get('Authorization');
if (!$authorizationHeader || !preg_match('/^Bearer\s+(.*?)$/', $authorizationHeader, $matches)) {
throw new CustomUserMessageAuthenticationException('Invalid authorization header', ['error' => 'Bearer token not found']);
}
$jwtToken = $matches[1]; // Extract the JWT token from the Authorization header
// Retrieve the secret key from the JWT_SECRET environment variable
$secretKey = $_ENV['JWT_SECRET'] ?? null;
if (!$secretKey) {
throw new \Exception('JWT_SECRET environment variable not found.');
}
// Decode the JWT token
$decodedToken = JWT::decode($jwtToken, new Key($secretKey, 'HS256'));
//dd($decodedToken);
// Extract the user information from the decoded token
$name = $decodedToken->name ?? null;
//dd($name);
if (!$name) {
throw new CustomUserMessageAuthenticationException('User not found in token');
}
// Load the user from the database
$user = $this->entityManager->getRepository(User::class)->findOneBy(['name' => $name]);
//dd($user);
if (!$user) {
throw new CustomUserMessageAuthenticationException('User not found', ['name' => $name]);
}
$userBadge = new UserBadge($user->getUsername(), function ($username) use ($user) {
return $user;
});
// Create and return a SelfValidatingPassport with the UserBadge
return new SelfValidatingPassport($userBadge);
} catch (\Exception $e) {
// Log the exception to PHP error log
error_log('Authentication error: ' . $e->getMessage());
// Rethrow the exception with a generic error message
throw new CustomUserMessageAuthenticationException('Error decoding token');
}
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(['error' => $exception->getMessage()], Response::HTTP_UNAUTHORIZED);
}
public function createToken(Passport $passport, string $firewallName): TokenInterface
{
$user = $passport->getUser();
// Retrieve the roles of the authenticated user using the UserRepository
$roles = $this->userRepository->loadUserByRole($user->getName());
//dd($roles);
// Create the UsernamePasswordToken with the provided $firewallName and user roles
return new UsernamePasswordToken($user, 'main', (array)$roles);
}
}