mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-29 20:19:04 +01: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
@@ -0,0 +1,283 @@
|
||||
package onetimeaccess
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"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"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
)
|
||||
|
||||
// authenticationMethodOneTimePassword identifies one-time password/code authentication
|
||||
// It must match the value emitted by the JWT service in the access token's "amr" claim
|
||||
const authenticationMethodOneTimePassword = "otp"
|
||||
|
||||
// TokenStore is the minimal interface needed to persist a one-time access token in the actor state store.
|
||||
// It's satisfied by both *actor.Service (used by the running application) and *local.Host (used by CLI commands, which don't run the full actor host).
|
||||
type TokenStore interface {
|
||||
SetState(ctx context.Context, actorType string, actorID string, state any, opts *actor.SetStateOpts) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
actorService *actor.Service
|
||||
userProvider UserProvider
|
||||
signer TokenService
|
||||
auditLog AuditLogger
|
||||
emailSender EmailSender
|
||||
}
|
||||
|
||||
func newService(deps Dependencies, actorService *actor.Service) *Service {
|
||||
return &Service{
|
||||
db: deps.DB,
|
||||
actorService: actorService,
|
||||
userProvider: deps.UserProvider,
|
||||
signer: deps.Signer,
|
||||
auditLog: deps.AuditLog,
|
||||
emailSender: deps.EmailSender,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) 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 *Service) 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 *Service) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool, dbConfig *appconfig.AppConfigModel) (*string, error) {
|
||||
// Load the user to ensure it exists and has an email address
|
||||
user, err := s.userProvider.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user.Email == nil {
|
||||
return nil, &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
oneTimeAccessToken, deviceToken, err := StoreToken(ctx, s.actorService, user.ID, ttl, withDeviceToken)
|
||||
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
|
||||
}
|
||||
|
||||
innerErr := s.emailSender.SendOneTimeAccessEmail(innerCtx, dbConfig, email.Address{
|
||||
Name: user.FullName(),
|
||||
Email: *user.Email,
|
||||
}, EmailData{
|
||||
Code: oneTimeAccessToken,
|
||||
LoginLink: link,
|
||||
LoginLinkWithCode: linkWithCode,
|
||||
ExpirationString: utils.DurationToString(ttl),
|
||||
})
|
||||
if innerErr != nil {
|
||||
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", innerErr), slog.String("address", *user.Email))
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
return deviceToken, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
|
||||
// Load the user to ensure it exists
|
||||
_, err = s.userProvider.GetUser(ctx, userID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", &common.UserNotFoundError{}
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token, _, err = StoreToken(ctx, s.actorService, userID, ttl, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExchangeToken(ctx context.Context, dbConfig *appconfig.AppConfigModel, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
|
||||
// Consume the token by invoking its actor: this atomically validates it and, if valid, deletes it.
|
||||
// It must happen outside of a DB transaction, since invoking an actor while a transaction is open would deadlock on SQLite.
|
||||
res, err := s.actorService.Invoke(ctx, TokenActorType, token, tokenMethodConsume, tokenConsumeRequest{
|
||||
DeviceToken: deviceToken,
|
||||
})
|
||||
if err != nil {
|
||||
return model.User{}, "", fmt.Errorf("error invoking one-time access token actor: %w", err)
|
||||
}
|
||||
|
||||
var consumeRes tokenConsumeResponse
|
||||
err = res.Decode(&consumeRes)
|
||||
if err != nil {
|
||||
return model.User{}, "", fmt.Errorf("error decoding one-time access token actor response: %w", err)
|
||||
}
|
||||
|
||||
switch consumeRes.Status {
|
||||
case tokenConsumeNotFound:
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
case tokenConsumeDeviceMismatch:
|
||||
return model.User{}, "", &common.DeviceCodeInvalid{}
|
||||
case tokenConsumeOK:
|
||||
// All good, continue below
|
||||
default:
|
||||
return model.User{}, "", fmt.Errorf("unexpected status from one-time access token actor: %s", consumeRes.Status)
|
||||
}
|
||||
|
||||
// The token has now been consumed. From this point on, if we hit an error we compensate by restoring the token (this is best-effort).
|
||||
user, accessToken, err := s.completeTokenExchange(ctx, dbConfig, consumeRes.State, ipAddress, userAgent)
|
||||
if err != nil {
|
||||
s.restoreToken(ctx, token, consumeRes.State)
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
return user, accessToken, nil
|
||||
}
|
||||
|
||||
// completeTokenExchange performs the work that follows consuming a token: loading the user, validating it, and issuing an access token.
|
||||
func (s *Service) completeTokenExchange(ctx context.Context, dbConfig *appconfig.AppConfigModel, state TokenState, ipAddress, userAgent string) (model.User, string, error) {
|
||||
var user model.User
|
||||
err := s.db.
|
||||
WithContext(ctx).
|
||||
Where("id = ?", state.UserID).
|
||||
First(&user).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
} else if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
return model.User{}, "", &common.UserDisabledError{}
|
||||
}
|
||||
|
||||
accessToken, err := s.signer.GenerateAccessToken(
|
||||
user,
|
||||
authenticationMethodOneTimePassword,
|
||||
dbConfig.SessionDuration.AsDurationMinutes(),
|
||||
)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
s.auditLog.Create(
|
||||
ctx, model.AuditLogEventOneTimeAccessTokenSignIn,
|
||||
ipAddress, userAgent,
|
||||
user.ID,
|
||||
model.AuditLogData{},
|
||||
s.db,
|
||||
)
|
||||
|
||||
return user, accessToken, nil
|
||||
}
|
||||
|
||||
// restoreToken restores a token that was consumed but whose exchange could not be completed.
|
||||
// It's a best-effort compensation: if it fails (or the process crashes before it runs) we accept that the token was consumed unnecessarily.
|
||||
func (s *Service) restoreToken(parentCtx context.Context, token string, state TokenState) {
|
||||
// Use a context that is not canceled when the original request ends
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := s.actorService.Invoke(ctx, TokenActorType, token, TokenMethodRestore, state)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to restore one-time access token after a failed exchange", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
// StoreToken generates a new one-time access token and persists it in the actor state store, with a TTL matching its lifetime.
|
||||
// It returns the token value and, when requested, the associated device token.
|
||||
func StoreToken(ctx context.Context, store TokenStore, userID string, ttl time.Duration, withDeviceToken bool) (token string, deviceToken *string, err error) {
|
||||
token, deviceToken, err = generateToken(ttl, withDeviceToken)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
now := time.Now().Round(time.Second)
|
||||
state := TokenState{
|
||||
UserID: userID,
|
||||
DeviceToken: deviceToken,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
}
|
||||
|
||||
err = store.SetState(ctx, TokenActorType, token, state, &actor.SetStateOpts{TTL: ttl})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("error saving one-time access token state: %w", err)
|
||||
}
|
||||
|
||||
return token, deviceToken, nil
|
||||
}
|
||||
|
||||
// generateToken generates the random token value (and optional device token) for a one-time access token.
|
||||
func generateToken(ttl time.Duration, withDeviceToken bool) (token string, deviceToken *string, err 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
|
||||
}
|
||||
|
||||
if withDeviceToken {
|
||||
dt, err := utils.GenerateRandomAlphanumericString(16)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
deviceToken = &dt
|
||||
}
|
||||
|
||||
return token, deviceToken, nil
|
||||
}
|
||||
Reference in New Issue
Block a user