mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-11 21:47:32 +00:00
feat: refresh token (#1728)
* begin refresh tokens * refresh tokens * list and revoke refresh tokens * handle remove * tests for refresh tokens * uniqueness and default expiration * rename oidc token methods * cleanup * migration version * Update internal/static/i18n/en.yaml Co-authored-by: Fabi <38692350+fgerschwiler@users.noreply.github.com> * fixes * feat: update oidc pkg for refresh tokens Co-authored-by: Fabi <38692350+fgerschwiler@users.noreply.github.com>
This commit is contained in:
@@ -2,19 +2,20 @@ package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/caos/zitadel/internal/api/authz"
|
||||
authz_repo "github.com/caos/zitadel/internal/authz/repository/eventsourcing"
|
||||
"github.com/caos/zitadel/internal/config/types"
|
||||
"github.com/caos/zitadel/internal/domain"
|
||||
"github.com/caos/zitadel/internal/eventstore"
|
||||
"time"
|
||||
|
||||
"github.com/caos/zitadel/internal/api/http"
|
||||
sd "github.com/caos/zitadel/internal/config/systemdefaults"
|
||||
"github.com/caos/zitadel/internal/crypto"
|
||||
"github.com/caos/zitadel/internal/id"
|
||||
iam_repo "github.com/caos/zitadel/internal/repository/iam"
|
||||
keypair "github.com/caos/zitadel/internal/repository/keypair"
|
||||
"github.com/caos/zitadel/internal/repository/keypair"
|
||||
"github.com/caos/zitadel/internal/repository/org"
|
||||
proj_repo "github.com/caos/zitadel/internal/repository/project"
|
||||
usr_repo "github.com/caos/zitadel/internal/repository/user"
|
||||
|
@@ -3,12 +3,14 @@ package command
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/caos/zitadel/internal/eventstore"
|
||||
"time"
|
||||
|
||||
"github.com/caos/zitadel/internal/eventstore"
|
||||
|
||||
"github.com/caos/zitadel/internal/eventstore/v1/models"
|
||||
|
||||
"github.com/caos/logging"
|
||||
|
||||
"github.com/caos/zitadel/internal/domain"
|
||||
caos_errs "github.com/caos/zitadel/internal/errors"
|
||||
"github.com/caos/zitadel/internal/repository/user"
|
||||
@@ -209,50 +211,57 @@ func (c *Commands) RemoveUser(ctx context.Context, userID, resourceOwner string,
|
||||
}
|
||||
|
||||
func (c *Commands) AddUserToken(ctx context.Context, orgID, agentID, clientID, userID string, audience, scopes []string, lifetime time.Duration) (*domain.Token, error) {
|
||||
if userID == "" {
|
||||
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-55n8M", "Errors.IDMissing")
|
||||
if orgID == "" || userID == "" {
|
||||
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-Dbge4", "Errors.IDMissing")
|
||||
}
|
||||
|
||||
existingUser, err := c.userWriteModelByID(ctx, userID, orgID)
|
||||
userWriteModel := NewUserWriteModel(userID, orgID)
|
||||
event, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, audience, scopes, lifetime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isUserStateExists(existingUser.UserState) {
|
||||
return nil, caos_errs.ThrowNotFound(nil, "COMMAND-1d6Gg", "Errors.User.NotFound")
|
||||
_, err = c.eventstore.PushEvents(ctx, event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return accessToken, nil
|
||||
}
|
||||
|
||||
func (c *Commands) addUserToken(ctx context.Context, userWriteModel *UserWriteModel, agentID, clientID string, audience, scopes []string, lifetime time.Duration) (*user.UserTokenAddedEvent, *domain.Token, error) {
|
||||
err := c.eventstore.FilterToQueryReducer(ctx, userWriteModel)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !isUserStateExists(userWriteModel.UserState) {
|
||||
return nil, nil, caos_errs.ThrowNotFound(nil, "COMMAND-1d6Gg", "Errors.User.NotFound")
|
||||
}
|
||||
|
||||
audience = domain.AddAudScopeToAudience(audience, scopes)
|
||||
|
||||
preferredLanguage := ""
|
||||
existingHuman, err := c.getHumanWriteModelByID(ctx, userID, orgID)
|
||||
existingHuman, err := c.getHumanWriteModelByID(ctx, userWriteModel.AggregateID, userWriteModel.ResourceOwner)
|
||||
if existingHuman != nil {
|
||||
preferredLanguage = existingHuman.PreferredLanguage.String()
|
||||
}
|
||||
expiration := time.Now().UTC().Add(lifetime)
|
||||
tokenID, err := c.idGenerator.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
userAgg := UserAggregateFromWriteModel(&existingUser.WriteModel)
|
||||
_, err = c.eventstore.PushEvents(ctx,
|
||||
user.NewUserTokenAddedEvent(ctx, userAgg, tokenID, clientID, agentID, preferredLanguage, audience, scopes, expiration))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &domain.Token{
|
||||
ObjectRoot: models.ObjectRoot{
|
||||
AggregateID: userID,
|
||||
},
|
||||
TokenID: tokenID,
|
||||
UserAgentID: agentID,
|
||||
ApplicationID: clientID,
|
||||
Audience: audience,
|
||||
Scopes: scopes,
|
||||
Expiration: expiration,
|
||||
PreferredLanguage: preferredLanguage,
|
||||
}, nil
|
||||
userAgg := UserAggregateFromWriteModel(&userWriteModel.WriteModel)
|
||||
return user.NewUserTokenAddedEvent(ctx, userAgg, tokenID, clientID, agentID, preferredLanguage, audience, scopes, expiration),
|
||||
&domain.Token{
|
||||
ObjectRoot: models.ObjectRoot{
|
||||
AggregateID: userWriteModel.AggregateID,
|
||||
},
|
||||
TokenID: tokenID,
|
||||
UserAgentID: agentID,
|
||||
ApplicationID: clientID,
|
||||
Audience: audience,
|
||||
Scopes: scopes,
|
||||
Expiration: expiration,
|
||||
PreferredLanguage: preferredLanguage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Commands) userDomainClaimed(ctx context.Context, userID string) (events []eventstore.EventPusher, _ *UserWriteModel, err error) {
|
||||
|
139
internal/command/user_human_refresh_token.go
Normal file
139
internal/command/user_human_refresh_token.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/caos/zitadel/internal/domain"
|
||||
caos_errs "github.com/caos/zitadel/internal/errors"
|
||||
"github.com/caos/zitadel/internal/eventstore"
|
||||
"github.com/caos/zitadel/internal/repository/user"
|
||||
)
|
||||
|
||||
func (c *Commands) AddAccessAndRefreshToken(ctx context.Context, orgID, agentID, clientID, userID, refreshToken string, audience, scopes, authMethodsReferences []string, accessLifetime, refreshIdleExpiration, refreshExpiration time.Duration, authTime time.Time) (*domain.Token, string, error) {
|
||||
userWriteModel := NewUserWriteModel(userID, orgID)
|
||||
accessTokenEvent, accessToken, err := c.addUserToken(ctx, userWriteModel, agentID, clientID, audience, scopes, accessLifetime)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
creator := func() (eventstore.EventPusher, string, error) {
|
||||
return c.addRefreshToken(ctx, accessToken, authMethodsReferences, authTime, refreshIdleExpiration, refreshExpiration)
|
||||
}
|
||||
if refreshToken != "" {
|
||||
creator = func() (eventstore.EventPusher, string, error) {
|
||||
return c.renewRefreshToken(ctx, userID, orgID, refreshToken, refreshIdleExpiration)
|
||||
}
|
||||
}
|
||||
refreshTokenEvent, token, err := creator()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
_, err = c.eventstore.PushEvents(ctx, accessTokenEvent, refreshTokenEvent)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return accessToken, token, nil
|
||||
}
|
||||
|
||||
func (c *Commands) addRefreshToken(ctx context.Context, accessToken *domain.Token, authMethodsReferences []string, authTime time.Time, idleExpiration, expiration time.Duration) (*user.HumanRefreshTokenAddedEvent, string, error) {
|
||||
tokenID, err := c.idGenerator.Next()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
refreshToken, err := domain.NewRefreshToken(accessToken.AggregateID, tokenID, c.keyAlgorithm)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
refreshTokenWriteModel := NewHumanRefreshTokenWriteModel(accessToken.AggregateID, accessToken.ResourceOwner, tokenID)
|
||||
userAgg := UserAggregateFromWriteModel(&refreshTokenWriteModel.WriteModel)
|
||||
return user.NewHumanRefreshTokenAddedEvent(ctx, userAgg, tokenID, accessToken.ApplicationID, accessToken.UserAgentID,
|
||||
accessToken.PreferredLanguage, accessToken.Audience, accessToken.Scopes, authMethodsReferences, authTime, idleExpiration, expiration),
|
||||
refreshToken, nil
|
||||
}
|
||||
|
||||
func (c *Commands) renewRefreshToken(ctx context.Context, userID, orgID, refreshToken string, idleExpiration time.Duration) (event *user.HumanRefreshTokenRenewedEvent, newRefreshToken string, err error) {
|
||||
if refreshToken == "" {
|
||||
return nil, "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-DHrr3", "Errors.IDMissing")
|
||||
}
|
||||
|
||||
tokenUserID, tokenID, token, err := domain.FromRefreshToken(refreshToken, c.keyAlgorithm)
|
||||
if err != nil {
|
||||
return nil, "", caos_errs.ThrowInvalidArgument(err, "COMMAND-Dbfe4", "Errors.User.RefreshToken.Invalid")
|
||||
}
|
||||
if tokenUserID != userID {
|
||||
return nil, "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-Ht2g2", "Errors.User.RefreshToken.Invalid")
|
||||
}
|
||||
refreshTokenWriteModel := NewHumanRefreshTokenWriteModel(userID, orgID, tokenID)
|
||||
err = c.eventstore.FilterToQueryReducer(ctx, refreshTokenWriteModel)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if refreshTokenWriteModel.UserState != domain.UserStateActive {
|
||||
return nil, "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-BHnhs", "Errors.User.RefreshToken.Invalid")
|
||||
}
|
||||
if refreshTokenWriteModel.RefreshToken != token ||
|
||||
refreshTokenWriteModel.IdleExpiration.Before(time.Now()) ||
|
||||
refreshTokenWriteModel.Expiration.Before(time.Now()) {
|
||||
return nil, "", caos_errs.ThrowInvalidArgument(nil, "COMMAND-Vr43e", "Errors.User.RefreshToken.Invalid")
|
||||
}
|
||||
|
||||
newToken, err := c.idGenerator.Next()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
newRefreshToken, err = domain.RefreshToken(userID, tokenID, newToken, c.keyAlgorithm)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
userAgg := UserAggregateFromWriteModel(&refreshTokenWriteModel.WriteModel)
|
||||
return user.NewHumanRefreshTokenRenewedEvent(ctx, userAgg, tokenID, newToken, idleExpiration), newRefreshToken, nil
|
||||
}
|
||||
|
||||
func (c *Commands) RevokeRefreshToken(ctx context.Context, userID, orgID, tokenID string) (*domain.ObjectDetails, error) {
|
||||
removeEvent, refreshTokenWriteModel, err := c.removeRefreshToken(ctx, userID, orgID, tokenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := c.eventstore.PushEvents(ctx, removeEvent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = AppendAndReduce(refreshTokenWriteModel, events...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return writeModelToObjectDetails(&refreshTokenWriteModel.WriteModel), nil
|
||||
}
|
||||
|
||||
func (c *Commands) RevokeRefreshTokens(ctx context.Context, userID, orgID string, tokenIDs []string) (err error) {
|
||||
if len(tokenIDs) == 0 {
|
||||
return caos_errs.ThrowInvalidArgument(nil, "COMMAND-Gfj42", "Errors.IDMissing")
|
||||
}
|
||||
events := make([]eventstore.EventPusher, len(tokenIDs))
|
||||
for i, tokenID := range tokenIDs {
|
||||
event, _, err := c.removeRefreshToken(ctx, userID, orgID, tokenID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events[i] = event
|
||||
}
|
||||
_, err = c.eventstore.PushEvents(ctx, events...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Commands) removeRefreshToken(ctx context.Context, userID, orgID, tokenID string) (*user.HumanRefreshTokenRemovedEvent, *HumanRefreshTokenWriteModel, error) {
|
||||
if userID == "" || orgID == "" || tokenID == "" {
|
||||
return nil, nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-GVDgf", "Errors.IDMissing")
|
||||
}
|
||||
refreshTokenWriteModel := NewHumanRefreshTokenWriteModel(userID, orgID, tokenID)
|
||||
err := c.eventstore.FilterToQueryReducer(ctx, refreshTokenWriteModel)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if refreshTokenWriteModel.UserState != domain.UserStateActive {
|
||||
return nil, nil, caos_errs.ThrowNotFound(nil, "COMMAND-BHt2w", "Errors.User.RefreshToken.NotFound")
|
||||
}
|
||||
userAgg := UserAggregateFromWriteModel(&refreshTokenWriteModel.WriteModel)
|
||||
return user.NewHumanRefreshTokenRemovedEvent(ctx, userAgg, tokenID), refreshTokenWriteModel, nil
|
||||
}
|
89
internal/command/user_human_refresh_token_model.go
Normal file
89
internal/command/user_human_refresh_token_model.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/caos/zitadel/internal/eventstore"
|
||||
|
||||
"github.com/caos/zitadel/internal/domain"
|
||||
"github.com/caos/zitadel/internal/repository/user"
|
||||
)
|
||||
|
||||
type HumanRefreshTokenWriteModel struct {
|
||||
eventstore.WriteModel
|
||||
|
||||
TokenID string
|
||||
RefreshToken string
|
||||
|
||||
UserState domain.UserState
|
||||
IdleExpiration time.Time
|
||||
Expiration time.Time
|
||||
}
|
||||
|
||||
func NewHumanRefreshTokenWriteModel(userID, resourceOwner, tokenID string) *HumanRefreshTokenWriteModel {
|
||||
return &HumanRefreshTokenWriteModel{
|
||||
WriteModel: eventstore.WriteModel{
|
||||
AggregateID: userID,
|
||||
ResourceOwner: resourceOwner,
|
||||
},
|
||||
TokenID: tokenID,
|
||||
}
|
||||
}
|
||||
|
||||
func (wm *HumanRefreshTokenWriteModel) AppendEvents(events ...eventstore.EventReader) {
|
||||
for _, event := range events {
|
||||
switch e := event.(type) {
|
||||
case *user.HumanRefreshTokenAddedEvent:
|
||||
if wm.TokenID != e.TokenID {
|
||||
continue
|
||||
}
|
||||
wm.WriteModel.AppendEvents(e)
|
||||
case *user.HumanRefreshTokenRenewedEvent:
|
||||
if wm.TokenID != e.TokenID {
|
||||
continue
|
||||
}
|
||||
wm.WriteModel.AppendEvents(e)
|
||||
case *user.HumanRefreshTokenRemovedEvent:
|
||||
if wm.TokenID != e.TokenID {
|
||||
continue
|
||||
}
|
||||
wm.WriteModel.AppendEvents(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (wm *HumanRefreshTokenWriteModel) Reduce() error {
|
||||
for _, event := range wm.Events {
|
||||
switch e := event.(type) {
|
||||
case *user.HumanRefreshTokenAddedEvent:
|
||||
wm.TokenID = e.TokenID
|
||||
wm.RefreshToken = e.TokenID
|
||||
wm.IdleExpiration = e.CreationDate().Add(e.IdleExpiration)
|
||||
wm.Expiration = e.CreationDate().Add(e.Expiration)
|
||||
wm.UserState = domain.UserStateActive
|
||||
case *user.HumanRefreshTokenRenewedEvent:
|
||||
if wm.UserState == domain.UserStateActive {
|
||||
wm.RefreshToken = e.RefreshToken
|
||||
}
|
||||
wm.RefreshToken = e.RefreshToken
|
||||
wm.IdleExpiration = e.CreationDate().Add(e.IdleExpiration)
|
||||
case *user.HumanRefreshTokenRemovedEvent:
|
||||
wm.UserState = domain.UserStateDeleted
|
||||
}
|
||||
}
|
||||
return wm.WriteModel.Reduce()
|
||||
}
|
||||
|
||||
func (wm *HumanRefreshTokenWriteModel) Query() *eventstore.SearchQueryBuilder {
|
||||
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent, user.AggregateType).
|
||||
AggregateIDs(wm.AggregateID).
|
||||
EventTypes(
|
||||
user.HumanRefreshTokenAddedType,
|
||||
user.HumanRefreshTokenRenewedType,
|
||||
user.HumanRefreshTokenRemovedType,
|
||||
user.UserRemovedType)
|
||||
if wm.ResourceOwner != "" {
|
||||
query.ResourceOwner(wm.ResourceOwner)
|
||||
}
|
||||
return query
|
||||
}
|
1103
internal/command/user_human_refresh_token_test.go
Normal file
1103
internal/command/user_human_refresh_token_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user