Add admin reset-password command
Features: - Add admin reset-password command to reset user passwords - Support SQLite, PostgreSQL, and MySQL databases - Use scrypt hashing compatible with CodiMD - Update README with reset-password usage examples
This commit is contained in:
21
README.md
21
README.md
@@ -5,13 +5,13 @@ CodiMD 的命令列工具 - 讓你可以透過終端機管理 CodiMD 協作筆
|
|||||||
## 安裝
|
## 安裝
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 從 Git 仓库安裝
|
# 從 Git 仓库安裝(含管理員功能)
|
||||||
pip install git+http://192.168.42.124:31337/timmy/cli-anything-codimd.git
|
pip install -e ".[admin]" git+http://192.168.42.124:31337/timmy/cli-anything-codimd.git
|
||||||
|
|
||||||
# 或是複製後手動安裝
|
# 或是複製後手動安裝
|
||||||
git clone http://192.168.42.124:31337/timmy/cli-anything-codimd.git
|
git clone http://192.168.42.124:31337/timmy/cli-anything-codimd.git
|
||||||
cd cli-anything-codimd/agent-harness
|
cd cli-anything-codimd/agent-harness
|
||||||
pip install -e .
|
pip install -e ".[admin]"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 使用方法
|
## 使用方法
|
||||||
@@ -75,12 +75,13 @@ cli-anything-codimd repl
|
|||||||
| `revision get <id> <time>` | 取得特定時間的版本 |
|
| `revision get <id> <time>` | 取得特定時間的版本 |
|
||||||
|
|
||||||
### 管理員指令
|
### 管理員指令
|
||||||
需要直接存取 CodiMD 資料庫。
|
需要直接存取 CodiMD 資料庫,需要安裝額外依賴:`pip install -e ".[admin]"`
|
||||||
|
|
||||||
| 指令 | 說明 |
|
| 指令 | 說明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `admin list-users` | 列出所有使用者 |
|
| `admin list-users` | 列出所有使用者 |
|
||||||
| `admin user-notes <id>` | 列出使用者的筆記 |
|
| `admin user-notes <id>` | 列出使用者的筆記 |
|
||||||
|
| `admin reset-password` | 重設使用者密碼 |
|
||||||
|
|
||||||
#### 資料庫使用範例
|
#### 資料庫使用範例
|
||||||
|
|
||||||
@@ -98,6 +99,18 @@ cli-anything-codimd admin list-users --db-type mysql \
|
|||||||
|
|
||||||
# 查看特定使用者的筆記
|
# 查看特定使用者的筆記
|
||||||
cli-anything-codimd admin user-notes <user-id> --db-path ./db.sqlite
|
cli-anything-codimd admin user-notes <user-id> --db-path ./db.sqlite
|
||||||
|
|
||||||
|
# 重設使用者密碼(透過 Email)
|
||||||
|
cli-anything-codimd admin reset-password --email user@example.com \
|
||||||
|
--db-path ./db.sqlite
|
||||||
|
|
||||||
|
# 重設使用者密碼(透過 User ID)
|
||||||
|
cli-anything-codimd admin reset-password --user-id <user-id> \
|
||||||
|
--db-path ./db.sqlite
|
||||||
|
|
||||||
|
# 重設密碼(PostgreSQL)
|
||||||
|
cli-anything-codimd admin reset-password --email user@example.com \
|
||||||
|
--db-type postgresql --db-host localhost --db-name codimd --db-user postgres
|
||||||
```
|
```
|
||||||
|
|
||||||
### 設定指令
|
### 設定指令
|
||||||
|
|||||||
@@ -708,6 +708,76 @@ def admin_user_notes(user_id, db_path):
|
|||||||
click.echo(f" {note_id} | {title[:50]} | [{permission}]")
|
click.echo(f" {note_id} | {title[:50]} | [{permission}]")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.command("reset-password")
|
||||||
|
@click.option("--db-type", type=click.Choice(["sqlite", "postgresql", "mysql"]), default="sqlite", help="Database type")
|
||||||
|
@click.option("--db-path", type=Path, help="SQLite database path")
|
||||||
|
@click.option("--db-host", help="Database host (for PostgreSQL/MySQL)")
|
||||||
|
@click.option("--db-name", help="Database name (for PostgreSQL/MySQL)")
|
||||||
|
@click.option("--db-user", help="Database user (for PostgreSQL/MySQL)")
|
||||||
|
@click.option("--db-password", help="Database password (for PostgreSQL/MySQL)")
|
||||||
|
@click.option("--db-port", type=int, default=5432, help="Database port")
|
||||||
|
@click.option("--email", help="User email to reset password for")
|
||||||
|
@click.option("--user-id", help="User ID to reset password for")
|
||||||
|
@click.option("--password", help="New password (will prompt if not provided)")
|
||||||
|
@click.confirmation_option(prompt="Are you sure you want to reset this user's password?")
|
||||||
|
@handle_error
|
||||||
|
def admin_reset_password(db_type, db_path, db_host, db_name, db_user, db_password, db_port, email, user_id, password):
|
||||||
|
"""Reset a user's password."""
|
||||||
|
from cli_anything.codimd.core import CodiMDDatabase
|
||||||
|
|
||||||
|
if not email and not user_id:
|
||||||
|
raise ValueError("Either --email or --user-id must be specified")
|
||||||
|
|
||||||
|
if not password:
|
||||||
|
password = click.prompt("New password", hide_input=True, confirmation_prompt=True)
|
||||||
|
|
||||||
|
if db_type == "sqlite":
|
||||||
|
if not db_path:
|
||||||
|
# Try default paths
|
||||||
|
default_paths = [
|
||||||
|
"./codimd/db.sqlite",
|
||||||
|
"./db.sqlite",
|
||||||
|
"/var/lib/codimd/db.sqlite"
|
||||||
|
]
|
||||||
|
for path in default_paths:
|
||||||
|
if Path(path).exists():
|
||||||
|
db_path = path
|
||||||
|
break
|
||||||
|
if not db_path:
|
||||||
|
raise ValueError("SQLite database path not found. Use --db-path")
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
CodiMDDatabase.reset_password_sqlite_by_id(str(db_path), user_id, password)
|
||||||
|
click.echo(f"Password reset for user ID: {user_id}")
|
||||||
|
else:
|
||||||
|
CodiMDDatabase.reset_password_sqlite(str(db_path), email, password)
|
||||||
|
click.echo(f"Password reset for: {email}")
|
||||||
|
|
||||||
|
elif db_type == "postgresql":
|
||||||
|
if not all([db_host, db_name, db_user]):
|
||||||
|
raise ValueError("--db-host, --db-name, and --db-user are required for PostgreSQL")
|
||||||
|
if not db_password:
|
||||||
|
db_password = click.prompt("Database password", hide_input=True)
|
||||||
|
|
||||||
|
CodiMDDatabase.reset_password_postgresql(
|
||||||
|
db_host, db_name, db_user, db_password, db_port, email, password
|
||||||
|
)
|
||||||
|
click.echo(f"Password reset for: {email}")
|
||||||
|
|
||||||
|
elif db_type == "mysql":
|
||||||
|
if not all([db_host, db_name, db_user]):
|
||||||
|
raise ValueError("--db-host, --db-name, and --db-user are required for MySQL")
|
||||||
|
if not db_password:
|
||||||
|
db_password = click.prompt("Database password", hide_input=True)
|
||||||
|
|
||||||
|
CodiMDDatabase.reset_password_mysql(
|
||||||
|
db_host, db_name, db_user, db_password, db_port, email, password
|
||||||
|
)
|
||||||
|
click.echo(f"Password reset for: {email}")
|
||||||
|
|
||||||
|
click.echo("✓ Password has been updated. The user can now log in with the new password.")
|
||||||
|
|
||||||
|
|
||||||
# REPL mode
|
# REPL mode
|
||||||
@cli.command("repl")
|
@cli.command("repl")
|
||||||
@handle_error
|
@handle_error
|
||||||
|
|||||||
@@ -9,6 +9,45 @@ from typing import List, Dict, Any, Optional
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordHasher:
|
||||||
|
"""Password hashing compatible with CodiMD (scrypt-kdf)."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash password using scrypt (compatible with CodiMD).
|
||||||
|
|
||||||
|
CodiMD uses scrypt-kdf with default parameters (N=16384, r=8, p=1).
|
||||||
|
Returns hex-encoded hash that CodiMD can verify.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import hashlib
|
||||||
|
import scrypt
|
||||||
|
import os
|
||||||
|
# Generate random salt (16 bytes)
|
||||||
|
salt = os.urandom(16)
|
||||||
|
# Scrypt parameters matching CodiMD defaults
|
||||||
|
N = 16384
|
||||||
|
r = 8
|
||||||
|
p = 1
|
||||||
|
# Derive key (64 bytes output)
|
||||||
|
derived_key = scrypt.hash(
|
||||||
|
password.encode(),
|
||||||
|
salt,
|
||||||
|
N,
|
||||||
|
r,
|
||||||
|
p,
|
||||||
|
64 # output length in bytes
|
||||||
|
)
|
||||||
|
# Return hex-encoded salt + hash (CodiMD format)
|
||||||
|
return salt.hex() + derived_key.hex()
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"scrypt required for password operations. "
|
||||||
|
"Install with: pip install scrypt\n"
|
||||||
|
"Or install with: pip install cli-anything-codimd[admin]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CodiMDDatabase:
|
class CodiMDDatabase:
|
||||||
"""Direct database access for CodiMD."""
|
"""Direct database access for CodiMD."""
|
||||||
|
|
||||||
@@ -148,3 +187,147 @@ class CodiMDDatabase:
|
|||||||
notes = [dict(row) for row in cursor.fetchall()]
|
notes = [dict(row) for row in cursor.fetchall()]
|
||||||
conn.close()
|
conn.close()
|
||||||
return notes
|
return notes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reset_password_sqlite(db_path: str, email: str, new_password: str) -> bool:
|
||||||
|
"""Reset user password in SQLite database."""
|
||||||
|
try:
|
||||||
|
import sqlite3
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("sqlite3 required for SQLite access")
|
||||||
|
|
||||||
|
# Hash the new password
|
||||||
|
hashed_password = PasswordHasher.hash_password(new_password)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if user exists by email
|
||||||
|
cursor.execute("SELECT id, email FROM Users WHERE email = ?", (email,))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
conn.close()
|
||||||
|
raise ValueError(f"User not found: {email}")
|
||||||
|
|
||||||
|
user_id, user_email = user
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
cursor.execute(
|
||||||
|
'UPDATE Users SET password = ? WHERE email = ?',
|
||||||
|
(hashed_password, email)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reset_password_sqlite_by_id(db_path: str, user_id: str, new_password: str) -> bool:
|
||||||
|
"""Reset user password in SQLite database by user ID."""
|
||||||
|
try:
|
||||||
|
import sqlite3
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("sqlite3 required for SQLite access")
|
||||||
|
|
||||||
|
# Hash the new password
|
||||||
|
hashed_password = PasswordHasher.hash_password(new_password)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if user exists
|
||||||
|
cursor.execute("SELECT id, email FROM Users WHERE id = ?", (user_id,))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
conn.close()
|
||||||
|
raise ValueError(f"User not found: {user_id}")
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
cursor.execute(
|
||||||
|
'UPDATE Users SET password = ? WHERE id = ?',
|
||||||
|
(hashed_password, user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reset_password_postgresql(host: str, database: str, user: str, password: str,
|
||||||
|
port: int, target_email: str, new_password: str) -> bool:
|
||||||
|
"""Reset user password in PostgreSQL database."""
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("psycopg2 required for PostgreSQL access")
|
||||||
|
|
||||||
|
# Hash the new password
|
||||||
|
hashed_password = PasswordHasher.hash_password(new_password)
|
||||||
|
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=host,
|
||||||
|
database=database,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
port=port
|
||||||
|
)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if user exists
|
||||||
|
cursor.execute('SELECT id, email FROM "Users" WHERE email = %s', (target_email,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
raise ValueError(f"User not found: {target_email}")
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
cursor.execute(
|
||||||
|
'UPDATE "Users" SET password = %s WHERE email = %s',
|
||||||
|
(hashed_password, target_email)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reset_password_mysql(host: str, database: str, user: str, password: str,
|
||||||
|
port: int, target_email: str, new_password: str) -> bool:
|
||||||
|
"""Reset user password in MySQL database."""
|
||||||
|
try:
|
||||||
|
import mysql.connector
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("mysql-connector-python required for MySQL access")
|
||||||
|
|
||||||
|
# Hash the new password
|
||||||
|
hashed_password = PasswordHasher.hash_password(new_password)
|
||||||
|
|
||||||
|
conn = mysql.connector.connect(
|
||||||
|
host=host,
|
||||||
|
database=database,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
port=port
|
||||||
|
)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if user exists
|
||||||
|
cursor.execute('SELECT id, email FROM Users WHERE email = %s', (target_email,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
raise ValueError(f"User not found: {target_email}")
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
cursor.execute(
|
||||||
|
'UPDATE Users SET password = %s WHERE email = %s',
|
||||||
|
(hashed_password, target_email)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|||||||
Reference in New Issue
Block a user