mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-20 03:46:29 +00:00
168 lines
5.4 KiB
Go
168 lines
5.4 KiB
Go
package onetimeaccess
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/italypaleale/francis/actor"
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
|
)
|
|
|
|
// One-time access tokens are stored entirely in the actor state store.
|
|
// Each token is its own actor, whose actor ID is the token value itself.
|
|
// The state is persisted with a TTL equal to the token's lifetime, so it's purged automatically when the token expires (there's no separate cleanup job).
|
|
|
|
// TokenActorType is the actor type for the one-time access token actor
|
|
const TokenActorType = "OneTimeAccessToken"
|
|
|
|
// Methods exposed by the one-time access token actor
|
|
// Because we cannot invoke an actor while a DB transaction is open (that would deadlock on SQLite), consuming a token is done by invoking the actor first (which atomically validates and deletes the token), and only afterwards performing the remaining work.
|
|
// On failure, the caller compensates by restoring the token via the "restore" method as best-effort.
|
|
const (
|
|
// TokenMethodRestore stores a token's state, and is also how a consumed token is put back
|
|
TokenMethodRestore = "restore"
|
|
|
|
tokenMethodConsume = "consume"
|
|
)
|
|
|
|
// tokenConsumeStatus is the outcome of a "consume" invocation.
|
|
type tokenConsumeStatus string
|
|
|
|
const (
|
|
// tokenConsumeOK indicates the token was valid and has been consumed
|
|
tokenConsumeOK tokenConsumeStatus = "ok"
|
|
// tokenConsumeNotFound indicates the token doesn't exist (or has expired)
|
|
tokenConsumeNotFound tokenConsumeStatus = "not_found"
|
|
// tokenConsumeDeviceMismatch indicates the provided device token doesn't match
|
|
tokenConsumeDeviceMismatch tokenConsumeStatus = "device_mismatch"
|
|
)
|
|
|
|
// TokenState is the persisted state of a one-time access token actor.
|
|
// The token value itself is the actor's ID, so it isn't repeated here.
|
|
type TokenState struct {
|
|
UserID string
|
|
DeviceToken *string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// tokenConsumeRequest is the payload for the "consume" method
|
|
type tokenConsumeRequest struct {
|
|
DeviceToken string
|
|
}
|
|
|
|
// tokenConsumeResponse is the response of the "consume" method
|
|
type tokenConsumeResponse struct {
|
|
Status tokenConsumeStatus
|
|
// State is included only when Status is "ok", so the caller can restore it if a later step fails
|
|
State TokenState
|
|
}
|
|
|
|
// tokenActor is the actor that manages a single one-time access token
|
|
type tokenActor struct {
|
|
log *slog.Logger
|
|
client actor.Client[TokenState]
|
|
}
|
|
|
|
// NewTokenActor allocates a new one-time access token actor
|
|
// It satisfies actor.Factory
|
|
func NewTokenActor(actorID string, service *actor.Service) actor.Actor {
|
|
return &tokenActor{
|
|
log: slog.With(
|
|
slog.String("scope", "actor"),
|
|
slog.String("actorType", TokenActorType),
|
|
),
|
|
client: actor.NewActorClient[TokenState](TokenActorType, actorID, service),
|
|
}
|
|
}
|
|
|
|
// Invoke implements actor.ActorInvoke
|
|
func (a *tokenActor) Invoke(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
|
|
switch method {
|
|
case tokenMethodConsume:
|
|
return a.consume(parentCtx, data)
|
|
case TokenMethodRestore:
|
|
return nil, a.restore(parentCtx, data)
|
|
default:
|
|
return nil, common.ErrUnsupportedActorMethod{Method: method}
|
|
}
|
|
}
|
|
|
|
// consume atomically validates the token and, if valid, deletes it.
|
|
func (a *tokenActor) consume(parentCtx context.Context, data actor.Envelope) (tokenConsumeResponse, error) {
|
|
var req tokenConsumeRequest
|
|
if data != nil {
|
|
err := data.Decode(&req)
|
|
if err != nil {
|
|
return tokenConsumeResponse{}, fmt.Errorf("request body is not valid for method '%s': %w", tokenMethodConsume, err)
|
|
}
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
|
defer cancel()
|
|
state, err := a.client.GetState(ctx)
|
|
if err != nil {
|
|
return tokenConsumeResponse{}, fmt.Errorf("error retrieving actor state: %w", err)
|
|
}
|
|
|
|
// An empty UserID means there's no state: the token doesn't exist (or its state already expired and was purged)
|
|
if state.UserID == "" || state.ExpiresAt.Before(time.Now()) {
|
|
return tokenConsumeResponse{
|
|
Status: tokenConsumeNotFound,
|
|
}, nil
|
|
}
|
|
|
|
// If the token requires a device token, it must match
|
|
// A mismatch leaves the token untouched, mirroring the pre-actor behavior
|
|
if state.DeviceToken != nil && req.DeviceToken != *state.DeviceToken {
|
|
return tokenConsumeResponse{
|
|
Status: tokenConsumeDeviceMismatch,
|
|
}, nil
|
|
}
|
|
|
|
// The token is valid: delete the state (one-time use)
|
|
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
|
|
defer cancel()
|
|
err = a.client.DeleteState(ctx)
|
|
if err != nil {
|
|
return tokenConsumeResponse{}, fmt.Errorf("error deleting actor state: %w", err)
|
|
}
|
|
|
|
return tokenConsumeResponse{
|
|
Status: tokenConsumeOK,
|
|
State: state,
|
|
}, nil
|
|
}
|
|
|
|
// restore re-creates the token state, used to compensate when a step after consuming the token fails.
|
|
func (a *tokenActor) restore(parentCtx context.Context, data actor.Envelope) error {
|
|
if data == nil {
|
|
return fmt.Errorf("request body is empty for method '%s'", TokenMethodRestore)
|
|
}
|
|
|
|
var state TokenState
|
|
err := data.Decode(&state)
|
|
if err != nil {
|
|
return fmt.Errorf("request body is not valid for method '%s': %w", TokenMethodRestore, err)
|
|
}
|
|
|
|
// If the token has meanwhile expired, there's nothing to restore
|
|
ttl := time.Until(state.ExpiresAt)
|
|
if ttl <= 0 {
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
|
defer cancel()
|
|
err = a.client.SetState(ctx, state, &actor.SetStateOpts{
|
|
TTL: ttl,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("error saving actor state: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|