diff --git a/README.md b/README.md index b9a1139d..aaf00a45 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,7 @@ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) This repository comprises of a Spring Security module for setting up a SAML Identity Provider -according to the Swedish eID -Framework specifications - https://docs.swedenconnect.se/technical-framework. +according to the [Swedish eID Framework specifications](https://docs.swedenconnect.se/technical-framework). ----- @@ -28,116 +27,16 @@ The repository comprises of the following modules: - `client` - A SAML SP that can be used to send authentication requests to the example IdP. -Javadoc for the project is available [here](https://docs.swedenconnect.se/saml-identity-provider/apidoc/). - -## Configuration and Deployment - -By including the SAML IdP Spring Boot starter as a dependency you basically get a ready-to-go SAML -IdP. - -``` - - se.swedenconnect.spring.saml.idp - saml-idp-spring-boot-starter - ${saml.idp.version} - -``` - -You will need to supply application properties (described in [Configuration Properties](#configuration-properties) below) and also define at least one [UserAuthenticationProvider](https://github.com/swedenconnect/saml-identity-provider/blob/main/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/provider/UserAuthenticationProvider.java) bean. This bean contains the -logic for user authentication. Normally, we need to redirect the user agent (browser) to a separate -endpoint where user authentication is performed. In those cases the [UserRedirectAuthenticationProvider](https://github.com/swedenconnect/saml-identity-provider/blob/main/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/provider/external/UserRedirectAuthenticationProvider.java) is used. - -See the supplied example IdP in this project (`demo-boot-idp`), or perhaps even better the [Swedish eID Reference IdP](https://github.com/swedenconnect/swedish-eid-idp). - - -### Configuration Properties - -This section documents all properties that can be provided to configure the IdP. - -| Property | Description | Type | Default value | -| :--- | :--- | :--- | :--- | -| `saml.idp.entity-id` | The Identity Provider SAML entityID. | String | Required - No default value | -| `saml.idp.base-url` | The Identity Provider base URL, i.e., the protocol, domain and context path. Must not end with an '/'. | String | Required - No default value | -| `saml.idp.hok-base-url` | The Identity Provider base URL for Holder-of-key support, i.e., the protocol, domain and context path. Must not end '/'. This setting is optional, and if HoK is being used **and** that requires a different IdP domain or context path this setting represents this base URL. | String | - | -| `saml.idp.requires-signed-requests` | Whether the IdP requires signed authentication requests. | Boolean | `true` | -| `saml.idp.clock-skew-adjustment` | Clock skew adjustment (in both directions) to consider for accepting messages based on their age. | Duration | 30 seconds | -| `saml.idp.max-message-age` | Maximum allowed age of received messages. | Duration | 3 minutes | -| `saml.idp.sso-duration-limit` | Based on a previous authentication, for how long may this authentication be re-used? Set to 0 seconds to disable SSO. | Duration | 1 hour | -| `saml.idp.credentials.*` | Configuration for IdP credentials, see [Credentials Configuration](#credentials-configuration) below. | [CredentialConfigurationProperties](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/CredentialConfigurationProperties.java) | No default value, but named beans may be provided (see below). | -| `saml.idp.endpoints.*` | Configuration for the endpoints that the IdP exposes, see [Endpoints Configuration](#endpoints-configuration) below. | [EndpointsConfigurationProperties](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/EndpointsConfigurationProperties.java) | See below. | -| `saml.idp.assertions.*` | Configuration for IdP Assertion issuance, see [Assertion Settings Configuration](#assertion-settings-configuration) below. | [AssertionSettingsConfigurationProperties](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/AssertionSettingsConfigurationProperties.java) | See below. | -| `saml.idp.metadata.*` | Configuration for the SAML metadata produced (and published) by the IdP, see [MetadataConfiguration](#metadata-configuration) below. | [MetadataConfigurationProperties](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataConfigurationProperties.java) | See below. | -| `saml.idp-metadata-providers[].*` | A list of "metadata providers" that tells how the IdP downloads federation metadata. See [Metadata Provider Configuration](#metadata-provider-configuration) below. | [MetadataProviderConfigurationProperties](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataProviderConfigurationProperties.java) | See below. | - - -#### Credentials Configuration - -The IdP needs to be configured with at least one credential (private key and certificate). Each of the credential types below may be created by declared named beans instead of using the property configuration. - -See https://github.com/swedenconnect/credentials-support for details about the [PkiCredential](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/PkiCredential.java) type and how it is configured. - -| Property | Description | Type | -| :--- | :--- | :--- | -| `default-credential.*` | The IdP default credential. This will be used if no specific credential is defined for the usages sign, encrypt or metadata signing.
It is also possible to define the default credential by declaring a bean of type [PkiCredential](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/PkiCredential.java) and name it `saml.idp.credentials.Default`. | [PkiCredentialConfigurationProperties](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/factory/PkiCredentialConfigurationProperties.java) | -| `sign.*` | The credential the IdP uses to sign (responses and assertions).
It is also possible to define the signing credential by declaring a bean of type [PkiCredential](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/PkiCredential.java) and name it `saml.idp.credentials.Sign`. | [PkiCredentialConfigurationProperties](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/factory/PkiCredentialConfigurationProperties.java) | -| `future-sign` | A certificate that will be the future signing certificate. Is set before a key-rollover is performed.
It is also possible to define the future signing certificate by declaring a bean of type `X509Certificate` and name it `saml.idp.credentials.FutureSign`. | [Resource](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/io/Resource.html) (pointing at a certificate resource). | -| `encrypt.*` | The IdP encryption credential. This will be used by SP:s to encrypt data (the certificate) for the IdP (for example sign messages), and by the IdP to decrypt these messages. If no Sweden Connect features are used, no encrypt-credential is needed.
It is also possible to define the encrypt credential by declaring a bean of type [PkiCredential](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/PkiCredential.java) and name it `saml.idp.credentials.Encrypt`. | [PkiCredentialConfigurationProperties](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/factory/PkiCredentialConfigurationProperties.java) | -| `previous-encrypt.*` | The previous IdP encryption credential. Assigned after a key-rollover of the encrypt credential.
It is also possible to define the previous encrypt credential by declaring a bean of type [PkiCredential](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/PkiCredential.java) and name it `saml.idp.credentials.PreviousEncrypt`. | [PkiCredentialConfigurationProperties](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/factory/PkiCredentialConfigurationProperties.java) | -| `metadata-sign.*` | The credential the IdP uses to sign its published metadata.
It is also possible to define the metadata signing credential by declaring a bean of type [PkiCredential](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/PkiCredential.java) and name it `saml.idp.credentials.MetadataSign`.

If no metadata sign credential is configured, the default credential will be used. If no default credential exists, metadata published will not be signed. | [PkiCredentialConfigurationProperties](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/factory/PkiCredentialConfigurationProperties.java) | - - -#### Endpoints Configuration - -| Property | Description | Type | Default value | -| :--- | :--- | :--- | :--- | -| `redirect-authn` | The endpoint where the Identity Provider receives authentication requests via HTTP redirect. | String | `/saml2/redirect/authn` | -| `post-authn` | The endpoint where the Identity Provider receives authentication requests via HTTP POST. | String | `/saml2/post/authn` | -| `hok-redirect-authn` | The endpoint where the Identity Provider receives authentication requests via HTTP redirect where Holder-of-key (HoK) is used. | String | - | -| `hok-post-authn` | The endpoint where the Identity Provider receives authentication requests via HTTP POST where Holder-of-key (HoK) is used. | String | - | -| `metadata` | The SAML metadata publishing endpoint. | String | `/saml2/metadata` | - - -#### Assertion Settings Configuration - -| Property | Description | Type | Default value | -| :--- | :--- | :--- | :--- | -| `encrypt` | Tells whether the Identity Provider encrypts assertions. | Boolean | `true` | -| `not-after` | A setting that tells the time restrictions the IdP puts on an Assertion concerning "not on or after". | Duration | 5 minutes | -| `not-before` | A setting that tells the time restrictions the IdP puts on an Assertion concerning "not before". | Duration | 10 seconds. | - - -#### Metadata Configuration - -| Property | Description | Type | Default value | -| :--- | :--- | :--- | :--- | -| `template` | A template for the IdP metadata. This is an XML document containing (partial) SAML metadata. | [Resource](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/io/Resource.html) (pointing at a XML-file resource). | - | -| `cache-duration` | Tells how long the published IdP metadata can remain in a cache. | Duration | 24 hours | -| `validity-period` | Tells for how long a published metadata entry should be valid. | Duration | 7 days | -| `ui-info.*` | Configuration for the metadata `UIInfo` element. See the `UIInfo` class in [MetadataConfigurationProperties](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataConfigurationProperties.java) for details. | [MetadataConfigurationProperties.UIInfo](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataConfigurationProperties.java) | - | -| `organization.*` | Settings for the `Organization` metadata element. See the `Organization` class in the [MetadataConfigurationProperties](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataConfigurationProperties.java) for details. | [MetadataConfigurationProperties.Organization](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataConfigurationProperties.java) | - | -| `contact-persons.*` | A map of the metadata `ContactPerson` elements, where the key is the type and the value is a `ContactPerson`. | [MetadataConfigurationProperties.ContactPerson](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataConfigurationProperties.java) | - | - - -#### Metadata Provider Configuration - -| Property | Description | Type | Default value | -| :--- | :--- | :--- | :--- | -| `location` | The location of the metadata. Can be an URL, a file, or even a classpath resource. | [Resource](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/io/Resource.html) | - | -| `backup-location` | If the `location` setting is an URL, a "backup location" may be assigned to store downloaded metadata. | [File](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/io/File.html) | - | -| `mdq` | If the `location` setting is an URL, setting the MDQ-flag means that the metadata MDQ (https://www.ietf.org/id/draft-young-md-query-17.html) protocol is used. | Boolean | `false` | -| `validation-certificate` | The certificate used to validate the metadata. | [Resource](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/io/Resource.html) pointing at the certificate resource. | - | -| `http-proxy.*` | If the `location` setting is an URL and a HTTP proxy is required this setting configures this proxy. | [MetadataProviderConfigurationProperties.HttpProxy](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataProviderConfigurationProperties.java) | - | - -## Example Application - -The `samples` directory contains a example SAML IdP using the SAML IdP Spring Boot starter and a -test SAML SP that can be used to send SAML authentication requests to the IdP and to receive and -process SAML response messages. - -You should be able to use the default configuration for the applications and just build and run them. -The only thing you need to do is to map "127.0.0.1" to `local.dev.swedenconnect.se` in your hosts file. - -Open your web browser and go to the test client: `https://localhost:8445/client/`. +## Documentation + +- [Java Documentation](https://docs.swedenconnect.se/saml-identity-provider/apidoc/) + +- [Configuration and Deployment](docs/configuration.md) + +- [Audit Logging](docs/audit.md) + +- [Example Application](docs/example.md) + ----- diff --git a/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/IdentityProviderAutoConfiguration.java b/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/IdentityProviderAutoConfiguration.java index 3a64f79e..660b0238 100644 --- a/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/IdentityProviderAutoConfiguration.java +++ b/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/IdentityProviderAutoConfiguration.java @@ -25,6 +25,7 @@ import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.DependsOn; import org.springframework.context.annotation.Import; @@ -35,6 +36,7 @@ import se.swedenconnect.opensaml.saml2.response.replay.MessageReplayCheckerImpl; import se.swedenconnect.security.credential.PkiCredential; import se.swedenconnect.spring.saml.idp.config.configurers.Saml2IdpConfigurer; +import se.swedenconnect.spring.saml.idp.events.Saml2IdpEventPublisher; import se.swedenconnect.spring.saml.idp.settings.AssertionSettings; import se.swedenconnect.spring.saml.idp.settings.CredentialSettings; import se.swedenconnect.spring.saml.idp.settings.EndpointSettings; @@ -189,6 +191,12 @@ IdentityProviderSettings identityProviderSettings() { } + @ConditionalOnMissingBean + @Bean + Saml2IdpEventPublisher saml2IdpEventPublisher(final ApplicationEventPublisher applicationEventPublisher) { + return new Saml2IdpEventPublisher(applicationEventPublisher); + } + @ConditionalOnMissingBean @Bean MessageReplayChecker messageReplayChecker(@Autowired(required = false) final ReplayCache replayCache) { diff --git a/docs/audit.md b/docs/audit.md new file mode 100644 index 00000000..6a04eeb8 --- /dev/null +++ b/docs/audit.md @@ -0,0 +1,162 @@ +![Logo](images/sweden-connect.png) + +# Identity Provider Auditing + +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) + +----- + +The library produces audit log entries using Spring Boot's auditing support, see +[Spring Boot Authentication Auditing Support](https://www.baeldung.com/spring-boot-authentication-audit). + +If you want to be able to obtain audit logs via Spring Boot Actuator you need to: + +- Set the property `management.auditevents.enabled` to `true`. + +- Include the string `auditevents` among the list specified by the setting +`management.endpoints.web.exposure.include`. + +- Make sure a `org.springframework.boot.actuate.audit.AuditEventRepository` bean exists. + +## Audit Events + +All audit events will contain the following fields: + +- `type` - The type of the audit entry, see below. + +- `timestamp` - The timestamp of when the audit event entry was created. + +- `principal` - The "owner" of the entry. This will always the the SAML entityID of the Service +Provider that requested authentication. + +- `data` - Auditing data that is specific to the type of audit event. However, the following fields +will always be present: + + - `sp-entity-id` - The "owner" of the entry. This will always the the SAML entityID of the Service Provider that requested authentication. If not available, `unknown` is used. + + - `authn-request-id` - The ID of the authentication request that is being processed (`AuthnRequest`). If not available, `unknown` is used. + +### Authentication Request Received + +**Type:** `SAML2_REQUEST_RECEIVED` + +**Description:** An event that is created when a SAML `AuthnRequest` has been received. At this point +the IdP has not performed any checks to validate the correctness of the message. + +**Audit data**: `authn-request` + +| Parameter | Description | Type | +| :--- | :--- | :--- | +| `id` | The ID of the `AuthnRequest`. | String | +| `issuer` | The entity that issued the authentication request (SP entityID). | String | +| `authn-context-class-refs` | The requested Authentication Context Class References, or, the requested Level of Assurance levels. | A list of strings | +| `force-authn` | Tells whether the SP requires the user to be authenticated. | Boolean | +| `is-passive` | Tells whether the SP requires that no user authentication is performed (i.e., requires SSO). | Boolean | +| `relay-state` | The RelayState variable of the request. | String | + +### Before User Authentication + +**Type:** `SAML2_BEFORE_USER_AUTHN` + +**Description:** The received authentication request has been successfully validated. No additional +data except for the common fields is included. The data is the same as for `SAML2_REQUEST_RECEIVED` +described above. + +### After User Authentication + +**Type:** `SAML2_AFTER_USER_AUTHN` + +**Description:** The Identity Provider has successfully authenticated the user. This can also be +a re-use of a previously performed authentication (SSO). In those cases this is reflected in the +audit data. + +**Audit data**: `user-authentication-info` + +| Parameter | Description | Type | +| :--- | :--- | :--- | +| `authn-instant` | The instant when the user authenticated. | String | +| `subject-locality` | The subject's locality (IP address). | String | +| `authn-context-class-ref` | The URI for the Authentication Context Class (LoA) under which the authentication was made. | String | +| `authn-authority` | Optional identity of an "authenticating authority", used for proxy IdP:s. | String | +| `user-attributes` | A list of elements listing the user attributes that was issued.
**Note:** This will be a complete list of user attributes as seen be the authenticator. It is not sure that all of them are released in the resulting SAML assertion. This depends on the release policy used. | List of attributes with fields `name` and `value`. | +| `sign-message-displayed` | If the request was sent by a "signature service" SP this field will indicate whether a "sign message" was displayed for the user or not. | Boolean | +| `allowed-to-reuse` | Tells whether the IdP will allow this particular authentication to be re-used in forthcoming operations (i.e., can it be used for SSO?). | Boolean | +| `sso-information` | If the current authentication was re-used from a previous user authentication (SSO) this field contains the fields `original-requester` and `original-authn-request-id`. These fields identify the requesting entity and the ID of the authentication request when the user authenticated. The `authn-instant` (see above) will in these cases be set to this instant. | SsoInfo | + + +### Successful SAML Response + +**Type:** `SAML2_SUCCESS_RESPONSE` + +**Description:** An event that is created before a success SAML response is sent. This means that the +request has been processed, the user authenticated and a SAML assertion created. + +**Audit data**: `saml-response` + +| Parameter | Description | Type | +| :--- | :--- | :--- | +| `id` | The ID of the SAML `Response` message. | String | +| `in-response-to` | The ID of the `AuthnRequest` message that triggered this operation. | String | +| `status.code` | The status code of the operation. Will always be `urn:oasis:names:tc:SAML:2.0:status:Success` | String | +| `issued-at` | The time of issuance. | String | +| `destination` | The "destination" of the response message, i.e., the URL to which the message is posted. | String | +| `is-signed` | Tells whether the message is signed. | Boolean | + +**Audit data**: `saml-assertion` + +| Parameter | Description | Type | +| :--- | :--- | :--- | +| `id` | The ID of the SAML `Assertion`. | String | +| `in-response-to` | The ID of the `AuthnRequest` message that triggered this operation. | String | +| `is-signed` | Tells whether the assertion is signed. | Boolean | +| `is-encrypted` | Tells whether the assertion is encrypted before being included in the response message. | String | +| `issued-at` | The issuance time for the assertion. | String | +| `issuer` | The entityID of the issuing entity (IdP). | String | +| `authn-instant` | The instant when the user authenticated. | String | +| `subject-id` | The `Subject` identity included in the assertion. | String | +| `subject-locality` | The subject's locality (IP address). | String | +| `authn-context-class-ref` | The URI for the Authentication Context Class (LoA) under which the authentication was made. | String | +| `authn-authority` | Optional identity of an "authenticating authority", used for proxy IdP:s. | String | +| `attributes` | A list of elements listing the SAML attributes that was issued. | List of attributes with fields `name` and `value`. | + +### Error SAML Response + +**Type:** `SAML2_AUDIT_ERROR_RESPONSE` + +**Description:** An event that is created before an error SAML response is sent. The error can represent +a bad request or that the user authentication failed. + +Note: The case when the user has cancelled the operation is represented by setting the +`status.subordinate-code` field to `http://id.elegnamnden.se/status/1.0/cancel`. + +**Audit data**: `saml-response` + +| Parameter | Description | Type | +| :--- | :--- | :--- | +| `id` | The ID of the SAML `Response` message. | String | +| `in-response-to` | The ID of the `AuthnRequest` message that triggered this operation. | String | +| `status.code` | The main status code of the operation (was the error due to an error by the requester or by the responder?). | String | +| `status.subordinate-code` | The subordinate status code. | String | +| `status.message` | Textual error message. | String +| `issued-at` | The time of issuance. | String | +| `destination` | The "destination" of the response message, i.e., the URL to which the message is posted. | String | +| `is-signed` | Tells whether the message is signed. | Boolean | + +### Unrecoverable Error + +**Type:** `SAML2_UNRECOVERABLE_ERROR` + +**Description:** If an error occurs during processing of an request and the IdP has no means of posting +a SAML error response back, this error is displayed in the user interface. In these cases this is also audited. + +**Audit data**: `unrecoverable-error` + +| Parameter | Description | Type | +| :--- | :--- | :--- | +| `error-code` | The error code. | String | +| `error-message` | The error message. | String | + + +--- + +Copyright © 2022-2023, [Myndigheten för digital förvaltning - Swedish Agency for Digital Government (DIGG)](http://www.digg.se). Licensed under version 2.0 of the [Apache License](http://www.apache.org/licenses/LICENSE-2.0). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..9c4254ab --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,108 @@ +![Logo](images/sweden-connect.png) + +# Identity Provider Configuration and Deployment + +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) + +----- + +By including the SAML IdP Spring Boot starter as a dependency you basically get a ready-to-go SAML +IdP. + +``` + + se.swedenconnect.spring.saml.idp + saml-idp-spring-boot-starter + ${saml.idp.version} + +``` + +You will need to supply application properties (described in [Configuration Properties](#configuration-properties) below) and also define at least one [UserAuthenticationProvider](https://github.com/swedenconnect/saml-identity-provider/blob/main/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/provider/UserAuthenticationProvider.java) bean. This bean contains the +logic for user authentication. Normally, we need to redirect the user agent (browser) to a separate +endpoint where user authentication is performed. In those cases the [UserRedirectAuthenticationProvider](https://github.com/swedenconnect/saml-identity-provider/blob/main/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/provider/external/UserRedirectAuthenticationProvider.java) is used. + +See the supplied example IdP in this project (`demo-boot-idp`), or perhaps even better the [Swedish eID Reference IdP](https://github.com/swedenconnect/swedish-eid-idp). + + +### Configuration Properties + +This section documents all properties that can be provided to configure the IdP. + +| Property | Description | Type | Default value | +| :--- | :--- | :--- | :--- | +| `saml.idp.entity-id` | The Identity Provider SAML entityID. | String | Required - No default value | +| `saml.idp.base-url` | The Identity Provider base URL, i.e., the protocol, domain and context path. Must not end with an '/'. | String | Required - No default value | +| `saml.idp.hok-base-url` | The Identity Provider base URL for Holder-of-key support, i.e., the protocol, domain and context path. Must not end '/'. This setting is optional, and if HoK is being used **and** that requires a different IdP domain or context path this setting represents this base URL. | String | - | +| `saml.idp.requires-signed-requests` | Whether the IdP requires signed authentication requests. | Boolean | `true` | +| `saml.idp.clock-skew-adjustment` | Clock skew adjustment (in both directions) to consider for accepting messages based on their age. | Duration | 30 seconds | +| `saml.idp.max-message-age` | Maximum allowed age of received messages. | Duration | 3 minutes | +| `saml.idp.sso-duration-limit` | Based on a previous authentication, for how long may this authentication be re-used? Set to 0 seconds to disable SSO. | Duration | 1 hour | +| `saml.idp.credentials.*` | Configuration for IdP credentials, see [Credentials Configuration](#credentials-configuration) below. | [CredentialConfigurationProperties](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/CredentialConfigurationProperties.java) | No default value, but named beans may be provided (see below). | +| `saml.idp.endpoints.*` | Configuration for the endpoints that the IdP exposes, see [Endpoints Configuration](#endpoints-configuration) below. | [EndpointsConfigurationProperties](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/EndpointsConfigurationProperties.java) | See below. | +| `saml.idp.assertions.*` | Configuration for IdP Assertion issuance, see [Assertion Settings Configuration](#assertion-settings-configuration) below. | [AssertionSettingsConfigurationProperties](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/AssertionSettingsConfigurationProperties.java) | See below. | +| `saml.idp.metadata.*` | Configuration for the SAML metadata produced (and published) by the IdP, see [MetadataConfiguration](#metadata-configuration) below. | [MetadataConfigurationProperties](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataConfigurationProperties.java) | See below. | +| `saml.idp-metadata-providers[].*` | A list of "metadata providers" that tells how the IdP downloads federation metadata. See [Metadata Provider Configuration](#metadata-provider-configuration) below. | [MetadataProviderConfigurationProperties](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataProviderConfigurationProperties.java) | See below. | + + +#### Credentials Configuration + +The IdP needs to be configured with at least one credential (private key and certificate). Each of the credential types below may be created by declared named beans instead of using the property configuration. + +See https://github.com/swedenconnect/credentials-support for details about the [PkiCredential](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/PkiCredential.java) type and how it is configured. + +| Property | Description | Type | +| :--- | :--- | :--- | +| `default-credential.*` | The IdP default credential. This will be used if no specific credential is defined for the usages sign, encrypt or metadata signing.
It is also possible to define the default credential by declaring a bean of type [PkiCredential](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/PkiCredential.java) and name it `saml.idp.credentials.Default`. | [PkiCredentialConfigurationProperties](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/factory/PkiCredentialConfigurationProperties.java) | +| `sign.*` | The credential the IdP uses to sign (responses and assertions).
It is also possible to define the signing credential by declaring a bean of type [PkiCredential](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/PkiCredential.java) and name it `saml.idp.credentials.Sign`. | [PkiCredentialConfigurationProperties](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/factory/PkiCredentialConfigurationProperties.java) | +| `future-sign` | A certificate that will be the future signing certificate. Is set before a key-rollover is performed.
It is also possible to define the future signing certificate by declaring a bean of type `X509Certificate` and name it `saml.idp.credentials.FutureSign`. | [Resource](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/io/Resource.html) (pointing at a certificate resource). | +| `encrypt.*` | The IdP encryption credential. This will be used by SP:s to encrypt data (the certificate) for the IdP (for example sign messages), and by the IdP to decrypt these messages. If no Sweden Connect features are used, no encrypt-credential is needed.
It is also possible to define the encrypt credential by declaring a bean of type [PkiCredential](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/PkiCredential.java) and name it `saml.idp.credentials.Encrypt`. | [PkiCredentialConfigurationProperties](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/factory/PkiCredentialConfigurationProperties.java) | +| `previous-encrypt.*` | The previous IdP encryption credential. Assigned after a key-rollover of the encrypt credential.
It is also possible to define the previous encrypt credential by declaring a bean of type [PkiCredential](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/PkiCredential.java) and name it `saml.idp.credentials.PreviousEncrypt`. | [PkiCredentialConfigurationProperties](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/factory/PkiCredentialConfigurationProperties.java) | +| `metadata-sign.*` | The credential the IdP uses to sign its published metadata.
It is also possible to define the metadata signing credential by declaring a bean of type [PkiCredential](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/PkiCredential.java) and name it `saml.idp.credentials.MetadataSign`.

If no metadata sign credential is configured, the default credential will be used. If no default credential exists, metadata published will not be signed. | [PkiCredentialConfigurationProperties](https://github.com/swedenconnect/credentials-support/blob/main/src/main/java/se/swedenconnect/security/credential/factory/PkiCredentialConfigurationProperties.java) | + + +#### Endpoints Configuration + +| Property | Description | Type | Default value | +| :--- | :--- | :--- | :--- | +| `redirect-authn` | The endpoint where the Identity Provider receives authentication requests via HTTP redirect. | String | `/saml2/redirect/authn` | +| `post-authn` | The endpoint where the Identity Provider receives authentication requests via HTTP POST. | String | `/saml2/post/authn` | +| `hok-redirect-authn` | The endpoint where the Identity Provider receives authentication requests via HTTP redirect where Holder-of-key (HoK) is used. | String | - | +| `hok-post-authn` | The endpoint where the Identity Provider receives authentication requests via HTTP POST where Holder-of-key (HoK) is used. | String | - | +| `metadata` | The SAML metadata publishing endpoint. | String | `/saml2/metadata` | + + +#### Assertion Settings Configuration + +| Property | Description | Type | Default value | +| :--- | :--- | :--- | :--- | +| `encrypt` | Tells whether the Identity Provider encrypts assertions. | Boolean | `true` | +| `not-after` | A setting that tells the time restrictions the IdP puts on an Assertion concerning "not on or after". | Duration | 5 minutes | +| `not-before` | A setting that tells the time restrictions the IdP puts on an Assertion concerning "not before". | Duration | 10 seconds. | + + +#### Metadata Configuration + +| Property | Description | Type | Default value | +| :--- | :--- | :--- | :--- | +| `template` | A template for the IdP metadata. This is an XML document containing (partial) SAML metadata. | [Resource](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/io/Resource.html) (pointing at a XML-file resource). | - | +| `cache-duration` | Tells how long the published IdP metadata can remain in a cache. | Duration | 24 hours | +| `validity-period` | Tells for how long a published metadata entry should be valid. | Duration | 7 days | +| `ui-info.*` | Configuration for the metadata `UIInfo` element. See the `UIInfo` class in [MetadataConfigurationProperties](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataConfigurationProperties.java) for details. | [MetadataConfigurationProperties.UIInfo](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataConfigurationProperties.java) | - | +| `organization.*` | Settings for the `Organization` metadata element. See the `Organization` class in the [MetadataConfigurationProperties](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataConfigurationProperties.java) for details. | [MetadataConfigurationProperties.Organization](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataConfigurationProperties.java) | - | +| `contact-persons.*` | A map of the metadata `ContactPerson` elements, where the key is the type and the value is a `ContactPerson`. | [MetadataConfigurationProperties.ContactPerson](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataConfigurationProperties.java) | - | + + +#### Metadata Provider Configuration + +| Property | Description | Type | Default value | +| :--- | :--- | :--- | :--- | +| `location` | The location of the metadata. Can be an URL, a file, or even a classpath resource. | [Resource](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/io/Resource.html) | - | +| `backup-location` | If the `location` setting is an URL, a "backup location" may be assigned to store downloaded metadata. | [File](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/io/File.html) | - | +| `mdq` | If the `location` setting is an URL, setting the MDQ-flag means that the metadata MDQ (https://www.ietf.org/id/draft-young-md-query-17.html) protocol is used. | Boolean | `false` | +| `validation-certificate` | The certificate used to validate the metadata. | [Resource](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/io/Resource.html) pointing at the certificate resource. | - | +| `http-proxy.*` | If the `location` setting is an URL and a HTTP proxy is required this setting configures this proxy. | [MetadataProviderConfigurationProperties.HttpProxy](https://github.com/swedenconnect/saml-identity-provider/blob/main/autoconfigure/src/main/java/se/swedenconnect/spring/saml/idp/autoconfigure/settings/MetadataProviderConfigurationProperties.java) | - | + + +--- + +Copyright © 2022-2023, [Myndigheten för digital förvaltning - Swedish Agency for Digital Government (DIGG)](http://www.digg.se). Licensed under version 2.0 of the [Apache License](http://www.apache.org/licenses/LICENSE-2.0). diff --git a/docs/example.md b/docs/example.md new file mode 100644 index 00000000..cfd38170 --- /dev/null +++ b/docs/example.md @@ -0,0 +1,20 @@ +![Logo](images/sweden-connect.png) + + +# Identity Provider Example Application + +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) + +The [samples directory](https://github.com/swedenconnect/saml-identity-provider/tree/main/samples) +contains a example SAML IdP using the SAML IdP Spring Boot starter and a +test SAML SP that can be used to send SAML authentication requests to the IdP and to receive and +process SAML response messages. + +You should be able to use the default configuration for the applications and just build and run them. +The only thing you need to do is to map "127.0.0.1" to `local.dev.swedenconnect.se` in your hosts file. + +Open your web browser and go to the test client: `https://localhost:8445/client/`. + +----- + +Copyright © 2022-2023, [Myndigheten för digital förvaltning - Swedish Agency for Digital Government (DIGG)](http://www.digg.se). Licensed under version 2.0 of the [Apache License](http://www.apache.org/licenses/LICENSE-2.0). \ No newline at end of file diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 5d3892aa..00000000 --- a/docs/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - saml-identity-provider - - - If you are not redirected automatically, follow this link. - - diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..b698bfe9 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,22 @@ +![Logo](images/sweden-connect.png) + +# Spring Security SAML Identity Provider + +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) + +The https://github.com/swedenconnect/saml-identity-provider repository comprises of a Spring Security +module for setting up a SAML Identity Provider according to the [Swedish eID Framework specifications]( +https://docs.swedenconnect.se/technical-framework). + + +- [Java Documentation](https://docs.swedenconnect.se/saml-identity-provider/apidoc/) + +- [Configuration and Deployment](configuration.md) + +- [Audit Logging](audit.md) + +- [Example Application](example.md) + +----- + +Copyright © 2022-2023, [Myndigheten för digital förvaltning - Swedish Agency for Digital Government (DIGG)](http://www.digg.se). Licensed under version 2.0 of the [Apache License](http://www.apache.org/licenses/LICENSE-2.0). \ No newline at end of file diff --git a/saml-identity-provider/pom.xml b/saml-identity-provider/pom.xml index 37e85614..1b062caf 100644 --- a/saml-identity-provider/pom.xml +++ b/saml-identity-provider/pom.xml @@ -69,6 +69,11 @@ spring-security-config + + org.springframework.boot + spring-boot-actuator + + org.thymeleaf thymeleaf-spring5 diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/attributes/UserAttribute.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/attributes/UserAttribute.java index f958ee9a..fd2a9c91 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/attributes/UserAttribute.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/attributes/UserAttribute.java @@ -295,7 +295,22 @@ public String toString() { sb.append(", value=").append(v.get(0)); } else { - sb.append(", values=").append(v); + sb.append(", values=").append(this.valuesToString()); + } + } + return sb.toString(); + } + + public String valuesToString() { + final StringBuffer sb = new StringBuffer(); + final List values = this.getValues(); + if (values.isEmpty()) { + return null; + } + sb.append(values.get(0)); + if (values.size() > 1) { + for (int i = 1; i < values.size(); i++) { + sb.append(",").append(values.get(i)); } } return sb.toString(); diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/attributes/nameid/PersistentNameIDGenerator.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/attributes/nameid/PersistentNameIDGenerator.java index c9871edc..d59cc526 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/attributes/nameid/PersistentNameIDGenerator.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/attributes/nameid/PersistentNameIDGenerator.java @@ -65,7 +65,8 @@ protected String getIdentifier(final Saml2UserAuthentication authentication) { final String userId = authentication.getName(); if (userId == null) { - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "Failed to compute NameID - missing user ID"); + throw new UnrecoverableSaml2IdpException( + UnrecoverableSaml2IdpError.INTERNAL, "Failed to compute NameID - missing user ID", authentication); } try { @@ -82,7 +83,8 @@ protected String getIdentifier(final Saml2UserAuthentication authentication) { return Base64.getEncoder().encodeToString(md.digest()); } catch (final NoSuchAlgorithmException e) { - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "Failed to compute NameID", e); + throw new UnrecoverableSaml2IdpException( + UnrecoverableSaml2IdpError.INTERNAL, "Failed to compute NameID", e, authentication); } } diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/attributes/release/DefaultAttributeProducer.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/attributes/release/DefaultAttributeProducer.java index 8e21147d..d31d01aa 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/attributes/release/DefaultAttributeProducer.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/attributes/release/DefaultAttributeProducer.java @@ -53,7 +53,7 @@ public List releaseAttributes(final Saml2UserAuthentication userAuthe Optional.ofNullable(userAuthentication.getAuthnRequirements()) .map(AuthenticationRequirements::getRequestedAttributes) .orElseThrow(() -> new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, - "No authentication requirements available")); + "No authentication requirements available", userAuthentication)); final List attributes = new ArrayList<>(); for (final UserAttribute ua : userAuthentication.getSaml2UserDetails().getAttributes()) { diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/Saml2AuditEvent.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/Saml2AuditEvent.java new file mode 100644 index 00000000..aa2d416d --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/Saml2AuditEvent.java @@ -0,0 +1,99 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.audit; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.springframework.boot.actuate.audit.AuditEvent; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; +import se.swedenconnect.spring.saml.idp.audit.data.Saml2AuditData; + +/** + * Audit event for creating event objects for the SAML IdP. + * + * @author Martin Lindström + */ +@JsonInclude(Include.NON_EMPTY) +public class Saml2AuditEvent extends AuditEvent { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** Symbolic constant for an unknown SP. */ + public static final String UNKNOWN_SP = "unknown"; + + /** Symbolic constant for an unknown AuthnRequest ID. */ + public static final String UNKNOWN_AUTHN_REQUEST_ID = "unknown"; + + /** + * Constructor. + * + * @param type the type of audit event + * @param timestamp the timestamp (in millis since epoch) + * @param spEntityId the entityID of the requesting SP + * @param authnRequestId the ID of the {@code AuthnRequest} + * @param data audit data + */ + public Saml2AuditEvent(final String type, final long timestamp, final String spEntityId, final String authnRequestId, + final Saml2AuditData... data) { + super(Instant.ofEpochMilli(timestamp), type, Optional.ofNullable(spEntityId).orElseGet(() -> UNKNOWN_SP), + buildData(spEntityId, authnRequestId, data)); + } + + /** + * Builds a {@link Map} given the supplied audit data + * + * @param spEntityId the entityID of the requesting SP + * @param authnRequestId the ID of the {@code AuthnRequest} + * @param data audit data + * @return a {@link Map} of audit data + */ + private static Map buildData( + final String spEntityId, final String authnRequestId, final Saml2AuditData... data) { + final Map auditData = new HashMap<>(); + + auditData.put("sp-entity-id", StringUtils.hasText(spEntityId) ? spEntityId : UNKNOWN_SP); + auditData.put("authn-request-id", StringUtils.hasText(authnRequestId) ? authnRequestId : UNKNOWN_AUTHN_REQUEST_ID); + if (data != null) { + for (final Saml2AuditData sad : data) { + auditData.put(sad.getName(), sad); + } + } + + return auditData; + } + + /** + * Gets a string suitable to include in log entries. It does not dump the entire audit data that can contain sensible + * data (that should not be present in proceess logs). + * + * @return a log string + */ + @JsonIgnore + public String getLogString() { + return String.format("type='%s', sp-entity-id='%s', authn-request-id='%s'", this.getType(), + this.getData().get("sp-entity-id"), this.getData().get("authn-request-id")); + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/Saml2AuditEvents.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/Saml2AuditEvents.java new file mode 100644 index 00000000..12182fbe --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/Saml2AuditEvents.java @@ -0,0 +1,47 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.audit; + +/** + * Symbolic constants for all audit event types produced by the SAML IdP. + * + * @author Martin Lindström + */ +public class Saml2AuditEvents { + + /** An {@code AuthnRequest} message has been received. */ + public static final String SAML2_AUDIT_REQUEST_RECEIVED = "SAML2_REQUEST_RECEIVED"; + + /** A successful SAML response is about to be sent. */ + public static final String SAML2_AUDIT_SUCCESSFUL_RESPONSE = "SAML2_SUCCESS_RESPONSE"; + + /** An error SAML response is about to be sent. */ + public static final String SAML2_AUDIT_ERROR_RESPONSE = "SAML2_ERROR_RESPONSE"; + + /** A request has been received and successfully processed, but the user has not yet been authenticated. */ + public static final String SAML2_AUDIT_BEFORE_USER_AUTHN = "SAML2_BEFORE_USER_AUTHN"; + + /** The user has been successfully authenticated, but the SAML assertion has not yet been created. */ + public static final String SAML2_AUDIT_AFTER_USER_AUTHN = "SAML2_AFTER_USER_AUTHN"; + + /** An error occurred and we could not direct the user back to the SP. */ + public static final String SAML2_AUDIT_UNRECOVERABLE_ERROR = "SAML2_UNRECOVERABLE_ERROR"; + + // Hidden constructor + private Saml2AuditEvents() { + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/Saml2IdpAuditListener.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/Saml2IdpAuditListener.java new file mode 100644 index 00000000..8b3d4246 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/Saml2IdpAuditListener.java @@ -0,0 +1,192 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.audit; + +import java.util.Objects; +import java.util.Optional; + +import org.opensaml.saml.saml2.core.AuthnRequest; +import org.opensaml.saml.saml2.core.Issuer; +import org.opensaml.saml.saml2.core.Response; +import org.springframework.boot.actuate.audit.listener.AuditApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; + +import lombok.extern.slf4j.Slf4j; +import se.swedenconnect.spring.saml.idp.audit.data.Saml2AssertionAuditData; +import se.swedenconnect.spring.saml.idp.audit.data.Saml2AuthnRequestAuditData; +import se.swedenconnect.spring.saml.idp.audit.data.Saml2ResponseAuditData; +import se.swedenconnect.spring.saml.idp.audit.data.Saml2UnrecoverableErrorAuditData; +import se.swedenconnect.spring.saml.idp.audit.data.Saml2UserAuthenticationInfoAuditData; +import se.swedenconnect.spring.saml.idp.authentication.Saml2UserAuthentication; +import se.swedenconnect.spring.saml.idp.authentication.Saml2UserAuthenticationInputToken; +import se.swedenconnect.spring.saml.idp.authnrequest.Saml2AuthnRequestAuthenticationToken; +import se.swedenconnect.spring.saml.idp.events.AbstractSaml2IdpEventListener; +import se.swedenconnect.spring.saml.idp.events.Saml2AuthnRequestReceivedEvent; +import se.swedenconnect.spring.saml.idp.events.Saml2ErrorResponseEvent; +import se.swedenconnect.spring.saml.idp.events.Saml2PostUserAuthenticationEvent; +import se.swedenconnect.spring.saml.idp.events.Saml2PreUserAuthenticationEvent; +import se.swedenconnect.spring.saml.idp.events.Saml2SuccessResponseEvent; +import se.swedenconnect.spring.saml.idp.events.Saml2UnrecoverableErrorEvent; + +/** + * An event listener that handles the events publishes by the SAML IdP, translates them to audit events and publishes + * them. + * + * @author Martin Lindström + */ +@Slf4j +public class Saml2IdpAuditListener extends AbstractSaml2IdpEventListener { + + /** The system event publisher. */ + private final ApplicationEventPublisher publisher; + + /** + * Constructor. + * + * @param publisher the system event publisher + */ + public Saml2IdpAuditListener(final ApplicationEventPublisher publisher) { + this.publisher = Objects.requireNonNull(publisher, "publisher must not be null"); + } + + /** + * An {@link AuthnRequest} has been received. Publishes an audit event containing {@link Saml2AuthnRequestAuditData}. + */ + @Override + protected void onAuthnRequestReceivedEvent(final Saml2AuthnRequestReceivedEvent event) { + + final Saml2AuditEvent auditEvent = + new Saml2AuditEvent(Saml2AuditEvents.SAML2_AUDIT_REQUEST_RECEIVED, event.getTimestamp(), + event.getSpEntityId(), Optional.ofNullable(event.getAuthnRequest()).map(AuthnRequest::getID).orElse(null), + Saml2AuthnRequestAuditData.of(event.getAuthnRequest(), event.getAuthnRequestToken().getRelayState())); + + log.info("Publishing audit event: {}", auditEvent.getLogString()); + + this.publish(auditEvent); + } + + /** + * A successful SAML response is about to be sent. Publishes an audit event containing a + * {@link Saml2ResponseAuditData} and a {@link Saml2AssertionAuditData}. + */ + @Override + protected void onSuccessResponseEvent(final Saml2SuccessResponseEvent event) { + + final Saml2AuditEvent auditEvent = + new Saml2AuditEvent(Saml2AuditEvents.SAML2_AUDIT_SUCCESSFUL_RESPONSE, event.getTimestamp(), + event.getSpEntityId(), Optional.ofNullable(event.getResponse()).map(Response::getInResponseTo).orElse(null), + Saml2ResponseAuditData.of(event.getResponse()), Saml2AssertionAuditData.of(event.getAssertion(), + Optional.ofNullable(event.getResponse()) + .map(Response::getEncryptedAssertions) + .filter(l -> !l.isEmpty()) + .isPresent())); + + log.info("Publishing audit event: {}", auditEvent.getLogString()); + + this.publish(auditEvent); + } + + /** + * An error SAML status is about to be sent. Publishes an audit event containing {@link Saml2ResponseAuditData}. + */ + @Override + protected void onErrorResponseEvent(final Saml2ErrorResponseEvent event) { + + final Saml2AuditEvent auditEvent = + new Saml2AuditEvent(Saml2AuditEvents.SAML2_AUDIT_ERROR_RESPONSE, event.getTimestamp(), + event.getSpEntityId(), Optional.ofNullable(event.getResponse()).map(Response::getInResponseTo).orElse(null), + Saml2ResponseAuditData.of(event.getResponse())); + + log.info("Publishing audit event: {}", auditEvent.getLogString()); + + this.publish(auditEvent); + } + + /** + * An event that is fired after we have received and successfully processed a SAML request, but before the user is + * authenticated. + */ + @Override + protected void onPreUserAuthenticationEvent(final Saml2PreUserAuthenticationEvent event) { + + final AuthnRequest authnRequest = Optional.ofNullable(event.getUserAuthenticationInput()) + .map(Saml2UserAuthenticationInputToken::getAuthnRequestToken) + .map(Saml2AuthnRequestAuthenticationToken::getAuthnRequest) + .orElse(null); + + final Saml2AuditEvent auditEvent = + new Saml2AuditEvent(Saml2AuditEvents.SAML2_AUDIT_BEFORE_USER_AUTHN, event.getTimestamp(), + Optional.ofNullable(authnRequest).map(AuthnRequest::getIssuer).map(Issuer::getValue).orElse(null), + Optional.ofNullable(authnRequest).map(AuthnRequest::getID).orElse(null)); + + log.info("Publishing audit event: {}", auditEvent.getLogString()); + + this.publish(auditEvent); + } + + /** + * The user has been successfully authenticated, but the SAML assertion has not yet been created. Publishes an audit + * event containing {@link Saml2UserAuthenticationInfoAuditData}. + */ + @Override + protected void onPostUserAuthenticationEvent(final Saml2PostUserAuthenticationEvent event) { + + final Saml2UserAuthentication userAuthn = event.getUserAuthentication(); + + final Saml2AuditEvent auditEvent = + new Saml2AuditEvent(Saml2AuditEvents.SAML2_AUDIT_AFTER_USER_AUTHN, event.getTimestamp(), + Optional.ofNullable(userAuthn.getAuthnRequestToken()) + .map(Saml2AuthnRequestAuthenticationToken::getEntityId) + .orElse(null), + Optional.ofNullable(userAuthn.getAuthnRequestToken()) + .map(Saml2AuthnRequestAuthenticationToken::getAuthnRequest) + .map(AuthnRequest::getID) + .orElse(null), + Saml2UserAuthenticationInfoAuditData.of(userAuthn, + Optional.ofNullable(userAuthn.getAuthnRequestToken()) + .map(Saml2AuthnRequestAuthenticationToken::isSignatureServicePeer) + .orElse(false))); + + log.info("Publishing audit event: {}", auditEvent.getLogString()); + + this.publish(auditEvent); + } + + /** + * An unrecoverable error has occurred. Publishes an audit event containing {@link Saml2UnrecoverableErrorAuditData}. + */ + @Override + protected void onUnrecoverableErrorEvent(final Saml2UnrecoverableErrorEvent event) { + final Saml2AuditEvent auditEvent = + new Saml2AuditEvent(Saml2AuditEvents.SAML2_AUDIT_UNRECOVERABLE_ERROR, event.getTimestamp(), + event.getError().getSpEntityId(), event.getError().getAuthnRequestId(), + Saml2UnrecoverableErrorAuditData.of(event.getError())); + + log.info("Publishing audit event: {}", auditEvent.getLogString()); + + this.publish(auditEvent); + } + + /** + * Publishes the {@link Saml2AuditEvent}. + * + * @param auditEvent the event to publish + */ + private void publish(final Saml2AuditEvent auditEvent) { + this.publisher.publishEvent(new AuditApplicationEvent(auditEvent)); + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2AssertionAuditData.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2AssertionAuditData.java new file mode 100644 index 00000000..d76c21c4 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2AssertionAuditData.java @@ -0,0 +1,227 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.audit.data; + +import java.io.Serializable; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import org.opensaml.saml.saml2.core.Assertion; +import org.opensaml.saml.saml2.core.AttributeStatement; +import org.opensaml.saml.saml2.core.AuthenticatingAuthority; +import org.opensaml.saml.saml2.core.AuthnContext; +import org.opensaml.saml.saml2.core.AuthnContextClassRef; +import org.opensaml.saml.saml2.core.AuthnStatement; +import org.opensaml.saml.saml2.core.Issuer; +import org.opensaml.saml.saml2.core.NameID; +import org.opensaml.saml.saml2.core.Subject; +import org.opensaml.saml.saml2.core.SubjectConfirmation; +import org.opensaml.saml.saml2.core.SubjectConfirmationData; +import org.opensaml.saml.saml2.core.SubjectLocality; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import se.swedenconnect.opensaml.saml2.attribute.AttributeUtils; +import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; + +/** + * Audit data for a SAML {@code Assertion}. + * + * @author Martin Lindström + */ +@JsonInclude(Include.NON_EMPTY) +public class Saml2AssertionAuditData extends Saml2AuditData { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** The assertion ID. */ + @Getter + @Setter + @JsonProperty(value = "id") + private String id; + + /** Holds the ID for the corresponding AuthnRequest. */ + @Getter + @Setter + @JsonProperty(value = "in-response-to") + private String inResponseTo; + + /** Whether the assertion is signed. */ + @Getter + @Setter + @JsonProperty(value = "is-signed") + private boolean signed; + + /** Whether the assertion is encrypted. */ + @Getter + @Setter + @JsonProperty(value = "is-encrypted") + private boolean encrypted; + + /** The issuance time for the assertion. */ + @Getter + @Setter + @JsonProperty(value = "issued-at") + private Instant issuedAt; + + /** The entityID of the issuing entity. */ + @Getter + @Setter + @JsonProperty(value = "issuer") + private String issuer; + + /** The authentication instant. */ + @Getter + @Setter + @JsonProperty(value = "authn-instant") + private Instant authnInstant; + + /** The subject's (assigned) ID. */ + @Getter + @Setter + @JsonProperty(value = "subject-id") + private String subjectId; + + /** The subject locality (IP). */ + @Getter + @Setter + @JsonProperty(value = "subject-locality") + private String subjectLocality; + + /** The LoA URI (level of assurance). */ + @Getter + @Setter + @JsonProperty(value = "authn-context-class-ref") + private String authnContextClassRef; + + /** Optional ID for authenticating authority. */ + @Getter + @Setter + @JsonProperty(value = "authn-authority") + private String authnAuthority; + + /** The SAML attributes. */ + @Getter + @Setter + @JsonProperty(value = "attributes") + private List attributes; + + /** {@inheritDoc} */ + @Override + public String getName() { + return "saml-assertion"; + } + + /** + * Creates a {@link Saml2AssertionAuditData} given an {@link Assertion}. + * + * @param assertion the SAML assertion + * @param encrypted whether this assertion is encrypted (when placed in response) + * @return a {@link Saml2AssertionAuditData} + */ + public static Saml2AssertionAuditData of(final Assertion assertion, final boolean encrypted) { + if (assertion == null) { + return null; + } + final Saml2AssertionAuditData data = new Saml2AssertionAuditData(); + data.setId(assertion.getID()); + data.setSigned(assertion.isSigned()); + data.setEncrypted(encrypted); + data.setIssuedAt(assertion.getIssueInstant()); + data.setIssuer(Optional.ofNullable(assertion.getIssuer()).map(Issuer::getValue).orElse(null)); + + final Subject subject = assertion.getSubject(); + if (subject != null) { + data.setSubjectId(Optional.ofNullable(subject.getNameID()).map(NameID::getValue).orElse(null)); + data.setInResponseTo( + subject.getSubjectConfirmations().stream() + .map(SubjectConfirmation::getSubjectConfirmationData) + .map(SubjectConfirmationData::getInResponseTo) + .findFirst() + .orElse(null)); + } + final AuthnStatement authnStatement = assertion.getAuthnStatements().stream().findFirst().orElse(null); + if (authnStatement != null) { + data.setAuthnInstant(authnStatement.getAuthnInstant()); + data.setSubjectLocality(Optional.ofNullable(authnStatement.getSubjectLocality()) + .map(SubjectLocality::getAddress) + .orElse(null)); + data.setAuthnContextClassRef(Optional.ofNullable(authnStatement.getAuthnContext()) + .map(AuthnContext::getAuthnContextClassRef) + .map(AuthnContextClassRef::getURI) + .orElse(null)); + data.setAuthnAuthority(Optional.ofNullable(authnStatement.getAuthnContext()) + .map(AuthnContext::getAuthenticatingAuthorities) + .map(a -> a.stream().map(AuthenticatingAuthority::getURI).findFirst().orElse(null)) + .orElse(null)); + } + final AttributeStatement attributeStatement = assertion.getAttributeStatements().stream().findFirst().orElse(null); + if (attributeStatement != null) { + data.setAttributes(attributeStatement.getAttributes().stream() + .map(a -> new SamlAttribute(a.getName(), AttributeUtils.getAttributeStringValue(a))) + .toList()); + } + + return data; + } + + /** {@inheritDoc} */ + @Override + public String toString() { + return String.format( + "id='%s', in-response-to='%s', is-signed='%s', is-encrypted='%s', issued-at='%s', issuer='%s', subject-id='%s'" + + ", authn-instant='%s', subject-locality='%s', authn-context-class-ref='%s', authn-authority='%s', attributes=%s", + this.id, this.inResponseTo, this.signed, this.encrypted, this.issuedAt, this.issuer, this.subjectId, + this.authnInstant, this.subjectLocality, this.authnContextClassRef, this.authnAuthority, this.attributes); + } + + /** + * Representation of a SAML attribute. + */ + @JsonInclude(Include.NON_EMPTY) + @AllArgsConstructor + @NoArgsConstructor + public static class SamlAttribute implements Serializable { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** The attribute name. */ + @Getter + @Setter + @JsonProperty(value = "name") + private String name; + + /** The attribute value. */ + @Getter + @Setter + @JsonProperty(value = "value") + private String value; + + /** {@inheritDoc} */ + @Override + public String toString() { + return String.format("%s='%s'", this.name, this.value); + } + + } +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2AuditData.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2AuditData.java new file mode 100644 index 00000000..dc561bf3 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2AuditData.java @@ -0,0 +1,43 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.audit.data; + +import java.io.Serializable; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; + +/** + * Base class for a SAML Audit data element. + * + * @author Martin Lindström + */ +@JsonInclude(Include.NON_EMPTY) +public abstract class Saml2AuditData implements Serializable { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** + * Gets the name of this data element. The name should be in "kebab-case", i.e., "data-element". + * + * @return the audit data name + */ + @JsonIgnore + public abstract String getName(); +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2AuthnRequestAuditData.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2AuthnRequestAuditData.java new file mode 100644 index 00000000..6288a37f --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2AuthnRequestAuditData.java @@ -0,0 +1,121 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.audit.data; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.opensaml.saml.saml2.core.AuthnRequest; +import org.opensaml.saml.saml2.core.Issuer; +import org.opensaml.saml.saml2.core.RequestedAuthnContext; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.Setter; +import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; + +/** + * An audit data element for an {@link AuthnRequest}. + * + * @author Martin Lindström + */ +@JsonInclude(Include.NON_EMPTY) +public class Saml2AuthnRequestAuditData extends Saml2AuditData { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** The AuthnRequest ID. */ + @Getter + @Setter + @JsonProperty(value = "id") + private String id; + + /** The issuer of the AuthnRequest. */ + @Getter + @Setter + @JsonProperty(value = "issuer") + private String issuer; + + /** Listing of requested "LoA:s". */ + @Getter + @Setter + @JsonProperty(value = "authn-context-class-refs") + private List authnContextClassRefs; + + /** Is "force authn" requested? */ + @Getter + @Setter + @JsonProperty(value = "force-authn") + private boolean forceAuthn; + + /** Is passive authentication requested? */ + @Getter + @Setter + @JsonProperty(value = "is-passive") + private boolean passive; + + /** The relay state. */ + @Getter + @Setter + @JsonProperty(value = "relay-state") + private String relayState; + + /** {@inheritDoc} */ + @Override + public String getName() { + return "authn-request"; + } + + /** + * Creates a {@link Saml2AuthnRequestAuditData} given the {@link AuthnRequest} and relay state. + * + * @param authnRequest the {@link AuthnRequest} + * @param relayState the relay state (or {@code null}) + * @return a {@link Saml2AuthnRequestAuditData} + */ + public static Saml2AuthnRequestAuditData of(final AuthnRequest authnRequest, final String relayState) { + if (authnRequest == null) { + return null; + } + final Saml2AuthnRequestAuditData data = new Saml2AuthnRequestAuditData(); + data.setId(authnRequest.getID()); + data.setIssuer(Optional.ofNullable(authnRequest.getIssuer()).map(Issuer::getValue).orElse(null)); + data.setAuthnContextClassRefs(Optional.ofNullable(authnRequest.getRequestedAuthnContext()) + .map(RequestedAuthnContext::getAuthnContextClassRefs) + .map(refs -> refs.stream() + .map(r -> r.getURI()) + .collect(Collectors.toList())) + .orElse(null)); + data.setForceAuthn(authnRequest.isForceAuthn()); + data.setPassive(authnRequest.isPassive()); + data.setRelayState(relayState); + + return data; + } + + /** {@inheritDoc} */ + @Override + public String toString() { + return String.format( + "id='%s', issuer='%s', authn-context-class-refs=%s, force-authn='%s', is-passive='%s', relay-state='%s'", + this.id, this.issuer, this.authnContextClassRefs, this.forceAuthn, this.passive, this.relayState); + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2ResponseAuditData.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2ResponseAuditData.java new file mode 100644 index 00000000..56814086 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2ResponseAuditData.java @@ -0,0 +1,171 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.audit.data; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Optional; + +import org.opensaml.saml.saml2.core.Response; +import org.opensaml.saml.saml2.core.Status; +import org.opensaml.saml.saml2.core.StatusCode; +import org.opensaml.saml.saml2.core.StatusMessage; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.Setter; +import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; + +/** + * Audit data representing a SAML response. + * + * @author Martin Lindström + */ +@JsonInclude(Include.NON_EMPTY) +public class Saml2ResponseAuditData extends Saml2AuditData { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** The Response ID. */ + @Getter + @Setter + @JsonProperty(value = "id") + private String id; + + /** The ID matching the AuthnRequest ID. */ + @Getter + @Setter + @JsonProperty(value = "in-response-to") + private String inResponseTo; + + /** The status. */ + @Getter + @Setter + @JsonProperty(value = "status") + private SamlStatus status; + + /** The response issuance time. */ + @Getter + @Setter + @JsonProperty(value = "issued-at") + private Instant issuedAt; + + /** The destination, i.e., where the response is being sent. */ + @Getter + @Setter + @JsonProperty(value = "destination") + private String destination; + + /** Tells whether the response is signed. */ + @Getter + @Setter + @JsonProperty(value = "is-signed") + private boolean signed; + + /** {@inheritDoc} */ + @Override + public String getName() { + return "saml-response"; + } + + /** + * Creates a {@link Saml2ResponseAuditData} given a {@link Response} object. + * + * @param response the SAML response + * @return a {@link Saml2ResponseAuditData} + */ + public static Saml2ResponseAuditData of(final Response response) { + if (response == null) { + return null; + } + final Saml2ResponseAuditData data = new Saml2ResponseAuditData(); + data.setId(response.getID()); + data.setInResponseTo(response.getInResponseTo()); + final Status status = response.getStatus(); + if (status != null) { + final SamlStatus samlStatus = new SamlStatus(); + samlStatus.setStatusCode(Optional.ofNullable(status.getStatusCode()).map(StatusCode::getValue).orElse(null)); + samlStatus.setSubordinateStatusCode(Optional.ofNullable(status.getStatusCode()) + .map(StatusCode::getStatusCode) + .map(StatusCode::getValue) + .orElse(null)); + samlStatus.setStatusMessage(Optional.ofNullable(status.getStatusMessage()) + .map(StatusMessage::getValue) + .orElse(null)); + data.setStatus(samlStatus); + } + data.setIssuedAt(response.getIssueInstant()); + data.setDestination(response.getDestination()); + data.setSigned(response.isSigned()); + + return data; + } + + /** {@inheritDoc} */ + @Override + public String toString() { + return String.format( + "id='%s', in-response-to='%s', status=[%s], issued-at='%s', destination='%s', signed='%s']", this.id, + this.inResponseTo, this.status, this.issuedAt, this.destination, this.signed); + } + + /** + * Represents a SAML {@code Status}. + */ + @JsonInclude(Include.NON_EMPTY) + public static class SamlStatus implements Serializable { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** The main status code. */ + @Getter + @Setter + @JsonProperty(value = "code") + private String statusCode; + + /** The subordinate status code. */ + @Getter + @Setter + @JsonProperty(value = "subordinate-code") + private String subordinateStatusCode; + + /** The status message. */ + @Getter + @Setter + @JsonProperty(value = "message") + private String statusMessage; + + /** {@inheritDoc} */ + @Override + public String toString() { + final StringBuffer sb = new StringBuffer("code='"); + sb.append(this.statusCode).append("'"); + + if (this.subordinateStatusCode != null) { + sb.append(", subordinate-code='").append(this.subordinateStatusCode).append("'"); + } + if (this.statusMessage != null) { + sb.append("', message='").append(this.statusMessage).append("'"); + } + return sb.toString(); + } + + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2UnrecoverableErrorAuditData.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2UnrecoverableErrorAuditData.java new file mode 100644 index 00000000..18106b9d --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2UnrecoverableErrorAuditData.java @@ -0,0 +1,78 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.audit.data; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.Setter; +import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; +import se.swedenconnect.spring.saml.idp.error.UnrecoverableSaml2IdpException; + +/** + * Audit data for unrecoverable errors that are reported in the UI. + * + * @author Martin Lindström + */ +@JsonInclude(Include.NON_EMPTY) +public class Saml2UnrecoverableErrorAuditData extends Saml2AuditData { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** The error code. */ + @Getter + @Setter + @JsonProperty(value = "error-code") + private String errorCode; + + /** The error message. */ + @Getter + @Setter + @JsonProperty(value = "error-message") + private String errorMessage; + + /** {@inheritDoc} */ + @Override + public String getName() { + return "unrecoverable-error"; + } + + /** + * Creates a {@link Saml2UnrecoverableErrorAuditData} given a {@link UnrecoverableSaml2IdpException}. + * + * @param error the exception + * @return a {@link Saml2UnrecoverableErrorAuditData} + */ + public static Saml2UnrecoverableErrorAuditData of(final UnrecoverableSaml2IdpException error) { + if (error == null) { + return null; + } + final Saml2UnrecoverableErrorAuditData data = new Saml2UnrecoverableErrorAuditData(); + data.setErrorCode(error.getError().getMessageCode()); + data.setErrorMessage(error.getMessage()); + + return data; + } + + /** {@inheritDoc} */ + @Override + public String toString() { + return String.format("error-code='%s', error-message='%s'", this.errorCode, this.errorMessage); + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2UserAuthenticationInfoAuditData.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2UserAuthenticationInfoAuditData.java new file mode 100644 index 00000000..a93d4287 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/Saml2UserAuthenticationInfoAuditData.java @@ -0,0 +1,189 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.audit.data; + +import java.io.Serializable; +import java.time.Instant; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; +import se.swedenconnect.spring.saml.idp.audit.data.Saml2AssertionAuditData.SamlAttribute; +import se.swedenconnect.spring.saml.idp.authentication.Saml2UserAuthentication; +import se.swedenconnect.spring.saml.idp.authentication.Saml2UserDetails; + +/** + * Audit data including information about the user authentication. + * + * @author Martin Lindström + */ +@JsonInclude(Include.NON_EMPTY) +public class Saml2UserAuthenticationInfoAuditData extends Saml2AuditData { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** The authentication instant. */ + @Getter + @Setter + @JsonProperty(value = "authn-instant") + private Instant authnInstant; + + /** The subject locality (IP). */ + @Getter + @Setter + @JsonProperty(value = "subject-locality") + private String subjectLocality; + + /** The LoA URI (level of assurance). */ + @Getter + @Setter + @JsonProperty(value = "authn-context-class-ref") + private String authnContextClassRef; + + /** Optional ID for authenticating authority. */ + @Getter + @Setter + @JsonProperty(value = "authn-authority") + private String authnAuthority; + + /** The SAML attributes delivered by the authenticator - it is not sure that all are relased. */ + @Getter + @Setter + @JsonProperty(value = "user-attributes") + private List userAttributes; + + /** If this was a signature operation, the field tells whether a sign message was displayed. */ + @Getter + @Setter + @JsonProperty(value = "sign-message-displayed") + private Boolean signMessageDisplayed; + + /** Whether this authentication is allowed to be re-used in SSO scenarios. */ + @Getter + @Setter + @JsonProperty(value = "allowed-to-reuse") + private boolean allowedToReuse; + + /** If SSO was applied, this field holds information about the instance when the user was authenticated. */ + @Getter + @Setter + @JsonProperty(value = "sso-information") + private SsoInformation ssoInformation; + + /** {@inheritDoc} */ + @Override + public String getName() { + return "user-authentication-info"; + } + + /** + * Creates a {@link Saml2UserAuthenticationInfoAuditData} based on the supplied {@link Saml2UserAuthentication} token. + * + * @param token a {@link Saml2UserAuthentication} object + * @param signServicePeer if the peer is a sign service + * @return a {@link Saml2UserAuthenticationInfoAuditData} + */ + public static Saml2UserAuthenticationInfoAuditData of( + final Saml2UserAuthentication token, final boolean signServicePeer) { + if (token == null) { + return null; + } + final Saml2UserAuthenticationInfoAuditData data = new Saml2UserAuthenticationInfoAuditData(); + final Saml2UserDetails details = token.getSaml2UserDetails(); + if (details == null) { + return null; + } + data.setAuthnInstant(details.getAuthnInstant()); + data.setSubjectLocality(details.getSubjectIpAddress()); + data.setAuthnContextClassRef(details.getAuthnContextUri()); + data.setAuthnAuthority(details.getAuthenticatingAuthority()); + if (details.getAttributes() != null) { + data.setUserAttributes(details.getAttributes().stream() + .map(ua -> new SamlAttribute(ua.getId(), ua.valuesToString())) + .toList()); + } + if (signServicePeer) { + data.setSignMessageDisplayed(details.isSignMessageDisplayed()); + } + data.setAllowedToReuse(token.isReuseAuthentication()); + if (token.isSsoApplied()) { + final Saml2UserAuthentication.AuthenticationInfoTrack track = token.getAuthenticationInfoTrack(); + data.setSsoInformation( + new SsoInformation(track.getOriginalAuthn().sp(), track.getOriginalAuthn().authnRequestId())); + } + + return data; + } + + /** {@inheritDoc} */ + @Override + public String toString() { + final String s = String.format("authn-instant='%s', subject-locality='%s', authn-context-class-ref='%s', " + + "authn-authority='%s', user-attributes=%s, sign-message-displayed='%s', allowed-to-reuse='%s'", + this.authnInstant, this.subjectLocality, this.authnContextClassRef, this.authnAuthority, this.userAttributes, + this.signMessageDisplayed, this.allowedToReuse); + if (this.ssoInformation != null) { + return String.format("%s, sso-information=[%s]", s, this.ssoInformation); + } + else { + return s; + } + } + + /** + * If the current authentication object is being re-used, i.e., if SSO was applied, this object holds information + * about the instance when the user was authenticated. + */ + @JsonInclude(Include.NON_EMPTY) + @AllArgsConstructor + @NoArgsConstructor + public static class SsoInformation implements Serializable { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** + * The SAML entityID of the SP that was the requester at the time the user was authenticated. + */ + @Getter + @Setter + @JsonProperty(value = "original-requester") + private String originalRequester; + + /** + * The {@code AuthnRequest} ID of the request that led to the user authentication. + */ + @Getter + @Setter + @JsonProperty(value = "original-authn-request-id") + private String originalAuthnRequestId; + + /** {@inheritDoc} */ + @Override + public String toString() { + return String.format("original-requester='%s', original-authn-request-id='%s'", + this.originalRequester, this.originalAuthnRequestId); + } + + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/package-info.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/package-info.java new file mode 100644 index 00000000..82968257 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/data/package-info.java @@ -0,0 +1,4 @@ +/** + * Audit data definitions. + */ +package se.swedenconnect.spring.saml.idp.audit.data; \ No newline at end of file diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/package-info.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/package-info.java new file mode 100644 index 00000000..1e19d3d7 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/audit/package-info.java @@ -0,0 +1,4 @@ +/** + * Audit logging support. + */ +package se.swedenconnect.spring.saml.idp.audit; \ No newline at end of file diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/Saml2AssertionBuilder.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/Saml2AssertionBuilder.java index 9b44a127..3290563b 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/Saml2AssertionBuilder.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/Saml2AssertionBuilder.java @@ -126,7 +126,7 @@ public Assertion buildAssertion(final Saml2UserAuthentication userAuthentication final Saml2AuthnRequestAuthenticationToken authnRequestToken = Optional.ofNullable(userAuthentication.getAuthnRequestToken()) .orElseThrow(() -> new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, - "No authn request token available in Saml2UserAuthentication")); + "No authn request token available in Saml2UserAuthentication", null)); final Instant now = Instant.now(); @@ -238,7 +238,8 @@ public Assertion buildAssertion(final Saml2UserAuthentication userAuthentication } catch (final SignatureException e) { log.error("Failed to sign Assertion - {} [{}]", e.getMessage(), authnRequestToken.getLogString(), e); - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "Failed to sign Assertion", e); + throw new UnrecoverableSaml2IdpException( + UnrecoverableSaml2IdpError.INTERNAL, "Failed to sign Assertion", e, userAuthentication); } } } diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/Saml2UserAuthentication.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/Saml2UserAuthentication.java index d6e89992..514b48f4 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/Saml2UserAuthentication.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/Saml2UserAuthentication.java @@ -15,7 +15,11 @@ */ package se.swedenconnect.spring.saml.idp.authentication; +import java.io.Serializable; +import java.time.Instant; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Objects; import org.springframework.security.authentication.AbstractAuthenticationToken; @@ -55,6 +59,9 @@ public class Saml2UserAuthentication extends AbstractAuthenticationToken { /** The authentication requirements deduced from the authentication request and IdP policy. */ private AuthenticationRequirements authnRequirements; + /** Tracking of all the times this user authentication object has been used. */ + private AuthenticationInfoTrack usage; + /** * Constructor. * @@ -134,6 +141,17 @@ public Saml2AuthnRequestAuthenticationToken getAuthnRequestToken() { public void setAuthnRequestToken( final Saml2AuthnRequestAuthenticationToken authnRequestToken) { this.authnRequestToken = authnRequestToken; + + if (this.authnRequestToken != null) { + if (this.usage == null) { + this.usage = new AuthenticationInfoTrack(this.userDetails.getAuthnInstant(), + this.authnRequestToken.getEntityId(), this.authnRequestToken.getAuthnRequest().getID()); + } + else { + this.usage.registerUse(Instant.now(), this.authnRequestToken.getEntityId(), + this.authnRequestToken.getAuthnRequest().getID()); + } + } } /** @@ -172,4 +190,87 @@ public void clearAuthnRequirements() { this.authnRequirements = null; } + /** + * Gets the tracking of all the times this user authentication object has been used. + * + * @return an {@link AuthenticationInfoTrack} + */ + public AuthenticationInfoTrack getAuthenticationInfoTrack() { + return this.usage; + } + + /** + * Predicate that tells whether the authentication object was issued based on a previous authentication. + * + * @return {@code true} if the authentication object is based on a previous authentication and {@code false} otherwise + */ + public boolean isSsoApplied() { + return this.usage != null && this.usage.getAllAuthnUsages().size() > 1; + } + + /** + * Remembers all (SAML) occurences where the user authentication has been used. + */ + public static class AuthenticationInfoTrack implements Serializable { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** Listing of all times the user authentication object has been used. */ + private final List usages; + + /** + * Constructor. + * + * @param authnInstant the instant for the original authentication + * @param sp the entityID of the SP that requested the original authentication + * @param authnRequestId the ID of the {@code AuthnRequest} that resulted in this authentication object + */ + public AuthenticationInfoTrack(final Instant authnInstant, final String sp, final String authnRequestId) { + this.usages = new ArrayList<>(); + this.usages.add(new AuthnUse( + Objects.requireNonNull(authnInstant, "authnInstant must not be null"), + Objects.requireNonNull(sp, "sp must not be null"), + Objects.requireNonNull(authnRequestId, "authnRequestId must not be null"))); + } + + /** + * Registers the use of the user authentication object. + * + * @param instant the instant when the authentication was used + * @param sp the entityID of the SP that ordered the authentication + * @param authnRequestId the ID of the {@code AuthnRequest} that resulted in this authentication object + */ + public void registerUse(final Instant instant, final String sp, final String authnRequestId) { + this.usages.add(new AuthnUse( + Objects.requireNonNull(instant, "instant must not be null"), + Objects.requireNonNull(sp, "sp must not be null"), + Objects.requireNonNull(authnRequestId, "authnRequestId must not be null"))); + } + + /** + * Gets information about the first time the user authentication object was used. + * + * @return the authentication instant and the SP that requested the original authentication + */ + public AuthnUse getOriginalAuthn() { + return this.usages.get(0); + } + + /** + * Gets a list of all usages of the user authentication object. + * + * @return a list of usage records + */ + public List getAllAuthnUsages() { + return Collections.unmodifiableList(this.usages); + } + + /** + * Record recording the usage time and requesting SP for an authentication. + */ + public static record AuthnUse(Instant use, String sp, String authnRequestId) { + } + + } + } diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/provider/UserAuthenticationProvider.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/provider/UserAuthenticationProvider.java index 6c386c84..e2f07a83 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/provider/UserAuthenticationProvider.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/provider/UserAuthenticationProvider.java @@ -72,7 +72,7 @@ default Authentication authenticate(final Authentication authentication) throws * * @param token the input token * @return the authentication token or {@code null} if the requested authentication context(s) can not be met by the - * authentication provider. + * authentication provider. * @throws Saml2ErrorStatusException for authentication errors */ Authentication authenticateUser(final Saml2UserAuthenticationInputToken token) throws Saml2ErrorStatusException; diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/provider/external/AbstractAuthenticationController.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/provider/external/AbstractAuthenticationController.java index dc428188..cd95b0a7 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/provider/external/AbstractAuthenticationController.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authentication/provider/external/AbstractAuthenticationController.java @@ -48,7 +48,7 @@ protected RedirectForAuthenticationToken getInputToken(final HttpServletRequest throws UnrecoverableSaml2IdpException { return Optional.ofNullable(this.getProvider().getTokenRepository().getExternalAuthenticationToken(request)) .orElseThrow(() -> new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_SESSION, - "No input token available")); + "No input token available", null)); } /** diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationConverter.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationConverter.java index a8cffc4c..a70b7528 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationConverter.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationConverter.java @@ -157,7 +157,7 @@ public Authentication convert(final HttpServletRequest request) { if (!AuthnRequest.class.isInstance(msgContext.getMessage())) { throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_AUTHNREQUEST_FORMAT, - "Incoming request is not an SAML V2 AuthnRequest message"); + "Incoming request is not an SAML V2 AuthnRequest message", null); } log.debug("AuthnRequest successfully decoded"); final AuthnRequest authnRequest = AuthnRequest.class.cast(msgContext.getMessage()); @@ -178,14 +178,14 @@ public Authentication convert(final HttpServletRequest request) { final SAMLVersion version = authnRequest.getVersion(); if (version.getMajorVersion() != 2) { throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_AUTHNREQUEST_FORMAT, - "Unsupported version on AuthnRequest message"); + "Unsupported version on AuthnRequest message", token); } // An ID is mandatory ... // if (!StringUtils.hasText(authnRequest.getID())) { throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_AUTHNREQUEST_FORMAT, - "Missing ID on received AuthnRequest message"); + "Missing ID on received AuthnRequest message", token); } // Assert that we have the issuer ... @@ -193,7 +193,7 @@ public Authentication convert(final HttpServletRequest request) { final String peerEntityId = Optional.ofNullable(authnRequest.getIssuer()) .map(Issuer::getValue) .orElseThrow(() -> new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_AUTHNREQUEST_FORMAT, - "Missing issuer of received AuthnRequest message")); + "Missing issuer of received AuthnRequest message", token)); // Check the validity of the SAML protocol message receiver endpoint against requirements // indicated in the message. @@ -204,7 +204,7 @@ public Authentication convert(final HttpServletRequest request) { catch (final MessageHandlerException e) { final String msg = String.format("Receiver endpoint check failed: %s", e.getMessage()); log.error("{}", msg, e); - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.ENDPOINT_CHECK_FAILURE, msg, e); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.ENDPOINT_CHECK_FAILURE, msg, e, token); } // Check the message lifetime, i.e., that the recived message is not too old. @@ -215,7 +215,7 @@ public Authentication convert(final HttpServletRequest request) { catch (final MessageHandlerException e) { final String msg = String.format("Message lifetime check failed: %s", e.getMessage()); log.error("{}", msg, e); - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.MESSAGE_TOO_OLD, msg, e); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.MESSAGE_TOO_OLD, msg, e, token); } // Locate peer metadata. @@ -228,7 +228,7 @@ public Authentication convert(final HttpServletRequest request) { if (spMetadata == null) { final String msg = String.format("Failed to lookup valid SAML metadata for SP %s", peerEntityId); log.info("{}", msg); - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.UNKNOWN_PEER, msg); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.UNKNOWN_PEER, msg, token); } log.debug("SAML metadata for SP {} successfully found", peerEntityId); token.setPeerMetadata(spMetadata); @@ -249,7 +249,7 @@ public Authentication convert(final HttpServletRequest request) { catch (final ResolverException e) { final String msg = "Error during metadata lookup: " + e.getMessage(); log.info("{}", msg, e); - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.UNKNOWN_PEER, msg, e); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.UNKNOWN_PEER, msg, e, token); } return token; @@ -257,7 +257,7 @@ public Authentication convert(final HttpServletRequest request) { catch (final MessageDecodingException e) { final String msg = "Unable to decode incoming authentication request"; log.error("{}", msg, e); - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.FAILED_DECODE, msg, e); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.FAILED_DECODE, msg, e, null); } } @@ -275,7 +275,7 @@ else if ("POST".equals(method)) { return this.httpPostDecoder; } else { - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "Illegal HTTP verb - " + method); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "Illegal HTTP verb - " + method, null); } } diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationProvider.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationProvider.java index 23294c7e..06b1a1ce 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationProvider.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationProvider.java @@ -45,6 +45,7 @@ import se.swedenconnect.spring.saml.idp.error.Saml2ErrorStatus; import se.swedenconnect.spring.saml.idp.error.Saml2ErrorStatusException; import se.swedenconnect.spring.saml.idp.error.UnrecoverableSaml2IdpException; +import se.swedenconnect.spring.saml.idp.events.Saml2IdpEventPublisher; import se.swedenconnect.spring.saml.idp.extensions.SadRequestExtension; import se.swedenconnect.spring.saml.idp.extensions.SignatureMessageExtension; import se.swedenconnect.spring.saml.idp.extensions.SignatureMessageExtensionExtractor; @@ -63,6 +64,9 @@ */ @Slf4j public class Saml2AuthnRequestAuthenticationProvider implements AuthenticationProvider { + + /** The event publisher. */ + private final Saml2IdpEventPublisher eventPublisher; /** The signature validator to use. */ private final AuthnRequestValidator signatureValidator; @@ -96,6 +100,7 @@ public class Saml2AuthnRequestAuthenticationProvider implements AuthenticationPr /** * Constructor. See {@link Saml2AuthnRequestAuthenticationProviderConfigurer} for how to configuration and setup. * + * @param eventPublisher the event publisher * @param signatureValidator the signature validator to use * @param assertionConsumerServiceValidator validator checking the AssertionConsumerService * @param replayValidator for protecting against replay attacks @@ -105,20 +110,21 @@ public class Saml2AuthnRequestAuthenticationProvider implements AuthenticationPr * instance */ public Saml2AuthnRequestAuthenticationProvider( + final Saml2IdpEventPublisher eventPublisher, final AuthnRequestValidator signatureValidator, final AuthnRequestValidator assertionConsumerServiceValidator, final AuthnRequestValidator replayValidator, final AuthnRequestValidator encryptCapabilitiesValidator, final List requestedAttributesProcessors, final NameIDGeneratorFactory nameIDGeneratorFactory) { - this(signatureValidator, assertionConsumerServiceValidator, replayValidator, encryptCapabilitiesValidator, - requestedAttributesProcessors, - nameIDGeneratorFactory, null, null); + this(eventPublisher, signatureValidator, assertionConsumerServiceValidator, replayValidator, + encryptCapabilitiesValidator, requestedAttributesProcessors, nameIDGeneratorFactory, null, null); } /** * Constructor. See {@link Saml2AuthnRequestAuthenticationProviderConfigurer} for how to configuration and setup. * + * @param eventPublisher the event publisher * @param signatureValidator the signature validator to use * @param assertionConsumerServiceValidator validator checking the AssertionConsumerService * @param replayValidator for protecting against replay attacks @@ -130,6 +136,7 @@ public Saml2AuthnRequestAuthenticationProvider( * @param principalSelectionProcessor extracts the {@code PrincipalSelection} attribute values (may be {@code null}) */ public Saml2AuthnRequestAuthenticationProvider( + final Saml2IdpEventPublisher eventPublisher, final AuthnRequestValidator signatureValidator, final AuthnRequestValidator assertionConsumerServiceValidator, final AuthnRequestValidator replayValidator, @@ -139,6 +146,7 @@ public Saml2AuthnRequestAuthenticationProvider( final SignatureMessageExtensionExtractor signatureMessageExtensionExtractor, final PrincipalSelectionProcessor principalSelectionProcessor) { + this.eventPublisher = Objects.requireNonNull(eventPublisher, "eventPublisher must not be null"); this.signatureValidator = Objects.requireNonNull(signatureValidator, "signatureValidator must not be null"); this.assertionConsumerServiceValidator = Objects.requireNonNull(assertionConsumerServiceValidator, "assertionConsumerServiceValidator must not be null"); @@ -159,6 +167,8 @@ public Authentication authenticate(final Authentication authentication) throws A final Saml2AuthnRequestAuthenticationToken token = Saml2AuthnRequestAuthenticationToken.class.cast(authentication); + + this.eventPublisher.publishAuthnRequestReceived(token); // Check message replay ... // diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationToken.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationToken.java index 3513cde1..14725588 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationToken.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationToken.java @@ -166,7 +166,7 @@ public String getBindingUri() { return Optional.ofNullable(this.messageContext.getSubcontext(SAMLBindingContext.class)) .map(SAMLBindingContext::getBindingUri) .orElseThrow( - () -> new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "Invalid message context")); + () -> new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "Invalid message context", this)); } /** diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/validation/AssertionConsumerServiceValidator.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/validation/AssertionConsumerServiceValidator.java index 1c72e822..70bda8a4 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/validation/AssertionConsumerServiceValidator.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/validation/AssertionConsumerServiceValidator.java @@ -72,7 +72,8 @@ public void validate(final Saml2AuthnRequestAuthenticationToken authnRequestToke final String msg = "No AssertionConsumerService given in AuthnRequest" + " and no valid AssertionConsumerService found in metadata"; log.info("{} [{}]", msg, authnRequestToken.getLogString()); - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_ASSERTION_CONSUMER_SERVICE, msg); + throw new UnrecoverableSaml2IdpException( + UnrecoverableSaml2IdpError.INVALID_ASSERTION_CONSUMER_SERVICE, msg, authnRequestToken); } authnRequestToken.setAssertionConsumerServiceUrl(acs.getLocation()); @@ -100,7 +101,8 @@ else if (assertionConsumerServiceUrl != null) { if (authnRequestToken.getAssertionConsumerServiceUrl() == null) { final String msg = "AssertionConsumerService given in AuthnRequest does not appear in metadata"; log.info("{} [{}]", msg, authnRequestToken.getLogString()); - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_ASSERTION_CONSUMER_SERVICE, msg); + throw new UnrecoverableSaml2IdpException( + UnrecoverableSaml2IdpError.INVALID_ASSERTION_CONSUMER_SERVICE, msg, authnRequestToken); } log.debug("Using AssertionConsumerServiceURL: {} [{}]", diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/validation/AuthnRequestReplayValidator.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/validation/AuthnRequestReplayValidator.java index 8a02336b..f0d20d9f 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/validation/AuthnRequestReplayValidator.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/validation/AuthnRequestReplayValidator.java @@ -71,7 +71,7 @@ public void validate(final Saml2AuthnRequestAuthenticationToken authnRequestToke } catch (final MessageReplayException e) { log.info("Replay of AuthnRequest was detected [{}]", authnRequestToken.getLogString()); - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.REPLAY_DETECTED); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.REPLAY_DETECTED, authnRequestToken); } } diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/validation/AuthnRequestSignatureValidator.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/validation/AuthnRequestSignatureValidator.java index 24e77d92..f03d1cff 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/validation/AuthnRequestSignatureValidator.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/authnrequest/validation/AuthnRequestSignatureValidator.java @@ -92,7 +92,7 @@ public void validate(final Saml2AuthnRequestAuthenticationToken token) throws Un final String msg = "Authentication request is required to be signed, but is not"; log.info("{} [entity-id: {}, authn-request: {}]", msg, token.getPeerMetadata().getEntityID(), token.getAuthnRequest().getID()); - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.MISSING_AUTHNREQUEST_SIGNATURE); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.MISSING_AUTHNREQUEST_SIGNATURE, token); } } @@ -113,7 +113,7 @@ public void validate(final Saml2AuthnRequestAuthenticationToken token) throws Un log.info("{} [entity-id: {}, authn-request: {}]", msg, token.getPeerMetadata().getEntityID(), token.getAuthnRequest().getID()); log.debug("", e); - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_AUTHNREQUEST_SIGNATURE, msg, e); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_AUTHNREQUEST_SIGNATURE, msg, e, token); } log.debug("Authentication request signature validation was successful [entity-id: {}, authn-request: {}]", diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/Saml2IdpConfiguration.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/Saml2IdpConfiguration.java index 4ad079b0..6c8b8e7f 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/Saml2IdpConfiguration.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/Saml2IdpConfiguration.java @@ -18,6 +18,7 @@ import java.util.List; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; @@ -27,6 +28,7 @@ import org.springframework.security.web.util.matcher.RequestMatcher; import lombok.extern.slf4j.Slf4j; +import se.swedenconnect.spring.saml.idp.audit.Saml2IdpAuditListener; import se.swedenconnect.spring.saml.idp.authentication.provider.UserAuthenticationProvider; import se.swedenconnect.spring.saml.idp.authentication.provider.external.UserRedirectAuthenticationProvider; import se.swedenconnect.spring.saml.idp.config.configurers.Saml2IdpConfigurer; @@ -60,10 +62,10 @@ SecurityFilterChain identityProviderSecurityFilterChain(final HttpSecurity http, @Autowired(required = false) final List adapters, @Autowired(required = false) final ResponsePage responsePage) throws Exception { applyDefaultSecurity(http, authenticationProviders); - + if (responsePage != null) { http.getConfigurer(Saml2IdpConfigurer.class) - .responseSender(s -> s.setResponsePage(responsePage)); + .responseSender(s -> s.setResponsePage(responsePage)); } if (adapters != null && !adapters.isEmpty()) { @@ -111,9 +113,20 @@ public static void applyDefaultSecurity(final HttpSecurity http, .apply(idpConfigurer); } + /** + * Creates the {@link Saml2IdpAuditListener}. + * + * @param publisher the event publisher + * @return a {@link Saml2IdpAuditListener} + */ + @Bean + Saml2IdpAuditListener saml2IdpAuditListener(final ApplicationEventPublisher publisher) { + return new Saml2IdpAuditListener(publisher); + } + @Bean RegisterMissingBeanPostProcessor registerMissingBeanPostProcessor() { - RegisterMissingBeanPostProcessor postProcessor = new RegisterMissingBeanPostProcessor(); + final RegisterMissingBeanPostProcessor postProcessor = new RegisterMissingBeanPostProcessor(); postProcessor.addBeanDefinition(IdentityProviderSettings.class, () -> IdentityProviderSettings.builder().build()); return postProcessor; } diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2AuthnRequestAuthenticationProviderConfigurer.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2AuthnRequestAuthenticationProviderConfigurer.java index cafac91e..ac344c37 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2AuthnRequestAuthenticationProviderConfigurer.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2AuthnRequestAuthenticationProviderConfigurer.java @@ -273,6 +273,7 @@ void init(final HttpSecurity httpSecurity) { @Override Saml2AuthnRequestAuthenticationProvider getObject(final HttpSecurity httpSecurity) { final Saml2AuthnRequestAuthenticationProvider object = new Saml2AuthnRequestAuthenticationProvider( + Saml2IdpConfigurerUtils.getEventPublisher(httpSecurity), this.signatureValidator, this.assertionConsumerServiceValidator, this.replayValidator, diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2IdpConfigurer.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2IdpConfigurer.java index afb116a5..bf038f1d 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2IdpConfigurer.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2IdpConfigurer.java @@ -135,7 +135,7 @@ public void init(final HttpSecurity httpSecurity) { final IdentityProviderSettings identityProviderSettings = Saml2IdpConfigurerUtils.getIdentityProviderSettings(httpSecurity); validateIdentityProviderSettings(identityProviderSettings); - + // Metadata resolver ... // MetadataResolver metadataResolver = identityProviderSettings.getMetadataProvider(); @@ -196,7 +196,7 @@ public void configure(final HttpSecurity httpSecurity) { final IdentityProviderSettings identityProviderSettings = Saml2IdpConfigurerUtils.getIdentityProviderSettings(httpSecurity); - + // Add context filter ... // final Saml2IdpContextFilter contextFilter = new Saml2IdpContextFilter(identityProviderSettings); @@ -208,7 +208,8 @@ public void configure(final HttpSecurity httpSecurity) { final Saml2ResponseSender responseSender = Saml2IdpConfigurerUtils.getResponseSender(httpSecurity); final Saml2ErrorResponseProcessingFilter errorResponsefilter = - new Saml2ErrorResponseProcessingFilter(this.getEndpointsMatcher(), responseBuilder, responseSender); + new Saml2ErrorResponseProcessingFilter(this.getEndpointsMatcher(), responseBuilder, responseSender, + Saml2IdpConfigurerUtils.getEventPublisher(httpSecurity)); httpSecurity.addFilterAfter(this.postProcess(errorResponsefilter), ExceptionTranslationFilter.class); diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2IdpConfigurerUtils.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2IdpConfigurerUtils.java index 9292c6a6..572c308f 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2IdpConfigurerUtils.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2IdpConfigurerUtils.java @@ -24,6 +24,8 @@ import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.NoUniqueBeanDefinitionException; import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.MessageSource; import org.springframework.core.ResolvableType; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -41,6 +43,7 @@ import se.swedenconnect.security.credential.PkiCredential; import se.swedenconnect.security.credential.opensaml.OpenSamlCredential; import se.swedenconnect.spring.saml.idp.authentication.provider.UserAuthenticationProvider; +import se.swedenconnect.spring.saml.idp.events.Saml2IdpEventPublisher; import se.swedenconnect.spring.saml.idp.response.Saml2ResponseBuilder; import se.swedenconnect.spring.saml.idp.response.Saml2ResponseSender; import se.swedenconnect.spring.saml.idp.settings.IdentityProviderSettings; @@ -80,6 +83,27 @@ static RequestMatcher getAuthnEndpointsRequestMatcher(final HttpSecurity httpSec return requestMatcher; } + /** + * Gets the {@link Saml2IdpEventPublisher} to use. + * + * @param httpSecurity the HTTP security object + * @return a {@link Saml2IdpEventPublisher} + */ + static Saml2IdpEventPublisher getEventPublisher(final HttpSecurity httpSecurity) { + Saml2IdpEventPublisher publisher = httpSecurity.getSharedObject(Saml2IdpEventPublisher.class); + if (publisher != null) { + return publisher; + } + publisher = getOptionalBean(httpSecurity, Saml2IdpEventPublisher.class); + if (publisher == null) { + final ApplicationEventPublisher applicationEventPublisher = + (ApplicationEventPublisher) httpSecurity.getSharedObject(ApplicationContext.class); + publisher = new Saml2IdpEventPublisher(applicationEventPublisher); + } + httpSecurity.setSharedObject(Saml2IdpEventPublisher.class, publisher); + return publisher; + } + /** * Gets the {@link Saml2ResponseBuilder} to use. If none has been set, a {@link Saml2ResponseBuilder} is created * according to the current {@link IdentityProviderSettings}. @@ -95,8 +119,13 @@ static Saml2ResponseBuilder getResponseBuilder(final HttpSecurity httpSecurity) responseBuilder = getOptionalBean(httpSecurity, Saml2ResponseBuilder.class); if (responseBuilder == null) { final IdentityProviderSettings settings = getIdentityProviderSettings(httpSecurity); - responseBuilder = new Saml2ResponseBuilder(settings.getEntityId(), getSignatureCredential(httpSecurity)); + responseBuilder = new Saml2ResponseBuilder( + settings.getEntityId(), getSignatureCredential(httpSecurity), getEventPublisher(httpSecurity)); responseBuilder.setEncryptAssertions(settings.getAssertionSettings().getEncryptAssertions()); + final MessageSource messageSource = getOptionalBean(httpSecurity, MessageSource.class); + if (messageSource != null) { + responseBuilder.setMessageSource(messageSource); + } } httpSecurity.setSharedObject(Saml2ResponseBuilder.class, responseBuilder); return responseBuilder; diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2UserAuthenticationConfigurer.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2UserAuthenticationConfigurer.java index 5c94601f..e136daec 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2UserAuthenticationConfigurer.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/config/configurers/Saml2UserAuthenticationConfigurer.java @@ -225,7 +225,7 @@ void configure(final HttpSecurity httpSecurity) { final Saml2UserAuthenticationProcessingFilter filter = new Saml2UserAuthenticationProcessingFilter( authenticationManager, this.authnRequestRequestMatcher, postAuthenticationProcessor, - assertionBuilder, responseBuilder, responseSender); + assertionBuilder, responseBuilder, responseSender, Saml2IdpConfigurerUtils.getEventPublisher(httpSecurity)); if (this.resumeAuthnRequestMatcher.isConfigured()) { filter.setResumeAuthnRequestMatcher(this.resumeAuthnRequestMatcher); diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/error/UnrecoverableSaml2IdpException.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/error/UnrecoverableSaml2IdpException.java index 5c81c51c..02d4890b 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/error/UnrecoverableSaml2IdpException.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/error/UnrecoverableSaml2IdpException.java @@ -15,7 +15,19 @@ */ package se.swedenconnect.spring.saml.idp.error; +import java.util.Collections; +import java.util.Optional; + +import org.opensaml.saml.saml2.core.AuthnRequest; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; + +import lombok.Getter; import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; +import se.swedenconnect.spring.saml.idp.authentication.Saml2UserAuthentication; +import se.swedenconnect.spring.saml.idp.authentication.Saml2UserAuthenticationInputToken; +import se.swedenconnect.spring.saml.idp.authentication.provider.external.ResumedAuthenticationToken; +import se.swedenconnect.spring.saml.idp.authnrequest.Saml2AuthnRequestAuthenticationToken; /** * Base class for unrecoverable SAML errors, i.e., such errors that can not be signalled back to the SAML SP. @@ -27,15 +39,22 @@ public class UnrecoverableSaml2IdpException extends RuntimeException { private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; /** The error. */ - private UnrecoverableSaml2IdpError error; + private final UnrecoverableSaml2IdpError error; + + /** The ID for the {@link AuthnRequest} message that was processed when the error occurred. */ + private String authnRequestId; + + /** The SAML entityID for the Service Provider that sent the request that was processed when the error occurred. */ + private String spEntityId; /** * Constructor. * + * @param authn the current {@link Authentication} object - may be {@code null} * @param error the error - */ - public UnrecoverableSaml2IdpException(final UnrecoverableSaml2IdpError error) { - this(error, null, null); + */ + public UnrecoverableSaml2IdpException(final UnrecoverableSaml2IdpError error, final Authentication authn) { + this(error, null, null, authn); } /** @@ -43,9 +62,10 @@ public UnrecoverableSaml2IdpException(final UnrecoverableSaml2IdpError error) { * * @param error the error * @param msg the message - */ - public UnrecoverableSaml2IdpException(final UnrecoverableSaml2IdpError error, final String msg) { - this(error, msg, null); + */ + public UnrecoverableSaml2IdpException(final UnrecoverableSaml2IdpError error, final String msg, + final Authentication authn) { + this(error, msg, null, authn); } /** @@ -53,9 +73,10 @@ public UnrecoverableSaml2IdpException(final UnrecoverableSaml2IdpError error, fi * * @param error the error * @param cause the cause of the error - */ - public UnrecoverableSaml2IdpException(final UnrecoverableSaml2IdpError error, final Throwable cause) { - this(error, null, cause); + */ + public UnrecoverableSaml2IdpException(final UnrecoverableSaml2IdpError error, final Throwable cause, + final Authentication authn) { + this(error, null, cause, authn); } /** @@ -65,10 +86,11 @@ public UnrecoverableSaml2IdpException(final UnrecoverableSaml2IdpError error, fi * @param msg the message * @param cause the cause of the error */ - public UnrecoverableSaml2IdpException(final UnrecoverableSaml2IdpError error, final String msg, - final Throwable cause) { + public UnrecoverableSaml2IdpException(final UnrecoverableSaml2IdpError error, final String msg, final Throwable cause, + final Authentication authn) { super(msg != null ? msg : error.getDescription(), cause); this.error = error; + this.setupTraceId(authn); } /** @@ -80,4 +102,92 @@ public UnrecoverableSaml2IdpError getError() { return this.error; } + /** + * Gets the ID for the {@link AuthnRequest} message that was processed when the error occurred. + * + * @return the ID (or {@code null} if not available) + */ + public String getAuthnRequestId() { + return this.authnRequestId; + } + + /** + * Gets the SAML entityID for the Service Provider that sent the request that was processed when the error occurred. + * + * @return the entityID (or {@code null} if not available) + */ + public String getSpEntityId() { + return this.spEntityId; + } + + /** + * Given the supplied {@link Authentication} object we save data useful for tracing and logging. + * + * @param authn the {@link Authentication} (may be {@code null}) + */ + private void setupTraceId(final Authentication authn) { + if (authn == null) { + return; + } + if (authn instanceof Saml2UserAuthentication ua) { + this.setupTraceId(ua.getAuthnRequestToken()); + } + else if (authn instanceof Saml2AuthnRequestAuthenticationToken ar) { + this.authnRequestId = Optional.ofNullable(ar.getAuthnRequest()).map(AuthnRequest::getID).orElse(null); + this.spEntityId = ar.getEntityId(); + } + else if (authn instanceof ResumedAuthenticationToken ra) { + this.setupTraceId(Optional.ofNullable(ra.getAuthnInputToken()) + .map(Saml2UserAuthenticationInputToken::getAuthnRequestToken) + .orElse(null)); + } + else if (authn instanceof TraceAuthentication ta) { + this.authnRequestId = ta.getAuthnRequestId(); + this.spEntityId = ta.getSpEntityId(); + } + } + + /** + * Dummy {@link Authentication} class that can be used if no {@link Authentication} object is available but the + * AuthnRequest ID and SP entityID are known. + * + * @author Martin Lindström + */ + public static class TraceAuthentication extends AbstractAuthenticationToken { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + @Getter + private final String authnRequestId; + + @Getter + private final String spEntityId; + + /** + * Constructor. + * + * @param authnRequestId the {@code AuthnRequest} ID + * @param spEntityId the SP entityID + */ + public TraceAuthentication(final String authnRequestId, final String spEntityId) { + super(Collections.emptyList()); + this.setAuthenticated(false); + this.authnRequestId = authnRequestId; + this.spEntityId = spEntityId; + } + + /** {@inheritDoc} */ + @Override + public Object getCredentials() { + return null; + } + + /** {@inheritDoc} */ + @Override + public Object getPrincipal() { + return null; + } + + } + } diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/AbstractSaml2IdpEvent.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/AbstractSaml2IdpEvent.java new file mode 100644 index 00000000..79d4efb6 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/AbstractSaml2IdpEvent.java @@ -0,0 +1,40 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.events; + +import org.springframework.context.ApplicationEvent; + +import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; + +/** + * Abstract base class for all events published by the SAML IdP. + * + * @author Martin Lindström + */ +public abstract class AbstractSaml2IdpEvent extends ApplicationEvent { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** + * Constructor. + * + * @param source the object with which the event is associated (never {@code null}) + */ + public AbstractSaml2IdpEvent(final Object source) { + super(source); + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/AbstractSaml2IdpEventListener.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/AbstractSaml2IdpEventListener.java new file mode 100644 index 00000000..0806e415 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/AbstractSaml2IdpEventListener.java @@ -0,0 +1,105 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.events; + +import org.springframework.context.ApplicationListener; + +import lombok.extern.slf4j.Slf4j; + +/** + * Abstract base class for an {@link ApplicationListener} for SAML2 events. + * + * @author Martin Lindström + */ +@Slf4j +public class AbstractSaml2IdpEventListener implements ApplicationListener { + + /** + * Routes the received event to the correct on-method. + */ + @Override + public void onApplicationEvent(final AbstractSaml2IdpEvent event) { + log.debug("Received {} event", event.getClass().getSimpleName()); + + if (event instanceof Saml2AuthnRequestReceivedEvent e) { + this.onAuthnRequestReceivedEvent(e); + } + else if (event instanceof Saml2SuccessResponseEvent e) { + this.onSuccessResponseEvent(e); + } + else if (event instanceof Saml2ErrorResponseEvent e) { + this.onErrorResponseEvent(e); + } + else if (event instanceof Saml2PreUserAuthenticationEvent e) { + this.onPreUserAuthenticationEvent(e); + } + else if (event instanceof Saml2PostUserAuthenticationEvent e) { + this.onPostUserAuthenticationEvent(e); + } + else if (event instanceof Saml2UnrecoverableErrorEvent e) { + this.onUnrecoverableErrorEvent(e); + } + } + + /** + * Handles a {@link Saml2AuthnRequestReceivedEvent} event. The default implementation does nothing. + * + * @param event the event + */ + protected void onAuthnRequestReceivedEvent(final Saml2AuthnRequestReceivedEvent event) { + } + + /** + * Handles a {@link Saml2SuccessResponseEvent} event. The default implementation does nothing. + * + * @param event the event + */ + protected void onSuccessResponseEvent(final Saml2SuccessResponseEvent event) { + } + + /** + * Handles a {@link Saml2ErrorResponseEvent} event. The default implementation does nothing. + * + * @param event the event + */ + protected void onErrorResponseEvent(final Saml2ErrorResponseEvent event) { + } + + /** + * Handles a {@link Saml2PreUserAuthenticationEvent} event. The default implementation does nothing. + * + * @param event the event + */ + protected void onPreUserAuthenticationEvent(final Saml2PreUserAuthenticationEvent event) { + } + + /** + * Handles a {@link Saml2PostUserAuthenticationEvent} event. The default implementation does nothing. + * + * @param event the event + */ + protected void onPostUserAuthenticationEvent(final Saml2PostUserAuthenticationEvent event) { + } + + /** + * Handles a {@link Saml2UnrecoverableErrorEvent} event. The default implementation does nothing. + * + * @param event the event + */ + protected void onUnrecoverableErrorEvent(final Saml2UnrecoverableErrorEvent event) { + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2AuthnRequestReceivedEvent.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2AuthnRequestReceivedEvent.java new file mode 100644 index 00000000..6a327f15 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2AuthnRequestReceivedEvent.java @@ -0,0 +1,70 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.events; + +import org.opensaml.saml.saml2.core.AuthnRequest; + +import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; +import se.swedenconnect.spring.saml.idp.authnrequest.Saml2AuthnRequestAuthenticationToken; + +/** + * Event that signals that a SAML2 {@link AuthnRequest} has been received. Note that the request has not been verified + * at this point. + * + * @author Martin Lindström + */ +public class Saml2AuthnRequestReceivedEvent extends AbstractSaml2IdpEvent { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** + * Constructor. + * + * @param token a {@link Saml2AuthnRequestAuthenticationToken} + */ + public Saml2AuthnRequestReceivedEvent(final Saml2AuthnRequestAuthenticationToken token) { + super(token); + } + + /** + * Gets the {@link Saml2AuthnRequestAuthenticationToken} for this event. + * + * @return a {@link Saml2AuthnRequestAuthenticationToken} + * @see #getSource() + */ + public Saml2AuthnRequestAuthenticationToken getAuthnRequestToken() { + return Saml2AuthnRequestAuthenticationToken.class.cast(this.getSource()); + } + + /** + * Gets the SAML entityID of the SP that sent the {@code AuthnRequest} message. + * + * @return the SP SAML entityID + */ + public String getSpEntityId() { + return this.getAuthnRequestToken().getEntityId(); + } + + /** + * Gets the received {@link AuthnRequest} message. + * + * @return the {@link AuthnRequest} + */ + public AuthnRequest getAuthnRequest() { + return this.getAuthnRequestToken().getAuthnRequest(); + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2ErrorResponseEvent.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2ErrorResponseEvent.java new file mode 100644 index 00000000..30aa4884 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2ErrorResponseEvent.java @@ -0,0 +1,73 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.events; + +import org.opensaml.saml.saml2.core.Response; +import org.opensaml.saml.saml2.core.Status; + +import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; + +/** + * An event that signals that a SAML error response is being sent. + * + * @author Martin Lindström + */ +public class Saml2ErrorResponseEvent extends AbstractSaml2IdpEvent { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** The entityID of the SP that we are sending the response to. */ + private final String spEntityId; + + /** + * Constructor. + * + * @param response the SAML response + * @param spEntityId the entityID of the SP that we are sending the response to + */ + public Saml2ErrorResponseEvent(final Response response, final String spEntityId) { + super(response); + this.spEntityId = spEntityId; + } + + /** + * Gets the SAML response. + * + * @return the {@link Response} + */ + public Response getResponse() { + return Response.class.cast(this.getSource()); + } + + /** + * Gets the entityID of the SP that we are sending the response to. + * + * @return SP SAML entityID + */ + public String getSpEntityId() { + return this.spEntityId; + } + + /** + * Gets the SAML {@link Status} that was sent. + * + * @return SAML {@link Status} + */ + public Status getStatus() { + return this.getResponse().getStatus(); + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2IdpEventPublisher.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2IdpEventPublisher.java new file mode 100644 index 00000000..7fd73b33 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2IdpEventPublisher.java @@ -0,0 +1,108 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.events; + +import java.util.Objects; + +import org.opensaml.saml.saml2.core.Assertion; +import org.opensaml.saml.saml2.core.Response; +import org.springframework.context.ApplicationEventPublisher; + +import se.swedenconnect.spring.saml.idp.authentication.Saml2UserAuthentication; +import se.swedenconnect.spring.saml.idp.authentication.Saml2UserAuthenticationInputToken; +import se.swedenconnect.spring.saml.idp.authentication.provider.UserAuthenticationProvider; +import se.swedenconnect.spring.saml.idp.authnrequest.Saml2AuthnRequestAuthenticationToken; +import se.swedenconnect.spring.saml.idp.error.UnrecoverableSaml2IdpException; + +/** + * A publisher for SAML IdP events. + * + * @author Martin Lindström + */ +public class Saml2IdpEventPublisher { + + /** The system's event publisher. */ + private final ApplicationEventPublisher publisher; + + /** + * Constructor. + * + * @param publisher the system's event publisher + */ + public Saml2IdpEventPublisher(final ApplicationEventPublisher publisher) { + this.publisher = Objects.requireNonNull(publisher, "publisher must not be null"); + } + + /** + * Publishes a {@link Saml2AuthnRequestReceivedEvent} indicating that a SAML {@code AuthnRequest} was received. + * + * @param token the {@link Saml2AuthnRequestAuthenticationToken} + */ + public void publishAuthnRequestReceived(final Saml2AuthnRequestAuthenticationToken token) { + this.publisher.publishEvent(new Saml2AuthnRequestReceivedEvent(token)); + } + + /** + * Publishes a {@link Saml2SuccessResponseEvent} indicating that a successful SAML response is about to be sent. + * + * @param response the SAML response + * @param assertion the SAML Assertion (before being encrypted) + * @param spEntityId the entityID of the SP that we are sending the response to + */ + public void publishSamlSuccessResponse(final Response response, final Assertion assertion, final String spEntityId) { + this.publisher.publishEvent(new Saml2SuccessResponseEvent(response, assertion, spEntityId)); + } + + /** + * Publishes a {@link Saml2ErrorResponseEvent} indicating that a SAML error response is about to be sent. + * + * @param response the SAML {@link Response} + * @param entityId the SAML entityID of the recipient + */ + public void publishSamlErrorResponse(final Response response, final String entityId) { + this.publisher.publishEvent(new Saml2ErrorResponseEvent(response, entityId)); + } + + /** + * Publishes a {@link Saml2PreUserAuthenticationEvent}. This is fired before the user is authenticated but after all + * the input SAML processing has been performed. + * + * @param token a {@link Saml2UserAuthenticationInputToken} token + */ + public void publishBeforeUserAuthenticated(final Saml2UserAuthenticationInputToken token) { + this.publisher.publishEvent(new Saml2PreUserAuthenticationEvent(token)); + } + + /** + * Publishes a {@link Saml2PostUserAuthenticationEvent} indicating that an {@link UserAuthenticationProvider} has + * authenticated the user. + * + * @param authn the {@link Saml2UserAuthentication} + */ + public void publishUserAuthenticated(final Saml2UserAuthentication authn) { + this.publisher.publishEvent(new Saml2PostUserAuthenticationEvent(authn)); + } + + /** + * Publishes a {@link Saml2UnrecoverableErrorEvent} indicating that an {@link UnrecoverableSaml2IdpException} has been + * thrown. + * + * @param error the {@link UnrecoverableSaml2IdpException} error + */ + public void publishUnrecoverableSamlError(final UnrecoverableSaml2IdpException error) { + this.publisher.publishEvent(new Saml2UnrecoverableErrorEvent(error)); + } +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2PostUserAuthenticationEvent.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2PostUserAuthenticationEvent.java new file mode 100644 index 00000000..ffc16464 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2PostUserAuthenticationEvent.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.events; + +import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; +import se.swedenconnect.spring.saml.idp.authentication.Saml2UserAuthentication; +import se.swedenconnect.spring.saml.idp.authentication.provider.UserAuthenticationProvider; + +/** + * An event that is fired after the user has been authenticated by a {@link UserAuthenticationProvider} but before we + * filter release attributes and compile the SAML assertion. + * + * @author Martin Lindström + */ +public class Saml2PostUserAuthenticationEvent extends AbstractSaml2IdpEvent { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** + * Constructor. + * + * @param auth the {@link Saml2UserAuthentication} + */ + public Saml2PostUserAuthenticationEvent(final Saml2UserAuthentication auth) { + super(auth); + } + + /** + * Gets the {@link Saml2UserAuthentication} representing the user authentication. + * + * @return a {@link Saml2UserAuthentication} + */ + public Saml2UserAuthentication getUserAuthentication() { + return Saml2UserAuthentication.class.cast(this.getSource()); + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2PreUserAuthenticationEvent.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2PreUserAuthenticationEvent.java new file mode 100644 index 00000000..857c81f2 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2PreUserAuthenticationEvent.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.events; + +import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; +import se.swedenconnect.spring.saml.idp.authentication.Saml2UserAuthenticationInputToken; +import se.swedenconnect.spring.saml.idp.authentication.provider.UserAuthenticationProvider; + +/** + * An event that is signalled before the user is handed over to the {@link UserAuthenticationProvider} to be + * authenticated. + * + * @author Martin Lindström + */ +public class Saml2PreUserAuthenticationEvent extends AbstractSaml2IdpEvent { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** + * Constructor. + * + * @param authn the {@link Saml2UserAuthenticationInputToken} + */ + public Saml2PreUserAuthenticationEvent(final Saml2UserAuthenticationInputToken authn) { + super(authn); + } + + /** + * Gets the {@link Saml2UserAuthenticationInputToken}. + * + * @return the {@link Saml2UserAuthenticationInputToken} + */ + public Saml2UserAuthenticationInputToken getUserAuthenticationInput() { + return Saml2UserAuthenticationInputToken.class.cast(this.getSource()); + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2SuccessResponseEvent.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2SuccessResponseEvent.java new file mode 100644 index 00000000..237eaff7 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2SuccessResponseEvent.java @@ -0,0 +1,78 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.events; + +import org.opensaml.saml.saml2.core.Assertion; +import org.opensaml.saml.saml2.core.Response; + +import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; + +/** + * An event that signals that a successful SAML response is being sent. + * + * @author Martin Lindström + */ +public class Saml2SuccessResponseEvent extends AbstractSaml2IdpEvent { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** The issued SAML assertion (un-encrypted). */ + private final Assertion assertion; + + /** The entityID of the SP that we are sending the response to. */ + private String spEntityId; + + /** + * Constructor. + * + * @param response the SAML response + * @param assertion the SAML Assertion (before being encrypted) + * @param spEntityId the entityID of the SP that we are sending the response to + */ + public Saml2SuccessResponseEvent(final Response response, final Assertion assertion, final String spEntityId) { + super(response); + this.assertion = assertion; + this.spEntityId = spEntityId; + } + + /** + * Gets the SAML response. + * + * @return the {@link Response} + */ + public Response getResponse() { + return Response.class.cast(this.getSource()); + } + + /** + * Gets the (un-encrypted) SAML {@link Assertion} + * + * @return an {@link Assertion} + */ + public Assertion getAssertion() { + return this.assertion; + } + + /** + * Gets the entityID of the SP that we are sending the response to. + * + * @return SP SAML entityID + */ + public String getSpEntityId() { + return this.spEntityId; + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2UnrecoverableErrorEvent.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2UnrecoverableErrorEvent.java new file mode 100644 index 00000000..9e1843cf --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/Saml2UnrecoverableErrorEvent.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023 Sweden Connect + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package se.swedenconnect.spring.saml.idp.events; + +import se.swedenconnect.spring.saml.idp.Saml2IdentityProviderVersion; +import se.swedenconnect.spring.saml.idp.error.UnrecoverableSaml2IdpException; + +/** + * An event that is signalled if an {@link UnrecoverableSaml2IdpException} is thrown. These types of errors means that + * the user can not be redirected back to the SP (i.e., no SAML response can be sent). Instead an error view is + * displayed. + * + * @author Martin Lindström + */ +public class Saml2UnrecoverableErrorEvent extends AbstractSaml2IdpEvent { + + private static final long serialVersionUID = Saml2IdentityProviderVersion.SERIAL_VERSION_UID; + + /** + * Constructor. + * + * @param error the {@link UnrecoverableSaml2IdpException} + */ + public Saml2UnrecoverableErrorEvent(final UnrecoverableSaml2IdpException error) { + super(error); + } + + /** + * Gets the error. + * + * @return the {@link UnrecoverableSaml2IdpException} + */ + public UnrecoverableSaml2IdpException getError() { + return UnrecoverableSaml2IdpException.class.cast(this.getSource()); + } + +} diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/package-info.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/package-info.java new file mode 100644 index 00000000..d86a7c14 --- /dev/null +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/events/package-info.java @@ -0,0 +1,4 @@ +/** + * Event handling. + */ +package se.swedenconnect.spring.saml.idp.events; \ No newline at end of file diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/response/Saml2ResponseBuilder.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/response/Saml2ResponseBuilder.java index 1f64ff95..32e61ce5 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/response/Saml2ResponseBuilder.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/response/Saml2ResponseBuilder.java @@ -16,6 +16,7 @@ package se.swedenconnect.spring.saml.idp.response; import java.time.Instant; +import java.util.Locale; import java.util.Objects; import java.util.Optional; @@ -31,6 +32,7 @@ import org.opensaml.xmlsec.encryption.EncryptedData; import org.opensaml.xmlsec.encryption.support.EncryptionException; import org.opensaml.xmlsec.signature.support.SignatureException; +import org.springframework.context.MessageSource; import org.springframework.security.config.Customizer; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -44,6 +46,7 @@ import se.swedenconnect.spring.saml.idp.error.Saml2ErrorStatusException; import se.swedenconnect.spring.saml.idp.error.UnrecoverableSaml2IdpError; import se.swedenconnect.spring.saml.idp.error.UnrecoverableSaml2IdpException; +import se.swedenconnect.spring.saml.idp.events.Saml2IdpEventPublisher; import se.swedenconnect.spring.saml.idp.utils.DefaultSaml2MessageIDGenerator; import se.swedenconnect.spring.saml.idp.utils.Saml2MessageIDGenerator; @@ -55,6 +58,9 @@ @Slf4j public class Saml2ResponseBuilder { + /** Event publisher. */ + private final Saml2IdpEventPublisher eventPublisher; + /** The issuer entityID for the {@link Response} objects being created. */ private final String responseIssuer; @@ -73,18 +79,25 @@ public class Saml2ResponseBuilder { /** The ID generator - defaults to {@link DefaultSaml2MessageIDGenerator}. */ private Saml2MessageIDGenerator idGenerator = new DefaultSaml2MessageIDGenerator(); + /** Optional message source for resolving error messages. */ + private MessageSource messageSource; + /** * Constructor. * + * @param idpEntityId the entityID for the IdP * @param signingCredential the IdP signing credential (for signing of {@link Response} objects) + * @param eventPublisher the event publisher */ - public Saml2ResponseBuilder(final String idpEntityId, final PkiCredential signingCredential) { + public Saml2ResponseBuilder(final String idpEntityId, final PkiCredential signingCredential, + final Saml2IdpEventPublisher eventPublisher) { this.responseIssuer = Optional.ofNullable(idpEntityId).filter(StringUtils::hasText) .orElseThrow(() -> new IllegalArgumentException("idpEntityId must be set")); Assert.notNull(signingCredential, "signingCredential must not be null"); this.signingCredential = OpenSamlCredential.class.isInstance(signingCredential) ? OpenSamlCredential.class.cast(signingCredential) : new OpenSamlCredential(signingCredential); + this.eventPublisher = Objects.requireNonNull(eventPublisher, "eventPublisher must not be null"); } /** @@ -99,16 +112,18 @@ public Saml2ResponseBuilder(final String idpEntityId, final PkiCredential signin public Response buildErrorResponse(final Saml2ResponseAttributes responseAttributes, final Status errorStatus) { Assert.notNull(errorStatus, "errorStatus must not be null"); final String code = Optional.ofNullable(errorStatus.getStatusCode()) - .map(StatusCode::getValue) - .orElseThrow(() -> new IllegalArgumentException("Supplied status object does not have status code set")); + .map(StatusCode::getValue) + .orElseThrow(() -> new IllegalArgumentException("Supplied status object does not have status code set")); if (StatusCode.SUCCESS.equals(code)) { throw new IllegalArgumentException("Can not send error response with status set to success"); } - + final Response response = this.createResponse(responseAttributes, errorStatus); this.responseCustomizer.customize(response); this.signResponse(response, responseAttributes.getPeerMetadata()); + this.eventPublisher.publishSamlErrorResponse(response, responseAttributes.getPeerMetadata().getEntityID()); + return response; } @@ -121,9 +136,13 @@ public Response buildErrorResponse(final Saml2ResponseAttributes responseAttribu * @return a {@link Response} object * @throws UnrecoverableSaml2IdpException for errors */ - public Response buildErrorResponse(final Saml2ResponseAttributes responseAttributes, final Saml2ErrorStatusException error) - throws UnrecoverableSaml2IdpException { - return this.buildErrorResponse(responseAttributes, error.getStatus()); + public Response buildErrorResponse(final Saml2ResponseAttributes responseAttributes, + final Saml2ErrorStatusException error) throws UnrecoverableSaml2IdpException { + + final Status status = this.messageSource != null + ? error.getStatus(this.messageSource, Locale.ENGLISH) + : error.getStatus(); + return this.buildErrorResponse(responseAttributes, status); } /** @@ -156,6 +175,9 @@ public Response buildResponse(final Saml2ResponseAttributes responseAttributes, this.responseCustomizer.customize(response); this.signResponse(response, responseAttributes.getPeerMetadata()); + this.eventPublisher.publishSamlSuccessResponse( + response, assertion, responseAttributes.getPeerMetadata().getEntityID()); + return response; } @@ -172,7 +194,7 @@ protected Response createResponse(final Saml2ResponseAttributes responseAttribut throws UnrecoverableSaml2IdpException { if (responseAttributes.getDestination() == null || responseAttributes.getInResponseTo() == null || status == null) { - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "No response data available"); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "No response data available", null); } final Response samlResponse = (Response) XMLObjectSupport.buildXMLObject(Response.DEFAULT_ELEMENT_NAME); @@ -210,7 +232,9 @@ protected void signResponse(final Response samlResponse, final EntityDescriptor e.getMessage(), samlResponse.getDestination(), samlResponse.getID(), samlResponse.getInResponseTo(), e); throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, - "Failed to sign Response message", e); + "Failed to sign Response message", e, + new UnrecoverableSaml2IdpException.TraceAuthentication( + samlResponse.getInResponseTo(), peerMetadata.getEntityID())); } } @@ -226,7 +250,7 @@ protected EncryptedAssertion encryptAssertion(final Assertion assertion, final E throws UnrecoverableSaml2IdpException { if (peerMetadata == null) { - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "No response data available"); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "No response data available", null); } try { @@ -240,7 +264,8 @@ protected EncryptedAssertion encryptAssertion(final Assertion assertion, final E return encryptedAssertion; } catch (final EncryptionException e) { - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "Failed to encrypt assertion", e); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "Failed to encrypt assertion", e, + new UnrecoverableSaml2IdpException.TraceAuthentication(null, peerMetadata.getEntityID())); } } @@ -293,4 +318,13 @@ public void setResponseCustomizer(final Customizer responseCustomizer) this.responseCustomizer = Objects.requireNonNull(responseCustomizer, "responseCustomizer must not be null"); } + /** + * Assigns a message source for resolving error messages. + * + * @param messageSource the {@link MessageSource} + */ + public void setMessageSource(final MessageSource messageSource) { + this.messageSource = messageSource; + } + } diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/response/Saml2ResponseSender.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/response/Saml2ResponseSender.java index 75b71627..157db2e6 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/response/Saml2ResponseSender.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/response/Saml2ResponseSender.java @@ -69,8 +69,8 @@ public void send(final HttpServletRequest httpServletRequest, final HttpServletR } catch (final IOException e) { log.error("Failed to send SAML Response to {} - {}", destinationUrl, e.getMessage(), e); - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, - "Failed to send Response message", e); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "Failed to send Response message", + e, new UnrecoverableSaml2IdpException.TraceAuthentication(response.getInResponseTo(), null)); } } @@ -100,8 +100,8 @@ protected String encodeResponse(final Response samlResponse) throws Unrecoverabl log.error("Failed to encode Response message - {} [destination: '{}', id: '{}', in-response-to: {}]", e.getMessage(), samlResponse.getDestination(), samlResponse.getID(), samlResponse.getInResponseTo(), e); - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, - "Failed to encode Response message", e); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "Failed to encode Response message", e, + new UnrecoverableSaml2IdpException.TraceAuthentication(samlResponse.getInResponseTo(), null)); } } diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/utils/OpenSamlUtils.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/utils/OpenSamlUtils.java index 13c84078..f5abac9d 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/utils/OpenSamlUtils.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/utils/OpenSamlUtils.java @@ -84,7 +84,7 @@ public static NonnullSupplier getHttpServletResponseSupplie .map(ServletRequestAttributes.class::cast) .map(ServletRequestAttributes::getResponse) .orElseThrow(() -> new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, - "Could not get HttpServletResponse")); + "Could not get HttpServletResponse", null)); }; } diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/web/filters/Saml2ErrorResponseProcessingFilter.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/web/filters/Saml2ErrorResponseProcessingFilter.java index 67d9a68f..31adb93d 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/web/filters/Saml2ErrorResponseProcessingFilter.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/web/filters/Saml2ErrorResponseProcessingFilter.java @@ -33,6 +33,7 @@ import se.swedenconnect.spring.saml.idp.context.Saml2IdpContextHolder; import se.swedenconnect.spring.saml.idp.error.Saml2ErrorStatusException; import se.swedenconnect.spring.saml.idp.error.UnrecoverableSaml2IdpException; +import se.swedenconnect.spring.saml.idp.events.Saml2IdpEventPublisher; import se.swedenconnect.spring.saml.idp.response.Saml2ResponseAttributes; import se.swedenconnect.spring.saml.idp.response.Saml2ResponseBuilder; import se.swedenconnect.spring.saml.idp.response.Saml2ResponseSender; @@ -52,6 +53,9 @@ public class Saml2ErrorResponseProcessingFilter extends OncePerRequestFilter { /** The response sender. */ private final Saml2ResponseSender responseSender; + + /** The event publisher. */ + private final Saml2IdpEventPublisher eventPublisher; /** An analyzer for handling exceptions. */ private ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer(); @@ -62,12 +66,15 @@ public class Saml2ErrorResponseProcessingFilter extends OncePerRequestFilter { * @param requestMatcher the request matcher * @param responseBuilder the {@link Saml2ResponseBuilder} * @param responseSender the {@link Saml2ResponseSender} + * @param eventPublisher the system event publisher */ public Saml2ErrorResponseProcessingFilter(final RequestMatcher requestMatcher, - final Saml2ResponseBuilder responseBuilder, final Saml2ResponseSender responseSender) { + final Saml2ResponseBuilder responseBuilder, final Saml2ResponseSender responseSender, + final Saml2IdpEventPublisher eventPublisher) { this.requestMatcher = Objects.requireNonNull(requestMatcher, "requestMatcher must not be null"); this.responseBuilder = Objects.requireNonNull(responseBuilder, "responseBuilder must not be null"); this.responseSender = Objects.requireNonNull(responseSender, "responseSender must not be null"); + this.eventPublisher = Objects.requireNonNull(eventPublisher, "eventPublisher must not be null"); } /** {@inheritDoc} */ @@ -92,6 +99,10 @@ protected void doFilterInternal( .getFirstThrowableOfType(Saml2ErrorStatusException.class, causeChain); if (samlException == null) { + if (e instanceof UnrecoverableSaml2IdpException unrecoverable) { + this.eventPublisher.publishUnrecoverableSamlError(unrecoverable); + } + if (e instanceof ServletException) { throw (ServletException) e; } diff --git a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/web/filters/Saml2UserAuthenticationProcessingFilter.java b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/web/filters/Saml2UserAuthenticationProcessingFilter.java index 5199a1de..ba5742f4 100644 --- a/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/web/filters/Saml2UserAuthenticationProcessingFilter.java +++ b/saml-identity-provider/src/main/java/se/swedenconnect/spring/saml/idp/web/filters/Saml2UserAuthenticationProcessingFilter.java @@ -29,6 +29,7 @@ import org.opensaml.saml.saml2.core.Assertion; import org.opensaml.saml.saml2.core.AuthnRequest; import org.opensaml.saml.saml2.core.Response; +import org.opensaml.saml.saml2.metadata.EntityDescriptor; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderNotFoundException; import org.springframework.security.core.Authentication; @@ -56,6 +57,7 @@ import se.swedenconnect.spring.saml.idp.error.Saml2ErrorStatusException; import se.swedenconnect.spring.saml.idp.error.UnrecoverableSaml2IdpError; import se.swedenconnect.spring.saml.idp.error.UnrecoverableSaml2IdpException; +import se.swedenconnect.spring.saml.idp.events.Saml2IdpEventPublisher; import se.swedenconnect.spring.saml.idp.response.Saml2ResponseAttributes; import se.swedenconnect.spring.saml.idp.response.Saml2ResponseBuilder; import se.swedenconnect.spring.saml.idp.response.Saml2ResponseSender; @@ -63,7 +65,7 @@ /** * A {@link Filter} that intercept an SAML authentication request that has been verified and translated into a * {@link Saml2UserAuthenticationInputToken}. - * + * * @author Martin Lindström */ @Slf4j @@ -97,15 +99,18 @@ public class Saml2UserAuthenticationProcessingFilter extends OncePerRequestFilte /** The assertion handler responsible of creating {@link Assertion}s. */ private final Saml2AssertionBuilder assertionHandler; + /** The event publisher. */ + private final Saml2IdpEventPublisher eventPublisher; + /** Repository storing authentication objects used for external authentication. */ private FilterAuthenticationTokenRepository authenticationTokenRepository = new SessionBasedExternalAuthenticationRepository(); - private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); /** * Constructor. - * + * * @param authenticationManager the authentication manager * @param requestMatcher the request matcher * @param postAuthenticationProcessor processor for checking the authentication token after the provider has @@ -113,13 +118,15 @@ public class Saml2UserAuthenticationProcessingFilter extends OncePerRequestFilte * @param assertionHandler the assertion handler responsible of creating {@link Assertion}s * @param responseBuilder the {@link Saml2ResponseBuilder} * @param responseSender the {@link Saml2ResponseSender} + * @param eventPublisher the event publisher */ public Saml2UserAuthenticationProcessingFilter(final AuthenticationManager authenticationManager, final RequestMatcher requestMatcher, final PostAuthenticationProcessor postAuthenticationProcessor, final Saml2AssertionBuilder assertionHandler, final Saml2ResponseBuilder responseBuilder, - final Saml2ResponseSender responseSender) { + final Saml2ResponseSender responseSender, + final Saml2IdpEventPublisher eventPublisher) { this.authenticationManager = Objects.requireNonNull(authenticationManager, "authenticationManager must not be null"); this.requestMatcher = Objects.requireNonNull(requestMatcher, "requestMatcher must not be null"); @@ -128,6 +135,7 @@ public Saml2UserAuthenticationProcessingFilter(final AuthenticationManager authe this.assertionHandler = Objects.requireNonNull(assertionHandler, "assertionHandler must not be null"); this.responseBuilder = Objects.requireNonNull(responseBuilder, "responseBuilder must not be null"); this.responseSender = Objects.requireNonNull(responseSender, "responseSender must not be null"); + this.eventPublisher = Objects.requireNonNull(eventPublisher, "eventPublisher must not be null"); } /** {@inheritDoc} */ @@ -150,15 +158,18 @@ protected void doFilterInternal( final HttpSession session = request.getSession(); final Saml2ResponseAttributes responseAttributes = (Saml2ResponseAttributes) session.getAttribute(RESPONSE_ATTRIBUTES_SESSION_KEY); - if (response == null) { - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_SESSION); + if (responseAttributes == null) { + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_SESSION, null); } session.removeAttribute(RESPONSE_ATTRIBUTES_SESSION_KEY); inputToken = this.authenticationTokenRepository.getCompletedExternalAuthentication(request); this.authenticationTokenRepository.clear(request); if (inputToken == null) { - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_SESSION); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_SESSION, + new UnrecoverableSaml2IdpException.TraceAuthentication(responseAttributes.getInResponseTo(), + Optional.ofNullable(responseAttributes.getPeerMetadata()).map(EntityDescriptor::getEntityID) + .orElse(null))); } final ResumedAuthenticationToken resumeToken = ResumedAuthenticationToken.class.cast(inputToken); @@ -170,14 +181,14 @@ protected void doFilterInternal( .map(Saml2AuthnRequestAuthenticationToken::getAuthnRequest) .map(AuthnRequest::getID) .orElseThrow(() -> new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_SESSION, - "Failed to get information about authentication request")); + "Failed to get information about authentication request", resumeToken)); if (!Objects.equals(currentAuthnRequestID, responseAttributes.getInResponseTo())) { final String msg = "State error: Saved response attributes does not match information about current request"; log.error("{} [{}]", msg, Optional.ofNullable(resumeToken.getAuthnInputToken()) .map(Saml2UserAuthenticationInputToken::getLogString) .orElseGet(() -> "-")); - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_SESSION, msg); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INVALID_SESSION, msg, resumeToken); } Saml2IdpContextHolder.getContext().getResponseAttributes().copyInto(responseAttributes); @@ -195,13 +206,15 @@ protected void doFilterInternal( inputToken = SecurityContextHolder.getContext().getAuthentication(); if (inputToken == null) { throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, - "Missing token - " + Saml2UserAuthenticationInputToken.class.getSimpleName()); + "Missing token - " + Saml2UserAuthenticationInputToken.class.getSimpleName(), null); } if (!Saml2UserAuthenticationInputToken.class.isInstance(inputToken)) { throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, "Expected token " + Saml2UserAuthenticationInputToken.class.getSimpleName() - + " but was " + inputToken.getClass().getSimpleName()); + + " but was " + inputToken.getClass().getSimpleName(), null); } + + this.eventPublisher.publishBeforeUserAuthenticated((Saml2UserAuthenticationInputToken) inputToken); } final Authentication auth; @@ -224,13 +237,14 @@ protected void doFilterInternal( // If a RedirectForAuthenticationToken is received, this is an order to initiate an "external authentication", // meaning that we should redirect the user agent. // - if (RedirectForAuthenticationToken.class.isInstance(auth)) { - final RedirectForAuthenticationToken redirectToken = RedirectForAuthenticationToken.class.cast(auth); + if (auth instanceof final RedirectForAuthenticationToken redirectToken) { this.authenticationTokenRepository.startExternalAuthentication(redirectToken, request); log.info("Re-directing to {} for external authentication [{}]", redirectToken.getAuthnPath(), redirectToken.getAuthnInputToken().getLogString()); + this.eventPublisher.publishBeforeUserAuthenticated(redirectToken.getAuthnInputToken()); + // Save the response attributes in the session so that we know how to send back a response // when the user returns to the flow. // @@ -246,7 +260,7 @@ protected void doFilterInternal( if (!Saml2UserAuthentication.class.isInstance(auth)) { throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, String.format("Expected {} from authentication manager but got {}", - Saml2UserAuthentication.class.getSimpleName(), auth.getClass().getSimpleName())); + Saml2UserAuthentication.class.getSimpleName(), auth.getClass().getSimpleName()), null); } final Saml2UserAuthentication authenticatedUser = Saml2UserAuthentication.class.cast(auth); @@ -255,6 +269,10 @@ protected void doFilterInternal( authenticatedUser.setAuthnRequestToken(getSamlInputToken(inputToken).getAuthnRequestToken()); authenticatedUser.setAuthnRequirements(getSamlInputToken(inputToken).getAuthnRequirements()); + // Publish the event that indicates that the user has been authenticated ... + // + this.eventPublisher.publishUserAuthenticated(authenticatedUser); + // Apply the post processor ... // this.postAuthenticationProcessor.process(authenticatedUser); @@ -289,13 +307,13 @@ private static Saml2UserAuthenticationInputToken getSamlInputToken(final Authent if (auth instanceof ResumedAuthenticationToken) { return ((ResumedAuthenticationToken) auth).getAuthnInputToken(); } - throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL); + throw new UnrecoverableSaml2IdpException(UnrecoverableSaml2IdpError.INTERNAL, null); } /** * Assigns a request matcher for handling when the user agent is redirected back to the flow after that the user has * been authenticated using a {@link AbstractUserRedirectAuthenticationProvider}. - * + * * @param resumeAuthnRequestMatcher request matcher */ public void setResumeAuthnRequestMatcher(final RequestMatcher resumeAuthnRequestMatcher) { @@ -310,7 +328,7 @@ public void setResumeAuthnRequestMatcher(final RequestMatcher resumeAuthnRequest * {@link AbstractUserRedirectAuthenticationProvider} is using the same persistence strategy as the assigned * repository bean. *

- * + * * @param authenticationTokenRepository token repository */ public void setAuthenticationTokenRepository( @@ -321,15 +339,13 @@ public void setAuthenticationTokenRepository( /** * Predicate telling whether any of this {@link Filter}s {@link RequestMatcher}s match the incoming request. - * + * * @param request the request to test * @return {@code true} for a match and {@code false} otherwise */ private boolean matches(final HttpServletRequest request) { - if (this.requestMatcher.matches(request)) { - return true; - } - if (this.resumeAuthnRequestMatcher != null && this.resumeAuthnRequestMatcher.matches(request)) { + if (this.requestMatcher.matches(request) + || (this.resumeAuthnRequestMatcher != null && this.resumeAuthnRequestMatcher.matches(request))) { return true; } return false; diff --git a/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/authentication/Saml2AssertionBuilderTest.java b/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/authentication/Saml2AssertionBuilderTest.java index 30d8ce99..24776ab6 100644 --- a/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/authentication/Saml2AssertionBuilderTest.java +++ b/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/authentication/Saml2AssertionBuilderTest.java @@ -87,6 +87,7 @@ public void testBuild() throws Exception { Mockito.mock(Saml2AuthnRequestAuthenticationToken.class); Mockito.when(authnRequestToken.getLogString()).thenReturn("logstring"); Mockito.when(authnRequestToken.getNameIDGenerator()).thenReturn(new PersistentNameIDGenerator(IDP, SP)); + Mockito.when(authnRequestToken.getEntityId()).thenReturn(SP); final AuthnRequest authnRequest = (AuthnRequest) XMLObjectSupport.buildXMLObject(AuthnRequest.DEFAULT_ELEMENT_NAME); authnRequest.setID(AUTHNREQUEST_ID); @@ -157,6 +158,7 @@ public void testBuildNotSignedAndAuthenticatingAuth() throws Exception { Mockito.mock(Saml2AuthnRequestAuthenticationToken.class); Mockito.when(authnRequestToken.getLogString()).thenReturn("logstring"); Mockito.when(authnRequestToken.getNameIDGenerator()).thenReturn(new PersistentNameIDGenerator(IDP, SP)); + Mockito.when(authnRequestToken.getEntityId()).thenReturn(SP); final AuthnRequest authnRequest = (AuthnRequest) XMLObjectSupport.buildXMLObject(AuthnRequest.DEFAULT_ELEMENT_NAME); authnRequest.setID(AUTHNREQUEST_ID); diff --git a/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/authentication/Saml2UserAuthenticationTest.java b/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/authentication/Saml2UserAuthenticationTest.java index e358fdb5..c148796a 100644 --- a/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/authentication/Saml2UserAuthenticationTest.java +++ b/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/authentication/Saml2UserAuthenticationTest.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import org.opensaml.saml.saml2.core.AuthnRequest; import se.swedenconnect.opensaml.sweid.saml2.attribute.AttributeConstants; import se.swedenconnect.opensaml.sweid.saml2.authn.LevelOfAssuranceUris; @@ -58,10 +59,25 @@ public void test() { Assertions.assertFalse(a.isReuseAuthentication()); Assertions.assertNull(a.getAuthnRequestToken()); - a.setAuthnRequestToken(Mockito.mock(Saml2AuthnRequestAuthenticationToken.class)); + Assertions.assertNull(a.getAuthenticationInfoTrack()); + + final Saml2AuthnRequestAuthenticationToken aToken = Mockito.mock(Saml2AuthnRequestAuthenticationToken.class); + Mockito.when(aToken.getEntityId()).thenReturn("SP"); + final AuthnRequest authnRequest = Mockito.mock(AuthnRequest.class); + Mockito.when(authnRequest.getID()).thenReturn("ID"); + Mockito.when(aToken.getAuthnRequest()).thenReturn(authnRequest); + a.setAuthnRequestToken(aToken); + Assertions.assertNotNull(a.getAuthnRequestToken()); + Assertions.assertNotNull(a.getAuthenticationInfoTrack()); + Assertions.assertFalse(a.isSsoApplied()); a.clearAuthnRequestToken(); Assertions.assertNull(a.getAuthnRequestToken()); + Assertions.assertNotNull(a.getAuthenticationInfoTrack()); + + a.setAuthnRequestToken(aToken); + Assertions.assertTrue(a.isSsoApplied()); + a.clearAuthnRequestToken(); Assertions.assertNull(a.getAuthnRequirements()); a.setAuthnRequirements(Mockito.mock(AuthenticationRequirements.class)); diff --git a/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationProviderTest.java b/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationProviderTest.java index 7e0d754a..2afcc5e1 100644 --- a/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationProviderTest.java +++ b/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/authnrequest/Saml2AuthnRequestAuthenticationProviderTest.java @@ -24,6 +24,7 @@ import org.mockito.Mockito; import org.opensaml.saml.saml2.core.AuthnRequest; import org.opensaml.saml.saml2.metadata.EntityDescriptor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.core.Authentication; import se.swedenconnect.opensaml.sweid.saml2.attribute.AttributeConstants; @@ -36,6 +37,7 @@ import se.swedenconnect.spring.saml.idp.authnrequest.validation.AuthnRequestValidator; import se.swedenconnect.spring.saml.idp.context.Saml2IdpContext; import se.swedenconnect.spring.saml.idp.context.Saml2IdpContextHolder; +import se.swedenconnect.spring.saml.idp.events.Saml2IdpEventPublisher; import se.swedenconnect.spring.saml.idp.extensions.SignatureMessageExtensionExtractor; import se.swedenconnect.spring.saml.idp.response.Saml2ResponseAttributes; import se.swedenconnect.spring.saml.idp.settings.IdentityProviderSettings; @@ -114,8 +116,10 @@ public void testSuccess() { Mockito.mock(SignatureMessageExtensionExtractor.class); final PrincipalSelectionProcessor principalSelectionProcessor = Mockito.mock(PrincipalSelectionProcessor.class); + final Saml2IdpEventPublisher publisher = new Saml2IdpEventPublisher(Mockito.mock(ApplicationEventPublisher.class)); + final Saml2AuthnRequestAuthenticationProvider provider = new Saml2AuthnRequestAuthenticationProvider( - signatureValidator, assertionConsumerServiceValidator, replayValidator, + publisher, signatureValidator, assertionConsumerServiceValidator, replayValidator, encryptCapabilitiesValidator, List.of(requestedAttributeProcessor), nameIDGeneratorFactory, signatureMessageExtensionExtractor, principalSelectionProcessor); diff --git a/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/it/AuthenticationIntegrationTest.java b/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/it/AuthenticationIntegrationTest.java index 29211aaf..90ed1152 100644 --- a/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/it/AuthenticationIntegrationTest.java +++ b/saml-identity-provider/src/test/java/se/swedenconnect/spring/saml/idp/it/AuthenticationIntegrationTest.java @@ -20,6 +20,7 @@ import java.io.ByteArrayInputStream; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -36,9 +37,11 @@ import org.opensaml.xmlsec.encryption.support.EncryptionException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.actuate.audit.listener.AuditApplicationEvent; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationListener; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @@ -56,6 +59,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; +import lombok.Getter; import se.swedenconnect.opensaml.saml2.request.AuthnRequestGenerator; import se.swedenconnect.opensaml.saml2.request.AuthnRequestGeneratorContext; import se.swedenconnect.opensaml.saml2.response.ResponseProcessingResult; @@ -70,12 +74,19 @@ import se.swedenconnect.security.credential.PkiCredential; import se.swedenconnect.spring.saml.idp.OpenSamlTestBase; import se.swedenconnect.spring.saml.idp.attributes.UserAttribute; +import se.swedenconnect.spring.saml.idp.audit.Saml2AuditEvent; import se.swedenconnect.spring.saml.idp.authentication.Saml2UserAuthentication; import se.swedenconnect.spring.saml.idp.authentication.Saml2UserAuthenticationInputToken; import se.swedenconnect.spring.saml.idp.authentication.Saml2UserDetails; import se.swedenconnect.spring.saml.idp.authentication.provider.AbstractUserAuthenticationProvider; import se.swedenconnect.spring.saml.idp.config.Saml2IdpConfiguration; import se.swedenconnect.spring.saml.idp.error.Saml2ErrorStatusException; +import se.swedenconnect.spring.saml.idp.events.AbstractSaml2IdpEvent; +import se.swedenconnect.spring.saml.idp.events.AbstractSaml2IdpEventListener; +import se.swedenconnect.spring.saml.idp.events.Saml2AuthnRequestReceivedEvent; +import se.swedenconnect.spring.saml.idp.events.Saml2PostUserAuthenticationEvent; +import se.swedenconnect.spring.saml.idp.events.Saml2PreUserAuthenticationEvent; +import se.swedenconnect.spring.saml.idp.events.Saml2SuccessResponseEvent; import se.swedenconnect.spring.saml.idp.it.AuthenticationIntegrationTest.ApplicationConfiguration; import se.swedenconnect.spring.saml.idp.settings.CredentialSettings; import se.swedenconnect.spring.saml.idp.settings.EndpointSettings; @@ -85,7 +96,7 @@ import se.swedenconnect.spring.saml.idp.settings.MetadataSettings.ContactPersonType; import se.swedenconnect.spring.saml.idp.settings.MetadataSettings.OrganizationSettings; -@SpringBootTest +@SpringBootTest(properties = { "management.auditevents.enabled=true" }) @ContextConfiguration(classes = { ApplicationConfiguration.class }) @WebAppConfiguration @AutoConfigureMockMvc @@ -96,9 +107,15 @@ public class AuthenticationIntegrationTest extends OpenSamlTestBase { @Autowired private WebApplicationContext webApplicationContext; + @Autowired + private Saml2EventListener eventListener; + + @Autowired + private AuditEventListener auditListener; + @MockBean MetadataResolver metadataResolver; - + MetadataResolver simulatedResolver; @BeforeEach @@ -106,14 +123,16 @@ public void setup() throws Exception { this.mvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext) .apply(springSecurity()) .build(); - + Mockito.when(metadataResolver.resolveSingle(Mockito.any())).thenAnswer(a -> { return this.simulatedResolver.resolveSingle(a.getArgument(0)); }); Mockito.when(metadataResolver.resolve(Mockito.any())).thenAnswer(a -> { return this.simulatedResolver.resolve(a.getArgument(0)); }); - + + this.eventListener.clear(); + this.auditListener.clear(); } @Test @@ -125,9 +144,9 @@ public void authenticatePost() throws Exception { final EntityDescriptor spMetadata = testSp.getSpMetadata(); final EntityDescriptor idpMetadata = this.getIdpMetadata(); this.simulatedResolver = TestSupport.createMetadataResolver(spMetadata, idpMetadata); - + testSp.setupResponseProcessor(this.simulatedResolver); - + final AuthnRequestGenerator generator = testSp.createAuthnRequestGenerator(idpMetadata); final AuthnRequestGeneratorContext context = new AuthnRequestGeneratorContext() { @@ -141,16 +160,25 @@ public String getPreferredBinding() { final RequestBuilder requestBuilder = testSp.generateRequest(TestSupport.IDP_ENTITY_ID, generator, context, "relay-state", null); - + final MvcResult result = mvc.perform(requestBuilder) .andDo(MockMvcResultHandlers.print()) .andExpect(status().isOk()) .andReturn(); - + final ResponseProcessingResult processingResult = testSp.processSamlResponse(result); Assertions.assertNotNull(processingResult); + + Assertions.assertTrue(this.eventListener.getEvents().size() == 4); + Assertions.assertTrue(this.eventListener.getEvents().get(0) instanceof Saml2AuthnRequestReceivedEvent); + Assertions.assertTrue(this.eventListener.getEvents().get(1) instanceof Saml2PreUserAuthenticationEvent); + Assertions.assertTrue(this.eventListener.getEvents().get(2) instanceof Saml2PostUserAuthenticationEvent); + Assertions.assertTrue(this.eventListener.getEvents().get(3) instanceof Saml2SuccessResponseEvent); + + // Auditing + Assertions.assertEquals(4, this.auditListener.getEvents().size()); } - + @Test public void authenticateSignService() throws Exception { final TestSp testSp = new TestSp(); @@ -161,9 +189,9 @@ public void authenticateSignService() throws Exception { final EntityDescriptor spMetadata = testSp.getSpMetadata(); final EntityDescriptor idpMetadata = this.getIdpMetadata(); this.simulatedResolver = TestSupport.createMetadataResolver(spMetadata, idpMetadata); - + testSp.setupResponseProcessor(this.simulatedResolver); - + final AuthnRequestGenerator generator = testSp.createAuthnRequestGenerator(idpMetadata); final AuthnRequestGeneratorContext context = new SwedishEidAuthnRequestGeneratorContext() { @@ -176,7 +204,8 @@ public String getPreferredBinding() { @Override public SignMessageBuilderFunction getSignMessageBuilderFunction() { return (metadata, signMessageEncrypter) -> { - final SignMessage signMessage = (SignMessage) XMLObjectSupport.buildXMLObject(SignMessage.DEFAULT_ELEMENT_NAME); + final SignMessage signMessage = + (SignMessage) XMLObjectSupport.buildXMLObject(SignMessage.DEFAULT_ELEMENT_NAME); signMessage.setDisplayEntity(TestSupport.IDP_ENTITY_ID); signMessage.setMimeType(SignMessageMimeTypeEnum.TEXT); signMessage.setMustShow(true); @@ -195,7 +224,7 @@ public SignMessageBuilderFunction getSignMessageBuilderFunction() { } @Override - public AuthnRequestCustomizer getAuthnRequestCustomizer() { + public AuthnRequestCustomizer getAuthnRequestCustomizer() { return (authnRequest) -> { final SADRequest sadRequest = (SADRequest) XMLObjectSupport.buildXMLObject(SADRequest.DEFAULT_ELEMENT_NAME); sadRequest.setID("ABCDEF"); @@ -214,15 +243,15 @@ public AuthnRequestCustomizer getAuthnRequestCustomizer() { final RequestBuilder requestBuilder = testSp.generateRequest(TestSupport.IDP_ENTITY_ID, generator, context, "relay-state", null); - + final MvcResult result = mvc.perform(requestBuilder) .andDo(MockMvcResultHandlers.print()) .andExpect(status().isOk()) .andReturn(); - + final ResponseProcessingResult processingResult = testSp.processSamlResponse(result); Assertions.assertNotNull(processingResult); - + Assertions.assertTrue(processingResult.getAttributes().stream() .filter(a -> AttributeConstants.ATTRIBUTE_NAME_SIGNMESSAGE_DIGEST.equals(a.getName())) .findFirst() @@ -231,8 +260,17 @@ public AuthnRequestCustomizer getAuthnRequestCustomizer() { .filter(a -> AttributeConstants.ATTRIBUTE_NAME_SAD.equals(a.getName())) .findFirst() .isPresent()); - } - + + Assertions.assertTrue(this.eventListener.getEvents().size() == 4); + Assertions.assertTrue(this.eventListener.getEvents().get(0) instanceof Saml2AuthnRequestReceivedEvent); + Assertions.assertTrue(this.eventListener.getEvents().get(1) instanceof Saml2PreUserAuthenticationEvent); + Assertions.assertTrue(this.eventListener.getEvents().get(2) instanceof Saml2PostUserAuthenticationEvent); + Assertions.assertTrue(this.eventListener.getEvents().get(3) instanceof Saml2SuccessResponseEvent); + + // Auditing + Assertions.assertEquals(4, this.auditListener.getEvents().size()); + } + @Test public void authenticateSso() throws Exception { final TestSp testSp = new TestSp(); @@ -242,13 +280,13 @@ public void authenticateSso() throws Exception { final EntityDescriptor spMetadata = testSp.getSpMetadata(); final EntityDescriptor idpMetadata = this.getIdpMetadata(); this.simulatedResolver = TestSupport.createMetadataResolver(spMetadata, idpMetadata); - + testSp.setupResponseProcessor(this.simulatedResolver); - + final AuthnRequestGenerator generator = testSp.createAuthnRequestGenerator(idpMetadata); AuthnRequestGeneratorContext context = new AuthnRequestGeneratorContext() { - + @Override public String getPreferredBinding() { return SAMLConstants.SAML2_POST_BINDING_URI; @@ -257,18 +295,26 @@ public String getPreferredBinding() { final RequestBuilder requestBuilder = testSp.generateRequest(TestSupport.IDP_ENTITY_ID, generator, context, "relay-state", null); - + final MvcResult result = mvc.perform(requestBuilder) .andDo(MockMvcResultHandlers.print()) .andExpect(status().isOk()) .andReturn(); - + final MockHttpSession session = (MockHttpSession) result.getRequest().getSession(); - + final ResponseProcessingResult processingResult = testSp.processSamlResponse(result); Assertions.assertNotNull(processingResult); final Instant authnInstant = processingResult.getAuthnInstant(); - + + Assertions.assertTrue(this.eventListener.getEvents().size() == 4); + Assertions.assertTrue(this.eventListener.getEvents().get(0) instanceof Saml2AuthnRequestReceivedEvent); + Assertions.assertTrue(this.eventListener.getEvents().get(1) instanceof Saml2PreUserAuthenticationEvent); + Assertions.assertTrue(this.eventListener.getEvents().get(2) instanceof Saml2PostUserAuthenticationEvent); + Assertions.assertFalse(Saml2PostUserAuthenticationEvent.class.cast(this.eventListener.getEvents().get(2)) + .getUserAuthentication().isSsoApplied()); + Assertions.assertTrue(this.eventListener.getEvents().get(3) instanceof Saml2SuccessResponseEvent); + // Authenticate again // context = new AuthnRequestGeneratorContext() { @@ -288,19 +334,29 @@ public String getPreferredBinding() { return SAMLConstants.SAML2_POST_BINDING_URI; } }; - + final RequestBuilder requestBuilder2 = testSp.generateRequest(TestSupport.IDP_ENTITY_ID, generator, context, "relay-state", session); - + final MvcResult result2 = mvc.perform(requestBuilder2) .andDo(MockMvcResultHandlers.print()) .andExpect(status().isOk()) .andReturn(); - + final ResponseProcessingResult processingResult2 = testSp.processSamlResponse(result2); Assertions.assertNotNull(processingResult2); Assertions.assertEquals(authnInstant, processingResult2.getAuthnInstant()); + + Assertions.assertTrue(this.eventListener.getEvents().size() == 8); + Assertions.assertTrue(this.eventListener.getEvents().get(4) instanceof Saml2AuthnRequestReceivedEvent); + Assertions.assertTrue(this.eventListener.getEvents().get(5) instanceof Saml2PreUserAuthenticationEvent); + Assertions.assertTrue(this.eventListener.getEvents().get(6) instanceof Saml2PostUserAuthenticationEvent); + Assertions.assertTrue(Saml2PostUserAuthenticationEvent.class.cast(this.eventListener.getEvents().get(6)) + .getUserAuthentication().isSsoApplied()); + Assertions.assertTrue(this.eventListener.getEvents().get(7) instanceof Saml2SuccessResponseEvent); + // Auditing + Assertions.assertEquals(8, this.auditListener.getEvents().size()); } private EntityDescriptor getIdpMetadata() throws Exception { @@ -357,12 +413,12 @@ protected Authentication authenticate(final Saml2UserAuthenticationInputToken to LevelOfAssuranceUris.AUTHN_CONTEXT_URI_LOA3, Instant.now(), "127.0.0.1"); - + if (token.getAuthnRequestToken().isSignatureServicePeer()) { if (Optional.ofNullable(token.getAuthnRequestToken().getAuthnRequest().getExtensions()) - .map(e -> e.getUnknownXMLObjects(SignMessage.DEFAULT_ELEMENT_NAME)) - .filter(l -> !l.isEmpty()) - .isPresent()) { + .map(e -> e.getUnknownXMLObjects(SignMessage.DEFAULT_ELEMENT_NAME)) + .filter(l -> !l.isEmpty()) + .isPresent()) { details.setSignMessageDisplayed(true); } } @@ -392,6 +448,16 @@ public static class ApplicationConfiguration { @Autowired MetadataResolver metadataResolver; + @Bean + Saml2EventListener saml2EventListener() { + return new Saml2EventListener(); + } + + @Bean + AuditEventListener auditListener() { + return new AuditEventListener(); + } + @Bean IdentityProviderSettings identityProviderSettings() { @@ -431,4 +497,39 @@ IdentityProviderSettings identityProviderSettings() { } + public static class Saml2EventListener extends AbstractSaml2IdpEventListener { + + @Getter + private List events = new ArrayList<>(); + + @Override + public void onApplicationEvent(final AbstractSaml2IdpEvent event) { + super.onApplicationEvent(event); + this.events.add(event); + } + + public void clear() { + this.events.clear(); + } + + } + + public static class AuditEventListener implements ApplicationListener { + + @Getter + private List events = new ArrayList<>(); + + @Override + public void onApplicationEvent(final AuditApplicationEvent event) { + if (event.getAuditEvent() instanceof Saml2AuditEvent e) { + events.add(e); + } + } + + public void clear() { + this.events.clear(); + } + + } + } diff --git a/samples/demo-boot-idp/pom.xml b/samples/demo-boot-idp/pom.xml index 08a21595..47633a1f 100644 --- a/samples/demo-boot-idp/pom.xml +++ b/samples/demo-boot-idp/pom.xml @@ -64,6 +64,11 @@ spring-boot-starter-thymeleaf
+ + org.springframework.boot + spring-boot-starter-security + + org.springframework.boot spring-boot-starter-actuator diff --git a/samples/demo-boot-idp/src/main/java/se/swedenconnect/spring/saml/idp/demo/IdpConfiguration.java b/samples/demo-boot-idp/src/main/java/se/swedenconnect/spring/saml/idp/demo/IdpConfiguration.java index 005437d7..3a63d508 100644 --- a/samples/demo-boot-idp/src/main/java/se/swedenconnect/spring/saml/idp/demo/IdpConfiguration.java +++ b/samples/demo-boot-idp/src/main/java/se/swedenconnect/spring/saml/idp/demo/IdpConfiguration.java @@ -17,10 +17,16 @@ import org.opensaml.saml.saml2.core.NameID; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.audit.InMemoryAuditEventRepository; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.thymeleaf.spring5.SpringTemplateEngine; import se.swedenconnect.spring.saml.idp.attributes.nameid.DefaultNameIDGeneratorFactory; @@ -98,4 +104,26 @@ Saml2IdpConfigurerAdapter samlIdpConfigurer(final SpringTemplateEngine templateE }; } + @Bean + @Order(2) + SecurityFilterChain defaultSecurityFilterChain(final HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) + .cors(Customizer.withDefaults()) + .authorizeHttpRequests((authorize) -> authorize + .antMatchers("/images/**", "/error", "/assets/**", "/scripts/**", "/webjars/**", "/view/**", "/api/**", + "/css/**", "/**/resume", SimulatedAuthenticationController.AUTHN_PATH + "/**") + .permitAll() + .antMatchers("/actuator/**") + .permitAll() + .anyRequest().denyAll()); + + return http.build(); + } + + @Bean + InMemoryAuditEventRepository repository() { + return new InMemoryAuditEventRepository(); + } + } diff --git a/samples/demo-boot-idp/src/main/resources/application.yml b/samples/demo-boot-idp/src/main/resources/application.yml index 7a8d24ad..25c4f553 100644 --- a/samples/demo-boot-idp/src/main/resources/application.yml +++ b/samples/demo-boot-idp/src/main/resources/application.yml @@ -14,6 +14,16 @@ server: include-exception: true include-stacktrace: always +management: + server: + port: 8444 + endpoints: + web: + exposure: + include: health, auditevents + auditevents: + enabled: true + spring: messages: basename: messages,idp-errors/idp-error-messages