Files
hanko/backend/handler/email.go
Lennart Fleischmann 455e8e3677 feat: third party custom providers
* feat: third party custom providers

- New configuration option `third_party.custom_providers`. `custom_providers`
  is a map of arbitrarily chosen keys to a `CustomThirdPartyProvider` - this is
  implemented as a new type differing from the existing configuration type
  `ThirdPartyProvider` used for built-in providers because they have different
  configuration requirements.

- Both `ThirdPartyProvider` and `CustomThirdPartyProvider` types get a non-
  configurable, automatically populated `Name` (during the config's `PostProcess`)
  that sort of serves as an identifier/slug for the provider in order to
  distinguish provider types at runtime.
    - A `CustomThirdPartyProvider`s `Name` is automatically prefixed during
      `PostProcess` with a "custom_" prefix to ensure that providers can be
      distinguished at runtime.
    - A (built-in) `ThirdPartyProvider`s `Name` is "hard-coded" through the
      `DefaultConfig`.

- Built-in OAuth/OIDC provider implementations are currently instantiated
  on-demand instead of once at appliation startup (i.e. unlike SAML
  providers) - i.e. when a user requests auth/authz with a third party
  provider, only then a provider is instantiated and created via factory
  function (`thirdparty.GetProvider`).  Custom providers follow this
  pattern, hence the factory function had to be adjusted to take into account
  providers with the aforementioned "custom_" prefix (i.e.: if it is a
  "custom_" provider, instantiate a `customProvider` implementation).

- The `customProvider` implementation uses the `go-oidc` library. Instances
  of providers of the type this library offers can be instantiated by passing
  in an `issuer` URL. Such an instantiation automatically attempts to retrieve
  an OIDC discovery document from a `.well-known` endpoint. This also performs
  an issuer validation. Providers configured to not use OIDC discovery (i.e.
  `use_discovery` in the `CustomThirdPartyProvider` is `false` or omitted) do
  not do this issuer check.

- The `customProvider` implementation is further based on the assumption that
  provider user data is only extracted from a userinfo endpoint response, i.e.
  in case of an OIDC provider, the implementation does not make use of the ID
  token - no validation is performed on the ID token.

- The `customProvider` implementation requires configuring a list of `scopes`:
  because the custom providers allow configuring both OAuth as well as OIDC
  providers, we cannot simply set a default set of scopes, e.g. `openid`, which
  is a required claim for OIDC - some providers return errors on unknown claims
  so setting this would make the third party auth process prone to errors.

- The `customProvider` implementation allows for a simple mapping of claims
  contained in the userinfo response from the provider to "known" standard OIDC
  conformant claims at the Hanko backend (defined in the `thirdparty.Claims`
  struct) through an `attribute_mapping` configuration option. The mapping is a
  simple one-to-one mapping, i.e. no complex mapping instructions are possible,
  e.g. mapping concatenations of multiple claims in the provider data source or
  similar. Any other non-standard claims returned by the provider are placed in
  a `custom_claims` attribute. Except for the user ID (`sub`), `email` and
  `email_verified` claims the third party functionality currently does not allow
  accessing this user data but there's a good chance this will change in the future,
  so I tried to make sure that any info retrieved from the provider is somehow
  preserved (it is persisted in the `data` column for an `identity` btw. and updated
  on every login with the provider).
    - I also noticed that the `thirdparty.Claims` were missing the `address` claim,
      so I added that for completeness' sake.

- The changes also fix a "bug" in the account `linking` logic whereby third party
  connections were established by simply assuming that the email retrieved from the
  provider was verified. So, even if the email address at the provider was not
  verified (or the provider simply did/does not provide info about the verification
  status) an account was created and/or linked and the flow API capabilities of
  automatically triggering a passcode if the backend was configured to require email
  verification would not take effect. This was a wrong assumption and the verification
  status is now based on the actual value retrieved from the provider.
- In case of a triggered passcode, the changes also modify the token exchange
   action to prevent showing a `back` button/link, since it does not make sense to
   go `back` to anything right after the token exchange - there is nothing to go
   "back" to.
2024-12-04 13:40:08 +01:00

264 lines
7.8 KiB
Go

package handler
import (
"errors"
"fmt"
"github.com/gobuffalo/pop/v6"
"github.com/gofrs/uuid"
"github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v2/jwt"
auditlog "github.com/teamhanko/hanko/backend/audit_log"
"github.com/teamhanko/hanko/backend/config"
"github.com/teamhanko/hanko/backend/dto"
"github.com/teamhanko/hanko/backend/persistence"
"github.com/teamhanko/hanko/backend/persistence/models"
"github.com/teamhanko/hanko/backend/session"
"github.com/teamhanko/hanko/backend/webhooks/events"
"github.com/teamhanko/hanko/backend/webhooks/utils"
"net/http"
"strings"
)
type EmailHandler struct {
persister persistence.Persister
cfg *config.Config
sessionManager session.Manager
auditLogger auditlog.Logger
}
func NewEmailHandler(cfg *config.Config, persister persistence.Persister, sessionManager session.Manager, auditLogger auditlog.Logger) *EmailHandler {
return &EmailHandler{
persister: persister,
cfg: cfg,
sessionManager: sessionManager,
auditLogger: auditLogger,
}
}
func (h *EmailHandler) List(c echo.Context) error {
sessionToken, ok := c.Get("session").(jwt.Token)
if !ok {
return errors.New("failed to cast session object")
}
userId, err := uuid.FromString(sessionToken.Subject())
if err != nil {
return fmt.Errorf("failed to parse subject as uuid: %w", err)
}
emails, err := h.persister.GetEmailPersister().FindByUserId(userId)
if err != nil {
return fmt.Errorf("failed to fetch emails from db: %w", err)
}
response := make([]*dto.EmailResponse, len(emails))
for i := range emails {
response[i] = dto.FromEmailModel(&emails[i], h.cfg)
}
return c.JSON(http.StatusOK, response)
}
func (h *EmailHandler) Create(c echo.Context) error {
sessionToken, ok := c.Get("session").(jwt.Token)
if !ok {
return errors.New("failed to cast session object")
}
userId, err := uuid.FromString(sessionToken.Subject())
if err != nil {
return fmt.Errorf("failed to parse subject as uuid: %w", err)
}
var body dto.EmailCreateRequest
err = (&echo.DefaultBinder{}).BindBody(c, &body)
if err != nil {
return dto.ToHttpError(err)
}
emailCount, err := h.persister.GetEmailPersister().CountByUserId(userId)
if err != nil {
return fmt.Errorf("failed to count user emails: %w", err)
}
if emailCount >= h.cfg.Email.Limit {
return echo.NewHTTPError(http.StatusConflict).SetInternal(errors.New("max number of email addresses reached"))
}
newEmailAddress := strings.ToLower(body.Address)
email, err := h.persister.GetEmailPersister().FindByAddress(newEmailAddress)
if err != nil {
return fmt.Errorf("failed to fetch email from db: %w", err)
}
return h.persister.Transaction(func(tx *pop.Connection) error {
user, err := h.persister.GetUserPersister().Get(userId)
if err != nil {
return fmt.Errorf("failed to fetch user from db: %w", err)
}
if email != nil {
// The email address already exists.
if email.UserID != nil {
// The email address exists and is assigned to a user already, therefore it can't be created.
return echo.NewHTTPError(http.StatusBadRequest).SetInternal(errors.New("email address already exists"))
}
if !h.cfg.Email.RequireVerification {
// Email verification is currently not required and there is no user assigned to the existing email
// address. This can happen, when email verification was turned on before, because then the email
// address will be assigned to the user only after passcode verification. The email was left unassigned
// and has not been verified, so we assign the email to the current user.
email.UserID = &user.ID
err = h.persister.GetEmailPersisterWithConnection(tx).Update(*email)
if err != nil {
return fmt.Errorf("failed to update the existing email: %w", err)
}
}
} else {
// The email address has not been registered so far.
if h.cfg.Email.RequireVerification {
// The email address will be assigned to the user only after passcode verification.
email = models.NewEmail(nil, newEmailAddress)
} else {
// No verification required - assign the email to the given user.
email = models.NewEmail(&user.ID, newEmailAddress)
}
err = h.persister.GetEmailPersisterWithConnection(tx).Create(*email)
if err != nil {
return fmt.Errorf("failed to store email to db: %w", err)
}
}
err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogEmailCreated, user, nil)
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
if !h.cfg.Email.RequireVerification {
var evt events.Event
if len(user.Emails) >= 1 {
evt = events.UserEmailCreate
} else {
evt = events.UserCreate
}
utils.NotifyUserChange(c, tx, h.persister, evt, userId)
}
return c.JSON(http.StatusOK, email)
})
}
func (h *EmailHandler) SetPrimaryEmail(c echo.Context) error {
sessionToken, ok := c.Get("session").(jwt.Token)
if !ok {
return errors.New("failed to cast session object")
}
userId, err := uuid.FromString(sessionToken.Subject())
if err != nil {
return fmt.Errorf("failed to parse subject as uuid: %w", err)
}
emailId, err := uuid.FromString(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest).SetInternal(err)
}
user, err := h.persister.GetUserPersister().Get(userId)
if err != nil {
return fmt.Errorf("failed to fetch user from db: %w", err)
}
email := user.GetEmailById(emailId)
if email == nil {
return echo.NewHTTPError(http.StatusNotFound).SetInternal(errors.New("the email address is not assigned to the current user"))
}
if email.IsPrimary() {
return c.NoContent(http.StatusNoContent)
}
return h.persister.Transaction(func(tx *pop.Connection) error {
var primaryEmail *models.PrimaryEmail
if e := user.Emails.GetPrimary(); e != nil {
primaryEmail = e.PrimaryEmail
}
if primaryEmail == nil {
primaryEmail = models.NewPrimaryEmail(email.ID, user.ID)
err = h.persister.GetPrimaryEmailPersisterWithConnection(tx).Create(*primaryEmail)
if err != nil {
return fmt.Errorf("failed to store new primary email: %w", err)
}
} else {
primaryEmail.EmailID = email.ID
err = h.persister.GetPrimaryEmailPersisterWithConnection(tx).Update(*primaryEmail)
if err != nil {
return fmt.Errorf("failed to change primary email: %w", err)
}
}
err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogPrimaryEmailChanged, user, nil)
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
utils.NotifyUserChange(c, tx, h.persister, events.UserEmailPrimary, userId)
return c.NoContent(http.StatusNoContent)
})
}
func (h *EmailHandler) Delete(c echo.Context) error {
sessionToken, ok := c.Get("session").(jwt.Token)
if !ok {
return errors.New("failed to cast session object")
}
userId, err := uuid.FromString(sessionToken.Subject())
if err != nil {
return fmt.Errorf("failed to parse subject as uuid: %w", err)
}
emailId, err := uuid.FromString(c.Param("id"))
user, err := h.persister.GetUserPersister().Get(userId)
if err != nil {
return fmt.Errorf("failed to fetch user from db: %w", err)
}
emailToBeDeleted := user.GetEmailById(emailId)
if emailToBeDeleted == nil {
return errors.New("email with given emailId not available")
}
if emailToBeDeleted.IsPrimary() {
return echo.NewHTTPError(http.StatusConflict).SetInternal(errors.New("primary email can't be deleted"))
}
return h.persister.Transaction(func(tx *pop.Connection) error {
err = h.persister.GetEmailPersisterWithConnection(tx).Delete(*emailToBeDeleted)
if err != nil {
return fmt.Errorf("failed to delete email from db: %w", err)
}
err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogEmailDeleted, user, nil)
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
utils.NotifyUserChange(c, tx, h.persister, events.UserEmailDelete, userId)
return c.NoContent(http.StatusNoContent)
})
}