mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-23 21:36:39 +00:00
197 lines
4.8 KiB
Go
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
|
|
}
|