Skip to content

Commit aba474d

Browse files
committed
feat: 支持通过粘贴 StringSession 字符串直接登录
新增第三种 Telegram 登录方式:用户可在登录页「Session 导入」tab 粘贴 Telethon StringSession 字符串,后端用临时 client 验证有效性后才写入 session.key 并重连主 client,无效 session 不会落盘。
1 parent a00b7e4 commit aba474d

4 files changed

Lines changed: 139 additions & 2 deletions

File tree

frontend/src/pages/Login.tsx

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState } from 'react';
22
import { useNavigate, useLocation } from 'react-router-dom';
33
import { setApiKey, getApiKey, api } from '../api/client';
4-
import { KeyRound, QrCode, Smartphone, ShieldCheck } from 'lucide-react';
4+
import { KeyRound, QrCode, Smartphone, ShieldCheck, FileKey } from 'lucide-react';
55

66
export default function Login() {
77
const location = useLocation();
@@ -12,7 +12,7 @@ export default function Login() {
1212
const [authed, setAuthed] = useState(skipKey);
1313
const navigate = useNavigate();
1414

15-
const [loginMethod, setLoginMethod] = useState<'qr' | 'phone'>('qr');
15+
const [loginMethod, setLoginMethod] = useState<'qr' | 'phone' | 'session'>('qr');
1616

1717
const [qrImage, setQrImage] = useState('');
1818
const [qrStatus, setQrStatus] = useState('');
@@ -23,6 +23,9 @@ export default function Login() {
2323

2424
const [password, setPassword] = useState('');
2525

26+
const [sessionString, setSessionString] = useState('');
27+
const [sessionLoading, setSessionLoading] = useState(false);
28+
2629
async function handleKeySubmit(e: React.FormEvent) {
2730
e.preventDefault();
2831
setApiKey(key);
@@ -124,6 +127,24 @@ export default function Login() {
124127
}
125128
}
126129

130+
async function handleSessionImport(e: React.FormEvent) {
131+
e.preventDefault();
132+
setError('');
133+
setSessionLoading(true);
134+
try {
135+
const data = await api<{ status: string; error?: string }>(
136+
'/api/auth/session-login',
137+
{ method: 'POST', body: JSON.stringify({ session_string: sessionString }) },
138+
);
139+
if (data.status === 'success') navigate('/');
140+
else if (data.error) setError(data.error);
141+
} catch (e: unknown) {
142+
setError(String(e));
143+
} finally {
144+
setSessionLoading(false);
145+
}
146+
}
147+
127148
const show2fa = qrStatus === '2fa_required' || phoneStatus === '2fa_required';
128149

129150
const inputClass = 'w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 transition-colors';
@@ -181,6 +202,17 @@ export default function Login() {
181202
<Smartphone className="w-4 h-4" />
182203
手机号登录
183204
</button>
205+
<button
206+
onClick={() => { setLoginMethod('session'); setError(''); }}
207+
className={`flex-1 py-2 text-sm font-medium border-b-2 transition-colors flex items-center justify-center gap-1.5 ${
208+
loginMethod === 'session'
209+
? 'border-cyan-400 text-cyan-400'
210+
: 'border-transparent text-slate-500 hover:text-slate-300'
211+
}`}
212+
>
213+
<FileKey className="w-4 h-4" />
214+
Session 导入
215+
</button>
184216
</div>
185217

186218
{loginMethod === 'qr' && (
@@ -273,6 +305,32 @@ export default function Login() {
273305
</>
274306
)}
275307

308+
{loginMethod === 'session' && (
309+
<form onSubmit={handleSessionImport} className="space-y-3">
310+
<div>
311+
<label className="block text-sm font-medium text-slate-400 mb-1">
312+
StringSession 字符串
313+
</label>
314+
<textarea
315+
value={sessionString}
316+
onChange={(e) => setSessionString(e.target.value)}
317+
className={inputClass + ' resize-none h-24 font-mono text-xs'}
318+
placeholder="粘贴 Telethon StringSession 字符串..."
319+
/>
320+
<p className="text-xs text-slate-500 mt-1">
321+
通过 Telethon 生成的 Session 字符串
322+
</p>
323+
</div>
324+
<button
325+
type="submit"
326+
disabled={!sessionString.trim() || sessionLoading}
327+
className={primaryBtn + ((!sessionString.trim() || sessionLoading) ? ' opacity-60 cursor-not-allowed' : '')}
328+
>
329+
{sessionLoading ? '验证中...' : '导入 Session'}
330+
</button>
331+
</form>
332+
)}
333+
276334
<button
277335
onClick={() => navigate('/')}
278336
className="w-full py-2 border border-slate-600 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-200 transition-colors"

src/teleapi/api/auth_routes.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,20 @@ async def submit_2fa(request: Request):
112112
return resp
113113

114114

115+
@router.post("/session-login")
116+
async def session_login(request: Request):
117+
body = await request.json()
118+
session_string = body.get("session_string", "").strip()
119+
if not session_string:
120+
return JSONResponse({"error": "session_string is required"}, status_code=400)
121+
cm = _get_client_manager(request)
122+
try:
123+
user_info = await cm.import_session(session_string)
124+
return {"status": "success", "user": user_info}
125+
except ValueError as e:
126+
return JSONResponse({"status": "error", "error": str(e)}, status_code=400)
127+
128+
115129
@router.get("/status")
116130
async def auth_status(request: Request):
117131
cm = _get_client_manager(request)

src/teleapi/telegram/client.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,43 @@ async def logout(self):
8484
if SESSION_FILE.exists():
8585
SESSION_FILE.unlink()
8686
logger.info("Telegram session cleared")
87+
88+
async def import_session(self, session_string: str) -> dict:
89+
try:
90+
StringSession(session_string)
91+
except Exception as e:
92+
raise ValueError(f"Session 字符串格式无效: {e}")
93+
94+
temp_client = TelegramClient(
95+
StringSession(session_string),
96+
self._api_id,
97+
self._api_hash,
98+
)
99+
try:
100+
await temp_client.connect()
101+
if not await temp_client.is_user_authorized():
102+
raise ValueError("Session 已失效或未授权,请提供有效的 Session")
103+
me = await temp_client.get_me()
104+
user_info = {
105+
"id": me.id,
106+
"first_name": me.first_name or "",
107+
"last_name": me.last_name or "",
108+
"username": me.username or "",
109+
"phone": me.phone or "",
110+
}
111+
except ValueError:
112+
raise
113+
except Exception as e:
114+
raise ValueError(f"无法验证 Session: {e}")
115+
finally:
116+
await temp_client.disconnect()
117+
118+
await self.disconnect()
119+
120+
tmp = SESSION_FILE.with_suffix(".tmp")
121+
tmp.write_text(session_string)
122+
tmp.rename(SESSION_FILE)
123+
logger.info("Imported external session, saved to %s", SESSION_FILE)
124+
125+
await self.connect()
126+
return user_info

tests/api/test_auth_routes.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,30 @@ async def test_logout(self, client, test_app, tmp_path, monkeypatch):
105105
assert resp.json()["status"] == "logged_out"
106106

107107

108+
class TestSessionLoginAPI:
109+
async def test_success(self, client, test_app):
110+
test_app.state.telegram_client.import_session = AsyncMock(
111+
return_value={"id": 123, "first_name": "Test", "last_name": "", "username": "test", "phone": "+1234567890"}
112+
)
113+
resp = await client.post("/api/auth/session-login", json={"session_string": "valid_session"})
114+
assert resp.status_code == 200
115+
data = resp.json()
116+
assert data["status"] == "success"
117+
assert data["user"]["id"] == 123
118+
119+
async def test_empty_string(self, client):
120+
resp = await client.post("/api/auth/session-login", json={"session_string": ""})
121+
assert resp.status_code == 400
122+
123+
async def test_invalid_session(self, client, test_app):
124+
test_app.state.telegram_client.import_session = AsyncMock(
125+
side_effect=ValueError("Session 已失效或未授权,请提供有效的 Session")
126+
)
127+
resp = await client.post("/api/auth/session-login", json={"session_string": "bad_session"})
128+
assert resp.status_code == 400
129+
assert "error" in resp.json()
130+
131+
108132
class TestAuthRequired:
109133
async def test_all_routes_need_auth(self, unauthed_client):
110134
routes = [
@@ -113,6 +137,7 @@ async def test_all_routes_need_auth(self, unauthed_client):
113137
("POST", "/api/auth/qr-login/refresh"),
114138
("POST", "/api/auth/phone-login/send-code"),
115139
("GET", "/api/auth/phone-login/status"),
140+
("POST", "/api/auth/session-login"),
116141
("GET", "/api/auth/status"),
117142
("POST", "/api/auth/logout"),
118143
]

0 commit comments

Comments
 (0)