package invite import ( "context" "errors" "fmt" "time" "tug/internal/service/password" "tug/internal/storage" ) const ResetLifetime = 24 * time.Hour const resetSegment = "reset/" var ErrResetInvalid = errors.New("invite: reset link is not valid") type IssuedReset struct { URL string Username string ExpiresAt time.Time } func (s *Service) IssueReset(ctx context.Context, actor, userID string) (IssuedReset, error) { token, hash, err := newToken() if err != nil { return IssuedReset{}, err } issued, err := s.store.IssueResetLinkAudited(ctx, userID, hash, ResetLifetime, actor) if err != nil { return IssuedReset{}, fmt.Errorf("invite: issue reset link: %w", err) } return IssuedReset{URL: s.url(resetSegment + token), Username: issued.Username, ExpiresAt: issued.ExpiresAt}, nil } func (s *Service) CheckReset(ctx context.Context, token string) (string, error) { hash, ok := tokenHash(token) if !ok { return "", ErrResetInvalid } target, err := s.store.CheckResetLink(ctx, hash) if err != nil { return "", resetError(err) } return target.Username, nil } func (s *Service) RedeemReset(ctx context.Context, token, newPassword, confirm string) (string, error) { hash, ok := tokenHash(token) if !ok { return "", ErrResetInvalid } if _, err := s.store.CheckResetLink(ctx, hash); err != nil { return "", resetError(err) } if err := password.Acceptable(newPassword); err != nil { return "", err } if confirm != newPassword { return "", ErrPasswordMismatch } passwordHash, err := password.HashContext(ctx, newPassword) if err != nil { return "", fmt.Errorf("invite: hash password: %w", err) } target, revoked, err := s.store.RedeemResetLinkAudited(ctx, hash, passwordHash) if err != nil { return "", resetError(err) } s.logger.Info("reset link redeemed", "username", target.Username, "sessions", revoked.Sessions) return target.Username, nil } func resetError(err error) error { if errors.Is(err, storage.ErrResetLinkInvalid) { return ErrResetInvalid } return fmt.Errorf("invite: %w", err) }