PHP项目Symfony saml与单点登录

wen PHP项目 2

本文目录导读:

PHP项目Symfony saml与单点登录

  1. SAML SSO基础概念
  2. Symfony SAML集成方案
  3. 高级配置和优化
  4. 测试和调试
  5. 常见问题解决

我来详细介绍在Symfony项目中集成SAML和单点登录(SSO)的实现方案。

SAML SSO基础概念

SAML SSO流程

sequenceDiagram
    participant User as 用户
    participant SP as Symfony (SP)
    participant IDP as 身份提供商 (IdP)
    User->>SP: 访问受保护资源
    SP->>User: 重定向到IdP
    User->>IDP: 认证请求
    IDP->>User: 登录表单
    User->>IDP: 提交凭证
    IDP->>User: SAML断言
    User->>SP: 提交SAML响应
    SP->>User: 创建会话,访问资源

Symfony SAML集成方案

使用 SAML Bundle

安装依赖

composer require hslavich/oneloginsaml-bundle

配置 SAML Bundle

config/packages/hslavich_saml.yaml

hslavich_saml:
    sp:
        entityId: 'https://your-app.com/saml/metadata'
        assertionConsumerService:
            url: 'https://your-app.com/saml/acs'
            binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
        singleLogoutService:
            url: 'https://your-app.com/saml/logout'
            binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
    idp:
        entityId: 'https://idp.example.com/metadata'
        singleSignOnService:
            url: 'https://idp.example.com/sso'
            binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
        singleLogoutService:
            url: 'https://idp.example.com/slo'
            binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
    security:
        keys:
            - private_key_path: '%kernel.project_dir%/config/saml/private.key'
              certificate_path: '%kernel.project_dir%/config/saml/public.cert'

安全配置

config/packages/security.yaml

security:
    firewalls:
        main:
            pattern: ^/
            saml:
                # 认证提供者
                provider: user_provider
                # SAML认证检查
                check_path: saml_acs
                # 登录路径
                login_path: /saml/login
                # 使用默认的认证成功处理器
                default_target_path: /
            logout:
                path: /logout
                target: /
    providers:
        user_provider:
            entity:
                class: App\Entity\User
                property: samlIdpUsername
    access_control:
        - { path: ^/saml/login, roles: PUBLIC_ACCESS }
        - { path: ^/saml/metadata, roles: PUBLIC_ACCESS }
        - { path: ^/saml/acs, roles: PUBLIC_ACCESS }
        - { path: ^/admin, roles: ROLE_USER }

创建认证控制器

src/Controller/SecurityController.php

<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
    #[Route('/login', name: 'app_login')]
    public function login(AuthenticationUtils $authenticationUtils): Response
    {
        // 重定向到SAML SSO
        return $this->redirectToRoute('saml_login');
    }
    #[Route('/saml/login', name: 'saml_login')]
    public function samlLogin(): Response
    {
        // SAML登录由Bundle自动处理
        return $this->render('security/login.html.twig');
    }
    #[Route('/logout', name: 'app_logout')]
    public function logout(): void
    {
        // Symfony会自动处理注销
        throw new \LogicException('This method can be blank');
    }
    #[Route('/saml/acs', name: 'saml_acs')]
    public function assertionConsumerService(): Response
    {
        // SAML断言处理由Bundle自动处理
        throw new \LogicException('This method can be blank');
    }
}

用户实体和提供者

src/Entity/User.php

<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private ?int $id = null;
    #[ORM\Column(type: 'string', length: 180, unique: true)]
    private ?string $email = null;
    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    private ?string $samlIdpUsername = null;
    #[ORM\Column(type: 'json')]
    private array $roles = [];
    #[ORM\Column(type: 'string', nullable: true)]
    private ?string $password = null;
    public function getId(): ?int
    {
        return $this->id;
    }
    public function getEmail(): ?string
    {
        return $this->email;
    }
    public function setEmail(string $email): self
    {
        $this->email = $email;
        return $this;
    }
    public function getSamlIdpUsername(): ?string
    {
        return $this->samlIdpUsername;
    }
    public function setSamlIdpUsername(?string $samlIdpUsername): self
    {
        $this->samlIdpUsername = $samlIdpUsername;
        return $this;
    }
    public function getUserIdentifier(): string
    {
        return (string) $this->email;
    }
    public function getRoles(): array
    {
        $roles = $this->roles;
        $roles[] = 'ROLE_USER';
        return array_unique($roles);
    }
    public function setRoles(array $roles): self
    {
        $this->roles = $roles;
        return $this;
    }
    public function getPassword(): ?string
    {
        return $this->password;
    }
    public function setPassword(?string $password): self
    {
        $this->password = $password;
        return $this;
    }
    public function eraseCredentials(): void
    {
        // 清除敏感数据
    }
}

SAML认证器

src/Security/SamlAuthenticator.php

<?php
namespace App\Security;
use Doctrine\ORM\EntityManagerInterface;
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\Core\Security;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
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 SamlAuthenticator extends AbstractLoginFormAuthenticator
{
    private $entityManager;
    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }
    public function authenticate(Request $request): Passport
    {
        // 从SAML断言中获取用户信息
        $samlAttributes = $request->getSession()->get('saml_attributes');
        if (!$samlAttributes) {
            throw new CustomUserMessageAuthenticationException('No SAML attributes found');
        }
        $email = $samlAttributes['email'] ?? null;
        if (!$email) {
            throw new CustomUserMessageAuthenticationException('Email not provided in SAML response');
        }
        return new SelfValidatingPassport(
            new UserBadge($email, function($userIdentifier) {
                return $this->findOrCreateUser($userIdentifier);
            })
        );
    }
    private function findOrCreateUser(string $email): \App\Entity\User
    {
        $user = $this->entityManager->getRepository(\App\Entity\User::class)
            ->findOneBy(['email' => $email]);
        if (!$user) {
            $user = new \App\Entity\User();
            $user->setEmail($email);
            $user->setRoles(['ROLE_USER']);
            $this->entityManager->persist($user);
            $this->entityManager->flush();
        }
        return $user;
    }
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null; // 使用默认的success handler
    }
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        throw $exception;
    }
    protected function getLoginUrl(Request $request): string
    {
        return $this->urlGenerator->generate('app_login');
    }
}

高级配置和优化

多IdP支持

# config/packages/hslavich_saml.yaml
hslavich_saml:
    sp:
        entityId: 'https://your-app.com/saml/metadata'
        # ... SP配置
    idps:
        main_idp:
            entityId: 'https://main-idp.com/metadata'
            singleSignOnService:
                url: 'https://main-idp.com/sso'
            # ...
        backup_idp:
            entityId: 'https://backup-idp.com/metadata'
            singleSignOnService:
                url: 'https://backup-idp.com/sso'
            # ...

SAML属性映射

// src/EventListener/SamlEventListener.php
namespace App\EventListener;
use Hslavich\OneloginSamlBundle\Event\SamlLoginEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class SamlEventListener implements EventSubscriberInterface
{
    public static function getSubscribedEvents()
    {
        return [
            SamlLoginEvent::class => 'onSamlLogin',
        ];
    }
    public function onSamlLogin(SamlLoginEvent $event)
    {
        $user = $event->getUser();
        $attributes = $event->getAttributes();
        // 映射SAML属性到用户实体
        if (isset($attributes['firstName'])) {
            $user->setFirstName($attributes['firstName'][0]);
        }
        if (isset($attributes['lastName'])) {
            $user->setLastName($attributes['lastName'][0]);
        }
        if (isset($attributes['groups'])) {
            $this->mapGroupsToRoles($user, $attributes['groups']);
        }
    }
}

自定义认证成功处理器

// src/Security/SamlAuthenticationSuccessHandler.php
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
class SamlAuthenticationSuccessHandler implements AuthenticationSuccessHandlerInterface
{
    public function onAuthenticationSuccess(Request $request, TokenInterface $token): ?Response
    {
        $user = $token->getUser();
        // 更新最后登录时间
        $user->setLastLogin(new \DateTime());
        // 记录审计日志
        $this->logAudit('USER_LOGIN_SUCCESS', $user->getEmail());
        // 重定向到原始请求的URL或默认页面
        $targetPath = $request->getSession()->get('_security.main.target_path');
        return $targetPath ? new RedirectResponse($targetPath) : new RedirectResponse('/');
    }
}

生成SAML证书

# 生成私钥
openssl genrsa -out config/saml/private.key 2048
# 生成证书签名请求
openssl req -new -key config/saml/private.key -out config/saml/certificate.csr
# 生成自签名证书
openssl x509 -req -days 365 -in config/saml/certificate.csr -signkey config/saml/private.key -out config/saml/public.cert
# 设置权限
chmod 600 config/saml/private.key
chmod 644 config/saml/public.cert

测试和调试

测试SAML配置

// src/Controller/SamlController.php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class SamlController extends AbstractController
{
    #[Route('/saml/metadata', name: 'saml_metadata')]
    public function metadata(): Response
    {
        // 返回SAML元数据XML
        $metadata = $this->get('hslavich_saml.auth')->getSettings()->getSPMetadata();
        return new Response($metadata, 200, ['Content-Type' => 'text/xml']);
    }
    #[Route('/saml/debug', name: 'saml_debug')]
    public function debug(): Response
    {
        $auth = $this->get('hslavich_saml.auth');
        $settings = $auth->getSettings();
        $debugInfo = [
            'sp_entity_id' => $settings->getSPData()['entityId'],
            'idp_entity_id' => $settings->getIdPData()['entityId'],
            'certificate_valid' => $auth->checkSettings(),
            'session_info' => $this->getUser() ? [
                'username' => $this->getUser()->getUserIdentifier(),
                'roles' => $this->getUser()->getRoles()
            ] : null
        ];
        return $this->json($debugInfo);
    }
}

使用测试IdP

# 使用 TestShib 或 SimpleSAMLphp 作为测试IdP
# config/packages/hslavich_saml.yaml (测试环境)
when@test:
    hslavich_saml:
        idp:
            entityId: 'https://idp.testshib.org/idp/shibboleth'
            singleSignOnService:
                url: 'https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO'

常见问题解决

时钟偏差

# config/packages/hslavich_saml.yaml
hslavich_saml:
    security:
        # 允许的时间偏差(秒)
        wantAssertionsEncrypted: false
        strict: true
        # 增加时间偏差容错
        allowedClockDrift: 60

Session问题

# config/packages/framework.yaml
framework:
    session:
        handler_id: ~
        cookie_secure: auto
        cookie_samesite: lax
        gc_maxlifetime: 3600

调试日志

# config/packages/monolog.yaml
monolog:
    channels: ['saml']
    handlers:
        saml:
            type: stream
            path: '%kernel.logs_dir%/saml_%kernel.environment%.log'
            level: debug
            channels: ['saml']

这个完整的解决方案提供了Symfony项目集成SAML SSO的所有必要组件,从基础配置到高级特性,包括安全考虑和调试工具。

抱歉,评论功能暂时关闭!