diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index e1c16f818..dcdd92a97 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -77,6 +77,7 @@ const ( MembershipProfileEntityType uint16 = 51 SCIMConfigurationEntityType uint16 = 52 SCIMEventEntityType uint16 = 53 + TokenEntityType uint16 = 54 ) func NewEntityFromID(id gid.GID) (any, bool) { @@ -187,6 +188,8 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &SCIMConfiguration{ID: id}, true case SCIMEventEntityType: return &SCIMEvent{ID: id}, true + case TokenEntityType: + return &Token{ID: id}, true default: return nil, false } diff --git a/pkg/coredata/migrations/20260116T104702Z.sql b/pkg/coredata/migrations/20260116T104702Z.sql new file mode 100644 index 000000000..537403921 --- /dev/null +++ b/pkg/coredata/migrations/20260116T104702Z.sql @@ -0,0 +1,6 @@ +CREATE TABLE iam_tokens ( + id TEXT PRIMARY KEY, + hashed_value BYTEA NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT iam_tokens_hashed_value_unique UNIQUE (hashed_value) +); \ No newline at end of file diff --git a/pkg/coredata/token.go b/pkg/coredata/token.go new file mode 100644 index 000000000..d25753822 --- /dev/null +++ b/pkg/coredata/token.go @@ -0,0 +1,128 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" +) + +type Token struct { + ID gid.GID `db:"id"` + HashedValue []byte `db:"hashed_value"` + CreatedAt time.Time `db:"created_at"` +} + +func (t *Token) LoadByHashedValueForUpdate( + ctx context.Context, + conn pg.Conn, + hashedValue []byte, +) error { + q := ` +SELECT + id, + hashed_value, + created_at +FROM + iam_tokens +WHERE + hashed_value = @hashed_value +LIMIT 1 +FOR UPDATE; +` + args := pgx.StrictNamedArgs{"hashed_value": hashedValue} + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query iam_tokens: %w", err) + } + + token, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Token]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect iam_tokens: %w", err) + } + + *t = token + + return nil +} + +func (t *Token) Insert( + ctx context.Context, + conn pg.Conn, +) error { + q := ` +INSERT INTO iam_tokens( + id, + hashed_value, + created_at +) VALUES ( + @id, + @hashed_value, + @created_at +) +` + + args := pgx.StrictNamedArgs{ + "id": t.ID, + "hashed_value": t.HashedValue, + "created_at": t.CreatedAt, + } + + _, err := conn.Exec(ctx, q, args) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if pgErr.Code == "23505" && pgErr.ConstraintName == "iam_tokens_hashed_value_unique" { + return ErrResourceAlreadyExists + } + } + + return fmt.Errorf("cannot insert iam_tokens: %w", err) + } + + return nil +} + +func (t *Token) Delete( + ctx context.Context, + conn pg.Conn, +) error { + q := ` +DELETE FROM iam_tokens +WHERE + id = @id +` + + args := pgx.StrictNamedArgs{"id": t.ID} + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot delete iam_tokens: %w", err) + } + + return nil +} diff --git a/pkg/iam/auth_service.go b/pkg/iam/auth_service.go index f26b047fe..067dbce77 100644 --- a/pkg/iam/auth_service.go +++ b/pkg/iam/auth_service.go @@ -16,6 +16,7 @@ package iam import ( "context" + "crypto/sha256" "errors" "fmt" "time" @@ -547,7 +548,7 @@ func (s AuthService) OpenSessionWithPassword(ctx context.Context, email mail.Add } func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkRequest) error { - token, err := statelesstoken.NewToken( + tokenString, err := statelesstoken.NewToken( s.tokenSecret, TokenTypeMagicLink, s.magicLinkTokenValidity, @@ -560,18 +561,30 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques } magicLinkURL := req.BaseURL. - WithQuery("token", token). + WithQuery("token", tokenString). MustString() return s.pg.WithTx( ctx, func(tx pg.Conn) error { + hashedToken := HashToken(tokenString) + token := &coredata.Token{ + ID: gid.New(gid.NilTenant, coredata.TokenEntityType), + HashedValue: hashedToken, + CreatedAt: time.Now(), + } + if err := token.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot insert token: %w", err) + } + fullName := req.Email.Username() identity := &coredata.Identity{} err := identity.LoadByEmail(ctx, tx, req.Email) if err == nil { - fullName = identity.FullName + if identity.FullName != "" { + fullName = identity.FullName + } } else { if !errors.Is(err, coredata.ErrResourceNotFound) { return fmt.Errorf("cannot load identity: %w", err) @@ -582,7 +595,7 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques s.baseURL, fullName, magicLinkURL, - s.invitationTokenValidity, + s.magicLinkTokenValidity, ) if err != nil { return fmt.Errorf("cannot render magic link email: %w", err) @@ -596,8 +609,7 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques htmlBody, ) - err = magicLinkEmail.Insert(ctx, tx) - if err != nil { + if err := magicLinkEmail.Insert(ctx, tx); err != nil { return fmt.Errorf("cannot insert email: %w", err) } @@ -606,21 +618,41 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques ) } -func (s AuthService) OpenSessionWithMagicLink(ctx context.Context, token string) (*coredata.Identity, *coredata.Session, error) { +func (s AuthService) OpenSessionWithMagicLink(ctx context.Context, tokenString string) (*coredata.Identity, *coredata.Session, error) { var ( now = time.Now() identity = &coredata.Identity{} session = &coredata.Session{} ) - payload, err := statelesstoken.ValidateToken[MagicLinkData](s.tokenSecret, TokenTypeMagicLink, token) + payload, err := statelesstoken.ValidateToken[MagicLinkData](s.tokenSecret, TokenTypeMagicLink, tokenString) if err != nil { return nil, nil, NewInvalidTokenError() } - err = s.pg.WithTx( + if err := s.pg.WithTx( ctx, func(tx pg.Conn) error { + hashedValue := HashToken(tokenString) + token := &coredata.Token{} + + if err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + if err := token.LoadByHashedValueForUpdate(ctx, conn, hashedValue); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return NewInvalidTokenError() + } + + return fmt.Errorf("cannot load token by hashed value: %w", err) + } + + return nil + }, + ); err != nil { + return fmt.Errorf("cannot load token: %w", err) + } + err := identity.LoadByEmail(ctx, tx, payload.Data.Email) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { @@ -646,11 +678,17 @@ func (s AuthService) OpenSessionWithMagicLink(ctx context.Context, token string) return fmt.Errorf("cannot insert session: %w", err) } + if err := token.Delete(ctx, tx); err != nil { + return fmt.Errorf("cannot delete token: %w", err) + } + return nil }, - ) + ); err != nil { + return nil, nil, err + } - return identity, session, err + return identity, session, nil } func (s *AuthService) UpdateIdentity(ctx context.Context, identityID gid.GID, fullName string) (*coredata.Identity, error) { @@ -684,3 +722,8 @@ func (s *AuthService) UpdateIdentity(ctx context.Context, identityID gid.GID, fu return identity, err } + +func HashToken(token string) []byte { + hash := sha256.Sum256([]byte(token)) + return hash[:] +} diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 224aa82fb..d308c532d 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -125,7 +125,7 @@ func New() *Implm { DisableSignup: false, InvitationConfirmationTokenValidity: 3600, PasswordResetTokenValidity: 3600, - MagicLinkTokenValidity: 3600, + MagicLinkTokenValidity: 900, SAML: samlConfig{ SessionDuration: 604800, CleanupIntervalSeconds: 86400, diff --git a/pkg/server/api/trust/v1/v1_resolver.go b/pkg/server/api/trust/v1/v1_resolver.go index f30349d03..6e191eb90 100644 --- a/pkg/server/api/trust/v1/v1_resolver.go +++ b/pkg/server/api/trust/v1/v1_resolver.go @@ -209,7 +209,6 @@ func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.Veri return nil, gqlutils.Internal(ctx) } - // FIXME: cookie domain w := gqlutils.HTTPResponseWriterFromContext(ctx) r.sessionCookie.Set(w, session)