本文目录导读:

我来详细介绍在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的所有必要组件,从基础配置到高级特性,包括安全考虑和调试工具。