You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于Symfony Lexik Bundle实现手机号+OTP无密码认证方案咨询

解决方案:Symfony + Lexik JWT 支持双注册/认证方式

核心思路

Lexik JWT Bundle生成令牌仅需UserInterface实例,不需要依赖密码验证——密码验证是认证环节的逻辑,而非令牌生成的必要条件。我们可以通过以下步骤实现双认证流程:

  1. 扩展用户实体,支持手机号字段,允许邮箱/密码为空(适配无密码注册)
  2. 实现OTP生成、存储、验证逻辑
  3. 自定义OTP认证器,独立处理手机号+OTP的认证流程
  4. 配置Security,让邮箱密码认证和OTP认证共存

1. 修改用户实体

添加手机号字段,放宽邮箱/密码的非空约束(注意:邮箱密码注册时仍需校验必填):

// src/Entity/User.php
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Doctrine\ORM\Mapping\UniqueConstraint;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[UniqueConstraint(name: 'unique_phone', columns: ['phone'])]
#[UniqueConstraint(name: 'unique_email', columns: ['email'])]
class User implements UserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\Column(type: 'string', length: 20, nullable: false)]
    private string $phone;

    #[ORM\Column(type: 'string', length: 180, nullable: true)]
    private ?string $email = null;

    #[ORM\Column(type: 'string', nullable: true)]
    private ?string $password = null;

    #[ORM\Column(type: 'json')]
    private array $roles = [];

    // Getters & Setters
    public function getPassword(): ?string
    {
        return $this->password;
    }

    public function getUsername(): string
    {
        // 兼容Symfony Security,返回手机号或邮箱
        return $this->email ?? $this->phone;
    }

    // 其他UserInterface方法(eraseCredentials、getSalt等)按需实现
}

2. OTP服务实现

用Symfony Cache存储OTP(带过期时间),避免重复使用:

// src/Service/OtpService.php
namespace App\Service;

use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;

class OtpService
{
    public function __construct(private AdapterInterface $cache) {}

    // 生成6位OTP,有效期5分钟
    public function generateOtp(string $phone): string
    {
        $otp = (string)rand(100000, 999999);
        $cacheItem = $this->cache->getItem("otp_{$phone}");
        $cacheItem->set($otp);
        $cacheItem->expiresAfter(300);
        $this->cache->save($cacheItem);

        return $otp;
    }

    // 验证OTP,成功后立即删除
    public function validateOtp(string $phone, string $otp): bool
    {
        $cacheItem = $this->cache->getItem("otp_{$phone}");
        if (!$cacheItem->isHit() || $cacheItem->get() !== $otp) {
            throw new AuthenticationException("无效或过期的验证码");
        }

        $this->cache->deleteItem("otp_{$phone}");
        return true;
    }
}

3. 自定义OTP认证器

处理手机号+OTP的认证请求,验证通过后生成JWT令牌:

// src/Security/OtpAuthenticator.php
namespace App\Security;

use App\Entity\User;
use App\Service\OtpService;
use Doctrine\ORM\EntityManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
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\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

class OtpAuthenticator extends AbstractAuthenticator
{
    public function __construct(
        private EntityManagerInterface $em,
        private OtpService $otpService,
        private JWTTokenManagerInterface $jwtManager
    ) {}

    // 匹配OTP验证接口
    public function supports(Request $request): ?bool
    {
        return $request->getPathInfo() === '/api/auth/otp-verify' && $request->isMethod('POST');
    }

    public function authenticate(Request $request): Passport
    {
        $payload = json_decode($request->getContent(), true);
        $phone = $payload['phone'] ?? '';
        $otp = $payload['otp'] ?? '';

        // 验证OTP
        $this->otpService->validateOtp($phone, $otp);

        // 自动注册新用户(如果手机号未注册)
        $user = $this->em->getRepository(User::class)->findOneBy(['phone' => $phone]);
        if (!$user) {
            $user = new User();
            $user->setPhone($phone);
            $user->setRoles(['ROLE_USER']);
            $this->em->persist($user);
            $this->em->flush();
        }

        return new SelfValidatingPassport(
            new UserBadge($phone, fn() => $user)
        );
    }

    // 认证成功返回JWT
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        /** @var User $user */
        $user = $token->getUser();
        return new JsonResponse([
            'token' => $this->jwtManager->create($user)
        ]);
    }

    // 认证失败返回错误信息
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        return new JsonResponse(
            ['error' => $exception->getMessage()],
            Response::HTTP_UNAUTHORIZED
        );
    }
}

4. 配置Security.yaml

让OTP认证器和邮箱密码认证共存:

# config/packages/security.yaml
security:
    enable_authenticator_manager: true
    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: username # 对应User实体的getUsername方法
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        api:
            pattern: ^/api
            stateless: true
            custom_authenticators:
                - App\Security\OtpAuthenticator
                - Lexik\Bundle\JWTAuthenticationBundle\Security\Authenticator\JWTAuthenticator
            # 邮箱密码登录接口
            json_login:
                check_path: /api/auth/login
                username_path: email
                password_path: password
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
    password_hashers:
        App\Entity\User: 'auto'

5. 编写注册/发送OTP接口

发送OTP接口

// src/Controller/AuthController.php
namespace App\Controller;

use App\Service\OtpService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

class AuthController extends AbstractController
{
    #[Route('/api/auth/send-otp', name: 'send_otp', methods: ['POST'])]
    public function sendOtp(Request $request, OtpService $otpService): JsonResponse
    {
        $payload = json_decode($request->getContent(), true);
        $phone = $payload['phone'] ?? '';

        if (empty($phone)) {
            return new JsonResponse(['error' => '手机号不能为空'], 400);
        }

        // 生成OTP并发送(此处需替换为实际短信API调用)
        $otp = $otpService->generateOtp($phone);
        // $smsService->send($phone, "您的验证码是:{$otp}");

        return new JsonResponse(['message' => '验证码已发送']);
    }
}

邮箱密码注册接口

#[Route('/api/auth/register', name: 'register', methods: ['POST'])]
public function register(
    Request $request,
    EntityManagerInterface $em,
    UserPasswordHasherInterface $passwordHasher,
    JWTTokenManagerInterface $jwtManager
): JsonResponse {
    $payload = json_decode($request->getContent(), true);
    $email = $payload['email'] ?? '';
    $password = $payload['password'] ?? '';

    if (empty($email) || empty($password)) {
        return new JsonResponse(['error' => '邮箱和密码不能为空'], 400);
    }

    $user = new User();
    $user->setEmail($email);
    $user->setPassword($passwordHasher->hashPassword($user, $password));
    $user->setRoles(['ROLE_USER']);

    $em->persist($user);
    $em->flush();

    return new JsonResponse([
        'token' => $jwtManager->create($user),
        'user' => ['email' => $user->getEmail()]
    ]);
}

关键注意事项

  • OTP安全:设置合理过期时间(5-10分钟),验证成功后立即删除缓存,避免重复使用
  • 用户实体约束:通过UniqueConstraint确保手机号/邮箱唯一
  • 短信服务:需集成第三方短信API(如阿里云、Twilio)实现OTP发送
  • 权限控制:根据业务需求调整用户角色配置

内容的提问来源于stack exchange,提问作者YOUSSEF

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.23 18:54:54