This commit is contained in:
parent
b6886efc6f
commit
5604b81ff8
@ -2,6 +2,7 @@ package controllers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
@ -42,6 +43,15 @@ func PublicRecoveryEnabled() bool {
|
||||
return hasConfiguredRecoveryHost(hosts...)
|
||||
}
|
||||
|
||||
// PublicMailRecoveryEnabled 仅判断主邮件域名下匿名恢复链接入口是否可用。
|
||||
// 旧主站恢复流程仍使用 PublicRecoveryEnabled,以保持原有兼容行为。
|
||||
func PublicMailRecoveryEnabled() bool {
|
||||
if !config.IsSetupReady() || !config.CurrentEmailPolicy().ExternalRecoveryEnabled {
|
||||
return false
|
||||
}
|
||||
return hasConfiguredRecoveryHost(config.CurrentMailDomains().Primary)
|
||||
}
|
||||
|
||||
func hasConfiguredRecoveryHost(values ...string) bool {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
@ -645,6 +655,64 @@ func CompletePasswordRecovery(ctx *context.Context, w http.ResponseWriter, req *
|
||||
response.NewSuccessResponse(map[string]any{"reset": true}).FPrint(w)
|
||||
}
|
||||
|
||||
// BeginPublicLinkPasswordRecovery 是主邮件域名 /rest/ 的无会话恢复入口。
|
||||
// 所有申请结果统一返回 accepted,避免泄露账户、邮箱绑定和角色状态。
|
||||
func BeginPublicLinkPasswordRecovery(w http.ResponseWriter, req *http.Request) {
|
||||
writePublicRecoveryJSON(w, http.StatusOK, map[string]bool{"accepted": true})
|
||||
if !PublicMailRecoveryEnabled() || req == nil {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Identifier string `json:"identifier"`
|
||||
}
|
||||
decoder := json.NewDecoder(req.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
return
|
||||
}
|
||||
runtime := currentSecurityRuntime()
|
||||
if runtime.recovery == nil {
|
||||
return
|
||||
}
|
||||
// 投递和账户状态异常均保持同样的受理响应,且恢复服务不记录令牌。
|
||||
_ = runtime.recovery.BeginPublicLink(input.Identifier)
|
||||
}
|
||||
|
||||
// CompletePublicLinkPasswordRecovery 仅消费独立链接票据;不使用主站会话、CSRF 或恢复上下文。
|
||||
func CompletePublicLinkPasswordRecovery(w http.ResponseWriter, req *http.Request) {
|
||||
if !PublicMailRecoveryEnabled() || req == nil {
|
||||
writePublicRecoveryJSON(w, http.StatusBadRequest, map[string]bool{"reset": false})
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
RecoveryCode string `json:"recovery_code"`
|
||||
}
|
||||
decoder := json.NewDecoder(req.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
writePublicRecoveryJSON(w, http.StatusBadRequest, map[string]bool{"reset": false})
|
||||
return
|
||||
}
|
||||
runtime := currentSecurityRuntime()
|
||||
if runtime.recovery == nil || runtime.recovery.CompletePublicLink(input.Token, input.NewPassword, input.RecoveryCode) != nil {
|
||||
writePublicRecoveryJSON(w, http.StatusBadRequest, map[string]bool{"reset": false})
|
||||
return
|
||||
}
|
||||
writePublicRecoveryJSON(w, http.StatusOK, map[string]bool{"reset": true})
|
||||
}
|
||||
|
||||
func writePublicRecoveryJSON(w http.ResponseWriter, status int, value any) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func recoveryBrowserContext(req *http.Request, w http.ResponseWriter, create bool) (string, bool) {
|
||||
if req == nil || w == nil {
|
||||
return "", false
|
||||
|
||||
@ -345,6 +345,7 @@ func startVerificationRuntime() error {
|
||||
return fmt.Errorf("start recovery service: %w", err)
|
||||
}
|
||||
recover.SetWebAuthnVerifier(webAuthnRuntimeService)
|
||||
recover.SetPublicLinkDelivery(newPublicRecoveryLinkDelivery())
|
||||
controllers.ConfigureSecurityRuntime(service, external, recover, credential)
|
||||
credentialService = credential
|
||||
verificationRuntime = runtime
|
||||
|
||||
@ -3,6 +3,7 @@ package res_init
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
@ -38,13 +39,32 @@ func newVerificationDeliveryWithDependencies(
|
||||
return fmt.Errorf("load verification mail settings: %w", err)
|
||||
}
|
||||
if smtpRelayRequested(runtimeSettings) {
|
||||
return deliverRelay(runtimeSettings, challenge, code)
|
||||
relayErr := deliverRelay(runtimeSettings, challenge, code)
|
||||
if relayErr == nil {
|
||||
return nil
|
||||
}
|
||||
if !smtpRelayComplete(runtimeSettings) {
|
||||
return relayErr
|
||||
}
|
||||
if projectErr := deliverProject(challenge, code); projectErr != nil {
|
||||
return fmt.Errorf("SMTP 中继投递失败且项目自身投递失败: %w", projectErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return deliverProject(challenge, code)
|
||||
}
|
||||
}
|
||||
|
||||
func smtpRelayComplete(settings config.SMTPRuntimeSettings) bool {
|
||||
relay := settings.Relay
|
||||
if strings.TrimSpace(relay.RelayHost) == "" || relay.RelayPort < 1 || relay.RelayPort > 65535 ||
|
||||
strings.TrimSpace(relay.Security) == "" || strings.TrimSpace(relay.From) == "" {
|
||||
return false
|
||||
}
|
||||
return (strings.TrimSpace(relay.Username) == "") == (strings.TrimSpace(settings.Password) == "")
|
||||
}
|
||||
|
||||
func smtpRelayRequested(settings config.SMTPRuntimeSettings) bool {
|
||||
relay := settings.Relay
|
||||
return strings.TrimSpace(relay.RelayHost) != "" ||
|
||||
@ -117,3 +137,46 @@ func deliverVerificationWithProjectMail(challenge verification.Challenge, code s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newPublicRecoveryLinkDelivery 固定使用项目自身邮件服务,不能走外部 SMTP 中继。
|
||||
func newPublicRecoveryLinkDelivery() recovery.PublicLinkDelivery {
|
||||
return func(email, token string) error {
|
||||
domain := strings.TrimSpace(config.CurrentMailDomains().Primary)
|
||||
if domain == "" || strings.TrimSpace(email) == "" || strings.TrimSpace(token) == "" {
|
||||
return verification.ErrProjectMailUnavailable
|
||||
}
|
||||
link, err := publicRecoveryLink(domain, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messageID, err := parsemail.GenerateMsgID(domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate public recovery message id: %w", err)
|
||||
}
|
||||
sender := &parsemail.User{Name: "Smail 安全中心", EmailAddress: "no-reply@" + domain}
|
||||
message := &parsemail.Email{
|
||||
From: sender, Sender: sender, To: []*parsemail.User{{EmailAddress: email}},
|
||||
Subject: "Smail 密码恢复链接",
|
||||
Text: []byte("您好,您正在恢复 Smail 密码。请在五分钟内打开以下一次性链接完成重置:\r\n" + link + "\r\n\r\n如果并非本人操作,请忽略此邮件。"),
|
||||
MsgID: messageID,
|
||||
}
|
||||
ctx := &appcontext.Context{Context: context.Background(), Values: make(map[string]any)}
|
||||
if deliveryErr, _ := send.SendTriggered(ctx, message, "系统密码恢复链接"); deliveryErr != nil {
|
||||
return fmt.Errorf("%w: project mail delivery failed", verification.ErrMailDeliveryFailed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func publicRecoveryLink(domain, token string) (string, error) {
|
||||
parsed, err := url.Parse("https://" + strings.TrimSpace(domain))
|
||||
if err != nil || parsed.Hostname() == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", fmt.Errorf("invalid primary mail domain")
|
||||
}
|
||||
if port := config.CurrentHTTPConfig().HTTPSPort; port > 0 && port != 443 && parsed.Port() == "" {
|
||||
parsed.Host = parsed.Hostname() + fmt.Sprintf(":%d", port)
|
||||
}
|
||||
parsed.Path = "/rest/"
|
||||
parsed.Fragment = "/reset-password/" + token
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
@ -33,9 +33,9 @@ const (
|
||||
|
||||
// Service 统一管理密保问题和离线恢复码。
|
||||
type Service struct {
|
||||
engine *xorm.Engine
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
engine *xorm.Engine
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
vaultRoot string
|
||||
}
|
||||
|
||||
@ -209,8 +209,35 @@ func (s *Service) ConsumeRecoveryCode(userID int, code string) error {
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.consumeRecoveryCode(s.engine, userID, code)
|
||||
}
|
||||
|
||||
// ConsumeRecoveryCodeWithSession 在调用方已开启的事务中原子核销一枚恢复码。
|
||||
// 密码重置等复合安全操作必须使用此方法,避免密码更新失败时恢复码被提前消耗。
|
||||
func (s *Service) ConsumeRecoveryCodeWithSession(session *xorm.Session, userID int, code string) error {
|
||||
if s == nil || s.engine == nil || session == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if userID <= 0 {
|
||||
return ErrInvalidUser
|
||||
}
|
||||
code = strings.TrimSpace(strings.ToUpper(code))
|
||||
if len(code) < 12 || len(code) > 64 {
|
||||
return ErrInvalidCode
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.consumeRecoveryCode(session, userID, code)
|
||||
}
|
||||
|
||||
type recoveryCodeSession interface {
|
||||
Where(queryInterface interface{}, args ...interface{}) *xorm.Session
|
||||
ID(id interface{}) *xorm.Session
|
||||
}
|
||||
|
||||
func (s *Service) consumeRecoveryCode(session recoveryCodeSession, userID int, code string) error {
|
||||
var items []models.RecoveryCode
|
||||
if err := s.engine.Where("user_id = ? AND used_at IS NULL", userID).Find(&items); err != nil {
|
||||
if err := session.Where("user_id = ? AND used_at IS NULL", userID).Find(&items); err != nil {
|
||||
return fmt.Errorf("load recovery codes: %w", err)
|
||||
}
|
||||
for _, item := range items {
|
||||
@ -218,7 +245,7 @@ func (s *Service) ConsumeRecoveryCode(userID int, code string) error {
|
||||
continue
|
||||
}
|
||||
now := s.now()
|
||||
affected, err := s.engine.ID(item.ID).Where("used_at IS NULL").Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now})
|
||||
affected, err := session.ID(item.ID).Where("used_at IS NULL").Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now})
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume recovery code: %w", err)
|
||||
}
|
||||
|
||||
225
backend/services/recovery/public_link.go
Normal file
225
backend/services/recovery/public_link.go
Normal file
@ -0,0 +1,225 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"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/utils/password"
|
||||
)
|
||||
|
||||
const maxPublicRecoveryCodeAttempts = 5
|
||||
|
||||
// PublicLinkDelivery 投递匿名恢复入口的一次性链接令牌。
|
||||
// token 仅在调用期间存在,调用方不得写入日志或持久化。
|
||||
type PublicLinkDelivery func(email, token string) error
|
||||
|
||||
// publicTicket 只保存在进程内存中,map key 为令牌摘要而非令牌明文。
|
||||
type publicTicket struct {
|
||||
UserID int
|
||||
Account string
|
||||
Role access.Role
|
||||
SessionVersion int
|
||||
SecurityVersion int
|
||||
CredentialID int
|
||||
CredentialVersion int
|
||||
ExpiresAt time.Time
|
||||
Attempts int
|
||||
Busy bool
|
||||
}
|
||||
|
||||
// BeginPublicLink 为非超级管理员生成独立匿名恢复链接。
|
||||
// 为避免账户枚举,调用方应将所有错误映射为同一已受理响应。
|
||||
func (s *Service) BeginPublicLink(identifier string) error {
|
||||
if s == nil || s.engine == nil || s.external == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
account, err := auth.NormalizeIdentifier(identifier)
|
||||
if err != nil || account == "" {
|
||||
return 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 ErrRecoveryDenied
|
||||
}
|
||||
binding, err := s.external.FindVerifiedPrimaryByUser(user.ID)
|
||||
if err != nil {
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
email, err := s.external.Decrypt(*binding)
|
||||
if err != nil || strings.TrimSpace(email) == "" {
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
|
||||
token, err := newTicketID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := ticketKey(token)
|
||||
s.mu.Lock()
|
||||
s.cleanupLocked(s.now())
|
||||
delivery := s.publicDelivery
|
||||
if delivery == nil || len(s.publicTickets) >= maxRecoveryTickets {
|
||||
s.mu.Unlock()
|
||||
return ErrUnavailable
|
||||
}
|
||||
for ticketKey, item := range s.publicTickets {
|
||||
if item.UserID == user.ID {
|
||||
delete(s.publicTickets, ticketKey)
|
||||
}
|
||||
}
|
||||
s.publicTickets[key] = &publicTicket{
|
||||
UserID: user.ID, Account: user.Account, Role: user.EffectiveRole(),
|
||||
SessionVersion: user.SessionVersion, SecurityVersion: user.SecurityVersion,
|
||||
CredentialID: binding.ID, CredentialVersion: binding.CredentialVersion,
|
||||
ExpiresAt: s.now().Add(authorizationTTL),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := delivery(email, token); err != nil {
|
||||
s.mu.Lock()
|
||||
delete(s.publicTickets, key)
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("deliver public recovery link: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompletePublicLink 在同一数据库事务中完成管理员恢复码核销、密码更新和旧凭据撤销。
|
||||
func (s *Service) CompletePublicLink(token, newPassword, recoveryCode string) error {
|
||||
defer clearString(&newPassword)
|
||||
defer clearString(&recoveryCode)
|
||||
if s == nil || s.engine == nil || s.external == nil || s.credentials == nil || strings.TrimSpace(token) == "" {
|
||||
return ErrInvalidRequest
|
||||
}
|
||||
if password.ValidateUserPassword(newPassword) != nil {
|
||||
return ErrInvalidNewPassword
|
||||
}
|
||||
key := ticketKey(token)
|
||||
s.mu.Lock()
|
||||
s.cleanupLocked(s.now())
|
||||
item, ok := s.publicTickets[key]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return ErrRecoveryExpired
|
||||
}
|
||||
if item.Busy {
|
||||
s.mu.Unlock()
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
item.Busy = true
|
||||
s.mu.Unlock()
|
||||
|
||||
consume := false
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.publicTickets[key] != item {
|
||||
return
|
||||
}
|
||||
if consume || !s.now().Before(item.ExpiresAt) {
|
||||
delete(s.publicTickets, key)
|
||||
return
|
||||
}
|
||||
item.Busy = false
|
||||
}()
|
||||
|
||||
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.EffectiveRole() != item.Role || user.SessionVersion != item.SessionVersion || user.SecurityVersion != item.SecurityVersion {
|
||||
consume = true
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
binding, err := s.external.FindVerifiedByID(item.UserID, item.CredentialID)
|
||||
if err != nil || binding.CredentialVersion != item.CredentialVersion {
|
||||
consume = true
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
|
||||
if user.EffectiveRole() == access.RoleAdmin {
|
||||
if strings.TrimSpace(recoveryCode) == "" {
|
||||
if s.recordPublicRecoveryCodeFailure(key, item) {
|
||||
consume = true
|
||||
}
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clearString(&hash)
|
||||
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
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = session.Rollback()
|
||||
}
|
||||
}()
|
||||
if user.EffectiveRole() == access.RoleAdmin {
|
||||
if err := s.credentials.ConsumeRecoveryCodeWithSession(session, user.ID, recoveryCode); err != nil {
|
||||
if s.recordPublicRecoveryCodeFailure(key, item) {
|
||||
consume = true
|
||||
}
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
}
|
||||
if affected, err := session.ID(item.UserID).Where("session_version = ? AND security_version = ? AND disabled = 0", item.SessionVersion, item.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 {
|
||||
return err
|
||||
} else if affected != 1 {
|
||||
consume = true
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
if err := s.credentials.RevokeAllWithSession(session, item.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := session.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
consume = true
|
||||
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) recordPublicRecoveryCodeFailure(key string, item *publicTicket) bool {
|
||||
if s == nil || item == nil {
|
||||
return true
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.publicTickets[key] != item {
|
||||
return true
|
||||
}
|
||||
item.Attempts++
|
||||
return item.Attempts >= maxPublicRecoveryCodeAttempts
|
||||
}
|
||||
@ -47,17 +47,19 @@ type WebAuthnVerifier interface {
|
||||
}
|
||||
|
||||
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
|
||||
emailPending map[string]*emailContinuation
|
||||
decoys map[string]decoyChallenge
|
||||
now func() time.Time
|
||||
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
|
||||
emailPending map[string]*emailContinuation
|
||||
publicTickets map[string]*publicTicket
|
||||
publicDelivery PublicLinkDelivery
|
||||
decoys map[string]decoyChallenge
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type ticket struct {
|
||||
@ -93,7 +95,7 @@ func NewService(engine *xorm.Engine, verify *verification.Service, external *ext
|
||||
}
|
||||
return &Service{
|
||||
engine: engine, verification: verify, external: external, credentials: credential,
|
||||
tickets: make(map[string]ticket), emailPending: make(map[string]*emailContinuation),
|
||||
tickets: make(map[string]ticket), emailPending: make(map[string]*emailContinuation), publicTickets: make(map[string]*publicTicket),
|
||||
decoys: make(map[string]decoyChallenge), now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
@ -116,6 +118,17 @@ func (s *Service) SetSecurityStateInvalidator(invalidator func(int, string)) {
|
||||
s.invalidate = invalidator
|
||||
}
|
||||
|
||||
// SetPublicLinkDelivery 设置独立公网恢复链接的系统邮件投递器。
|
||||
// 投递器只接收目的邮箱和一次性令牌,不得记录令牌或完整链接。
|
||||
func (s *Service) SetPublicLinkDelivery(delivery PublicLinkDelivery) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.publicDelivery = delivery
|
||||
}
|
||||
|
||||
func (s *Service) currentWebAuthnVerifier() WebAuthnVerifier {
|
||||
if s == nil {
|
||||
return nil
|
||||
@ -471,6 +484,11 @@ func (s *Service) cleanupLocked(now time.Time) {
|
||||
delete(s.emailPending, id)
|
||||
}
|
||||
}
|
||||
for id, item := range s.publicTickets {
|
||||
if !now.Before(item.ExpiresAt) {
|
||||
delete(s.publicTickets, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) issueDecoyChallenge(account, email string) (string, error) {
|
||||
@ -569,6 +587,11 @@ func (s *Service) InvalidateUser(userID int) {
|
||||
delete(s.emailPending, id)
|
||||
}
|
||||
}
|
||||
for id, item := range s.publicTickets {
|
||||
if item.UserID == userID {
|
||||
delete(s.publicTickets, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) invalidateTicket(ticketID string) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user