Files

197 lines
4.8 KiB
Go

package emailverification
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"time"
"github.com/italypaleale/francis/actor"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// ActorType is the actor type for email verification state
const ActorType = "EmailVerification"
const (
// MethodIssue replaces the outstanding verification state for a user
MethodIssue = "issue"
methodConsume = "consume"
methodDiscard = "discard"
methodRestore = "restore"
)
type consumeStatus string
const (
consumeOK consumeStatus = "ok"
consumeNotFound consumeStatus = "not_found"
)
// State is the persisted verification state for one user
type State struct {
TokenHash string
Email string
ExpiresAt time.Time
}
type tokenRequest struct {
TokenHash string
}
type consumeResponse struct {
Status consumeStatus
State State
}
type emailVerificationActor struct {
client actor.Client[State]
}
// NewActor allocates the email verification actor for a user
func NewActor(actorID string, service *actor.Service) actor.Actor {
return &emailVerificationActor{
client: actor.NewActorClient[State](ActorType, actorID, service),
}
}
// Invoke implements actor.ActorInvoke
func (a *emailVerificationActor) Invoke(ctx context.Context, method string, data actor.Envelope) (any, error) {
switch method {
case MethodIssue:
return nil, a.issue(ctx, data)
case methodConsume:
return a.consume(ctx, data)
case methodDiscard:
return nil, a.discard(ctx, data)
case methodRestore:
return nil, a.restore(ctx, data)
default:
return nil, common.ErrUnsupportedActorMethod{Method: method}
}
}
func (a *emailVerificationActor) issue(ctx context.Context, data actor.Envelope) error {
state, err := decodeState(data, MethodIssue)
if err != nil {
return err
}
return a.setState(ctx, state)
}
func (a *emailVerificationActor) consume(ctx context.Context, data actor.Envelope) (consumeResponse, error) {
request, err := decodeTokenRequest(data, methodConsume)
if err != nil {
return consumeResponse{}, err
}
state, err := a.client.GetState(ctx)
if err != nil {
return consumeResponse{}, fmt.Errorf("error retrieving actor state: %w", err)
}
// Compare if the hash matches
if state.TokenHash == "" || state.ExpiresAt.Before(time.Now()) ||
subtle.ConstantTimeCompare([]byte(state.TokenHash), []byte(request.TokenHash)) != 1 {
return consumeResponse{Status: consumeNotFound}, nil
}
err = a.client.DeleteState(ctx)
if err != nil {
return consumeResponse{}, fmt.Errorf("error deleting actor state: %w", err)
}
return consumeResponse{
Status: consumeOK,
State: state,
}, nil
}
func (a *emailVerificationActor) discard(ctx context.Context, data actor.Envelope) error {
request, err := decodeTokenRequest(data, methodDiscard)
if err != nil {
return err
}
state, err := a.client.GetState(ctx)
if err != nil {
return fmt.Errorf("error retrieving actor state: %w", err)
}
// Only discard if the token hash matches, to avoid discarding a newer token that may have been issued after the one being discarded
if state.TokenHash == "" || subtle.ConstantTimeCompare([]byte(state.TokenHash), []byte(request.TokenHash)) != 1 {
return nil
}
err = a.client.DeleteState(ctx)
if err != nil && !errors.Is(err, actor.ErrStateNotFound) {
return fmt.Errorf("error deleting actor state: %w", err)
}
return nil
}
func (a *emailVerificationActor) restore(ctx context.Context, data actor.Envelope) error {
state, err := decodeState(data, methodRestore)
if err != nil {
return err
}
current, err := a.client.GetState(ctx)
if err != nil {
return fmt.Errorf("error retrieving actor state: %w", err)
}
// Preserve a newer verification request that may have been issued after consumption
if current.TokenHash != "" {
return nil
}
return a.setState(ctx, state)
}
func (a *emailVerificationActor) setState(ctx context.Context, state State) error {
ttl := time.Until(state.ExpiresAt)
if ttl <= 0 {
return nil
}
err := a.client.SetState(ctx, state, &actor.SetStateOpts{TTL: ttl})
if err != nil {
return fmt.Errorf("error saving actor state: %w", err)
}
return nil
}
func decodeState(data actor.Envelope, method string) (State, error) {
if data == nil {
return State{}, fmt.Errorf("request body is empty for method '%s'", method)
}
var state State
err := data.Decode(&state)
if err != nil {
return State{}, fmt.Errorf("request body is not valid for method '%s': %w", method, err)
}
return state, nil
}
func decodeTokenRequest(data actor.Envelope, method string) (tokenRequest, error) {
if data == nil {
return tokenRequest{}, fmt.Errorf("request body is empty for method '%s'", method)
}
var request tokenRequest
err := data.Decode(&request)
if err != nil {
return tokenRequest{}, fmt.Errorf("request body is not valid for method '%s': %w", method, err)
}
return request, nil
}