feat: replace master password auth with time-limited authorization codes
Auth codes use format BW-YYYYMMDD-TOKEN (24-hex, 96-bit entropy). Token doubles as the AES-256-CBC decryption key for secrets.enc. Expiry is enforced client-side; format/expiry errors don't consume the 3 valid-attempt quota. - gen-authcode.js: new admin tool — generates BW auth code + re-encrypts secrets.enc - install.ps1: Parse-AuthCode validates format/expiry, Decrypt-Secrets uses token as key - auto-setup.ps1: Show-AuthCodeDialog WinForms input + Parse-AuthCode-GUI loop - Bookworm-Setup.sh: parse_authcode() bash function + while-loop with format/expiry handling Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
197396c5fe
commit
b83c508c22
@ -274,17 +274,48 @@ _decrypt_secrets() {
|
||||
fi
|
||||
}
|
||||
|
||||
# 解析授权码: BW-YYYYMMDD-TOKEN (24位Hex)
|
||||
# 返回: 小写 token (成功) | "EXPIRED" (已过期) | "" (格式错误)
|
||||
parse_authcode() {
|
||||
local code
|
||||
code=$(echo "$1" | tr '[:lower:]' '[:upper:]' | tr -d ' ')
|
||||
# 格式校验: BW-8位数字-24位Hex
|
||||
if [[ ! "$code" =~ ^BW-([0-9]{8})-([A-F0-9]{24})$ ]]; then
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
local expiry_str="${BASH_REMATCH[1]}"
|
||||
local token_upper="${BASH_REMATCH[2]}"
|
||||
local today
|
||||
today=$(date +%Y%m%d)
|
||||
if [ "$expiry_str" -lt "$today" ]; then
|
||||
echo "EXPIRED"
|
||||
return
|
||||
fi
|
||||
echo "${token_upper,,}" # bash4+ 转小写
|
||||
}
|
||||
|
||||
# 先尝试缓存
|
||||
if load_cached_secrets 2>/dev/null; then
|
||||
: # 缓存加载成功
|
||||
elif [ -f "$SECRETS_ENC" ]; then
|
||||
DECRYPTED=""
|
||||
for attempt in 1 2 3; do
|
||||
valid_attempts=0
|
||||
while [ $valid_attempts -lt 3 ]; do
|
||||
echo ""
|
||||
read -rs -p " 输入主密码解密凭证 (第 $attempt/3 次): " PASSWORD
|
||||
echo ""
|
||||
DECRYPTED=$(_decrypt_secrets "$PASSWORD" "$SECRETS_ENC") || true
|
||||
PASSWORD=""
|
||||
read -p " 输入授权码 (BW-YYYYMMDD-XXXXXX, 第 $((valid_attempts+1))/3 次): " AUTH_CODE
|
||||
TOKEN=$(parse_authcode "$AUTH_CODE")
|
||||
AUTH_CODE=""
|
||||
if [ "$TOKEN" = "EXPIRED" ]; then
|
||||
warn "授权码已过期, 请联系管理员获取新授权码"
|
||||
continue # 不消耗尝试次数
|
||||
elif [ -z "$TOKEN" ]; then
|
||||
warn "授权码格式错误 (格式: BW-YYYYMMDD-24位字母数字)"
|
||||
continue # 不消耗尝试次数
|
||||
fi
|
||||
valid_attempts=$((valid_attempts + 1))
|
||||
DECRYPTED=$(_decrypt_secrets "$TOKEN" "$SECRETS_ENC") || true
|
||||
TOKEN=""
|
||||
if [ -n "$DECRYPTED" ]; then
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
@ -306,10 +337,10 @@ elif [ -f "$SECRETS_ENC" ]; then
|
||||
fi
|
||||
break
|
||||
else
|
||||
if [ $attempt -lt 3 ]; then
|
||||
warn "密码错误, 剩余重试: $((3 - attempt)) 次"
|
||||
if [ $valid_attempts -lt 3 ]; then
|
||||
warn "授权码无效 (解密失败), 剩余重试: $((3 - valid_attempts)) 次"
|
||||
else
|
||||
fail "3 次密码均错误, 凭证未解密"
|
||||
fail "3 次授权码均无效, 凭证未解密"
|
||||
warn "可稍后手动配置 API Key"
|
||||
fi
|
||||
fi
|
||||
@ -317,7 +348,7 @@ elif [ -f "$SECRETS_ENC" ]; then
|
||||
else
|
||||
if [ ! -f "$SECRETS_ENC" ]; then
|
||||
warn "secrets.enc 不存在, 跳过凭证解密"
|
||||
info "请联系管理员获取加密凭证文件"
|
||||
info "请联系管理员获取授权码"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
102
auto-setup.ps1
102
auto-setup.ps1
@ -48,10 +48,21 @@ function Show-MsgBox($text, $title = "Bookworm 安装", $buttons = "OK", $icon =
|
||||
[System.Windows.Forms.MessageBox]::Show($text, $title, $buttons, $icon)
|
||||
}
|
||||
|
||||
function Show-PasswordDialog($prompt = "输入主密码解密凭证", $attempt = 1, $maxAttempts = 3) {
|
||||
function Parse-AuthCode-GUI {
|
||||
param([string]$code)
|
||||
$code = $code.Trim()
|
||||
if ($code -notmatch '^BW-(\d{8})-([A-Fa-f0-9]{24})$') { return $null }
|
||||
$expiryStr = $Matches[1]
|
||||
$token = $Matches[2].ToLower()
|
||||
$today = (Get-Date).ToString("yyyyMMdd")
|
||||
if ([int]$expiryStr -lt [int]$today) { return 'EXPIRED' }
|
||||
return $token
|
||||
}
|
||||
|
||||
function Show-AuthCodeDialog($attempt = 1, $maxAttempts = 3) {
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.Text = "Bookworm - 凭证解密 ($attempt/$maxAttempts)"
|
||||
$form.Size = New-Object System.Drawing.Size(420, 220)
|
||||
$form.Text = "Bookworm - 授权码验证 ($attempt/$maxAttempts)"
|
||||
$form.Size = New-Object System.Drawing.Size(480, 240)
|
||||
$form.StartPosition = "CenterScreen"
|
||||
$form.FormBorderStyle = "FixedDialog"
|
||||
$form.MaximizeBox = $false
|
||||
@ -59,40 +70,49 @@ function Show-PasswordDialog($prompt = "输入主密码解密凭证", $attempt =
|
||||
$form.TopMost = $true
|
||||
|
||||
$label = New-Object System.Windows.Forms.Label
|
||||
$label.Location = New-Object System.Drawing.Point(20, 20)
|
||||
$label.Size = New-Object System.Drawing.Size(360, 40)
|
||||
$label.Text = $prompt
|
||||
$label.Font = New-Object System.Drawing.Font("Segoe UI", 10)
|
||||
$label.Location = New-Object System.Drawing.Point(20, 18)
|
||||
$label.Size = New-Object System.Drawing.Size(440, 36)
|
||||
$label.Text = "请输入管理员提供的授权码:`n格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXX XXXX"
|
||||
$label.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
||||
$form.Controls.Add($label)
|
||||
|
||||
$passBox = New-Object System.Windows.Forms.TextBox
|
||||
$passBox.Location = New-Object System.Drawing.Point(20, 70)
|
||||
$passBox.Size = New-Object System.Drawing.Size(360, 30)
|
||||
$passBox.PasswordChar = '*'
|
||||
$passBox.Font = New-Object System.Drawing.Font("Consolas", 12)
|
||||
$form.Controls.Add($passBox)
|
||||
# 授权码可见 (用于粘贴验证), 不用 PasswordChar
|
||||
$codeBox = New-Object System.Windows.Forms.TextBox
|
||||
$codeBox.Location = New-Object System.Drawing.Point(20, 65)
|
||||
$codeBox.Size = New-Object System.Drawing.Size(430, 30)
|
||||
$codeBox.Font = New-Object System.Drawing.Font("Consolas", 11)
|
||||
$codeBox.CharacterCasing = "Upper" # 自动转大写
|
||||
$form.Controls.Add($codeBox)
|
||||
|
||||
$hint = New-Object System.Windows.Forms.Label
|
||||
$hint.Location = New-Object System.Drawing.Point(20, 100)
|
||||
$hint.Size = New-Object System.Drawing.Size(440, 20)
|
||||
$hint.Text = "提示: 直接粘贴管理员发送的授权码即可 (Ctrl+V)"
|
||||
$hint.Font = New-Object System.Drawing.Font("Segoe UI", 8)
|
||||
$hint.ForeColor = [System.Drawing.Color]::Gray
|
||||
$form.Controls.Add($hint)
|
||||
|
||||
$btnOK = New-Object System.Windows.Forms.Button
|
||||
$btnOK.Location = New-Object System.Drawing.Point(200, 120)
|
||||
$btnOK.Location = New-Object System.Drawing.Point(250, 145)
|
||||
$btnOK.Size = New-Object System.Drawing.Size(90, 35)
|
||||
$btnOK.Text = "确定"
|
||||
$btnOK.Text = "验证"
|
||||
$btnOK.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
||||
$form.AcceptButton = $btnOK
|
||||
$form.Controls.Add($btnOK)
|
||||
|
||||
$btnCancel = New-Object System.Windows.Forms.Button
|
||||
$btnCancel.Location = New-Object System.Drawing.Point(300, 120)
|
||||
$btnCancel.Location = New-Object System.Drawing.Point(350, 145)
|
||||
$btnCancel.Size = New-Object System.Drawing.Size(80, 35)
|
||||
$btnCancel.Text = "取消"
|
||||
$btnCancel.Text = "跳过"
|
||||
$btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
||||
$form.CancelButton = $btnCancel
|
||||
$form.Controls.Add($btnCancel)
|
||||
|
||||
$form.Add_Shown({ $passBox.Focus() })
|
||||
$form.Add_Shown({ $codeBox.Focus() })
|
||||
$result = $form.ShowDialog()
|
||||
|
||||
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
return $passBox.Text
|
||||
return $codeBox.Text.Trim()
|
||||
}
|
||||
return $null
|
||||
}
|
||||
@ -592,24 +612,34 @@ if (-not $useNode -and -not $opensslCmd) {
|
||||
Log-Fail "无解密工具 (需要 Node.js 或 OpenSSL)"
|
||||
}
|
||||
elseif (Test-Path $SecretsEnc) {
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
$password = Show-PasswordDialog "输入主密码解密凭证`n(非 Gitea 密码, 区分大小写)" $attempt 3
|
||||
if (-not $password) {
|
||||
Log-Warn "用户取消密码输入"
|
||||
$validAttempts = 0
|
||||
while ($validAttempts -lt 3) {
|
||||
$rawCode = Show-AuthCodeDialog ($validAttempts + 1) 3
|
||||
if (-not $rawCode) {
|
||||
Log-Warn "用户跳过授权码输入"
|
||||
break
|
||||
}
|
||||
|
||||
$token = Parse-AuthCode-GUI $rawCode
|
||||
if ($token -eq 'EXPIRED') {
|
||||
Show-MsgBox "授权码已过期。`n请联系管理员获取新授权码。" "授权码过期" "OK" "Warning"
|
||||
continue
|
||||
}
|
||||
if (-not $token) {
|
||||
Show-MsgBox "格式错误。`n正确格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXX XXXX`n`n请检查后重新粘贴。" "格式错误" "OK" "Warning"
|
||||
continue
|
||||
}
|
||||
$validAttempts++
|
||||
|
||||
try {
|
||||
if ($useNode) {
|
||||
# Node.js crypto-helper (BWENC1 格式, 跨平台一致)
|
||||
$decrypted = & node $cryptoHelper decrypt $password $SecretsEnc 2>&1
|
||||
$decrypted = & node $cryptoHelper decrypt $token $SecretsEnc 2>&1
|
||||
$decExit = $LASTEXITCODE
|
||||
} else {
|
||||
# OpenSSL 回退 (仅支持 Salted__ 格式)
|
||||
$decrypted = & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in $SecretsEnc -pass "pass:$password" 2>$null
|
||||
$decrypted = & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in $SecretsEnc -pass "pass:$token" 2>$null
|
||||
$decExit = $LASTEXITCODE
|
||||
}
|
||||
$password = $null # 立即清零
|
||||
$token = $null # 立即清零
|
||||
|
||||
if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'WRONG_PASSWORD|WRONG_FORMAT|bad decrypt|bad magic') {
|
||||
$count = 0
|
||||
@ -624,26 +654,26 @@ elseif (Test-Path $SecretsEnc) {
|
||||
$count++
|
||||
}
|
||||
}
|
||||
$decrypted = $null # 清零
|
||||
$decrypted = $null
|
||||
$secretsDecrypted = $true
|
||||
|
||||
# 询问缓存
|
||||
$cacheResult = Show-MsgBox "凭证解密成功 ($count 个变量)。`n`n是否缓存至今日 23:59?`n(下次启动免输密码)" "本日免密" "YesNo" "Question"
|
||||
$cacheResult = Show-MsgBox "授权码验证成功 ($count 个凭证)。`n`n是否缓存至今日 23:59?`n(下次启动免输授权码)" "本日免密" "YesNo" "Question"
|
||||
if ($cacheResult -eq "Yes") {
|
||||
Save-SecretsToCache
|
||||
Log-OK "凭证已缓存至今日 23:59"
|
||||
}
|
||||
break
|
||||
} else {
|
||||
$password = $null
|
||||
if ($attempt -lt 3) {
|
||||
Show-MsgBox "密码错误, 剩余重试: $(3 - $attempt) 次" "密码错误" "OK" "Warning"
|
||||
$token = $null
|
||||
$remaining = 3 - $validAttempts
|
||||
if ($remaining -gt 0) {
|
||||
Show-MsgBox "授权码无效(解密失败),剩余重试: $remaining 次`n`n请确认管理员已更新 secrets.enc。" "验证失败" "OK" "Warning"
|
||||
} else {
|
||||
Show-MsgBox "3 次密码均错误。`n凭证未解密, Claude Code 可能无法启动。`n请联系管理员确认密码。" "解密失败" "OK" "Error"
|
||||
Show-MsgBox "3 次验证均失败。`n请联系管理员重新获取授权码。" "解密失败" "OK" "Error"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$password = $null
|
||||
$token = $null
|
||||
Log-Warn "解密异常: $_"
|
||||
}
|
||||
}
|
||||
|
||||
96
gen-authcode.js
Normal file
96
gen-authcode.js
Normal file
@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
/**
|
||||
* Bookworm 授权码生成工具 (管理员使用)
|
||||
*
|
||||
* 用法:
|
||||
* node gen-authcode.js <days> [secrets.txt路径]
|
||||
* node gen-authcode.js 30
|
||||
* node gen-authcode.js 90 /path/to/secrets.txt
|
||||
*
|
||||
* 原理:
|
||||
* 1. 生成随机 24位Hex Token (96bit 熵)
|
||||
* 2. 将 Token 作为密码重新加密 secrets.enc (BWENC1 格式)
|
||||
* 3. 输出授权码 BW-YYYYMMDD-TOKEN (发给用户)
|
||||
* 4. 安全说明: Token = 解密密钥, YYYYMMDD = 客户端到期校验
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ALGO = 'aes-256-cbc';
|
||||
const ITERATIONS = 600000;
|
||||
const DIGEST = 'sha256';
|
||||
const SALT_LEN = 16;
|
||||
const KEY_LEN = 32;
|
||||
const IV_LEN = 16;
|
||||
|
||||
function deriveKey(password, salt) {
|
||||
return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LEN + IV_LEN, DIGEST);
|
||||
}
|
||||
|
||||
function encrypt(plaintext, password) {
|
||||
const salt = crypto.randomBytes(SALT_LEN);
|
||||
const derived = deriveKey(password, salt);
|
||||
const key = derived.slice(0, KEY_LEN);
|
||||
const iv = derived.slice(KEY_LEN, KEY_LEN + IV_LEN);
|
||||
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
// 与 crypto-helper.js 保持 BWENC1 格式兼容
|
||||
return Buffer.concat([Buffer.from('BWENC1'), salt, encrypted]);
|
||||
}
|
||||
|
||||
// ─── CLI ────────────────────────────────────────────────────────
|
||||
const DAYS = parseInt(process.argv[2]);
|
||||
if (!DAYS || DAYS < 1 || DAYS > 3650) {
|
||||
console.error('用法: node gen-authcode.js <有效天数> [secrets.txt路径]');
|
||||
console.error('示例: node gen-authcode.js 30');
|
||||
console.error('示例: node gen-authcode.js 90 /path/to/secrets.txt');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SCRIPT_DIR = path.dirname(path.resolve(__filename));
|
||||
const SECRETS_TXT = process.argv[3] || path.join(SCRIPT_DIR, 'secrets.txt');
|
||||
const SECRETS_ENC = path.join(SCRIPT_DIR, 'secrets.enc');
|
||||
|
||||
if (!fs.existsSync(SECRETS_TXT)) {
|
||||
console.error(`[错误] 找不到 secrets.txt: ${SECRETS_TXT}`);
|
||||
console.error('请先创建 secrets.txt, 每行格式: KEY=VALUE');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 生成到期日 (YYYYMMDD)
|
||||
const expiry = new Date();
|
||||
expiry.setDate(expiry.getDate() + DAYS);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const expiryStr = `${expiry.getFullYear()}${pad(expiry.getMonth()+1)}${pad(expiry.getDate())}`;
|
||||
|
||||
// 生成随机 Token (12 bytes = 24 hex, 96bit 熵)
|
||||
const token = crypto.randomBytes(12).toString('hex'); // 小写
|
||||
|
||||
// 构造授权码 (Token 展示为大写, 解密时转小写)
|
||||
const authCode = `BW-${expiryStr}-${token.toUpperCase()}`;
|
||||
|
||||
// 读取并加密 secrets.txt
|
||||
const secretsPlain = fs.readFileSync(SECRETS_TXT, 'utf8').trim();
|
||||
const encBuffer = encrypt(secretsPlain, token); // 用小写 token 加密
|
||||
fs.writeFileSync(SECRETS_ENC, encBuffer);
|
||||
|
||||
const expiryDisplay = `${expiryStr.slice(0,4)}-${expiryStr.slice(4,6)}-${expiryStr.slice(6,8)}`;
|
||||
|
||||
console.log('\n═══════════════════════════════════════════════════');
|
||||
console.log(' Bookworm 授权码生成完毕');
|
||||
console.log('═══════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
console.log(` 授权码: ${authCode}`);
|
||||
console.log(` 有效期: ${DAYS} 天 (至 ${expiryDisplay})`);
|
||||
console.log('');
|
||||
console.log(' ▶ 操作步骤:');
|
||||
console.log(' 1. 将授权码通过微信/邮件发给用户');
|
||||
console.log(' 2. 推送新 secrets.enc 到 Gitea:');
|
||||
console.log(' git add secrets.enc && git commit -m "update secrets" && git push');
|
||||
console.log('');
|
||||
console.log(' ⚠ 安全提醒:');
|
||||
console.log(' - 授权码即解密密钥, 请勿通过不安全渠道明文发送');
|
||||
console.log(` - 到期日 (${expiryDisplay}) 后自动失效, 无需撤销操作`);
|
||||
console.log('═══════════════════════════════════════════════════\n');
|
||||
37
install.ps1
37
install.ps1
@ -197,6 +197,26 @@ function New-DesktopShortcuts {
|
||||
}
|
||||
}
|
||||
|
||||
function Parse-AuthCode {
|
||||
param([string]$code)
|
||||
$code = $code.Trim()
|
||||
# 格式: BW-YYYYMMDD-24位HexToken
|
||||
if ($code -notmatch '^BW-(\d{8})-([A-Fa-f0-9]{24})$') {
|
||||
Write-Host " [!!] 格式错误,应为 BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXX XXXX" -ForegroundColor Red
|
||||
return $null
|
||||
}
|
||||
$expiryStr = $Matches[1]
|
||||
$token = $Matches[2].ToLower() # 解密用小写
|
||||
$today = (Get-Date).ToString("yyyyMMdd")
|
||||
if ([int]$expiryStr -lt [int]$today) {
|
||||
$d = "$($expiryStr.Substring(0,4))-$($expiryStr.Substring(4,2))-$($expiryStr.Substring(6,2))"
|
||||
Write-Host " [!!] 授权码已过期 (有效期至 $d)" -ForegroundColor Red
|
||||
Write-Host " 请联系管理员获取新授权码" -ForegroundColor Yellow
|
||||
return $null
|
||||
}
|
||||
return $token
|
||||
}
|
||||
|
||||
function Decrypt-Secrets {
|
||||
if ($SkipSecrets -or -not (Test-Path $SecretsEnc)) {
|
||||
Write-Host " [!] 跳过凭证解密 (无 secrets.enc)" -ForegroundColor Yellow
|
||||
@ -213,10 +233,16 @@ function Decrypt-Secrets {
|
||||
|
||||
$maxRetries = 3
|
||||
for ($attempt = 1; $attempt -le $maxRetries; $attempt++) {
|
||||
$label = if ($attempt -gt 1) { " 重新输入主密码 (第 $attempt/$maxRetries 次)" } else { " 输入主密码解密凭证" }
|
||||
$password = Read-Host $label -AsSecureString
|
||||
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($password)
|
||||
$plainPwd = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
|
||||
$label = if ($attempt -gt 1) { " 重新输入授权码 (第 $attempt/$maxRetries 次)" } else { " 输入授权码 (格式: BW-YYYYMMDD-...)" }
|
||||
$authCodeRaw = Read-Host $label
|
||||
$plainPwd = Parse-AuthCode $authCodeRaw
|
||||
if (-not $plainPwd) {
|
||||
# 格式错误或已过期: 不计入密码重试, 直接继续
|
||||
$attempt--
|
||||
$maxRetries-- # 最多给 3 次有效尝试
|
||||
if ($maxRetries -lt 1) { break }
|
||||
continue
|
||||
}
|
||||
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
@ -232,9 +258,8 @@ function Decrypt-Secrets {
|
||||
}
|
||||
$ErrorActionPreference = $prevEAP
|
||||
|
||||
# 清除内存中的密码
|
||||
# 清除内存中的 token
|
||||
$plainPwd = $null
|
||||
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||
|
||||
if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'PASSWORD_ERROR|FORMAT_ERROR|bad decrypt') {
|
||||
# 解密成功,注入环境变量
|
||||
|
||||
Loading…
Reference in New Issue
Block a user