279 lines
8.1 KiB
Go
279 lines
8.1 KiB
Go
package adminreset
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Smail/models"
|
|
"git.pcux.cn/xuans/Smail/services/access"
|
|
"git.pcux.cn/xuans/Smail/services/credentials"
|
|
"git.pcux.cn/xuans/Smail/services/externalemail"
|
|
"git.pcux.cn/xuans/Smail/services/totp"
|
|
"git.pcux.cn/xuans/Smail/services/verification"
|
|
"git.pcux.cn/xuans/Smail/utils/password"
|
|
"xorm.io/xorm"
|
|
)
|
|
|
|
// VerificationService 的限流状态跨连接保留,不能通过重新启动客户端刷新尝试次数。
|
|
type VerificationService struct {
|
|
engine *xorm.Engine
|
|
email *externalemail.Repository
|
|
codes *credentials.Service
|
|
totp *totp.Service
|
|
verify *verification.Service
|
|
invalidate func(int, string)
|
|
mu sync.Mutex
|
|
attempts int
|
|
window time.Time
|
|
}
|
|
|
|
func NewVerificationService(engine *xorm.Engine, email *externalemail.Repository, codes *credentials.Service, authenticator *totp.Service, verify *verification.Service, invalidate func(int, string)) *VerificationService {
|
|
return &VerificationService{engine: engine, email: email, codes: codes, totp: authenticator, verify: verify, invalidate: invalidate}
|
|
}
|
|
|
|
type verifiedReset struct {
|
|
service *VerificationService
|
|
user models.User
|
|
methods []Method
|
|
emails map[string]models.ExternalEmail
|
|
authenticator models.TOTPAuthenticator
|
|
selected string
|
|
challenge verification.Challenge
|
|
context string
|
|
email string
|
|
recovery models.RecoveryCode
|
|
recoveryCode string
|
|
verified bool
|
|
expires time.Time
|
|
}
|
|
|
|
func (s *VerificationService) Begin() (ResetSession, error) {
|
|
if s == nil || s.engine == nil || s.email == nil || s.codes == nil || s.totp == nil || s.verify == nil {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
var users []models.User
|
|
if err := s.engine.Find(&users); err != nil {
|
|
return nil, err
|
|
}
|
|
var targets []models.User
|
|
for _, user := range users {
|
|
if user.EffectiveRole() == access.RoleSuperAdmin {
|
|
targets = append(targets, user)
|
|
}
|
|
}
|
|
if len(targets) != 1 || targets[0].Disabled != 0 {
|
|
return nil, ErrSuperAdminNotFound
|
|
}
|
|
flow := &verifiedReset{service: s, user: targets[0], emails: make(map[string]models.ExternalEmail), expires: time.Now().Add(controlRequestTimeout)}
|
|
items, err := s.email.List(flow.user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, item := range items {
|
|
if item.Status != models.ExternalEmailStatusVerified {
|
|
continue
|
|
}
|
|
address, err := s.email.Decrypt(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id := fmt.Sprintf("email:%d", item.ID)
|
|
flow.emails[id] = item
|
|
flow.methods = append(flow.methods, Method{ID: id, Label: "发送验证码到 " + address})
|
|
}
|
|
has, err := s.engine.Where("user_id = ? AND enabled = ? AND revoked_at IS NULL", flow.user.ID, true).Get(&flow.authenticator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if has {
|
|
flow.methods = append(flow.methods, Method{ID: "totp", Label: "手机验证器动态码"})
|
|
}
|
|
has, err = s.codes.HasValidRecoveryCode(flow.user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if has {
|
|
flow.methods = append(flow.methods, Method{ID: "recovery", Label: "安全码"})
|
|
}
|
|
if len(flow.methods) == 0 {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
flow.context, err = NewNonce()
|
|
return flow, err
|
|
}
|
|
|
|
func (f *verifiedReset) Methods() []Method { return f.methods }
|
|
|
|
func (f *verifiedReset) Start(method string) error {
|
|
if f.selected != "" || !time.Now().Before(f.expires) {
|
|
return ErrInvalidRequest
|
|
}
|
|
available := false
|
|
for _, item := range f.methods {
|
|
if item.ID == method {
|
|
available = true
|
|
}
|
|
}
|
|
if !available {
|
|
return ErrInvalidRequest
|
|
}
|
|
f.selected = method
|
|
if item, ok := f.emails[method]; ok {
|
|
if err := f.checkCurrent(nil); err != nil {
|
|
return err
|
|
}
|
|
address, err := f.service.email.Decrypt(item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f.email = address
|
|
f.challenge, err = f.service.verify.Issue(verification.IssueRequest{
|
|
Purpose: verification.PurposeAdminLocalReset, Account: f.user.Account, UserID: f.user.ID,
|
|
Email: address, ResourceID: item.ID, CredentialVersion: item.CredentialVersion,
|
|
BrowserTransaction: f.context, Environment: "local-ipc", TTL: controlRequestTimeout,
|
|
}, "local-ipc")
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *VerificationService) allowAttempt() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if time.Since(s.window) >= 15*time.Minute {
|
|
s.window = time.Now()
|
|
s.attempts = 0
|
|
}
|
|
if s.attempts >= 5 {
|
|
return false
|
|
}
|
|
s.attempts++
|
|
return true
|
|
}
|
|
|
|
func (f *verifiedReset) Verify(code string) error {
|
|
if f.verified || f.selected == "" || !f.service.allowAttempt() {
|
|
return ErrInvalidRequest
|
|
}
|
|
if err := f.checkCurrent(nil); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
switch f.selected {
|
|
case "totp":
|
|
err = f.service.totp.Verify(f.user.ID, strings.TrimSpace(code))
|
|
case "recovery":
|
|
code = strings.ToUpper(strings.TrimSpace(code))
|
|
if len(code) < 12 || len(code) > 64 {
|
|
return ErrInvalidRequest
|
|
}
|
|
var items []models.RecoveryCode
|
|
if err := f.service.engine.Where("user_id = ? AND used_at IS NULL", f.user.ID).Find(&items); err != nil {
|
|
return err
|
|
}
|
|
err = ErrInvalidRequest
|
|
for _, item := range items {
|
|
matched, checkErr := password.Check(item.CodeHash, code)
|
|
if checkErr != nil {
|
|
return checkErr
|
|
}
|
|
if matched {
|
|
f.recovery = item
|
|
f.recoveryCode = code
|
|
err = nil
|
|
break
|
|
}
|
|
}
|
|
default:
|
|
item, ok := f.emails[f.selected]
|
|
if !ok {
|
|
return ErrInvalidRequest
|
|
}
|
|
err = f.service.verify.VerifyBoundWithContext(f.challenge.ID, f.user.Account, f.email, strings.TrimSpace(code), verification.PurposeAdminLocalReset, item.ID, "local-ipc", f.context)
|
|
}
|
|
if err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
f.verified = true
|
|
return nil
|
|
}
|
|
|
|
// checkCurrent 同时绑定账号版本与所选凭据,验证后更换邮箱或撤销凭据不能继续重置。
|
|
func (f *verifiedReset) checkCurrent(tx *xorm.Session) error {
|
|
if !time.Now().Before(f.expires) {
|
|
return ErrExpiredSession
|
|
}
|
|
if tx == nil {
|
|
tx = f.service.engine.NewSession()
|
|
defer tx.Close()
|
|
}
|
|
var user models.User
|
|
found, err := tx.ID(f.user.ID).Get(&user)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || user.Disabled != 0 || user.EffectiveRole() != access.RoleSuperAdmin || user.Account != f.user.Account || user.Password != f.user.Password || user.SessionVersion != f.user.SessionVersion || user.SecurityVersion != f.user.SecurityVersion {
|
|
return ErrInvalidRequest
|
|
}
|
|
if item, ok := f.emails[f.selected]; ok {
|
|
var current models.ExternalEmail
|
|
found, err := tx.ID(item.ID).Get(¤t)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || current.UserID != user.ID || current.Status != models.ExternalEmailStatusVerified || current.CredentialVersion != item.CredentialVersion || current.EmailCiphertext != item.EmailCiphertext {
|
|
return ErrInvalidRequest
|
|
}
|
|
} else if f.selected == "totp" {
|
|
var current models.TOTPAuthenticator
|
|
found, err := tx.ID(f.authenticator.ID).Get(¤t)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || current.UserID != user.ID || !current.Enabled || current.RevokedAt != nil || current.CredentialVersion != f.authenticator.CredentialVersion || current.SecretCiphertext != f.authenticator.SecretCiphertext {
|
|
return ErrInvalidRequest
|
|
}
|
|
} else if f.selected == "recovery" && f.verified {
|
|
var current models.RecoveryCode
|
|
found, err := tx.ID(f.recovery.ID).Get(¤t)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || current.UserID != user.ID || current.UsedAt != nil || current.CodeHash != f.recovery.CodeHash {
|
|
return ErrInvalidRequest
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *verifiedReset) Reset(value string) error {
|
|
if !f.verified {
|
|
return ErrInvalidRequest
|
|
}
|
|
defer f.Close()
|
|
return resetSuperAdmin(f.service.engine, value, func(tx *xorm.Session, target models.User) error {
|
|
if target.ID != f.user.ID {
|
|
return ErrInvalidRequest
|
|
}
|
|
if err := f.checkCurrent(tx); err != nil {
|
|
return err
|
|
}
|
|
if f.selected == "recovery" {
|
|
return f.service.codes.ConsumeRecoveryCodeWithSession(tx, target.ID, f.recoveryCode)
|
|
}
|
|
return nil
|
|
}, f.service.invalidate)
|
|
}
|
|
|
|
func (f *verifiedReset) Close() {
|
|
f.verified = false
|
|
f.expires = time.Time{}
|
|
clearPassword(&f.recoveryCode)
|
|
if f.challenge.ID != "" {
|
|
f.service.verify.Store.InvalidateChallenge(f.challenge.ID)
|
|
}
|
|
}
|