ai_co_discuss 0.2.40 → 0.2.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ """Single site administrator account: registration, login sessions and profile.
2
+
3
+ Passwords and session tokens are stored only as hashes. The Worker backend implements
4
+ the same contract in ``worker.mjs``; both operate on ``site_admin`` and ``admin_sessions``.
5
+ """
6
+
7
+ import hashlib
8
+ import hmac
9
+ import secrets
10
+ import time
11
+
12
+ from .runtime import begin_write
13
+
14
+ PBKDF2_ITERATIONS = 100000
15
+ LOCK_AFTER_FAILURES = 5
16
+ LOCK_SECONDS = 300
17
+ USERNAME_MAX = 30
18
+ PASSWORD_MIN = 8
19
+ PASSWORD_MAX = 128
20
+ AVATAR_MAX = 60000
21
+
22
+
23
+ class AdminError(Exception):
24
+ """A request failure that needs a specific HTTP status."""
25
+
26
+ def __init__(self, message, status):
27
+ super().__init__(message)
28
+ self.status = status
29
+
30
+
31
+ def hash_password(password):
32
+ """Return a salted PBKDF2 hash in the portable ``pbkdf2_sha256$n$salt$hash`` format."""
33
+ salt = secrets.token_bytes(16)
34
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS)
35
+ return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
36
+
37
+
38
+ def verify_password(password, stored):
39
+ """Compare a password with a stored hash in constant time."""
40
+ try:
41
+ _, iterations, salt, expected = stored.split("$")
42
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), int(iterations))
43
+ except ValueError:
44
+ return False
45
+ return hmac.compare_digest(digest.hex(), expected)
46
+
47
+
48
+ def session_hash(token):
49
+ """Hash a session token; malformed values hash to an empty string and never match."""
50
+ return hashlib.sha256(token.encode()).hexdigest() if isinstance(token, str) and len(token) == 64 else ""
51
+
52
+
53
+ def same_secret(supplied, expected):
54
+ """Compare two secrets through equal-length digests."""
55
+ return hmac.compare_digest(hashlib.sha256(supplied.encode()).digest(), hashlib.sha256(expected.encode()).digest())
56
+
57
+
58
+ def valid_username(value):
59
+ """Return a trimmed username, or raise when it is empty or too long."""
60
+ name = value.strip() if isinstance(value, str) else ""
61
+ if not 1 <= len(name) <= USERNAME_MAX:
62
+ raise ValueError(f"用户名需为 1–{USERNAME_MAX} 个字符")
63
+ return name
64
+
65
+
66
+ def valid_password(value):
67
+ """Return a password within the accepted length."""
68
+ if not isinstance(value, str) or not PASSWORD_MIN <= len(value) <= PASSWORD_MAX:
69
+ raise ValueError(f"密码需为 {PASSWORD_MIN}–{PASSWORD_MAX} 个字符")
70
+ return value
71
+
72
+
73
+ def valid_avatar(value):
74
+ """Accept an empty default or a bounded image data URL."""
75
+ if not isinstance(value, str) or len(value) > AVATAR_MAX or (value and not value.startswith("data:image/")):
76
+ raise ValueError("头像需为不超过 60000 字符的图片数据")
77
+ return value
78
+
79
+
80
+ def admin_row(connection):
81
+ """Return the administrator row, or None before registration."""
82
+ return connection.execute("SELECT * FROM site_admin WHERE id=1").fetchone()
83
+
84
+
85
+ def profile_of(row):
86
+ """Public profile shown to every visitor."""
87
+ return {"username": row["username"], "avatar": row["avatar"]} if row else None
88
+
89
+
90
+ def session_valid(connection, token):
91
+ """Whether the token belongs to a stored session; sessions last until logout."""
92
+ hashed = session_hash(token)
93
+ return bool(hashed and connection.execute("SELECT 1 FROM admin_sessions WHERE token_hash=?", (hashed,)).fetchone())
94
+
95
+
96
+ def require_session(connection, token):
97
+ """Require a valid administrator session."""
98
+ if not session_valid(connection, token):
99
+ raise AdminError("需要登录管理员账号", 401)
100
+
101
+
102
+ def admin_status(connection, token, setup_available, local=False):
103
+ """Describe registration and login state; readable without logging in.
104
+
105
+ A loopback-only local site has no account: the visitor is always the administrator.
106
+ """
107
+ row = admin_row(connection)
108
+ if local:
109
+ return {"local": True, "registered": True, "setup_available": False, "logged_in": True, "profile": profile_of(row)}
110
+ return {"registered": bool(row), "setup_available": bool(setup_available and not row),
111
+ "logged_in": session_valid(connection, token) if row else False, "profile": profile_of(row)}
112
+
113
+
114
+ def open_session(connection, now):
115
+ """Create a session and return its raw token, which is shown only once."""
116
+ token = secrets.token_hex(32)
117
+ connection.execute("INSERT INTO admin_sessions (token_hash,created_at) VALUES (?,?)", (session_hash(token), now))
118
+ return token
119
+
120
+
121
+ def register_admin(connection, payload, setup_key, now):
122
+ """Create the administrator when the install token matches; only the first call succeeds."""
123
+ begin_write(connection)
124
+ if admin_row(connection):
125
+ raise AdminError("管理员账号已存在", 409)
126
+ username = valid_username(payload.get("username"))
127
+ password = valid_password(payload.get("password"))
128
+ supplied = payload.get("setup_key")
129
+ if not isinstance(supplied, str) or not same_secret(supplied.strip(), setup_key):
130
+ raise AdminError("安装令牌不正确", 403)
131
+ connection.execute("INSERT INTO site_admin (id,username,password_hash,created_at) VALUES (1,?,?,?)",
132
+ (username, hash_password(password), now))
133
+ return {"token": open_session(connection, now), "profile": {"username": username, "avatar": ""}}
134
+
135
+
136
+ def record_failure(connection, row):
137
+ """Count a failed attempt durably; the fifth in a row locks login for a fixed time."""
138
+ failures = row["failed_attempts"] + 1
139
+ locked = failures >= LOCK_AFTER_FAILURES
140
+ connection.execute("UPDATE site_admin SET failed_attempts=?, locked_until=? WHERE id=1",
141
+ (0 if locked else failures, int(time.time()) + LOCK_SECONDS if locked else 0))
142
+ connection.commit()
143
+
144
+
145
+ def check_not_locked(row, now):
146
+ """Reject attempts while the account is locked, even with a correct password."""
147
+ if row["locked_until"] > now:
148
+ raise AdminError(f"尝试次数过多,请 {row['locked_until'] - now} 秒后再试", 429)
149
+
150
+
151
+ def login_admin(connection, payload, now):
152
+ """Verify the account and open a session; failures are generic and rate limited."""
153
+ begin_write(connection)
154
+ row = admin_row(connection)
155
+ username, password = payload.get("username"), payload.get("password")
156
+ if not row or not isinstance(username, str) or not isinstance(password, str):
157
+ raise AdminError("用户名或密码错误", 401)
158
+ check_not_locked(row, now)
159
+ if username.strip().lower() != row["username"].lower() or not verify_password(password, row["password_hash"]):
160
+ record_failure(connection, row)
161
+ raise AdminError("用户名或密码错误", 401)
162
+ connection.execute("UPDATE site_admin SET failed_attempts=0, locked_until=0 WHERE id=1")
163
+ return {"token": open_session(connection, now), "profile": profile_of(row)}
164
+
165
+
166
+ def logout_admin(connection, token):
167
+ """Remove the current session; succeeds even without one."""
168
+ connection.execute("DELETE FROM admin_sessions WHERE token_hash=?", (session_hash(token),))
169
+ return {"ok": True}
170
+
171
+
172
+ def update_profile(connection, token, payload, local=False):
173
+ """Update the display name and avatar shown to all visitors.
174
+
175
+ A local site stores the profile without a usable password, so no one can log in to it.
176
+ """
177
+ begin_write(connection)
178
+ if not local:
179
+ require_session(connection, token)
180
+ elif not admin_row(connection):
181
+ connection.execute("INSERT INTO site_admin (id,username,password_hash,created_at) VALUES (1,'User','!',?)", (int(time.time()),))
182
+ if "username" in payload:
183
+ connection.execute("UPDATE site_admin SET username=? WHERE id=1", (valid_username(payload["username"]),))
184
+ if "avatar" in payload:
185
+ connection.execute("UPDATE site_admin SET avatar=? WHERE id=1", (valid_avatar(payload["avatar"]),))
186
+ return {"profile": profile_of(admin_row(connection))}