mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-06 17:59:03 +02:00
feat: migrate one-time and signup tokens to an actor (#1611)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
co-authored by
Elias Schneider
parent
531bb5f0cf
commit
a1b4e1d2b2
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user