mirror of
https://github.com/zitadel/zitadel.git
synced 2024-12-23 16:27:35 +00:00
ec222a13d7
# Which Problems Are Solved As already mentioned and (partially) fixed in #7992 we discovered, issues with v2 tokens that where obtained through an IDP, with passwordless authentication or with password authentication (wihtout any 2FA set up) using the v1 login for zitadel API calls - (Previous) authentication through an IdP is now correctly treated as auth method in case of a reauth even when the user is not redirected to the IdP - There were some cases where passwordless authentication was successfully checked but not correctly set as auth method, which denied access to ZITADEL API - Users with password and passwordless, but no 2FA set up which authenticate just wich password can access the ZITADEL API again Additionally while testing we found out that because of #7969 the login UI could completely break / block with the following error: `sql: Scan error on column index 3, name "state": converting NULL to int32 is unsupported (Internal)` # How the Problems Are Solved - IdP checks are treated the same way as other factors and it's ensured that a succeeded check within the configured timeframe will always provide the idp auth method - `MFATypesAllowed` checks for possible passwordless authentication - As with the v1 login, the token check now only requires MFA if the policy is set or the user has 2FA set up - UserAuthMethodsRequirements now always uses the correctly policy to check for MFA enforcement - `State` column is handled as nullable and additional events set the state to active (as before #7969) # Additional Changes - Console now also checks for 403 (mfa required) errors (e.g. after setting up the first 2FA in console) and redirects the user to the login UI (with the current id_token as id_token_hint) - Possible duplicates in auth methods / AMRs are removed now as well. # Additional Context - Bugs were introduced in #7822 and # and 7969 and only part of a pre-release. - partially already fixed with #7992 - Reported internally.
356 lines
13 KiB
Go
356 lines
13 KiB
Go
package eventstore
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v4"
|
|
"github.com/muhlemmer/gu"
|
|
"github.com/zitadel/logging"
|
|
"github.com/zitadel/oidc/v3/pkg/oidc"
|
|
"github.com/zitadel/oidc/v3/pkg/op"
|
|
|
|
"github.com/zitadel/zitadel/internal/api/authz"
|
|
http_util "github.com/zitadel/zitadel/internal/api/http"
|
|
"github.com/zitadel/zitadel/internal/authz/repository/eventsourcing/view"
|
|
"github.com/zitadel/zitadel/internal/command"
|
|
"github.com/zitadel/zitadel/internal/crypto"
|
|
"github.com/zitadel/zitadel/internal/domain"
|
|
"github.com/zitadel/zitadel/internal/eventstore"
|
|
"github.com/zitadel/zitadel/internal/query"
|
|
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
|
usr_model "github.com/zitadel/zitadel/internal/user/model"
|
|
usr_view "github.com/zitadel/zitadel/internal/user/repository/view"
|
|
"github.com/zitadel/zitadel/internal/user/repository/view/model"
|
|
"github.com/zitadel/zitadel/internal/zerrors"
|
|
)
|
|
|
|
type TokenVerifierRepo struct {
|
|
TokenVerificationKey crypto.EncryptionAlgorithm
|
|
Eventstore *eventstore.Eventstore
|
|
View *view.View
|
|
Query *query.Queries
|
|
ExternalSecure bool
|
|
}
|
|
|
|
func (repo *TokenVerifierRepo) Health() error {
|
|
return repo.View.Health()
|
|
}
|
|
|
|
func (repo *TokenVerifierRepo) tokenByID(ctx context.Context, tokenID, userID string) (_ *usr_model.TokenView, err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
instanceID := authz.GetInstance(ctx).InstanceID()
|
|
|
|
// always load the latest sequence first, so in case the token was not found by id,
|
|
// the sequence will be equal or lower than the actual projection and no events are lost
|
|
sequence, err := repo.View.GetLatestState(ctx)
|
|
logging.WithFields("instanceID", instanceID, "userID", userID, "tokenID", tokenID).
|
|
OnError(err).
|
|
Errorf("could not get current sequence for token check")
|
|
|
|
token, viewErr := repo.View.TokenByIDs(tokenID, userID, instanceID)
|
|
if viewErr != nil && !zerrors.IsNotFound(viewErr) {
|
|
return nil, viewErr
|
|
}
|
|
if zerrors.IsNotFound(viewErr) {
|
|
token = new(model.TokenView)
|
|
token.ID = tokenID
|
|
token.UserID = userID
|
|
if sequence != nil {
|
|
token.ChangeDate = sequence.EventCreatedAt
|
|
}
|
|
}
|
|
|
|
events, esErr := repo.getUserEvents(ctx, userID, instanceID, token.ChangeDate, token.GetRelevantEventTypes())
|
|
if zerrors.IsNotFound(viewErr) && len(events) == 0 {
|
|
return nil, zerrors.ThrowNotFound(nil, "EVENT-4T90g", "Errors.Token.NotFound")
|
|
}
|
|
|
|
if esErr != nil {
|
|
logging.WithError(viewErr).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("error retrieving new events")
|
|
return model.TokenViewToModel(token), nil
|
|
}
|
|
viewToken := *token
|
|
for _, event := range events {
|
|
err := token.AppendEventIfMyToken(event)
|
|
if err != nil {
|
|
return model.TokenViewToModel(&viewToken), nil
|
|
}
|
|
}
|
|
if !token.Expiration.After(time.Now().UTC()) || token.Deactivated {
|
|
return nil, zerrors.ThrowNotFound(nil, "EVENT-5Bm9s", "Errors.Token.NotFound")
|
|
}
|
|
return model.TokenViewToModel(token), nil
|
|
}
|
|
|
|
func (repo *TokenVerifierRepo) VerifyAccessToken(ctx context.Context, tokenString, verifierClientID, projectID string) (userID string, agentID string, clientID, prefLang, resourceOwner string, err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
tokenID, subject, ok := repo.getTokenIDAndSubject(ctx, tokenString)
|
|
if !ok {
|
|
return "", "", "", "", "", zerrors.ThrowUnauthenticated(nil, "APP-Reb32", "invalid token")
|
|
}
|
|
if strings.HasPrefix(tokenID, command.IDPrefixV2) {
|
|
return repo.verifyAccessTokenV2(ctx, tokenID, verifierClientID, projectID)
|
|
}
|
|
if sessionID, ok := strings.CutPrefix(tokenID, authz.SessionTokenPrefix); ok {
|
|
userID, clientID, resourceOwner, err = repo.verifySessionToken(ctx, sessionID, tokenString)
|
|
return
|
|
}
|
|
return repo.verifyAccessTokenV1(ctx, tokenID, subject, verifierClientID, projectID)
|
|
}
|
|
|
|
func (repo *TokenVerifierRepo) verifyAccessTokenV1(ctx context.Context, tokenID, subject, verifierClientID, projectID string) (userID, agentID, clientID, prefLang, resourceOwner string, err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
_, tokenSpan := tracing.NewNamedSpan(ctx, "tokenByID")
|
|
token, err := repo.tokenByID(ctx, tokenID, subject)
|
|
tokenSpan.EndWithError(err)
|
|
if err != nil {
|
|
return "", "", "", "", "", zerrors.ThrowUnauthenticated(err, "APP-BxUSiL", "invalid token")
|
|
}
|
|
if token.Actor != nil {
|
|
return "", "", "", "", "", zerrors.ThrowPermissionDenied(nil, "APP-wai8O", "Errors.TokenExchange.Token.NotForAPI")
|
|
}
|
|
if !token.Expiration.After(time.Now().UTC()) {
|
|
return "", "", "", "", "", zerrors.ThrowUnauthenticated(err, "APP-k9KS0", "invalid token")
|
|
}
|
|
if token.IsPAT {
|
|
return token.UserID, "", "", "", token.ResourceOwner, nil
|
|
}
|
|
if err = verifyAudience(token.Audience, verifierClientID, projectID); err != nil {
|
|
return "", "", "", "", "", err
|
|
}
|
|
return token.UserID, token.UserAgentID, token.ApplicationID, token.PreferredLanguage, token.ResourceOwner, nil
|
|
}
|
|
|
|
func (repo *TokenVerifierRepo) verifyAccessTokenV2(ctx context.Context, token, verifierClientID, projectID string) (userID, agentID, clientID, prefLang, resourceOwner string, err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
activeToken, err := repo.Query.ActiveAccessTokenByToken(ctx, token)
|
|
if err != nil {
|
|
return "", "", "", "", "", err
|
|
}
|
|
if activeToken.Actor != nil {
|
|
return "", "", "", "", "", zerrors.ThrowPermissionDenied(nil, "APP-Shi0J", "Errors.TokenExchange.Token.NotForAPI")
|
|
}
|
|
if err = verifyAudience(activeToken.Audience, verifierClientID, projectID); err != nil {
|
|
return "", "", "", "", "", err
|
|
}
|
|
if err = repo.checkAuthentication(ctx, activeToken.AuthMethods, activeToken.UserID); err != nil {
|
|
return "", "", "", "", "", err
|
|
}
|
|
prefLang = gu.Value(activeToken.PreferredLanguage).String()
|
|
agentID = gu.Value(gu.Value(activeToken.UserAgent).FingerprintID)
|
|
|
|
return activeToken.UserID, agentID, activeToken.ClientID, prefLang, activeToken.ResourceOwner, nil
|
|
}
|
|
|
|
func (repo *TokenVerifierRepo) verifySessionToken(ctx context.Context, sessionID, token string) (userID, clientID, resourceOwner string, err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
session, err := repo.Query.SessionByID(ctx, true, sessionID, token)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
if !session.Expiration.IsZero() && session.Expiration.Before(time.Now()) {
|
|
return "", "", "", zerrors.ThrowPermissionDenied(nil, "AUTHZ-EGDo3", "session expired")
|
|
}
|
|
if err = repo.checkAuthentication(ctx, authMethodsFromSession(session), session.UserFactor.UserID); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
return session.UserFactor.UserID, "", session.UserFactor.ResourceOwner, nil
|
|
}
|
|
|
|
// checkAuthentication ensures the session or token was authenticated (at least a single [domain.UserAuthMethodType]).
|
|
// It will also check if there was a multi factor authentication, if either MFA is forced by the login policy or if the user has set up any second factor
|
|
func (repo *TokenVerifierRepo) checkAuthentication(ctx context.Context, authMethods []domain.UserAuthMethodType, userID string) error {
|
|
if len(authMethods) == 0 {
|
|
return zerrors.ThrowPermissionDenied(nil, "AUTHZ-Kl3p0", "authentication required")
|
|
}
|
|
if domain.HasMFA(authMethods) {
|
|
return nil
|
|
}
|
|
requirements, err := repo.Query.ListUserAuthMethodTypesRequired(setCallerCtx(ctx, userID), userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if requirements.UserType == domain.UserTypeMachine {
|
|
return nil
|
|
}
|
|
if domain.RequiresMFA(
|
|
requirements.ForceMFA,
|
|
requirements.ForceMFALocalOnly,
|
|
!hasIDPAuthentication(authMethods)) ||
|
|
domain.Has2FA(requirements.AuthMethods) {
|
|
return zerrors.ThrowPermissionDenied(nil, "AUTHZ-Kl3p0", "mfa required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasIDPAuthentication(authMethods []domain.UserAuthMethodType) bool {
|
|
for _, method := range authMethods {
|
|
if method == domain.UserAuthMethodTypeIDP {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func authMethodsFromSession(session *query.Session) []domain.UserAuthMethodType {
|
|
types := make([]domain.UserAuthMethodType, 0, domain.UserAuthMethodTypeIDP)
|
|
if !session.PasswordFactor.PasswordCheckedAt.IsZero() {
|
|
types = append(types, domain.UserAuthMethodTypePassword)
|
|
}
|
|
if !session.WebAuthNFactor.WebAuthNCheckedAt.IsZero() {
|
|
if session.WebAuthNFactor.UserVerified {
|
|
types = append(types, domain.UserAuthMethodTypePasswordless)
|
|
} else {
|
|
types = append(types, domain.UserAuthMethodTypeU2F)
|
|
}
|
|
}
|
|
if !session.IntentFactor.IntentCheckedAt.IsZero() {
|
|
types = append(types, domain.UserAuthMethodTypeIDP)
|
|
}
|
|
if !session.TOTPFactor.TOTPCheckedAt.IsZero() {
|
|
types = append(types, domain.UserAuthMethodTypeTOTP)
|
|
}
|
|
if !session.OTPSMSFactor.OTPCheckedAt.IsZero() {
|
|
types = append(types, domain.UserAuthMethodTypeOTPSMS)
|
|
}
|
|
if !session.OTPEmailFactor.OTPCheckedAt.IsZero() {
|
|
types = append(types, domain.UserAuthMethodTypeOTPEmail)
|
|
}
|
|
return types
|
|
}
|
|
|
|
func setCallerCtx(ctx context.Context, userID string) context.Context {
|
|
ctxData := authz.GetCtxData(ctx)
|
|
ctxData.UserID = userID
|
|
return authz.SetCtxData(ctx, ctxData)
|
|
}
|
|
|
|
func (repo *TokenVerifierRepo) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error) {
|
|
app, err := repo.View.ApplicationByOIDCClientID(ctx, clientID)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return app.ProjectID, app.OIDCConfig.AllowedOrigins, nil
|
|
}
|
|
|
|
func (repo *TokenVerifierRepo) VerifierClientID(ctx context.Context, appName string) (clientID, projectID string, err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
app, err := repo.View.ApplicationByProjecIDAndAppName(ctx, authz.GetInstance(ctx).ProjectID(), appName)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if app.OIDCConfig != nil {
|
|
clientID = app.OIDCConfig.ClientID
|
|
} else if app.APIConfig != nil {
|
|
clientID = app.APIConfig.ClientID
|
|
}
|
|
return clientID, app.ProjectID, nil
|
|
}
|
|
|
|
func (repo *TokenVerifierRepo) getUserEvents(ctx context.Context, userID, instanceID string, changeDate time.Time, eventTypes []eventstore.EventType) (_ []eventstore.Event, err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
query, err := usr_view.UserByIDQuery(userID, instanceID, changeDate, eventTypes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return repo.Eventstore.Filter(ctx, query)
|
|
}
|
|
|
|
// getTokenIDAndSubject returns the TokenID and Subject of both opaque tokens and JWTs
|
|
func (repo *TokenVerifierRepo) getTokenIDAndSubject(ctx context.Context, accessToken string) (tokenID string, subject string, valid bool) {
|
|
// accessToken can be either opaque or JWT
|
|
// let's try opaque first:
|
|
tokenIDSubject, err := repo.decryptAccessToken(accessToken)
|
|
if err != nil {
|
|
logging.WithError(err).Warn("token verifier repo: decrypt access token")
|
|
// if decryption did not work, it might be a JWT
|
|
accessTokenClaims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](ctx, accessToken, repo.jwtTokenVerifier(ctx))
|
|
if err != nil {
|
|
logging.WithError(err).Warn("token verifier repo: verify JWT access token")
|
|
return "", "", false
|
|
}
|
|
return accessTokenClaims.JWTID, accessTokenClaims.Subject, true
|
|
}
|
|
splitToken := strings.Split(tokenIDSubject, ":")
|
|
if len(splitToken) != 2 {
|
|
return "", "", false
|
|
}
|
|
return splitToken[0], splitToken[1], true
|
|
}
|
|
|
|
func (repo *TokenVerifierRepo) jwtTokenVerifier(ctx context.Context) *op.AccessTokenVerifier {
|
|
keySet := &openIDKeySet{repo.Query}
|
|
issuer := http_util.BuildOrigin(authz.GetInstance(ctx).RequestedHost(), repo.ExternalSecure)
|
|
return op.NewAccessTokenVerifier(issuer, keySet)
|
|
}
|
|
|
|
func (repo *TokenVerifierRepo) decryptAccessToken(token string) (string, error) {
|
|
tokenData, err := base64.RawURLEncoding.DecodeString(token)
|
|
if err != nil {
|
|
return "", zerrors.ThrowUnauthenticated(nil, "APP-ASdgg", "invalid token")
|
|
}
|
|
tokenIDSubject, err := repo.TokenVerificationKey.DecryptString(tokenData, repo.TokenVerificationKey.EncryptionKeyID())
|
|
if err != nil {
|
|
return "", zerrors.ThrowUnauthenticated(nil, "APP-8EF0zZ", "invalid token")
|
|
}
|
|
return tokenIDSubject, nil
|
|
}
|
|
|
|
func verifyAudience(audience []string, verifierClientID, projectID string) error {
|
|
for _, aud := range audience {
|
|
if verifierClientID == aud || projectID == aud {
|
|
return nil
|
|
}
|
|
}
|
|
return zerrors.ThrowUnauthenticated(nil, "APP-Zxfako", "invalid audience")
|
|
}
|
|
|
|
type openIDKeySet struct {
|
|
*query.Queries
|
|
}
|
|
|
|
// VerifySignature implements the oidc.KeySet interface
|
|
// providing an implementation for the keys retrieved directly from Queries
|
|
func (o *openIDKeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) ([]byte, error) {
|
|
keySet, err := o.Queries.ActivePublicKeys(ctx, time.Now())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error fetching keys: %w", err)
|
|
}
|
|
keyID, alg := oidc.GetKeyIDAndAlg(jws)
|
|
key, err := oidc.FindMatchingKey(keyID, oidc.KeyUseSignature, alg, jsonWebKeys(keySet.Keys)...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid signature: %w", err)
|
|
}
|
|
return jws.Verify(&key)
|
|
}
|
|
|
|
func jsonWebKeys(keys []query.PublicKey) []jose.JSONWebKey {
|
|
webKeys := make([]jose.JSONWebKey, len(keys))
|
|
for i, key := range keys {
|
|
webKeys[i] = jose.JSONWebKey{
|
|
KeyID: key.ID(),
|
|
Algorithm: key.Algorithm(),
|
|
Use: key.Use().String(),
|
|
Key: key.Key(),
|
|
}
|
|
}
|
|
return webKeys
|
|
}
|