595 lines
19 KiB
Go
595 lines
19 KiB
Go
package recovery
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Smail/models"
|
|
"git.pcux.cn/xuans/Smail/services/access"
|
|
"git.pcux.cn/xuans/Smail/services/auth"
|
|
"git.pcux.cn/xuans/Smail/services/credentials"
|
|
"git.pcux.cn/xuans/Smail/services/externalemail"
|
|
"git.pcux.cn/xuans/Smail/services/verification"
|
|
"git.pcux.cn/xuans/Smail/utils/password"
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
"xorm.io/xorm"
|
|
)
|
|
|
|
var (
|
|
ErrUnavailable = errors.New("recovery service unavailable")
|
|
ErrInvalidRequest = errors.New("recovery request is invalid")
|
|
ErrRecoveryDenied = errors.New("recovery is denied")
|
|
ErrRecoveryExpired = errors.New("recovery authorization expired")
|
|
ErrRecoveryConsumed = errors.New("recovery authorization already consumed")
|
|
ErrInvalidNewPassword = errors.New("new password is invalid")
|
|
)
|
|
|
|
const (
|
|
authorizationTTL = 5 * time.Minute
|
|
decoyChallengeTTL = 10 * time.Minute
|
|
maxDecoyChallenges = 1024
|
|
maxDecoyAttempts = 5
|
|
maxRecoveryTickets = 10000
|
|
)
|
|
|
|
type WebAuthnVerifier interface {
|
|
BeginDiscoverableLogin() (*protocol.CredentialAssertion, string, error)
|
|
FinishDiscoverableLogin(sessionID string, request *http.Request) (*models.User, *models.WebAuthnCredential, error)
|
|
}
|
|
|
|
type Service struct {
|
|
engine *xorm.Engine
|
|
verification *verification.Service
|
|
external *externalemail.Repository
|
|
credentials *credentials.Service
|
|
webauthn WebAuthnVerifier
|
|
invalidate func(int, string)
|
|
mu sync.Mutex
|
|
tickets map[string]ticket
|
|
decoys map[string]decoyChallenge
|
|
now func() time.Time
|
|
}
|
|
|
|
type ticket struct {
|
|
UserID int
|
|
SessionVersion int
|
|
Method string
|
|
CredentialID int
|
|
CredentialVersion int
|
|
BrowserContextDigest [32]byte
|
|
HasBrowserContextDigest bool
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
type decoyChallenge struct {
|
|
AccountDigest [32]byte
|
|
EmailDigest [32]byte
|
|
BrowserContextDigest [32]byte
|
|
HasBrowserContextDigest bool
|
|
Verifier [32]byte
|
|
ExpiresAt time.Time
|
|
Attempts int
|
|
}
|
|
|
|
type BeginRequest struct {
|
|
Identifier string
|
|
Email string
|
|
BrowserContext string
|
|
}
|
|
|
|
func NewService(engine *xorm.Engine, verify *verification.Service, external *externalemail.Repository, credential *credentials.Service) (*Service, error) {
|
|
if engine == nil || credential == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
return &Service{
|
|
engine: engine, verification: verify, external: external, credentials: credential,
|
|
tickets: make(map[string]ticket), decoys: make(map[string]decoyChallenge), now: time.Now,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) SetWebAuthnVerifier(verifier WebAuthnVerifier) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.webauthn = verifier
|
|
}
|
|
|
|
func (s *Service) SetSecurityStateInvalidator(invalidator func(int, string)) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.invalidate = invalidator
|
|
}
|
|
|
|
func (s *Service) currentWebAuthnVerifier() WebAuthnVerifier {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.webauthn
|
|
}
|
|
|
|
func (s *Service) invalidateSecurityState(userID int, account string) {
|
|
s.mu.Lock()
|
|
invalidator := s.invalidate
|
|
s.mu.Unlock()
|
|
// 回调可能重新进入本服务,必须在释放内部锁后调用。
|
|
if invalidator != nil {
|
|
invalidator(userID, account)
|
|
}
|
|
}
|
|
|
|
func (s *Service) BeginWebAuthn(identifier string) (*protocol.CredentialAssertion, string, error) {
|
|
verifier := s.currentWebAuthnVerifier()
|
|
if s == nil || s.engine == nil || verifier == nil {
|
|
return nil, "", ErrUnavailable
|
|
}
|
|
options, sessionID, err := verifier.BeginDiscoverableLogin()
|
|
if err != nil {
|
|
return nil, "", ErrRecoveryDenied
|
|
}
|
|
return options, sessionID, nil
|
|
}
|
|
|
|
func (s *Service) VerifyWebAuthn(identifier, sessionID string, request *http.Request) (string, error) {
|
|
verifier := s.currentWebAuthnVerifier()
|
|
if s == nil || s.engine == nil || verifier == nil || request == nil || strings.TrimSpace(sessionID) == "" {
|
|
return "", ErrInvalidRequest
|
|
}
|
|
user, _, err := verifier.FinishDiscoverableLogin(sessionID, request)
|
|
if err != nil || user == nil {
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
account, err := auth.NormalizeIdentifier(identifier)
|
|
if err != nil || !strings.EqualFold(account, user.Account) || user.Disabled != 0 || user.EffectiveRole() == access.RoleSuperAdmin {
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
return s.issueTicketFor(user.ID, user.SessionVersion, "webauthn", 0, 0, "")
|
|
}
|
|
|
|
func (s *Service) VerifyWebAuthnWithContext(identifier, sessionID string, request *http.Request, browserContext string) (string, error) {
|
|
verifier := s.currentWebAuthnVerifier()
|
|
if s == nil || s.engine == nil || verifier == nil || request == nil || strings.TrimSpace(sessionID) == "" {
|
|
return "", ErrInvalidRequest
|
|
}
|
|
user, credential, err := verifier.FinishDiscoverableLogin(sessionID, request)
|
|
if err != nil || user == nil || credential == nil {
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
account, err := auth.NormalizeIdentifier(identifier)
|
|
if err != nil || !strings.EqualFold(account, user.Account) || user.Disabled != 0 || user.EffectiveRole() == access.RoleSuperAdmin {
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
return s.issueTicketFor(user.ID, user.SessionVersion, "webauthn", credential.ID, 0, browserContext)
|
|
}
|
|
|
|
func (s *Service) Begin(request BeginRequest, remoteIP string) (string, error) {
|
|
if s == nil || s.engine == nil || s.verification == nil || s.external == nil {
|
|
return "", ErrUnavailable
|
|
}
|
|
account, err := auth.NormalizeIdentifier(request.Identifier)
|
|
if err != nil || account == "" || strings.TrimSpace(request.Email) == "" {
|
|
return s.issueDecoyChallengeWithContext(request.Identifier, request.Email, request.BrowserContext)
|
|
}
|
|
var user models.User
|
|
has, err := s.engine.Where("account = ? AND disabled = 0", account).Get(&user)
|
|
if err != nil {
|
|
id, decoyErr := s.issueDecoyChallengeWithContext(account, request.Email, request.BrowserContext)
|
|
if decoyErr != nil {
|
|
return "", fmt.Errorf("load recovery user: %w", err)
|
|
}
|
|
return id, fmt.Errorf("load recovery user: %w", err)
|
|
}
|
|
if !has || user.EffectiveRole() == access.RoleSuperAdmin {
|
|
return s.issueDecoyChallengeWithContext(account, request.Email, request.BrowserContext)
|
|
}
|
|
item, err := s.external.FindVerifiedByEmail(request.Email)
|
|
if err != nil || item.UserID != user.ID {
|
|
return s.issueDecoyChallengeWithContext(account, request.Email, request.BrowserContext)
|
|
}
|
|
challenge, err := s.verification.Issue(verification.IssueRequest{
|
|
Purpose: verification.PurposePasswordRecover,
|
|
Account: user.Account,
|
|
Email: request.Email,
|
|
BrowserTransaction: request.BrowserContext,
|
|
CredentialVersion: item.CredentialVersion,
|
|
ResourceID: item.ID,
|
|
}, remoteIP)
|
|
if err != nil {
|
|
id, decoyErr := s.issueDecoyChallengeWithContext(account, request.Email, request.BrowserContext)
|
|
if decoyErr != nil {
|
|
return "", err
|
|
}
|
|
return id, err
|
|
}
|
|
return challenge.ID, nil
|
|
}
|
|
|
|
func (s *Service) Verify(challengeID, account, email, code, recoveryCode, remoteIP string) (string, error) {
|
|
return s.VerifyWithContext(challengeID, account, email, code, recoveryCode, remoteIP, "")
|
|
}
|
|
|
|
func (s *Service) VerifyWithContext(challengeID, account, email, code, recoveryCode, remoteIP, browserContext string) (string, error) {
|
|
if s == nil || s.verification == nil {
|
|
return "", ErrUnavailable
|
|
}
|
|
if s.consumeDecoyChallengeWithContext(challengeID, account, email, code, browserContext) {
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
normalizedAccount, normalizeErr := auth.NormalizeIdentifier(account)
|
|
if normalizeErr != nil {
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
var user models.User
|
|
has, err := s.engine.Where("account = ? AND disabled = 0", normalizedAccount).Get(&user)
|
|
if err != nil || !has || user.EffectiveRole() == access.RoleSuperAdmin || s.external == nil {
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
item, err := s.external.FindVerifiedByEmail(email)
|
|
if err != nil || item.UserID != user.ID {
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
if err := s.verification.VerifyBoundWithContext(challengeID, normalizedAccount, email, code, verification.PurposePasswordRecover, item.ID, remoteIP, browserContext); err != nil {
|
|
return "", err
|
|
}
|
|
ticketID, err := s.issueTicketFor(user.ID, user.SessionVersion, "email", item.ID, item.CredentialVersion, browserContext)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if user.EffectiveRole() == access.RoleAdmin {
|
|
if strings.TrimSpace(recoveryCode) == "" {
|
|
s.invalidateTicket(ticketID)
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
if err := s.credentials.ConsumeRecoveryCode(user.ID, recoveryCode); err != nil {
|
|
s.invalidateTicket(ticketID)
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
}
|
|
return ticketID, nil
|
|
}
|
|
|
|
func (s *Service) VerifyOffline(identifier, question, answer, recoveryCode, remoteIP string) (string, error) {
|
|
return s.VerifyOfflineWithContext(identifier, question, answer, recoveryCode, remoteIP, "")
|
|
}
|
|
|
|
func (s *Service) VerifyOfflineWithContext(identifier, question, answer, recoveryCode, remoteIP, browserContext string) (string, error) {
|
|
if s == nil || s.engine == nil || s.credentials == nil {
|
|
return "", ErrUnavailable
|
|
}
|
|
defer clearString(&answer)
|
|
defer clearString(&recoveryCode)
|
|
if strings.TrimSpace(question) == "" || strings.TrimSpace(answer) == "" || strings.TrimSpace(recoveryCode) == "" {
|
|
return "", ErrInvalidRequest
|
|
}
|
|
user, err := s.loadRecoverableUser(identifier)
|
|
if err != nil {
|
|
password.VerifyDummy(answer)
|
|
password.VerifyDummy(recoveryCode)
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
storedQuestion, configured, questionErr := s.credentials.GetSecurityQuestion(user.ID)
|
|
answerVerified, answerErr := s.credentials.VerifySecurityQuestion(user.ID, answer)
|
|
providedQuestionDigest := sha256.Sum256([]byte(strings.TrimSpace(question)))
|
|
storedQuestionDigest := sha256.Sum256([]byte(strings.TrimSpace(storedQuestion)))
|
|
questionVerified := configured && subtle.ConstantTimeCompare(providedQuestionDigest[:], storedQuestionDigest[:]) == 1
|
|
if questionErr != nil || answerErr != nil || !questionVerified || !answerVerified {
|
|
password.VerifyDummy(recoveryCode)
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
ticketID, err := s.issueTicketFor(user.ID, user.SessionVersion, "offline", 0, 0, browserContext)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := s.credentials.ConsumeRecoveryCode(user.ID, recoveryCode); err != nil {
|
|
s.invalidateTicket(ticketID)
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
return ticketID, nil
|
|
}
|
|
func (s *Service) Complete(ticketID, newPassword string) error {
|
|
return s.CompleteWithContext(ticketID, newPassword, "")
|
|
}
|
|
|
|
func (s *Service) CompleteWithContext(ticketID, newPassword, browserContext string) error {
|
|
defer clearString(&newPassword)
|
|
if s == nil || s.engine == nil || s.credentials == nil || strings.TrimSpace(ticketID) == "" {
|
|
return ErrInvalidRequest
|
|
}
|
|
if password.ValidateUserPassword(newPassword) != nil {
|
|
return ErrInvalidNewPassword
|
|
}
|
|
s.mu.Lock()
|
|
ticketDigest := ticketKey(ticketID)
|
|
item, ok := s.tickets[ticketDigest]
|
|
if !ok || !s.now().Before(item.ExpiresAt) {
|
|
if ok {
|
|
delete(s.tickets, ticketDigest)
|
|
}
|
|
s.mu.Unlock()
|
|
return ErrRecoveryExpired
|
|
}
|
|
if item.HasBrowserContextDigest {
|
|
providedDigest := sha256.Sum256([]byte(strings.TrimSpace(browserContext)))
|
|
if strings.TrimSpace(browserContext) == "" || subtle.ConstantTimeCompare(providedDigest[:], item.BrowserContextDigest[:]) != 1 {
|
|
s.mu.Unlock()
|
|
return ErrRecoveryDenied
|
|
}
|
|
}
|
|
delete(s.tickets, ticketDigest)
|
|
s.mu.Unlock()
|
|
|
|
hash, err := auth.HashPassword(newPassword)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer clearString(&hash)
|
|
var user models.User
|
|
has, err := s.engine.ID(item.UserID).Get(&user)
|
|
if err != nil || !has || user.Disabled != 0 || user.EffectiveRole() == access.RoleSuperAdmin || user.SessionVersion != item.SessionVersion {
|
|
return ErrRecoveryDenied
|
|
}
|
|
if item.Method == "email" {
|
|
if s.external == nil || item.CredentialID <= 0 {
|
|
return ErrRecoveryDenied
|
|
}
|
|
externalItem, lookupErr := s.external.FindVerifiedByID(item.UserID, item.CredentialID)
|
|
if lookupErr != nil || externalItem.CredentialVersion != item.CredentialVersion {
|
|
return ErrRecoveryDenied
|
|
}
|
|
}
|
|
if item.Method == "webauthn" && item.CredentialID > 0 {
|
|
var credential models.WebAuthnCredential
|
|
found, lookupErr := s.engine.Where("id = ? AND user_id = ? AND revoked_at IS NULL", item.CredentialID, item.UserID).Get(&credential)
|
|
if lookupErr != nil || !found {
|
|
return ErrRecoveryDenied
|
|
}
|
|
}
|
|
version := user.SessionVersion + 1
|
|
if version <= 0 {
|
|
version = 1
|
|
}
|
|
securityVersion := user.SecurityVersion + 1
|
|
if securityVersion <= 0 {
|
|
securityVersion = 1
|
|
}
|
|
now := s.now().UTC()
|
|
session := s.engine.NewSession()
|
|
if session == nil {
|
|
return ErrUnavailable
|
|
}
|
|
defer session.Close()
|
|
if err := session.Begin(); err != nil {
|
|
return err
|
|
}
|
|
if affected, err := session.ID(item.UserID).Where("session_version = ? AND security_version = ? AND disabled = 0", item.SessionVersion, user.SecurityVersion).Cols("password", "session_version", "security_version", "must_change_password", "password_changed_at").Update(&models.User{
|
|
Password: hash,
|
|
SessionVersion: version,
|
|
SecurityVersion: securityVersion,
|
|
MustChangePassword: 0,
|
|
PasswordChangedAt: &now,
|
|
}); err != nil {
|
|
_ = session.Rollback()
|
|
return err
|
|
} else if affected != 1 {
|
|
_ = session.Rollback()
|
|
return ErrRecoveryDenied
|
|
}
|
|
if err := s.credentials.RevokeAllWithSession(session, item.UserID); err != nil {
|
|
_ = session.Rollback()
|
|
return err
|
|
}
|
|
if err := session.Commit(); err != nil {
|
|
return err
|
|
}
|
|
s.InvalidateUser(item.UserID)
|
|
if s.verification != nil && s.verification.Store != nil {
|
|
_ = s.verification.Store.InvalidateAccount(user.Account)
|
|
}
|
|
s.invalidateSecurityState(item.UserID, user.Account)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) loadRecoverableUser(identifier string) (*models.User, error) {
|
|
account, err := auth.NormalizeIdentifier(identifier)
|
|
if err != nil || account == "" {
|
|
return nil, ErrRecoveryDenied
|
|
}
|
|
var user models.User
|
|
has, err := s.engine.Where("account = ? AND disabled = 0", account).Get(&user)
|
|
if err != nil || !has || user.EffectiveRole() == access.RoleSuperAdmin {
|
|
return nil, ErrRecoveryDenied
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
func (s *Service) issueTicket(userID, sessionVersion int) (string, error) {
|
|
return s.issueTicketFor(userID, sessionVersion, "", 0, 0, "")
|
|
}
|
|
|
|
func (s *Service) issueTicketFor(userID, sessionVersion int, method string, credentialID, credentialVersion int, browserContext string) (string, error) {
|
|
if userID <= 0 || sessionVersion <= 0 {
|
|
return "", ErrRecoveryDenied
|
|
}
|
|
ticketID, err := newTicketID()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
now := s.now()
|
|
s.mu.Lock()
|
|
s.cleanupLocked(now)
|
|
if len(s.tickets) >= maxRecoveryTickets {
|
|
s.mu.Unlock()
|
|
return "", ErrUnavailable
|
|
}
|
|
contextValue := strings.TrimSpace(browserContext)
|
|
var contextDigest [32]byte
|
|
if contextValue != "" {
|
|
contextDigest = sha256.Sum256([]byte(contextValue))
|
|
}
|
|
s.tickets[ticketKey(ticketID)] = ticket{
|
|
UserID: userID, SessionVersion: sessionVersion, Method: method,
|
|
CredentialID: credentialID, CredentialVersion: credentialVersion,
|
|
BrowserContextDigest: contextDigest, HasBrowserContextDigest: contextValue != "",
|
|
ExpiresAt: now.Add(authorizationTTL),
|
|
}
|
|
s.mu.Unlock()
|
|
return ticketID, nil
|
|
}
|
|
|
|
func (s *Service) cleanupLocked(now time.Time) {
|
|
for id, item := range s.tickets {
|
|
if !now.Before(item.ExpiresAt) {
|
|
delete(s.tickets, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) issueDecoyChallenge(account, email string) (string, error) {
|
|
return s.issueDecoyChallengeWithContext(account, email, "")
|
|
}
|
|
|
|
func (s *Service) issueDecoyChallengeWithContext(account, email, browserContext string) (string, error) {
|
|
if s == nil {
|
|
return "", ErrUnavailable
|
|
}
|
|
id, err := newChallengeID()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var verifier [32]byte
|
|
if _, err := rand.Read(verifier[:]); err != nil {
|
|
return "", err
|
|
}
|
|
now := s.now()
|
|
item := decoyChallenge{
|
|
AccountDigest: sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(account)))),
|
|
EmailDigest: sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email)))),
|
|
BrowserContextDigest: sha256.Sum256([]byte(strings.TrimSpace(browserContext))),
|
|
HasBrowserContextDigest: strings.TrimSpace(browserContext) != "",
|
|
Verifier: verifier,
|
|
ExpiresAt: now.Add(decoyChallengeTTL),
|
|
}
|
|
s.mu.Lock()
|
|
s.cleanupDecoysLocked(now)
|
|
if len(s.decoys) < maxDecoyChallenges {
|
|
s.decoys[id] = item
|
|
}
|
|
s.mu.Unlock()
|
|
return id, nil
|
|
}
|
|
|
|
func (s *Service) consumeDecoyChallenge(id, account, email, code string) bool {
|
|
return s.consumeDecoyChallengeWithContext(id, account, email, code, "")
|
|
}
|
|
|
|
func (s *Service) consumeDecoyChallengeWithContext(id, account, email, code, browserContext string) bool {
|
|
if s == nil || strings.TrimSpace(id) == "" {
|
|
return false
|
|
}
|
|
now := s.now()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.cleanupDecoysLocked(now)
|
|
item, ok := s.decoys[id]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if item.HasBrowserContextDigest {
|
|
providedContext := sha256.Sum256([]byte(strings.TrimSpace(browserContext)))
|
|
if strings.TrimSpace(browserContext) == "" || subtle.ConstantTimeCompare(providedContext[:], item.BrowserContextDigest[:]) != 1 {
|
|
return true
|
|
}
|
|
}
|
|
accountDigest := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(account))))
|
|
emailDigest := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email))))
|
|
provided := sha256.Sum256([]byte(id + "\x00" + account + "\x00" + email + "\x00" + code))
|
|
_ = subtle.ConstantTimeCompare(accountDigest[:], item.AccountDigest[:])
|
|
_ = subtle.ConstantTimeCompare(emailDigest[:], item.EmailDigest[:])
|
|
_ = subtle.ConstantTimeCompare(provided[:], item.Verifier[:])
|
|
item.Attempts++
|
|
if item.Attempts >= maxDecoyAttempts {
|
|
delete(s.decoys, id)
|
|
} else {
|
|
s.decoys[id] = item
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Service) cleanupDecoysLocked(now time.Time) {
|
|
for id, item := range s.decoys {
|
|
if !now.Before(item.ExpiresAt) {
|
|
delete(s.decoys, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// InvalidateUser 撤销指定用户尚未消费的全部密码恢复票据。
|
|
func (s *Service) InvalidateUser(userID int) {
|
|
if s == nil || userID <= 0 {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for id, item := range s.tickets {
|
|
if item.UserID == userID {
|
|
delete(s.tickets, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) invalidateTicket(ticketID string) {
|
|
if s == nil || ticketID == "" {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
delete(s.tickets, ticketKey(ticketID))
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func ticketKey(ticketID string) string {
|
|
digest := sha256.Sum256([]byte(ticketID))
|
|
return hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
func newTicketID() (string, error) {
|
|
var raw [32]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(raw[:]), nil
|
|
}
|
|
|
|
func newChallengeID() (string, error) {
|
|
var raw [16]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(raw[:]), nil
|
|
}
|
|
|
|
func clearString(value *string) {
|
|
if value == nil {
|
|
return
|
|
}
|
|
buffer := []byte(*value)
|
|
for index := range buffer {
|
|
buffer[index] = 0
|
|
}
|
|
*value = ""
|
|
}
|