feat: migrate one-time and signup tokens to an actor (#1611)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Alessandro (Ale) Segala
2026-07-26 15:32:43 +02:00
committed by GitHub
co-authored by Elias Schneider
parent 531bb5f0cf
commit a1b4e1d2b2
37 changed files with 7989 additions and 905 deletions
+116 -80
View File
@@ -15,6 +15,8 @@ import (
"github.com/go-webauthn/webauthn/protocol"
"github.com/google/uuid"
"github.com/italypaleale/francis/actor"
"github.com/italypaleale/francis/host/local"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
@@ -31,6 +33,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/usersignup"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -41,6 +44,7 @@ import (
type TestService struct {
db *gorm.DB
actors *local.Host
jwtService *JwtService
appConfigService *appconfig.AppConfigService
ldapService *LdapService
@@ -56,9 +60,10 @@ const (
e2eRefreshTokenExpiredFixtureToken = "X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4"
)
func NewTestService(db *gorm.DB, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
func NewTestService(db *gorm.DB, actors *local.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
s := &TestService{
db: db,
actors: actors,
appConfigService: appConfigService,
jwtService: jwtService,
ldapService: ldapService,
@@ -136,29 +141,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
}
oneTimeAccessTokens := []model.OneTimeAccessToken{{
Base: model.Base{
ID: "bf877753-4ea4-4c9c-bbbd-e198bb201cb8",
},
Token: "HPe6k6uiDRRVuAQV",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
},
{
Base: model.Base{
ID: "d3afae24-fe2d-4a98-abec-cf0b8525096a",
},
Token: "YCGDtftvsvYWiXd0",
ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Second)), // expired
UserID: users[0].ID,
},
}
for _, token := range oneTimeAccessTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
userGroups := []model.UserGroup{
{
Base: model.Base{
@@ -340,15 +322,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
return err
}
accessToken := model.OneTimeAccessToken{
Token: "one-time-token",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
}
if err := tx.Create(&accessToken).Error; err != nil {
return err
}
userAuthorizedClients := []model.UserAuthorizedOidcClient{
{
Scope: datatype.StringList{"openid", "profile", "email"},
@@ -506,53 +479,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
}
signupTokens := []usersignup.SignupToken{
{
Base: model.Base{
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
Token: "VALID1234567890A",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
UsageLimit: 1,
UsageCount: 0,
UserGroups: []model.UserGroup{
userGroups[0],
},
},
{
Base: model.Base{
ID: "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
},
Token: "PARTIAL567890ABC",
ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)),
UsageLimit: 5,
UsageCount: 2,
},
{
Base: model.Base{
ID: "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
},
Token: "EXPIRED34567890B",
ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired
UsageLimit: 3,
UsageCount: 1,
},
{
Base: model.Base{
ID: "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
},
Token: "FULLYUSED567890C",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
UsageLimit: 1,
UsageCount: 1, // Usage limit reached
},
}
for _, token := range signupTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
emailVerificationTokens := []model.EmailVerificationToken{
{
Base: model.Base{
@@ -599,6 +525,116 @@ func (s *TestService) SeedDatabase(baseURL string) error {
return err
}
// One-time access tokens and signup tokens live in the actor state store, so they're seeded separately from the DB transaction above.
err = s.seedOneTimeAccessTokens(context.Background())
if err != nil {
return fmt.Errorf("failed to seed one-time access tokens: %w", err)
}
err = s.seedSignupTokens(context.Background())
if err != nil {
return fmt.Errorf("failed to seed signup tokens: %w", err)
}
return nil
}
// seedSignupTokens seeds the signup tokens used by E2E tests into the signup token singleton actor.
// The already-expired fixture token is intentionally not seeded, since the actor would purge it right away via its cleanup alarm.
func (s *TestService) seedSignupTokens(ctx context.Context) error {
now := time.Now().Round(time.Second)
tokens := map[string]usersignup.SignupTokenState{
"VALID1234567890A": {
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ExpiresAt: now.Add(24 * time.Hour),
UsageLimit: 1,
UsageCount: 0,
UserGroupIDs: []string{"c7ae7c01-28a3-4f3c-9572-1ee734ea8368"},
CreatedAt: now,
},
"PARTIAL567890ABC": {
ID: "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
ExpiresAt: now.Add(7 * 24 * time.Hour),
UsageLimit: 5,
UsageCount: 2,
CreatedAt: now,
},
"FULLYUSED567890C": {
ID: "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
ExpiresAt: now.Add(24 * time.Hour),
UsageLimit: 1,
UsageCount: 1, // Usage limit reached
CreatedAt: now,
},
}
// The actor state store isn't wiped by ResetDatabase, so remove any signup token left over from a previous test first
err := s.deleteAllSignupTokens(ctx)
if err != nil {
return err
}
// Each signup token is its own actor, whose actor ID is the token's value
for token, state := range tokens {
_, err = s.actors.Service().Invoke(ctx, usersignup.SignupTokenActorType, token, usersignup.SignupTokenMethodCreate, state)
if err != nil {
return fmt.Errorf("failed to seed signup token %q: %w", token, err)
}
}
return nil
}
// deleteAllSignupTokens removes every signup token currently stored in the actor state store
func (s *TestService) deleteAllSignupTokens(ctx context.Context) error {
var after string
for {
res, err := s.actors.Service().ListStates(ctx, usersignup.SignupTokenActorType, &actor.ListStatesOpts{After: after})
if err != nil {
return fmt.Errorf("failed to list signup tokens: %w", err)
}
for _, st := range res.States {
_, err = s.actors.Service().Invoke(ctx, usersignup.SignupTokenActorType, st.ActorID, usersignup.SignupTokenMethodDelete, nil)
if err != nil {
return fmt.Errorf("failed to delete signup token %q: %w", st.ActorID, err)
}
}
// An empty cursor means we've just read the last page
after = res.AfterID()
if after == "" {
return nil
}
}
}
// seedOneTimeAccessTokens seeds the one-time access tokens used by E2E tests into the actor state store.
// Expired tokens are intentionally not seeded: with actor-backed storage an expired token is simply one that has no state, which the exchange flow already reports as invalid/expired.
func (s *TestService) seedOneTimeAccessTokens(ctx context.Context) error {
tokens := []struct {
token string
ttl time.Duration
}{
{token: "HPe6k6uiDRRVuAQV", ttl: time.Hour},
{token: "one-time-token", ttl: time.Hour},
}
for _, t := range tokens {
state := onetimeaccess.TokenState{
UserID: e2eRefreshTokenUserID,
ExpiresAt: time.Now().Add(t.ttl).Round(time.Second),
}
// Seed through the actor's "restore" method (which sets the state) rather than writing the
// state directly: if an actor for this token is still active from a previous test (for
// example, one whose token was already consumed), invoking it refreshes its in-memory cache
// too, whereas a direct state write would leave that cache stale.
_, err := s.actors.Service().Invoke(ctx, onetimeaccess.TokenActorType, t.token, onetimeaccess.TokenMethodRestore, state)
if err != nil {
return fmt.Errorf("failed to seed one-time access token %q: %w", t.token, err)
}
}
return nil
}
@@ -0,0 +1,29 @@
package service
import (
"context"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
)
// OneTimeAccessEmailSender sends the one-time access email.
// It adapts the email service, which owns the email templates, to the interface the onetimeaccess module depends on.
type OneTimeAccessEmailSender struct {
emailService *EmailService
}
func NewOneTimeAccessEmailSender(emailService *EmailService) *OneTimeAccessEmailSender {
return &OneTimeAccessEmailSender{emailService: emailService}
}
// SendOneTimeAccessEmail implements onetimeaccess.EmailSender
func (s *OneTimeAccessEmailSender) SendOneTimeAccessEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, to email.Address, data onetimeaccess.EmailData) error {
return SendEmail(ctx, s.emailService, dbConfig, to, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Code: data.Code,
LoginLink: data.LoginLink,
LoginLinkWithCode: data.LoginLinkWithCode,
ExpirationString: data.ExpirationString,
})
}
@@ -1,261 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type OneTimeAccessService struct {
db *gorm.DB
userService *UserService
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
}
func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) *OneTimeAccessService {
return &OneTimeAccessService{
db: db,
userService: userService,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
}
}
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, ttl time.Duration) error {
if !dbConfig.EmailOneTimeAccessAsAdminEnabled.IsTrue() {
return &common.OneTimeAccessDisabledError{}
}
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false, dbConfig)
return err
}
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, redirectPath string) (string, error) {
if !dbConfig.EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() {
return "", &common.OneTimeAccessDisabledError{}
}
var userId string
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Do not return error if user not found to prevent email enumeration
return "", nil
} else if err != nil {
return "", err
}
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true, dbConfig)
if err != nil {
return "", err
} else if deviceToken == nil {
return "", errors.New("device token expected but not returned")
}
return *deviceToken, nil
}
func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool, dbConfig *appconfig.AppConfigModel) (*string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.userService.getUserInternal(ctx, userID, tx)
if err != nil {
return nil, err
}
if user.Email == nil {
return nil, &common.UserEmailNotSetError{}
}
oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
go func() {
// This runs in background, so use a context without cancellation (or it would be stopped when the request ends)
// We still want to have a context derived from the request's to carry over tracing info
innerCtx := context.WithoutCancel(ctx)
link := common.EnvConfig.AppURL + "/lc"
linkWithCode := link + "/" + oneTimeAccessToken
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath
}
errInternal := SendEmail(innerCtx, s.emailService, dbConfig, email.Address{
Name: user.FullName(),
Email: *user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Code: oneTimeAccessToken,
LoginLink: link,
LoginLinkWithCode: linkWithCode,
ExpirationString: utils.DurationToString(ttl),
})
if errInternal != nil {
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", *user.Email))
return
}
}()
return deviceToken, nil
}
func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// Load the user to ensure it exists
_, err = s.userService.getUserInternal(ctx, userID, tx)
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", &common.UserNotFoundError{}
} else if err != nil {
return "", err
}
// Create the one-time access token
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, tx)
if err != nil {
return "", err
}
// Commit
err = tx.Commit().Error
if err != nil {
return "", fmt.Errorf("error committing transaction: %w", err)
}
return token, nil
}
func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken)
if err != nil {
return "", nil, err
}
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
if err != nil {
return "", nil, err
}
return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil
}
func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, dbConfig *appconfig.AppConfigModel, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var oneTimeAccessToken model.OneTimeAccessToken
err := tx.
WithContext(ctx).
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).
Preload("User").
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&oneTimeAccessToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
}
if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken {
return model.User{}, "", &common.DeviceCodeInvalid{}
}
if oneTimeAccessToken.User.Disabled {
return model.User{}, "", &common.UserDisabledError{}
}
accessToken, err := s.jwtService.GenerateAccessToken(
oneTimeAccessToken.User,
AuthenticationMethodOneTimePassword,
dbConfig.SessionDuration.AsDurationMinutes(),
)
if err != nil {
return model.User{}, "", err
}
err = tx.
WithContext(ctx).
Delete(&oneTimeAccessToken).
Error
if err != nil {
return model.User{}, "", err
}
s.auditLogService.Create(
ctx, model.AuditLogEventOneTimeAccessTokenSignIn,
ipAddress, userAgent,
oneTimeAccessToken.User.ID, model.AuditLogData{},
tx,
)
err = tx.Commit().Error
if err != nil {
return model.User{}, "", fmt.Errorf("error committing transaction: %w", err)
}
return oneTimeAccessToken.User, accessToken, nil
}
func NewOneTimeAccessToken(userID string, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
if ttl <= 15*time.Minute {
tokenLength = 6
}
token, err := utils.GenerateRandomUnambiguousString(tokenLength)
if err != nil {
return nil, err
}
var deviceToken *string
if withDeviceToken {
dt, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
deviceToken = &dt
}
now := time.Now().Round(time.Second)
o := &model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
Token: token,
DeviceToken: deviceToken,
}
return o, nil
}
@@ -1,53 +0,0 @@
package service
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
appConfig := appconfig.NewTestAppConfigService(nil)
instanceID := newInstanceID(t, db)
jwtService := initJwtService(t, db, instanceID, appConfig, newTestEnvConfig())
auditLogService := NewAuditLogService(db, nil, &GeoLiteService{}, appConfig)
oneTimeAccessService := NewOneTimeAccessService(db, nil, jwtService, auditLogService, nil)
user := model.User{
Base: model.Base{ID: "disabled-user"},
Username: "disabled-user",
Disabled: true,
}
require.NoError(t, db.Create(&user).Error)
loginCode := model.OneTimeAccessToken{
Base: model.Base{ID: "disabled-user-login-code"},
Token: "ABCDEF",
ExpiresAt: datatype.DateTime(time.Now().Add(time.Minute)),
UserID: user.ID,
}
require.NoError(t, db.Create(&loginCode).Error)
dbConfig := appconfig.NewTestConfig(nil)
exchangedUser, accessToken, err := oneTimeAccessService.ExchangeOneTimeAccessToken(t.Context(), dbConfig, loginCode.Token, "", "", "")
var userDisabledErr *common.UserDisabledError
require.ErrorAs(t, err, &userDisabledErr)
require.Empty(t, exchangedUser.ID)
require.Empty(t, accessToken)
var remainingLoginCode model.OneTimeAccessToken
require.NoError(t, db.Where("token = ?", loginCode.Token).First(&remainingLoginCode).Error)
var auditLogCount int64
require.NoError(t, db.Model(&model.AuditLog{}).Where("user_id = ?", user.ID).Count(&auditLogCount).Error)
require.Zero(t, auditLogCount)
}