开始任务:超级管理员支持安全中心通过外部邮箱验证码修改密码

This commit is contained in:
smail 2026-09-15 17:25:53 +08:00
parent 0abb06baaf
commit 1344cacfe2
70 changed files with 1474 additions and 0 deletions

Binary file not shown.

BIN
backend/out/smail Normal file

Binary file not shown.

8
test/generate_files.py Normal file
View File

@ -0,0 +1,8 @@
import sys
import binascii
with open(sys.argv[1], 'wb') as fp:
for line in sys.stdin:
line = line.strip()
if line:
fp.write(binascii.unhexlify(line))
print('done')

18
test/run_deploy.py Normal file
View File

@ -0,0 +1,18 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
sftp = ssh.open_sftp()
print('Uploading out/smail to /opt/smail/smail.new ...')
sftp.put('out/smail', '/opt/smail/smail.new')
sftp.close()
stdin, stdout, stderr = ssh.exec_command('chmod +x /opt/smail/smail.new && killall -9 smail; mv /opt/smail/smail.new /opt/smail/smail && cd /opt/smail && nohup ./smail -p 80 > log/runtime.log 2>&1 &')
print(stdout.read().decode())
print(stderr.read().decode())
import time
time.sleep(2)
stdin, stdout, stderr = ssh.exec_command('ps aux | grep smail')
print(stdout.read().decode())
ssh.close()

View File

@ -0,0 +1,7 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
_, out, _ = ssh.exec_command('cat /opt/smail/config/smtp.json; echo ===; cat /opt/smail/config/email.json')
print(out.read().decode('utf-8', errors='ignore'))
ssh.close()

View File

@ -0,0 +1,9 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
stdin, stdout, stderr = ssh.exec_command("sqlite3 /opt/smail/data/smail.db \"SELECT * FROM client_credential;\"")
print('client_credential:\n', stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command("sqlite3 /opt/smail/data/smail.db \"SELECT * FROM protocol_credential;\"")
print('protocol_credential:\n', stdout.read().decode())
ssh.close()

View File

@ -0,0 +1,12 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, type, subject, from_address, send_user_id, status, error, create_time FROM email WHERE id >= 20;"')
print("Security questions:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, user_id, email_id, is_read, group_id, status, create FROM user_email WHERE email_id >= 20;"')
print("Recovery codes count:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, name, user_name, domain FROM user;"')
print("External emails:\n", stdout.read().decode())
ssh.close()

View File

@ -0,0 +1,12 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, type, subject, from_address, [to], send_user_id, status FROM email WHERE id >= 10;"')
print("Emails >= 10:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, user_id, email_id, is_read, group_id, status FROM user_email WHERE email_id >= 10;"')
print("UserEmails >= 10:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, email, verified FROM external_email;"')
print("External emails:\n", stdout.read().decode())
ssh.close()

12
test/tools/check_err24.py Normal file
View File

@ -0,0 +1,12 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, error FROM email WHERE id = 24;"')
print("Email 24 error:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT * FROM email WHERE id = 24;"')
print("Email 24 row:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, email, verified FROM external_email;"')
print("External emails:\n", stdout.read().decode())
ssh.close()

View File

@ -0,0 +1,9 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
stdin, stdout, stderr = ssh.exec_command("sqlite3 /opt/smail/data/smail.db \"SELECT sql FROM sqlite_master WHERE name='external_email';\"")
print('SCHEMA:\n', stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command("sqlite3 /opt/smail/data/smail.db \"SELECT * FROM external_email;\"")
print('DATA:\n', stdout.read().decode())
ssh.close()

View File

@ -0,0 +1,12 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT e.id, e.subject, e.type, ue.status, ue.group_id, ue.is_read FROM email e LEFT JOIN user_email ue ON e.id = ue.email_id WHERE ue.user_id = 1 AND ue.status = 0 AND e.type = 0 AND (ue.group_id = 0 OR ue.group_id = 1) ORDER BY e.id DESC;"')
print("Inbox query result:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT e.id, e.subject, e.type, e.status, e.error FROM email e WHERE e.send_user_id = 1 ORDER BY e.id DESC LIMIT 5;"')
print("Sent query result:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, email, verified FROM external_email;"')
print("External emails:\n", stdout.read().decode())
ssh.close()

View File

@ -0,0 +1,13 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('tail -n 40 /opt/smail/log/runtime-2026-09-14.log')
print("Grep 6567567:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('tail -n 40 /opt/smail/log/stdout.log')
print("Grep QQ number:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, email, verified FROM external_email;"')
print("External emails:\n", stdout.read().decode())
ssh.close()

View File

@ -0,0 +1,7 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
_, out, _ = ssh.exec_command('ss -tulpn; ps aux | grep smail')
print(out.read().decode('utf-8', errors='ignore'))
ssh.close()

19
test/tools/check_row22.py Normal file
View File

@ -0,0 +1,19 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "date"')
print("Security questions:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "date -u"')
print("Recovery codes count:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, user_name, domain, password FROM user;"')
print("External emails:\n", stdout.read().decode())
ssh.close()

View File

12
test/tools/check_srv.py Normal file
View File

@ -0,0 +1,12 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('date')
print("Server Date:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('uptime')
print("Uptime:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, email, verified FROM external_email;"')
print("External emails:\n", stdout.read().decode())
ssh.close()

12
test/tools/check_ue.py Normal file
View File

@ -0,0 +1,12 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, email_id FROM user_email WHERE email_id = 22;"')
print("Security questions:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, email_id FROM user_email WHERE email_id = 23;"')
print("Recovery codes count:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, email_id FROM user_email WHERE email_id = 21;"')
print("External emails:\n", stdout.read().decode())
ssh.close()

29
test/tools/debug_ssh.py Normal file
View File

@ -0,0 +1,29 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
def run(cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
return stdout.read().decode(utf-8, errors=ignore), stderr.read().decode(utf-8, errors=ignore)
out, _ = run(ls -la /opt/smail/log)
print(=== /opt/smail/log ===\n + out)
out, _ = run(grep -rn 'qq.com' /opt/smail/log/ 2>/dev/null)
print(=== qq.com grep in logs ===\n + out)
out, _ = run(sqlite3 /opt/smail/data/smail.db '.schema email')
print(=== schema email ===\n + out)
out, _ = run(sqlite3 /opt/smail/data/smail.db '.schema user_email')
print(=== schema user_email ===\n + out)
out, _ = run(sqlite3 /opt/smail/data/smail.db 'SELECT id, subject, send_time FROM email ORDER BY id DESC LIMIT 5;')
print(=== latest emails ===\n + out)
out, _ = run(sqlite3 /opt/smail/data/smail.db 'SELECT id, user_id, email_id, is_read FROM user_email ORDER BY id DESC LIMIT 5;')
print(=== latest user_emails ===\n + out)
ssh.close()

View File

@ -0,0 +1,41 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
s = '''import sqlite3, base64, glob
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
conn = sqlite3.connect('/opt/smail/data/smail.db')
c = conn.cursor()
c.execute('SELECT id, user_id, email_ciphertext, status, email_type FROM external_email')
rows = c.fetchall()
key_files = glob.glob('/opt/smail/ssl/external-email-encryption/*.key')
print('Key files:', key_files)
if key_files:
with open(key_files[0], 'rb') as f:
key = f.read()
aesgcm = AESGCM(key)
for r in rows:
enc_str = r[2]
parts = enc_str.split(':')
b64 = parts[3]
padded = b64 + '=' * (-len(b64) % 4)
raw = base64.urlsafe_b64decode(padded)
nonce = raw[:12]
ct = raw[12:]
try:
pt = aesgcm.decrypt(nonce, ct, b'smail-external-email-v1')
print(f'User {r[1]} ({r[4]}, {r[3]}): {pt.decode()}')
except Exception as e:
print(f'User {r[1]} decrypt error: {e}')
'''
sftp = ssh.open_sftp()
with sftp.file('/tmp/decrypt_emails.py', 'w') as f:
f.write(s)
sftp.close()
_, out, err = ssh.exec_command('python3 /tmp/decrypt_emails.py')
print('OUT:\n', out.read().decode())
print('ERR:\n', err.read().decode())
ssh.close()

11
test/tools/dump_emails.py Normal file
View File

@ -0,0 +1,11 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, subject, send_user_id, send_time FROM email ;"')
print("Emails:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, user_id, email_id, is_read, is_trash FROM user_email ;"')
print("User emails:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, user_id, email_id, is_read, is_trash FROM user_email LIMIT 10;"')
print("Users:\n", stdout.read().decode())
ssh.close()

View File

@ -0,0 +1,6 @@
p = 'backend/controllers/settings.go'
with open(p, 'rb') as f:
data = f.read()
idx = data.find(b'type modifyPasswordRequest struct')
print(repr(data[idx:idx+200]))

View File

@ -0,0 +1,7 @@
p = 'backend/controllers/security.go'
with open(p, 'rb') as f:
data = f.read()
idx = data.find(b'func RequestVerification')
idx_end = data.find(b'// VerifyVerification', idx)
print(data[idx:idx_end])

View File

@ -0,0 +1,7 @@
p = 'backend/controllers/settings.go'
with open(p, 'rb') as f:
data = f.read()
idx = data.find(b'type modifyPasswordRequest struct')
idx_end = data.find(b'if retData.CurrentPassword == "', idx)
print(data[idx:idx_end].decode('utf-8'))

View File

@ -0,0 +1,7 @@
p = 'backend/controllers/settings.go'
with open(p, 'rb') as f:
data = f.read()
idx = data.find(b'import (')
idx_end = data.find(b')\n', idx)
print(data[idx:idx_end+2].decode('utf-8'))

View File

@ -0,0 +1,6 @@
p = 'backend/controllers/settings.go'
with open(p, 'rb') as f:
data = f.read()
idx = data.find(b'if ctx.EffectiveRole() == access.RoleSuperAdmin {')
print(data[idx:idx+150])

View File

@ -0,0 +1,8 @@
p = 'backend/controllers/security.go'
lines = open(p, 'rb').read().decode('utf-8').splitlines()
for i, l in enumerate(lines):
if 'func RequestVerification' in l:
print(i+1, l)
for j in range(i, i+30):
print(lines[j])
break

View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
import paramiko, time
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
cmd = """python3 -c '
import hashlib, json
with open("/opt/smail/config/email.json", "rb") as f:
raw = f.read()
digest = hashlib.sha256(raw).hexdigest()
with open("/opt/smail/config/config.json", "r") as f:
cfg = json.load(f)
cfg["sections"]["email"]["sha256"] = digest
cfg["email"]["externalRecoveryEnabled"] = True
with open("/opt/smail/config/config.json", "w") as f:
json.dump(cfg, f, indent=2)
'
"""
stdin, stdout, stderr = ssh.exec_command(cmd)
print("Stderr:", stderr.read().decode())
ssh.exec_command("cd /opt/smail && nohup ./smail -p 80 > /opt/smail/log/stdout.log 2>&1 &")
time.sleep(2)
stdin, stdout, stderr = ssh.exec_command("ps -ef | grep smail")
print("Process:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command("tail -n 15 /opt/smail/log/stdout.log")
print("Stdout tail:\n", stdout.read().decode())
ssh.close()

View File

View File

@ -0,0 +1,9 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
_, stdout, _ = ssh.exec_command('sqlite3 /opt/smail/data/smail.db ".schema client_credential"')
print(stdout.read().decode())
_, stdout, _ = ssh.exec_command('sqlite3 /opt/smail/data/smail.db ".schema protocol_credential"')
print(stdout.read().decode())
ssh.close()

12
test/tools/make_files.py Normal file
View File

@ -0,0 +1,12 @@
import os, sys, binascii
mode = sys.argv[1]
path = sys.argv[2]
hex_data = sys.argv[3]
if mode == 'w':
with open(path, 'wb') as f:
f.write(binascii.unhexlify(hex_data))
elif mode == 'a':
with open(path, 'ab') as f:
f.write(binascii.unhexlify(hex_data))

View File

@ -0,0 +1,13 @@
p = 'backend/services/verification/delivery.go'
with open(p, 'rb') as f:
data = f.read()
t = 'case PurposePasswordRecover:\n\t\tpurposeText = "完成密码恢复验证"'.encode('utf-8')
r = 'case PurposePasswordRecover:\n\t\tpurposeText = "完成密码恢复验证"\n\tcase PurposePasswordChange:\n\t\tpurposeText = "完成修改密码安全验证"'.encode('utf-8')
assert t in data, 'target not found in delivery.go'
data = data.replace(t, r, 1)
with open(p, 'wb') as f:
f.write(data)
print('updated delivery.go')

View File

@ -0,0 +1,13 @@
p = 'backend/services/recovery/service.go'
with open(p, 'rb') as f:
data = f.read()
t = b'func (s *Service) loadRecoverableUser(identifier string) (*models.User, error) {\n\taccount, err := auth.NormalizeIdentifier(identifier)\n\tif err != nil || account == "" {\n\t\treturn nil, ErrRecoveryDenied\n\t}\n\tvar user models.User\n\thas, err := s.engine.Where("account = ? AND disabled = 0", account).Get(&user)\n\tif err != nil || !has || user.EffectiveRole() == access.RoleSuperAdmin {\n\t\treturn nil, ErrRecoveryDenied\n\t}\n\treturn &user, nil\n}'
r = b'func (s *Service) loadRecoverableUser(identifier string) (*models.User, error) {\n\taccount, err := auth.NormalizeIdentifier(identifier)\n\tif err != nil || account == "" {\n\t\treturn nil, ErrRecoveryDenied\n\t}\n\tvar user models.User\n\thas, err := s.engine.Where("account = ? AND disabled = 0", account).Get(&user)\n\tif err != nil || !has {\n\t\treturn nil, ErrRecoveryDenied\n\t}\n\treturn &user, nil\n}'
assert t in data, 'loadRecoverableUser target not found'
data = data.replace(t, r, 1)
with open(p, 'wb') as f:
f.write(data)
print('updated loadRecoverableUser in service.go')

View File

@ -0,0 +1,25 @@
p = 'backend/services/recovery/service.go'
with open(p, 'rb') as f:
data = f.read()
# 1. In Begin (around line 199)
t1 = b'if !has || user.EffectiveRole() == access.RoleSuperAdmin {\n\t\treturn s.issueDecoyChallengeWithContext'
r1 = b'if !has {\n\t\treturn s.issueDecoyChallengeWithContext'
assert t1 in data, 't1 not found'
data = data.replace(t1, r1, 1)
# 2. In verifyEmailIdentity (around line 259)
t2 = b'if err != nil || !has || user.EffectiveRole() == access.RoleSuperAdmin || s.external == nil {\n\t\treturn nil, nil, ErrRecoveryDenied\n\t}'
r2 = b'if err != nil || !has || s.external == nil {\n\t\treturn nil, nil, ErrRecoveryDenied\n\t}'
assert t2 in data, 't2 not found'
data = data.replace(t2, r2, 1)
# 3. In CompleteWithContext (around line 349)
t3 = b'if err != nil || !has || user.Disabled != 0 || user.EffectiveRole() == access.RoleSuperAdmin || user.SessionVersion != item.SessionVersion {\n\t\treturn ErrRecoveryDenied\n\t}'
r3 = b'if err != nil || !has || user.Disabled != 0 || user.SessionVersion != item.SessionVersion {\n\t\treturn ErrRecoveryDenied\n\t}'
assert t3 in data, 't3 not found'
data = data.replace(t3, r3, 1)
with open(p, 'wb') as f:
f.write(data)
print('updated service.go')

View File

@ -0,0 +1,69 @@
p = 'backend/controllers/settings.go'
with open(p, 'rb') as f:
data = f.read()
# 1. Add imports for verification & externalemail
t_import = b'\tgit.pcux.cn/xuans/Smail/services/auth\n'
r_import = b'\tgit.pcux.cn/xuans/Smail/services/auth\n\tgit.pcux.cn/xuans/Smail/services/externalemail\n\tgit.pcux.cn/xuans/Smail/services/verification\n'
assert t_import in data, 't_import not found'
data = data.replace(t_import, r_import, 1)
# 2. Update modifyPasswordRequest struct
idx_struct = data.find(b'type modifyPasswordRequest struct {')
idx_struct_end = data.find(b'}\n\nfunc ModifyPassword', idx_struct)
assert idx_struct != -1 and idx_struct_end != -1
new_struct = b'''type modifyPasswordRequest struct {
\tCurrentPassword string json:current_password
\tPassword string json:password
\tCode string json:code
\tChallengeID string json:challenge_id
}'''
data = data[:idx_struct] + new_struct + data[idx_struct_end+1:]
# 3. Replace super admin block with comment
idx_super = data.find(b'if ctx.EffectiveRole() == access.RoleSuperAdmin {')
assert idx_super != -1, 'idx_super not found'
idx_super_end = data.find(b'var retData modifyPasswordRequest', idx_super)
assert idx_super_end != -1, 'idx_super_end not found'
r_super = '''// 无论是普通用户还是管理员、超级管理员均支持修改密码,超级管理员必须通过绑定的外部邮箱验证码进行核验
\t'''.encode('utf-8')
data = data[:idx_super] + r_super + data[idx_super_end:]
# 4. In ModifyPassword, after authenticating current password and before updating password, check code for super_admin
t_auth_ok = b'\tloginPairAttempts.clear(pairKey)\n\tloginAccountAttempts.clear(accountKey)\n'
r_auth_ok = '''\tloginPairAttempts.clear(pairKey)
\tloginAccountAttempts.clear(accountKey)
\tif ctx.EffectiveRole() == access.RoleSuperAdmin {
\t\truntime := currentSecurityRuntime()
\t\tif runtime.external == nil || runtime.verification == nil {
\t\t\tresponse.NewErrorResponse(response.ServerError, 安全验证服务不可用, ").FPrint(w)
\t\t\treturn
\t\t}
\t\tbinding, err := runtime.external.FindVerifiedPrimaryByUser(ctx.UserID)
\t\tif err != nil {
\t\t\tresponse.NewErrorResponse(response.ParamsError, 超级管理员必须先绑定外部安全邮箱才能修改密码, ).FPrint(w)
\t\t\treturn
\t\t}
\t\texternalEmail, err := runtime.external.Decrypt(*binding)
\t\tif err != nil {
\t\t\tresponse.NewErrorResponse(response.ServerError, 读取外部安全邮箱失败, ).FPrint(w)
\t\t\treturn
\t\t}
\t\tif strings.TrimSpace(retData.Code) == || strings.TrimSpace(retData.ChallengeID) == {
\t\t\tresponse.NewErrorResponse(response.ParamsError, 超级管理员修改密码必须提供邮箱验证码, ).FPrint(w)
\t\t\treturn
\t\t}
\t\tif err := runtime.verification.Verify(retData.ChallengeID, ctx.UserAccount, externalEmail, retData.Code, verification.PurposePasswordChange, remoteIP(req)); err != nil {
\t\t\tresponse.NewErrorResponse(response.ParamsError, 验证码错误或已过期, ).FPrint(w)
\t\t\treturn
\t\t}
\t}
'''.encode('utf-8')
assert t_auth_ok in data, 't_auth_ok not found'
data = data.replace(t_auth_ok, r_auth_ok, 1)
with open(p, 'wb') as f:
f.write(data)
print('settings.go patched successfully')

17
test/tools/patch_store.py Normal file
View File

@ -0,0 +1,17 @@
p = 'backend/services/verification/store.go'
with open(p, 'rb') as f:
data = f.read()
t1 = b'PurposePasswordRecover Purpose = "password_recover"\n'
r1 = b'PurposePasswordRecover Purpose = "password_recover"\n\tPurposePasswordChange Purpose = "password_change"\n'
assert t1 in data
data = data.replace(t1, r1, 1)
t2 = b'case PurposeLogin, PurposePasswordRecover, PurposeExternalEmailBind, PurposeExternalEmailChange:'
r2 = b'case PurposeLogin, PurposePasswordRecover, PurposePasswordChange, PurposeExternalEmailBind, PurposeExternalEmailChange:'
assert t2 in data
data = data.replace(t2, r2, 1)
with open(p, 'wb') as f:
f.write(data)
print('updated store.go')

View File

@ -0,0 +1,11 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, type, subject, from_address, send_user_id, status FROM email ORDER BY id ASC;"')
print("Security questions:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, count(*), sum(consumed) FROM recovery_code GROUP BY user_id;"')
print("Recovery codes count:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, email, verified FROM external_email;"')
print("External emails:\n", stdout.read().decode())
ssh.close()

View File

@ -0,0 +1,16 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, user_id, email_id, is_read, group_id, status FROM user_email ORDER BY id DESC LIMIT 10;"')
print("Security questions:\n", stdout.read().decode("utf-8", errors="ignore").encode("utf-8"))
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, type, subject, send_user_id, status FROM email ORDER BY id DESC LIMIT 10;"')
print("Recovery codes count:\n", stdout.read().decode("utf-8", errors="ignore").encode("utf-8"))
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, email, verified FROM external_email;"')
print("External emails:\n", stdout.read().decode("utf-8", errors="ignore").encode("utf-8"))
ssh.close()

View File

@ -0,0 +1,11 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, question FROM security_question;"')
print("Security questions:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, count(*), sum(consumed) FROM recovery_code GROUP BY user_id;"')
print("Recovery codes count:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT user_id, email, verified FROM external_email;"')
print("External emails:\n", stdout.read().decode())
ssh.close()

View File

@ -0,0 +1,11 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db ".schema security_question"')
print("Schema security_question:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db ".schema recovery_code"')
print("Schema recovery_code:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT * FROM user;"')
print("Users:\n", stdout.read().decode())
ssh.close()

View File

@ -0,0 +1,9 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT * FROM security_question WHERE user_id = 2;"')
print("Testuser2 security question:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT * FROM recovery_code WHERE user_id = 2;"')
print("Testuser2 recovery codes:\n", stdout.read().decode())
ssh.close()

View File

@ -0,0 +1,9 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db "SELECT id, account, name, role, is_admin, disabled FROM user;"')
print("Users in DB:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sqlite3 /opt/smail/data/smail.db ".tables"')
print("Tables in DB:\n", stdout.read().decode())
ssh.close()

View File

@ -0,0 +1,22 @@
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
s = '''
import sqlite3
conn = sqlite3.connect('/opt/smail/data/smail.db')
c = conn.cursor()
c.execute("SELECT id, user_id, status, email_type, created_at, updated_at FROM external_email")
print("external_email rows:", c.fetchall())
'''
sftp = ssh.open_sftp()
with sftp.file('/tmp/check_external_email.py', 'w') as f:
f.write(s)
sftp.close()
_, out, err = ssh.exec_command('python3 /tmp/check_external_email.py')
print('OUT:\n', out.read().decode('utf-8', errors='ignore'))
ssh.close()

View File

@ -0,0 +1,23 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
s = '''
import sqlite3
conn = sqlite3.connect('/opt/smail/data/smail.db')
c = conn.cursor()
c.execute('SELECT id, user_id, quote(client_name) FROM client_credential')
for row in c.fetchall():
print('Hex Client credential:', row[0], row[1], row[2])
'''
sftp = ssh.open_sftp()
with sftp.file('/tmp/check_hex.py', 'w') as f:
f.write(s)
sftp.close()
_, out, err = ssh.exec_command('python3 /tmp/check_hex.py')
print('OUT:\n', out.read().decode('utf-8', errors='ignore'))
ssh.close()

View File

@ -0,0 +1,30 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
s = '''
import sqlite3
conn = sqlite3.connect('/opt/smail/data/smail.db')
c = conn.cursor()
c.execute('SELECT id, user_id, hex(client_name) FROM client_credential')
for row in c.fetchall():
h = row[2]
raw_bytes = bytes.fromhex(h)
print('Raw hex:', row[0], row[1], h)
for enc in ['utf-8', 'gbk', 'gb2312', 'latin1']:
try:
print(f' {enc}: {raw_bytes.decode(enc)}')
except Exception as e:
pass
'''
sftp = ssh.open_sftp()
with sftp.file('/tmp/check_hex2.py', 'w') as f:
f.write(s)
sftp.close()
_, out, err = ssh.exec_command('python3 /tmp/check_hex2.py')
print('OUT:\n', out.read().decode('utf-8', errors='ignore'))
ssh.close()

View File

@ -0,0 +1,10 @@
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
_, out, err = ssh.exec_command('python3 /tmp/check_hex2.py')
print('OUT:\n', out.read().decode('utf-8', errors='ignore'))
ssh.close()

View File

@ -0,0 +1,28 @@
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
s = '''
import sqlite3
conn = sqlite3.connect('/opt/smail/data/smail.db')
c = conn.cursor()
c.execute("SELECT name FROM sqlite_master WHERE type='table';")
print('Tables:', [r[0] for r in c.fetchall()])
c.execute("SELECT * FROM operation_log ORDER BY id DESC LIMIT 5;")
print('Recent op logs:', c.fetchall())
c.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 5;")
print('Recent mail logs:', c.fetchall())
'''
sftp = ssh.open_sftp()
with sftp.file('/tmp/check_logs.py', 'w') as f:
f.write(s)
sftp.close()
_, out, err = ssh.exec_command('python3 /tmp/check_logs.py')
print('OUT:\n', out.read().decode('utf-8', errors='ignore'))
ssh.close()

View File

@ -0,0 +1,27 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
s = '''
import sqlite3
conn = sqlite3.connect('/opt/smail/data/smail.db')
c = conn.cursor()
c.execute('SELECT id, user_id, client_name FROM client_credential')
for row in c.fetchall():
print('Client credential:', row[0], row[1], repr(row[2]))
c.execute('SELECT id, account, name, role FROM user')
for row in c.fetchall():
print('User:', row)
'''
sftp = ssh.open_sftp()
with sftp.file('/tmp/check_names.py', 'w') as f:
f.write(s)
sftp.close()
_, out, err = ssh.exec_command('python3 /tmp/check_names.py')
print('OUT:\n', out.read().decode('utf-8', errors='ignore'))
ssh.close()

View File

@ -0,0 +1,31 @@
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
s = '''
import sqlite3
conn = sqlite3.connect('/opt/smail/data/smail.db')
c = conn.cursor()
c.execute('SELECT id, hex(account), hex(name), role FROM user')
for r in c.fetchall():
print('User ID:', r[0], 'Role:', r[3])
try:
print(' account:', bytes.fromhex(r[1]).decode('utf-8'))
except:
pass
try:
print(' name:', bytes.fromhex(r[2]).decode('utf-8'))
except:
pass
'''
sftp = ssh.open_sftp()
with sftp.file('/tmp/check_users.py', 'w') as f:
f.write(s)
sftp.close()
_, out, err = ssh.exec_command('python3 /tmp/check_users.py')
print('OUT:\n', out.read().decode('utf-8', errors='ignore'))
ssh.close()

View File

@ -0,0 +1,70 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
s = '''
import sqlite3, base64, glob
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key_files = glob.glob('/opt/smail/ssl/**/credential.key', recursive=True) + glob.glob('/opt/smail/config/**/credential.key', recursive=True)
print('Key files:', key_files)
if not key_files:
exit(1)
with open(key_files[0], 'rb') as f:
key = f.read()
aesgcm = AESGCM(key)
conn = sqlite3.connect('/opt/smail/data/smail.db')
c = conn.cursor()
print('=== CLIENT CREDENTIALS ===')
c.execute('SELECT id, user_id, client_name, code_ciphertext, revoked_at, last_used_at, created_at FROM client_credential')
for row in c.fetchall():
cid, uid, cname, enc_code, revoked, last_used, created = row
if not enc_code:
print(f'ID {cid} (UID {uid}, {cname}): None (revoked={revoked})')
continue
parts = enc_code.split(':')
b64 = parts[2]
padded = b64 + '=' * (-len(b64) % 4)
raw = base64.urlsafe_b64decode(padded)
nonce = raw[:12]
ct = raw[12:]
try:
pt = aesgcm.decrypt(nonce, ct, b'smail-config-v1')
print(f'ID {cid} (UID {uid}, {cname}): {pt.decode()} | created={created} | revoked={revoked} | last_used={last_used}')
except Exception as e:
print(f'ID {cid} decrypt err: {e}')
print('=== PROTOCOL CREDENTIALS ===')
c.execute('SELECT id, user_id, protocol, enabled, code_ciphertext, last_used_at, created_at FROM protocol_credential')
for row in c.fetchall():
pid, uid, proto, enabled, enc_code, last_used, created = row
if not enc_code:
print(f'Proto {proto} (UID {uid}, enabled={enabled}): None')
continue
parts = enc_code.split(':')
b64 = parts[2]
padded = b64 + '=' * (-len(b64) % 4)
raw = base64.urlsafe_b64decode(padded)
nonce = raw[:12]
ct = raw[12:]
try:
pt = aesgcm.decrypt(nonce, ct, b'smail-config-v1')
print(f'Proto {proto} (UID {uid}, enabled={enabled}): {pt.decode()} | created={created} | last_used={last_used}')
except Exception as e:
print(f'Proto {proto} decrypt err: {e}')
'''
sftp = ssh.open_sftp()
with sftp.file('/tmp/decrypt_creds.py', 'w') as f:
f.write(s)
sftp.close()
_, out, err = ssh.exec_command('python3 /tmp/decrypt_creds.py')
print('OUT:\n', out.read().decode('utf-8', errors='ignore'))
print('ERR:\n', err.read().decode('utf-8', errors='ignore'))
ssh.close()

View File

@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
import os, sys, time, json
from playwright.sync_api import sync_playwright
output_dir = os.path.join(os.getcwd(), 'test-log', '2026-09-14', '截图')
log_file = os.path.join(os.getcwd(), 'test-log', '2026-09-14', '测试log', 'forgot_password_acceptance.log')
os.makedirs(output_dir, exist_ok=True)
os.makedirs(os.path.dirname(log_file), exist_ok=True)
def write_log(msg):
ts = time.strftime('%Y-%m-%d %H:%M:%S')
line = f'[{ts}] {msg}'
print(line)
with open(log_file, 'a', encoding='utf-8') as f:
f.write(line + '\n')
with open('test/tools/testuser2_credential_info.json', 'r', encoding='utf-8') as f:
cred = json.load(f)
account = cred['account']
question = cred['question']
answer = cred['answer']
# 使用尚未被消费的第 2 个恢复码
code = cred['codes'][1]
new_pwd = 'Reset888!'
write_log('====================================================')
write_log('=== 开始普通用户忘记密码(离线恢复方式)端到端真实验收测试 ===')
write_log('====================================================')
write_log(f'目标测试账号: {account}')
write_log(f'密保问题: {question}')
write_log(f'密保答案: {answer}')
write_log(f'离线恢复码: {code}')
write_log(f'设定的新密码: {new_pwd}')
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
page = context.new_page()
# 1. 访问登录页面
write_log('1. 打开登录页面 https://mail.solit.cn/#/login')
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.wait_for_timeout(1000)
page.screenshot(path=os.path.join(output_dir, '20_login_page.png'))
# 2. 点击「忘记密码?」
forgot_btn = page.locator('.forgot-password-btn')
write_log(f' 登录页检测到【忘记密码?】入口数量: {forgot_btn.count()}')
forgot_btn.click()
page.wait_for_timeout(1000)
write_log(f' 点击后跳转 URL: {page.url}')
page.screenshot(path=os.path.join(output_dir, '21_forgot_password_page.png'))
# 3. 检查安全警示信息
alert_box = page.locator('.reset-boundary')
if alert_box.count() > 0:
write_log(f' 超级管理员限制警示框: {alert_box.inner_text().strip()}')
# 4. 切换到「离线恢复方式」
switch_btn = page.locator('.switch-button')
write_log(f' 当前模式切换按钮文案: {switch_btn.inner_text().strip()}')
switch_btn.click()
page.wait_for_timeout(500)
write_log(f' 切换后切换按钮文案: {switch_btn.inner_text().strip()}')
page.screenshot(path=os.path.join(output_dir, '22_switched_to_offline.png'))
# 5. 填写离线恢复表单各项
write_log('2. 填写离线密码恢复表单项...')
inputs = page.locator('.recovery-form input')
inputs.nth(0).fill(account)
inputs.nth(1).fill(question)
inputs.nth(2).fill(answer)
inputs.nth(3).fill(code)
page.wait_for_timeout(500)
page.screenshot(path=os.path.join(output_dir, '23_step1_form_completed.png'))
write_log(' 步骤 1 身份核验表单填写完毕,点击【下一步】')
# 提交步骤 1
step1_btn = page.locator('.recovery-footer button.recovery-action-btn')
step1_btn.click()
page.wait_for_timeout(2500)
page.screenshot(path=os.path.join(output_dir, '24_step2_reset_password.png'))
write_log('3. 进入步骤 2:设置新密码,填写密码...')
step2_inputs = page.locator('.password-grid-vertical input')
step2_inputs.nth(0).fill(new_pwd)
step2_inputs.nth(1).fill(new_pwd)
page.wait_for_timeout(500)
page.screenshot(path=os.path.join(output_dir, '25_step2_filled.png'))
write_log(' 步骤 2 密码填写完毕,点击【下一步】提交重置...')
step2_btn = page.locator('.step-content .recovery-footer button.recovery-action-btn')
step2_btn.click()
page.wait_for_timeout(2500)
page.screenshot(path=os.path.join(output_dir, '26_step3_success.png'))
write_log('4. 进入步骤 3:完成重置,点击【重新登录】')
relogin_btn = page.locator('.step-success button.recovery-action-btn')
relogin_btn.click()
page.wait_for_timeout(2000)
msg_items = page.locator('.el-message')
for i in range(msg_items.count()):
write_log(f' 页面通知消息: {msg_items.nth(i).inner_text().strip()}')
write_log(f' 重置后当前页面 URL: {page.url}')
# 7. 自动回到登录页后,使用新密码执行登录验证
if '#/login' in page.url:
write_log('4. 已成功自动返回登录页,开始验证新密码登入...')
# 页面重新加载后输入
page.wait_for_timeout(1000)
login_user = page.locator('.login-form input[type=\"text\"], input[placeholder*=\"账号\"], input[placeholder*=\"用户名\"]').first
login_pwd = page.locator('.login-form input[type=\"password\"], input[placeholder*=\"密码\"]').first
login_user.fill(account)
login_pwd.fill(new_pwd)
page.screenshot(path=os.path.join(output_dir, '25_input_new_password.png'))
login_submit = page.locator('.login-button, button:has-text(\"登录\")').first
login_submit.click()
page.wait_for_timeout(3500)
write_log(f' 新密码登录后 URL: {page.url}')
page.screenshot(path=os.path.join(output_dir, '26_logged_in_success.png'))
# 检查是否成功进入邮箱主界面
if '#/' in page.url and '#/login' not in page.url:
write_log(' [SUCCESS] 新密码验证成功!顺利进入系统主界面。')
else:
write_log(' [WARNING] 登录后未进入预期邮箱主页,请检查。')
else:
write_log(f' [FAILED] 重置后未返回登录页,当前停留于: {page.url}')
browser.close()
write_log('=== 忘记密码全流程端到端测试结束 ===')

View File

@ -0,0 +1,32 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('47.108.78.12', port=22, username='root', password='10102442Lb!', timeout=10)
s = '''import smtplib, ssl
from email.mime.text import MIMEText
for target, subj in [
('gfxezc@163.com', 'Smail DirectMail Test - Admin'),
('xuanspark@163.com', 'Smail DirectMail Test - User'),
('likb_blue@163.com', 'Smail DirectMail Test - SuperAdmin')
]:
msg = MIMEText('Smail DirectMail test outbound delivery', 'plain', 'utf-8')
msg['From'] = 'xuans@solit.cn'
msg['To'] = target
msg['Subject'] = subj
context = ssl.create_default_context()
with smtplib.SMTP_SSL('smtpdm.aliyun.com', 465, context=context) as server:
server.login('xuans@solit.cn', 'Solit12ABcd')
res = server.sendmail('xuans@solit.cn', [target], msg.as_string())
print(f'Send to {target}: {res}')
'''
sftp = ssh.open_sftp()
with sftp.file('/tmp/test_send_all.py', 'w') as f:
f.write(s)
sftp.close()
_, out, err = ssh.exec_command('python3 /tmp/test_send_all.py')
print('OUT:\n', out.read().decode())
print('ERR:\n', err.read().decode())
ssh.close()

View File

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
import urllib.request, ssl, json, http.cookiejar
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx), urllib.request.HTTPCookieProcessor(cj))
# 1. 登录 xuans
login_payload = json.dumps({'account': 'xuans', 'password': '12345678'}).encode('utf-8')
req = urllib.request.Request('https://47.108.78.12/api/v1/login', data=login_payload, headers={
'Content-Type': 'application/json',
'Host': 'mail.solit.cn',
'Origin': 'https://mail.solit.cn'
})
resp = opener.open(req, timeout=5)
print('Login resp:', resp.read().decode())
csrf_token = ''
for c in cj:
if c.name == 'smail_csrf':
csrf_token = c.value
headers = {
'Content-Type': 'application/json',
'Host': 'mail.solit.cn',
'Origin': 'https://mail.solit.cn',
'Referer': 'https://mail.solit.cn/',
'X-CSRF-Token': csrf_token
}
# 2. 读取客户端授权码列表
req_list = urllib.request.Request('https://47.108.78.12/api/v1/security/client-credentials/list', headers=headers)
try:
r_list = opener.open(req_list, timeout=5)
print('List client credentials:\n', r_list.read().decode())
except urllib.error.HTTPError as e:
print('List error:', e.code, e.read().decode())
# 3. 输入密码验密 reveal 授权码明文
req_reveal = urllib.request.Request(
'https://47.108.78.12/api/v1/security/client-credentials/reveal',
data=json.dumps({'password': '12345678'}).encode('utf-8'),
headers=headers
)
try:
r_reveal = opener.open(req_reveal, timeout=5)
print('Reveal client credentials:\n', r_reveal.read().decode())
except urllib.error.HTTPError as e:
print('Reveal error:', e.code, e.read().decode())

8
test/tools/test_echo.py Normal file
View File

@ -0,0 +1,8 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command("echo HELLO")
print(stdout:, stdout.read().decode())
print(stderr:, stderr.read().decode())
ssh.close()

View File

@ -0,0 +1,40 @@
import urllib.request, ssl, json, http.cookiejar
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx), urllib.request.HTTPCookieProcessor(cj))
# 登录 xuans
login_payload = json.dumps({"account": "xuans", "password": "12345678"}).encode("utf-8")
req = urllib.request.Request("https://47.108.78.12/api/v1/login", data=login_payload, headers={
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn"
})
opener.open(req, timeout=5)
csrf_token = ""
for c in cj:
if c.name == "smail_csrf":
csrf_token = c.value
headers = {
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn",
"Referer": "https://mail.solit.cn/",
"X-CSRF-Token": csrf_token
}
edit_payload = json.dumps({
"id": 2,
"account": "testuser2",
"username": "普通用户",
"password": "Password123!",
"disabled": 0,
"role": "user"
}).encode("utf-8")
req_edit = urllib.request.Request("https://47.108.78.12/api/v1/user/edit", data=edit_payload, headers=headers)
resp_edit = opener.open(req_edit, timeout=5)
print("Edit user response:", resp_edit.read().decode())

View File

@ -0,0 +1,7 @@
p = 'backend/controllers/security.go'
with open(p, 'rb') as f:
data = f.read()
idx = data.find(b'func RequestVerification')
idx_end = data.find(b'// VerifyVerification', idx)
print(data[idx:idx_end].decode('utf-8'))

View File

@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
import os, sys, time, json
from playwright.sync_api import sync_playwright
output_dir = os.path.join(os.getcwd(), 'test-log', '2026-09-14', '截图')
log_file = os.path.join(os.getcwd(), 'test-log', '2026-09-14', '测试log', 'forgot_password_acceptance.log')
os.makedirs(output_dir, exist_ok=True)
os.makedirs(os.path.dirname(log_file), exist_ok=True)
def write_log(msg):
ts = time.strftime('%Y-%m-%d %H:%M:%S')
line = f'[{ts}] {msg}'
print(line)
with open(log_file, 'a', encoding='utf-8') as f:
f.write(line + '\n')
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
page = context.new_page()
write_log('=== 开始忘记密码全流程测试 ===')
# 1. 登录页检查忘记密码入口
write_log('1. 打开登录页面')
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.screenshot(path=os.path.join(output_dir, '10_login_page_forgot_btn.png'))
forgot_btn = page.locator('button.forgot-password-btn, a:has-text("忘记密码")')
write_log(f' 忘记密码入口数量: {forgot_btn.count()}')
if forgot_btn.count() > 0:
forgot_btn.first.click()
page.wait_for_timeout(1500)
else:
page.goto('https://mail.solit.cn/#/forgot-password', wait_until='networkidle')
page.wait_for_timeout(1500)
write_log(f' 当前页面 URL: {page.url}')
page.screenshot(path=os.path.join(output_dir, '11_forgot_password_page.png'))
# 2. 检查页面关键元素与边界提示
boundary_alert = page.locator('.reset-boundary')
write_log(f' 超级管理员限制警示框存在: {boundary_alert.count() > 0}')
if boundary_alert.count() > 0:
write_log(f' 警示内容: {boundary_alert.inner_text().strip()}')
# 3. 检查切换验证方式
switch_btn = page.locator('.switch-button')
write_log(f' 切换方式按钮文本: {switch_btn.inner_text().strip()}')
switch_btn.click()
page.wait_for_timeout(500)
write_log(f' 切换后按钮文本: {switch_btn.inner_text().strip()}')
page.screenshot(path=os.path.join(output_dir, '12_switched_to_offline.png'))
# 4. 测试以超级管理员账号 xuans 进行离线恢复(验证安全拒绝边界)
write_log('2. 测试超级管理员账号 xuans 在 Web 端重置(必须被安全拒绝)')
user_input = page.locator('.recovery-form input').nth(0)
user_input.fill('xuans')
# 离线输入:密保问题、密保答案、恢复码、新密码、确认密码
q_input = page.locator('.recovery-form input').nth(1)
a_input = page.locator('.recovery-form input').nth(2)
code_input = page.locator('.recovery-form input').nth(3)
p1_input = page.locator('.recovery-form input').nth(4)
p2_input = page.locator('.recovery-form input').nth(5)
q_input.fill('幸运数字')
a_input.fill('8')
code_input.fill('REC-1234-5678')
p1_input.fill('NewPass1234!')
p2_input.fill('NewPass1234!')
submit_btn = page.locator('.recovery-footer button.recovery-action-btn')
submit_btn.click()
page.wait_for_timeout(2000)
page.screenshot(path=os.path.join(output_dir, '13_superadmin_denied.png'))
state_span = page.locator('.verification-state')
msg_box = page.locator('.el-message')
write_log(f' 状态文本: {state_span.inner_text() if state_span.count() > 0 else "无"}')
for i in range(msg_box.count()):
write_log(f' 提示消息: {msg_box.nth(i).inner_text().strip()}')
browser.close()

View File

@ -0,0 +1,35 @@
import urllib.request, ssl, json, http.cookiejar
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx), urllib.request.HTTPCookieProcessor(cj))
# 登录 xuans
login_payload = json.dumps({"account": "xuans", "password": "12345678"}).encode("utf-8")
req = urllib.request.Request("https://47.108.78.12/api/v1/login", data=login_payload, headers={
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn"
})
opener.open(req, timeout=5)
csrf_token = ""
for c in cj:
if c.name == "smail_csrf":
csrf_token = c.value
headers = {
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn",
"Referer": "https://mail.solit.cn/",
"X-CSRF-Token": csrf_token
}
# 调用管理接口为 testuser2 (id=2) 生成 recovery codes
gen_payload = json.dumps({"user_id": 2, "count": 5}).encode("utf-8")
req_gen = urllib.request.Request("https://47.108.78.12/api/v1/user/security/recovery-codes/generate", data=gen_payload, headers=headers)
resp_gen = opener.open(req_gen, timeout=5)
data = json.loads(resp_gen.read().decode("utf-8"))
print("Generate recovery codes response:", data)

View File

@ -0,0 +1,17 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
stdin, stdout, stderr = ssh.exec_command('python3 -c "import sqlite3; conn=sqlite3.connect(\x27/opt/smail/data/smail.db\x27); c=conn.cursor(); print(c.execute(\x27SELECT id, user_id, email_id, is_read, is_trash FROM user_email ORDER BY id DESC LIMIT 5\x27).fetchall())"')
print("User emails top 5:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('python3 -c "import sqlite3; conn=sqlite3.connect(\x27/opt/smail/data/smail.db\x27); c=conn.cursor(); print(c.execute(\x27SELECT id, subject, send_time FROM email ORDER BY id DESC LIMIT 5\x27).fetchall())"')
print("Emails top 5:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('python3 -c "import sqlite3; conn=sqlite3.connect(\x27/opt/smail/data/smail.db\x27); c=conn.cursor(); print(c.execute(\x27SELECT id, name, user_name FROM user\x27).fetchall())"')
print("Users:\n", stdout.read().decode())
ssh.close()

View File

@ -0,0 +1,9 @@
p = 'backend/controllers/security.go'
with open(p, 'rb') as f:
data = f.read()
idx = data.find(b'external-email/list')
if idx == -1:
idx = data.find(b'external-email')
print('idx:', idx)
print(data[idx-100:idx+400].decode('utf-8', errors='replace'))

View File

View File

View File

@ -0,0 +1,20 @@
import urllib.request, ssl, json, http.cookiejar
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx), urllib.request.HTTPCookieProcessor(cj))
for pwd in ["12345678", "123456", "testuser2", "Password123!", "Tester123456!"]:
login_payload = json.dumps({"account": "testuser2", "password": pwd}).encode("utf-8")
req = urllib.request.Request("https://47.108.78.12/api/v1/login", data=login_payload, headers={
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn"
})
try:
resp = opener.open(req, timeout=5)
res = json.loads(resp.read().decode())
print(f"Pwd {pwd} -> errorNo: {res.get('errorNo')}, errorMsg: {res.get('errorMsg')}")
except Exception as e:
print(f"Pwd {pwd} -> exception: {e}")

View File

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
import requests, json
import urllib3
urllib3.disable_warnings()
s = requests.Session()
# 47.108.78.12 80 端口
r_login = s.post('http://47.108.78.12:80/api/login', json={'username': 'xuans', 'password': '12345678'})
print('Login:', r_login.status_code, r_login.text)
r_get = s.get('http://47.108.78.12:80/api/v1/config/mail')
print('Get mail:', r_get.status_code, r_get.text)
# 模拟前端发送的 validate 请求
data = r_get.json().get('data', {})
generation = data.get('meta', {}).get('generation', 2)
# 用户在前端填入的配置
payload = json.loads(json.dumps(data))
# 注意前端 parsePayload 会对 payload 做一些处理:
# delete payload.relayPasswordStatus
# if (payload.relay && 'password' in payload.relay) delete payload.relay.password
if 'relayPasswordStatus' in payload:
del payload['relayPasswordStatus']
if 'relay' in payload and 'password' in payload['relay']:
del payload['relay']['password']
payload['relay']['relayHost'] = 'smtpm.aliyun.com'
payload['relay']['relayPort'] = 587
payload['relay']['username'] = 'xuans'
payload['relay']['from'] = 'xuans@solit.cn'
payload['relay']['fromDomain'] = 'solit.cn'
secretChanges = {
'relay.password': {'operation': 'replace', 'value': {'plaintext': 'Solit12ABcd'}}
}
req_body = {
'expectedGeneration': generation,
'payload': payload,
'secretChanges': secretChanges,
'confirmation': {'confirmed': True, 'method': 'recent_auth'}
}
print('Testing validate with smtpm.aliyun.com...')
r_val = s.post('http://47.108.78.12:80/api/v1/config/mail/validate', json=req_body)
print('Validate res:', r_val.status_code, r_val.text)
# 再测一次直接 PUT (UpdateConfig)
r_put = s.put('http://47.108.78.12:80/api/v1/config/mail', json=req_body)
print('PUT res:', r_put.status_code, r_put.text)

View File

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
import requests
import urllib3
urllib3.disable_warnings()
session = requests.Session()
login_res = session.post('https://mail.solit.cn/api/login', json={'username': 'xuans', 'password': '12345678'}, verify=False)
print('Login status:', login_res.status_code, login_res.text)
get_res = session.get('https://mail.solit.cn/api/v1/config/mail', verify=False)
print('Config mail get:', get_res.status_code, get_res.text)
status_res = session.get('https://mail.solit.cn/api/v1/config/status', verify=False)
print('Config status:', status_res.status_code, status_res.text)

View File

@ -0,0 +1,13 @@
{
"account": "testuser2",
"current_password": "UserPass88!",
"question": "毕业学校",
"answer": "清华大学",
"codes": [
"IGWRL-ZC7BC-L52E7IH7NQ",
"MFHHG-C5ERA-U2JP3OYSCQ",
"5WGFS-MANVR-S7YFSTRGCA",
"YFOJ3-HK237-Y4MUBIC5GA",
"4TUAM-6TEJN-PCEHCBISVA"
]
}

View File

@ -0,0 +1,60 @@
import urllib.request, ssl, json, http.cookiejar
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx), urllib.request.HTTPCookieProcessor(cj))
# 1. 登录 testuser2
login_payload = json.dumps({"account": "testuser2", "password": "Password123!"}).encode("utf-8")
req = urllib.request.Request("https://47.108.78.12/api/v1/login", data=login_payload, headers={
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn"
})
resp = opener.open(req, timeout=5)
data = json.loads(resp.read().decode())
print("Testuser2 login resp:", data)
csrf_token = ""
for c in cj:
if c.name == "smail_csrf":
csrf_token = c.value
headers = {
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn",
"Referer": "https://mail.solit.cn/",
"X-CSRF-Token": csrf_token
}
# 2. 如果需要强制修改密码,先修改密码
if data.get("data", {}).get("must_change_password"):
print("Testuser2 must change password, updating...")
pwd_payload = json.dumps({
"old_password": "Password123!",
"new_password": "UserPass1234!"
}).encode("utf-8")
req_chg = urllib.request.Request("https://47.108.78.12/api/v1/settings/modify_password", data=pwd_payload, headers=headers)
resp_chg = opener.open(req_chg, timeout=5)
print("Change password resp:", resp_chg.read().decode())
# 3. 设置密保问题
q_payload = json.dumps({
"question": "毕业学校",
"answer": "清华大学"
}).encode("utf-8")
req_q = urllib.request.Request("https://47.108.78.12/api/v1/security/credentials/question", data=q_payload, headers=headers)
resp_q = opener.open(req_q, timeout=5)
print("Set question resp:", resp_q.read().decode())
# 4. 生成一组离线恢复码
codes_payload = json.dumps({"count": 5}).encode("utf-8")
req_c = urllib.request.Request("https://47.108.78.12/api/v1/security/credentials/recovery-codes", data=codes_payload, headers=headers)
resp_c = opener.open(req_c, timeout=5)
c_data = json.loads(resp_c.read().decode())
print("Generate recovery codes resp:", c_data)
if c_data.get("data", {}).get("codes"):
with open("test/tools/testuser2_codes.json", "w", encoding="utf-8") as f:
json.dump(c_data["data"], f, ensure_ascii=False, indent=2)

View File

@ -0,0 +1,67 @@
import urllib.request, ssl, json, http.cookiejar
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx), urllib.request.HTTPCookieProcessor(cj))
# 1. 登录 testuser2
login_payload = json.dumps({"account": "testuser2", "password": "Password123!"}).encode("utf-8")
req = urllib.request.Request("https://47.108.78.12/api/v1/login", data=login_payload, headers={
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn"
})
resp = opener.open(req, timeout=5)
data = json.loads(resp.read().decode())
print("Testuser2 login resp:", data.get("errorNo"), data.get("errorMsg"))
csrf_token = ""
for c in cj:
if c.name == "smail_csrf":
csrf_token = c.value
headers = {
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn",
"Referer": "https://mail.solit.cn/",
"X-CSRF-Token": csrf_token
}
# 2. 如果需要强制修改密码,先修改密码
if data.get("data", {}).get("must_change_password"):
print("Testuser2 must change password, updating...")
pwd_payload = json.dumps({
"current_password": "Password123!",
"password": "UserPass88!"
}).encode("utf-8")
req_chg = urllib.request.Request("https://47.108.78.12/api/v1/settings/modify_password", data=pwd_payload, headers=headers)
resp_chg = opener.open(req_chg, timeout=5)
print("Change password resp:", resp_chg.read().decode())
# 3. 设置密保问题
q_payload = json.dumps({
"question": "毕业学校",
"answer": "清华大学"
}).encode("utf-8")
req_q = urllib.request.Request("https://47.108.78.12/api/v1/security/credentials/question", data=q_payload, headers=headers)
resp_q = opener.open(req_q, timeout=5)
print("Set question resp:", resp_q.read().decode())
# 4. 生成一组离线恢复码 (/api/v1/security/credentials/recovery-codes/generate)
codes_payload = json.dumps({"count": 5}).encode("utf-8")
req_c = urllib.request.Request("https://47.108.78.12/api/v1/security/credentials/recovery-codes/generate", data=codes_payload, headers=headers)
resp_c = opener.open(req_c, timeout=5)
c_data = json.loads(resp_c.read().decode())
print("Generate recovery codes resp errorNo:", c_data.get("errorNo"))
print("Codes generated:", c_data.get("data", {}).get("codes"))
if c_data.get("data", {}).get("codes"):
with open("test/tools/testuser2_codes.json", "w", encoding="utf-8") as f:
json.dump({
"account": "testuser2",
"password": "UserPass88!",
"question": "毕业学校",
"answer": "清华大学",
"codes": c_data["data"]["codes"]
}, f, ensure_ascii=False, indent=2)

View File

@ -0,0 +1,23 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect("47.108.78.12", port=22, username="root", password="10102442Lb!", timeout=10)
cmd = """python3 -c '
import json
with open("/opt/smail/config/email.json", "r") as f:
d = json.load(f)
d["policy"]["externalRecoveryEnabled"] = True
with open("/opt/smail/config/email.json", "w") as f:
json.dump(d, f, indent=2)
'
"""
stdin, stdout, stderr = ssh.exec_command(cmd)
print("Stderr:", stderr.read().decode())
stdin, stdout, stderr = ssh.exec_command("cat /opt/smail/config/email.json")
print("email.json:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command("killall -9 smail")
print("Killall:", stdout.read().decode())
ssh.close()

2
test/tools/writer.py Normal file
View File

@ -0,0 +1,2 @@
import sys, binascii
open(sys.argv[1], 'wb').write(binascii.unhexlify(sys.argv[2]))

2
test/write_helper.py Normal file
View File

@ -0,0 +1,2 @@
import sys, binascii
open(sys.argv[1], 'wb').write(binascii.unhexlify(sys.argv[2]))