303 lines
8.3 KiB
Go
303 lines
8.3 KiB
Go
package adminreset
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base32"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Smail/models"
|
|
"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/totp"
|
|
"git.pcux.cn/xuans/Smail/services/verification"
|
|
"git.pcux.cn/xuans/Smail/utils/password"
|
|
"xorm.io/xorm"
|
|
)
|
|
|
|
func verificationFixture(t *testing.T) (*VerificationService, models.User, *string) {
|
|
t.Helper()
|
|
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "verified-reset.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = engine.Close() })
|
|
if err := engine.Sync2(&models.User{}, &models.ExternalEmail{}, &models.TOTPAuthenticator{}, &models.SecurityQuestion{}, &models.RecoveryCode{}, &models.ProtocolCredential{}, &models.WebAuthnCredential{}, &models.ClientCredential{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user := models.User{Account: "root", Role: "super_admin", SessionVersion: 2, SecurityVersion: 3}
|
|
if err := auth.SetPassword(&user, "OldPass123"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := engine.Insert(&user); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
root := t.TempDir()
|
|
vault, err := externalemail.NewVault(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
email, err := externalemail.NewRepository(engine, vault)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
codes, err := credentials.NewService(engine)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
authenticator, err := totp.NewService(engine, root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err := verification.NewStore(20)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var delivered string
|
|
verify, err := verification.NewService(store, func(_ verification.Challenge, code string) error { delivered = code; return nil }, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return NewVerificationService(engine, email, codes, authenticator, verify, nil), user, &delivered
|
|
}
|
|
|
|
func addRecovery(t *testing.T, s *VerificationService, user models.User) string {
|
|
t.Helper()
|
|
code := "ABCD-EFGH-IJKL-MNOP"
|
|
hash, err := password.Hash(code)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.engine.Insert(&models.RecoveryCode{UserID: user.ID, CodeHash: hash}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return code
|
|
}
|
|
|
|
func TestVerifiedResetWithoutEmailOverProtocol(t *testing.T) {
|
|
s, user, delivered := verificationFixture(t)
|
|
if _, err := s.Begin(); err == nil {
|
|
t.Fatal("unconfigured account allowed")
|
|
}
|
|
code := addRecovery(t, s, user)
|
|
server, client := net.Pipe()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
defer server.Close()
|
|
handleResetConnection(server, server.SetDeadline, s.Begin, NewSessionGuard(time.Minute))
|
|
}()
|
|
defer client.Close()
|
|
steps := 0
|
|
err := SendVerifiedReset(context.Background(), client, func(string) (string, error) {
|
|
steps++
|
|
if steps == 1 {
|
|
return "1", nil
|
|
}
|
|
return code, nil
|
|
}, func() (string, error) {
|
|
if steps != 2 {
|
|
t.Error("password asked before verification")
|
|
}
|
|
return "NewPass123", nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
<-done
|
|
var updated models.User
|
|
if _, err := s.engine.ID(user.ID).Get(&updated); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !password.Verify(updated.Password, "NewPass123") || updated.SessionVersion != 3 || updated.SecurityVersion != 4 {
|
|
t.Fatal("reset did not commit")
|
|
}
|
|
if ready, err := s.codes.HasValidRecoveryCode(user.ID); err != nil || ready {
|
|
t.Fatal("recovery code not consumed")
|
|
}
|
|
if *delivered != "" {
|
|
t.Fatal("mail sent without selection")
|
|
}
|
|
}
|
|
|
|
func TestVerifiedResetRejectsRevokedCredentialAndExpiredGrant(t *testing.T) {
|
|
for _, change := range []string{"code", "account", "expired"} {
|
|
t.Run(change, func(t *testing.T) {
|
|
s, user, _ := verificationFixture(t)
|
|
code := addRecovery(t, s, user)
|
|
flow, err := s.Begin()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer flow.Close()
|
|
if err := flow.Start("recovery"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := flow.Reset("NewPass123"); err == nil {
|
|
t.Fatal("unverified reset accepted")
|
|
}
|
|
if err := flow.Verify(code); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
switch change {
|
|
case "code":
|
|
if err := s.codes.RevokeRecoveryCodes(user.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case "account":
|
|
if _, err := s.engine.ID(user.ID).Cols("security_version").Update(&models.User{SecurityVersion: 8}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case "expired":
|
|
flow.(*verifiedReset).expires = time.Now().Add(-time.Second)
|
|
}
|
|
if err := flow.Reset("NewPass123"); err == nil {
|
|
t.Fatal("stale grant accepted")
|
|
}
|
|
var unchanged models.User
|
|
if _, err := s.engine.ID(user.ID).Get(&unchanged); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if unchanged.Password != user.Password {
|
|
t.Fatal("rejected request changed password")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVerifiedResetEmailIsExplicitAndBound(t *testing.T) {
|
|
s, user, delivered := verificationFixture(t)
|
|
item, err := s.email.Bind(externalemail.BindRequest{UserID: user.ID, Email: "root@example.com", Type: models.ExternalEmailTypeBackup})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.engine.ID(item.ID).Cols("status").Update(&models.ExternalEmail{Status: models.ExternalEmailStatusVerified}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
flow, err := s.Begin()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer flow.Close()
|
|
if *delivered != "" {
|
|
t.Fatal("mail sent before selection")
|
|
}
|
|
if err := flow.Start(flow.Methods()[0].ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if *delivered == "" {
|
|
t.Fatal("mail not sent")
|
|
}
|
|
if err := flow.Verify(*delivered); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.engine.ID(item.ID).Cols("status").Update(&models.ExternalEmail{Status: models.ExternalEmailStatusDisabled}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := flow.Reset("NewPass123"); err == nil {
|
|
t.Fatal("disabled email accepted")
|
|
}
|
|
}
|
|
|
|
func TestVerifiedResetTOTPAndReplay(t *testing.T) {
|
|
s, user, _ := verificationFixture(t)
|
|
enrollment, err := s.totp.BeginEnrollment(user.ID, user.Account)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(enrollment.Secret)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
makeCode := func(step int64) string {
|
|
counter := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(counter, uint64(step))
|
|
mac := hmac.New(sha256.New, secret)
|
|
_, _ = mac.Write(counter)
|
|
digest := mac.Sum(nil)
|
|
offset := digest[len(digest)-1] & 15
|
|
return fmt.Sprintf("%06d", (binary.BigEndian.Uint32(digest[offset:offset+4])&0x7fffffff)%1000000)
|
|
}
|
|
step := time.Now().Unix() / 30
|
|
if err := s.totp.ConfirmEnrollment(user.ID, enrollment.ID, makeCode(step-1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
flow, err := s.Begin()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer flow.Close()
|
|
if err := flow.Start("totp"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := flow.Verify(makeCode(step)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.totp.Verify(user.ID, makeCode(step)); err == nil {
|
|
t.Fatal("TOTP replay accepted")
|
|
}
|
|
if err := flow.Reset("NewPass123"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestVerifiedResetLimitsAcrossConnections(t *testing.T) {
|
|
s, user, _ := verificationFixture(t)
|
|
code := addRecovery(t, s, user)
|
|
for i := 0; i < 5; i++ {
|
|
flow, err := s.Begin()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := flow.Start("recovery"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := flow.Verify("WRONG-CODE-123456"); err == nil {
|
|
t.Fatal("wrong code accepted")
|
|
}
|
|
flow.Close()
|
|
}
|
|
flow, err := s.Begin()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer flow.Close()
|
|
if err := flow.Start("recovery"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := flow.Verify(code); err == nil {
|
|
t.Fatal("new connection bypassed limit")
|
|
}
|
|
}
|
|
|
|
func TestProtocolRejectsSkippedVerification(t *testing.T) {
|
|
server, client := net.Pipe()
|
|
defer client.Close()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
defer server.Close()
|
|
handleResetConnection(server, server.SetDeadline, testResetHandler(func(string) error { t.Error("reset bypassed verification"); return nil }), NewSessionGuard(time.Minute))
|
|
}()
|
|
challenge, err := DecodeResponse(client)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.NewEncoder(client).Encode(Request{Version: ProtocolVersion, Nonce: challenge.Nonce, Action: "reset", NewPassword: "NewPass123"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := DecodeResponse(client)
|
|
if err != nil || result.OK {
|
|
t.Fatal("skipped verification accepted")
|
|
}
|
|
<-done
|
|
}
|