From 5a1657e23feebf86b87f98dddc3cb28c2ce63427 Mon Sep 17 00:00:00 2001 From: Tnix Date: Mon, 26 Aug 2024 03:42:11 +1200 Subject: [PATCH] feat: sessions and hmac tokens --- .env.example | 2 + cloudlink.py | 12 +++-- database.py | 57 ++++++++-------------- errors.py | 3 ++ grpc_auth/service.py | 23 +++++---- main.py | 4 ++ requirements.txt | 3 +- rest_api/__init__.py | 22 ++++++--- rest_api/admin.py | 32 ++++++------- rest_api/v0/auth.py | 41 ++++++++++++---- rest_api/v0/me.py | 17 +++---- security.py | 83 ++++++++------------------------ sessions.py | 112 +++++++++++++++++++++++++++++++++++++++++++ supporter.py | 9 +++- 14 files changed, 262 insertions(+), 158 deletions(-) create mode 100644 errors.py create mode 100644 sessions.py diff --git a/.env.example b/.env.example index de92fd4..3e06e5c 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,8 @@ API_ROOT= INTERNAL_API_ENDPOINT="http://127.0.0.1:3001" # used for proxying CL3 commands INTERNAL_API_TOKEN="" # used for authenticating internal API requests (gives access to any account, meant to be used by CL3) +SENTRY_DSN= + CAPTCHA_SITEKEY= CAPTCHA_SECRET= diff --git a/cloudlink.py b/cloudlink.py index 65ea0a7..feae122 100755 --- a/cloudlink.py +++ b/cloudlink.py @@ -226,7 +226,8 @@ def __init__( self.server = server self.websocket = websocket - # Set username, protocol version, IP, and trusted status + # Set account session ID, username, protocol version, IP, and trusted status + self.acc_session_id: Optional[str] = None self.username: Optional[str] = None try: self.proto_version: int = int(self.req_params.get("v")[0]) @@ -255,7 +256,7 @@ def ip(self): else: return self.websocket.remote_address - def authenticate(self, account: dict[str, Any], token: str, listener: Optional[str] = None): + def authenticate(self, acc_session: dict[str, Any], token: str, account: dict[str, Any], listener: Optional[str] = None): if self.username: self.logout() @@ -265,6 +266,7 @@ def authenticate(self, account: dict[str, Any], token: str, listener: Optional[s return self.send_statuscode("Banned", listener) # Authenticate + self.acc_session_id = acc_session["_id"] self.username = account["_id"] if self.username in self.server.usernames: self.server.usernames[self.username].append(self) @@ -275,6 +277,7 @@ def authenticate(self, account: dict[str, Any], token: str, listener: Optional[s # Send auth payload self.send("auth", { "username": self.username, + "session": acc_session, "token": token, "account": account, "relationships": self.proxy_api_request("/me/relationships", "get")["autoget"], @@ -307,6 +310,7 @@ def proxy_api_request( headers.update({ "X-Internal-Token": os.environ["INTERNAL_API_TOKEN"], "X-Internal-Ip": self.ip, + "X-Internal-UA": self.websocket.request_headers.get("User-Agent"), }) if self.username: headers["X-Internal-Username"] = self.username @@ -356,7 +360,7 @@ def send_statuscode(self, statuscode: str, listener: Optional[str] = None): def kick(self): async def _kick(): await self.websocket.close() - asyncio.create_task(_kick()) + asyncio.run(_kick()) class CloudlinkCommands: @staticmethod @@ -389,7 +393,7 @@ async def authpswd(client: CloudlinkClient, val, listener: Optional[str] = None) else: if resp and not resp["error"]: # Authenticate client - client.authenticate(resp["account"], resp["token"], listener=listener) + client.authenticate(resp["session"], resp["token"], resp["account"], listener=listener) # Tell the client it is authenticated client.send_statuscode("OK", listener) diff --git a/database.py b/database.py index 8ee5b78..5b8e057 100644 --- a/database.py +++ b/database.py @@ -4,11 +4,12 @@ import os import secrets from radix import Radix -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from hashlib import sha256 +from base64 import urlsafe_b64encode from utils import log -CURRENT_DB_VERSION = 9 +CURRENT_DB_VERSION = 10 # Create Redis connection log("Connecting to Redis...") @@ -43,8 +44,6 @@ # Create usersv0 indexes try: db.usersv0.create_index([("lower_username", pymongo.ASCENDING)], name="lower_username", unique=True) except: pass -try: db.usersv0.create_index([("tokens", pymongo.ASCENDING)], name="tokens", unique=True) -except: pass try: db.usersv0.create_index([("created", pymongo.DESCENDING)], name="recent_users") except: pass try: @@ -193,7 +192,6 @@ "avatar_color": None, "quote": None, "pswd": None, - "tokens": None, "flags": 1, "permissions": None, "ban": None, @@ -214,41 +212,17 @@ "registration": True }) except pymongo.errors.DuplicateKeyError: pass - - -# Load existing signing keys or create new ones -signing_keys = {} -if db.config.count_documents({"_id": "signing_keys"}, limit=1): - data = db.config.count_documents({"_id": "signing_keys"}, limit=1) - - acc_priv = Ed25519PrivateKey.from_private_bytes(data["acc_priv"]) - email_priv = Ed25519PrivateKey.from_private_bytes(data["email_priv"]) - - signing_keys.update({ - "acc_priv": acc_priv, - "acc_pub": acc_priv.public_key(), - - "email_priv": email_priv, - "email_pub": email_priv.public_key() +try: + db.config.insert_one({ + "_id": "signing_keys", + "acc": secrets.token_bytes(64), + "email": secrets.token_bytes(64) }) -else: - acc_priv = Ed25519PrivateKey.generate() - email_priv = Ed25519PrivateKey.generate() +except pymongo.errors.DuplicateKeyError: pass - signing_keys.update({ - "acc_priv": acc_priv, - "acc_pub": acc_priv.public_key(), - "email_priv": email_priv, - "email_pub": email_priv.public_key() - }) - - data = { - "_id": "signing_keys", - "acc_priv": acc_priv.private_bytes_raw(), - "email_priv": email_priv.private_bytes_raw() - } - db.confing.insert_one(signing_keys) +# Load signing keys +signing_keys = db.config.find_one({"_id": "signing_keys"}) # Load netblocks @@ -343,6 +317,15 @@ def get_total_pages(collection: str, query: dict, page_size: int = 25) -> int: "mfa_recovery_code": user["mfa_recovery_code"][:10] }}) + # New sessions + log("[Migrator] Adding new sessions") + from sessions import AccSession + for user in db.usersv0.find({"tokens": {"$exists": True}}, projection={"_id": 1, "tokens": 1}): + if user["tokens"]: + for token in user["tokens"]: + rdb.set(urlsafe_b64encode(sha256(token.encode()).digest()), user["_id"], ex=1209600) # 14 days + db.usersv0.update_one({"_id": user["_id"]}, {"$set": {"tokens": []}}) + db.config.update_one({"_id": "migration"}, {"$set": {"database": CURRENT_DB_VERSION}}) log(f"[Migrator] Finished Migrating DB to version {CURRENT_DB_VERSION}") diff --git a/errors.py b/errors.py new file mode 100644 index 0000000..6bb0c3e --- /dev/null +++ b/errors.py @@ -0,0 +1,3 @@ +class InvalidTokenSignature(Exception): pass + +class SessionNotFound(Exception): pass \ No newline at end of file diff --git a/grpc_auth/service.py b/grpc_auth/service.py index e4c9181..9a433bd 100644 --- a/grpc_auth/service.py +++ b/grpc_auth/service.py @@ -6,7 +6,10 @@ auth_service_pb2 as pb2 ) +from sentry_sdk import capture_exception + from database import db +from sessions import AccSession class AuthService(pb2_grpc.AuthServicer): @@ -22,15 +25,19 @@ def CheckToken(self, request, context): if not authed: context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid or missing token") - account = db.usersv0.find_one({"tokens": request.token}, projection={ - "_id": 1, - "ban.state": 1, - "ban.expires": 1 - }) - if account: + try: + username = AccSession.get_username_by_token(request.token) + except Exception as e: + capture_exception(e) + else: + account = db.usersv0.find_one({"_id": username}, projection={ + "_id": 1, + "ban.state": 1, + "ban.expires": 1 + }) if account and \ - (account["ban"]["state"] == "perm_ban" or \ - (account["ban"]["state"] == "temp_ban" and account["ban"]["expires"] > time.time())): + (account["ban"]["state"] == "perm_ban" or \ + (account["ban"]["state"] == "temp_ban" and account["ban"]["expires"] > time.time())): account = None return pb2.CheckTokenResp( diff --git a/main.py b/main.py index b453bb1..a2a1955 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,7 @@ import asyncio import os import uvicorn +import sentry_sdk from threading import Thread @@ -16,6 +17,9 @@ if __name__ == "__main__": + # Initialise Sentry (uses SENTRY_DSN env var) + sentry_sdk.init() + # Create Cloudlink server cl = CloudlinkServer() diff --git a/requirements.txt b/requirements.txt index 5c8dca2..11e0c6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,4 +16,5 @@ protobuf pyotp emoji websockets -qrcode \ No newline at end of file +qrcode +sentry-sdk \ No newline at end of file diff --git a/rest_api/__init__.py b/rest_api/__init__.py index bd76c2d..47bddad 100755 --- a/rest_api/__init__.py +++ b/rest_api/__init__.py @@ -2,6 +2,7 @@ from quart_cors import cors from quart_schema import QuartSchema, RequestSchemaValidationError, validate_headers, hide from pydantic import BaseModel +from sentry_sdk import capture_exception import time, os from .v0 import v0 @@ -10,6 +11,7 @@ from .admin import admin_bp from database import db, blocked_ips, registration_blocked_ips +from sessions import AccSession import security @@ -41,6 +43,7 @@ async def internal_auth(): abort(401) request.internal_ip = request.headers.get("X-Internal-Ip") + request.headers["User-Agent"] = request.headers.get("X-Internal-UA") request.internal_username = request.headers.get("X-Internal-Username") request.bypass_captcha = True @@ -74,13 +77,18 @@ async def check_auth(headers: TokenHeader): "ban.expires": 1 }) elif headers.token: # external auth - account = db.usersv0.find_one({"tokens": headers.token}, projection={ - "_id": 1, - "flags": 1, - "permissions": 1, - "ban.state": 1, - "ban.expires": 1 - }) + try: + username = AccSession.get_username_by_token(headers.token) + except Exception as e: + capture_exception(e) + else: + account = db.usersv0.find_one({"_id": username}, projection={ + "_id": 1, + "flags": 1, + "permissions": 1, + "ban.state": 1, + "ban.expires": 1 + }) if account: if account["ban"]["state"] == "perm_ban" or (account["ban"]["state"] == "temp_ban" and account["ban"]["expires"] > time.time()): diff --git a/rest_api/admin.py b/rest_api/admin.py index 5240fdc..6da89b6 100644 --- a/rest_api/admin.py +++ b/rest_api/admin.py @@ -8,6 +8,7 @@ import security from database import db, get_total_pages, blocked_ips, registration_blocked_ips +from sessions import AccSession admin_bp = Blueprint("admin_bp", __name__, url_prefix="/admin") @@ -651,17 +652,17 @@ async def delete_user(username, query_args: DeleteUserQueryArgs): {"_id": username}, {"$set": {"delete_after": None}} ) elif deletion_mode in ["schedule", "immediate", "purge"]: - db.usersv0.update_one( - {"_id": username}, - { - "$set": { - "tokens": [], - "delete_after": int(time.time()) + (604800 if deletion_mode == "schedule" else 0), - } - }, - ) - for client in app.cl.usernames.get(username, []): - client.kick() + if deletion_mode == "schedule": + db.usersv0.update_one( + {"_id": username}, + { + "$set": { + "delete_after": int(time.time()) + (604800 if deletion_mode == "schedule" else 0), + } + }, + ) + for session in AccSession.get_all(username): + session.revoke() if deletion_mode in ["immediate", "purge"]: security.delete_account(username, purge=(deletion_mode == "purge")) else: @@ -828,12 +829,9 @@ async def kick_user(username): if not security.has_permission(request.permissions, security.AdminPermissions.KICK_USERS): abort(401) - # Revoke tokens - db.usersv0.update_one({"_id": username}, {"$set": {"tokens": []}}) - - # Kick clients - for client in app.cl.usernames.get(username, []): - client.kick() + # Revoke sessions + for session in AccSession.get_all(username): + session.revoke() # Add log security.add_audit_log( diff --git a/rest_api/v0/auth.py b/rest_api/v0/auth.py index ead9fc3..e1aadd7 100644 --- a/rest_api/v0/auth.py +++ b/rest_api/v0/auth.py @@ -4,7 +4,10 @@ from quart_schema import validate_request from pydantic import Field from typing import Optional -from database import db, registration_blocked_ips +from base64 import urlsafe_b64encode +from hashlib import sha256 +from database import db, rdb, registration_blocked_ips +from sessions import AccSession import security auth_bp = Blueprint("auth_bp", __name__, url_prefix="/auth") @@ -29,7 +32,6 @@ async def login(data: AuthRequest): account = db.usersv0.find_one({"lower_username": data.username.lower()}, projection={ "_id": 1, "flags": 1, - "tokens": 1, "pswd": 1, "mfa_recovery_code": 1 }) @@ -44,8 +46,19 @@ async def login(data: AuthRequest): if security.ratelimited(f"login:u:{account['_id']}"): abort(429) - # Check credentials - if data.password not in account["tokens"]: + # Legacy tokens (remove in the future at some point) + if len(data.password) == 86: + encoded_token = urlsafe_b64encode(sha256(data.password.encode()).digest()) + username = rdb.get(encoded_token) + if username and username.decode() == account["_id"]: + data.password = AccSession.create(username.decode(), request.ip, request.headers.get("User-Agent")).token + rdb.delete(encoded_token) + + # Check credentials & get session + try: # token for already existing session + session = AccSession.get_by_token(data.password) + session.refresh(request.ip, request.headers.get("User-Agent"), check_token=data.password) + except: # no error capturing here, as it's probably just a password rather than a token, and we don't want to capture passwords # Check password password_valid = security.check_password_hash(data.password, account["pswd"]) @@ -106,11 +119,15 @@ async def login(data: AuthRequest): "mfa_methods": list(mfa_methods) }, 401 - # Return account and token + # Create session + session = AccSession.create(account["_id"], request.ip, request.headers.get("User-Agent")) + + # Return session and account details return { "error": False, - "account": security.get_account(account['_id'], True), - "token": security.create_user_token(account['_id'], request.ip, used_token=data.password) + "session": session.v0, + "token": session.token, + "account": security.get_account(account['_id'], True) }, 200 @auth_bp.post("/register") @@ -156,9 +173,13 @@ async def register(data: AuthRequest): # Ratelimit security.ratelimit(f"register:{request.ip}:s", 5, 900) - # Return account and token + # Create session + session, token = security.create_session(data.username, request.ip, request.headers.get("User-Agent")) + + # Return session and account details return { "error": False, - "account": security.get_account(data.username, True), - "token": security.create_user_token(data.username, request.ip) + "session": session, + "token": token, + "account": security.get_account(data.username, True) }, 200 diff --git a/rest_api/v0/me.py b/rest_api/v0/me.py index a2ead10..793ff06 100644 --- a/rest_api/v0/me.py +++ b/rest_api/v0/me.py @@ -17,6 +17,7 @@ import security from database import db, rdb, get_total_pages from uploads import claim_file, delete_file +from sessions import AccSession from utils import log @@ -106,13 +107,12 @@ async def delete_account(data: DeleteAccountBody): # Schedule account for deletion db.usersv0.update_one({"_id": request.user}, {"$set": { - "tokens": [], "delete_after": int(time.time())+604800 # 7 days }}) - # Disconnect clients - for client in app.cl.usernames.get(request.user, []): - client.kick() + # Revoke sessions + for session in AccSession.get_all(request.user): + session.revoke() return {"error": False}, 200 @@ -432,12 +432,9 @@ async def delete_tokens(): if not request.user: abort(401) - # Revoke tokens - db.usersv0.update_one({"_id": request.user}, {"$set": {"tokens": []}}) - - # Disconnect clients - for client in app.cl.usernames.get(request.user, []): - client.kick() + # Revoke sessions + for session in AccSession.get_all(request.user): + session.revoke() return {"error": False}, 200 diff --git a/security.py b/security.py index 78279cc..414409d 100644 --- a/security.py +++ b/security.py @@ -1,14 +1,15 @@ -from hashlib import sha256 from typing import Optional, Any, Literal +from hashlib import sha256 from base64 import urlsafe_b64encode, urlsafe_b64decode from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formataddr -import time, requests, os, uuid, secrets, bcrypt, msgpack, jinja2, smtplib +import time, requests, os, uuid, secrets, bcrypt, hmac, msgpack, jinja2, smtplib from database import db, rdb, signing_keys from utils import log from uploads import clear_files +import errors """ Meower Security Module @@ -19,7 +20,6 @@ "email", "pswd", "mfa_recovery_code", - "tokens", "delete_after" } @@ -152,7 +152,6 @@ def create_account(username: str, password: str, ip: str): "email": "", "pswd": hash_password(password), "mfa_recovery_code": secrets.token_hex(5), - "tokens": [], "flags": 0, "permissions": 0, "ban": { @@ -233,73 +232,34 @@ def get_account(username, include_config=False): return account -def create_user_token(username: str, ip: str, used_token: Optional[str] = None) -> str: - # Get required account details - account = db.usersv0.find_one({"_id": username}, projection={ - "_id": 1, - "tokens": 1, - "delete_after": 1 - }) - - # Update netlog - db.netlog.update_one({"_id": { - "ip": ip, - "user": username, - }}, {"$set": {"last_used": int(time.time())}}, upsert=True) - - # Restore account - if account["delete_after"]: - db.usersv0.update_one({"_id": account["_id"]}, {"$set": {"delete_after": None}}) - rdb.publish("admin", msgpack.packb({ - "op": "alert_user", - "user": account["_id"], - "content": "Your account was scheduled for deletion but you logged back in. Your account is no longer scheduled for deletion! If you didn't request for your account to be deleted, please change your password immediately." - })) - - # Generate new token, revoke used token, and update last seen timestamp - new_token = secrets.token_urlsafe(TOKEN_BYTES) - account["tokens"].append(new_token) - if used_token in account["tokens"]: - account["tokens"].remove(used_token) - db.usersv0.update_one({"_id": account["_id"]}, {"$set": { - "tokens": account["tokens"], - "last_seen": int(time.time()) - }}) - - # Return new token - return new_token +def create_token(ttype: TOKEN_TYPES, claims: Any) -> str: + # Encode claims + encoded_claims = msgpack.packb(claims) + # Sign encoded claims + signature = hmac.digest(signing_keys[ttype], encoded_claims, digest=sha256) -def create_token(ttype: TOKEN_TYPES, claims: Any, expires_in: Optional[int] = None) -> str: - token = b"miau_" + ttype.encode() - - # Add claims - token += b"." + urlsafe_b64encode(msgpack(claims)) - - # Add expiration - token += b"." + urlsafe_b64encode(str(int(time.time())+expires_in).encode()) - - # Sign token and add signature to token - token += b"." + urlsafe_b64encode(signing_keys[ttype + "_priv"].sign(token)) + # Construct token + token = b".".join([urlsafe_b64encode(encoded_claims), urlsafe_b64encode(signature)]) return token.decode() def extract_token(token: str, expected_type: TOKEN_TYPES) -> Optional[Any]: - # Extract data from the token - ttype, claims, expires_at, signature = token.split(".") - - # Check type - if ttype.replace("miau_", "") != expected_type: - return None + # Extract data and signature + encoded_claims, signature = token.split(".") + encoded_claims = urlsafe_b64decode(encoded_claims) + signature = urlsafe_b64decode(signature) # Check signature - signing_keys[ttype.replace("miau_", "") + "_pub"].verify( - urlsafe_b64decode(signature), - (ttype.encode() + b"." + claims.encode() + b"." + expires_at.encode()) - ) + expected_signature = hmac.digest(signing_keys[expected_type], encoded_claims, digest=sha256) + if not hmac.compare_digest(signature, expected_signature): + raise errors.InvalidTokenSignature - return msgpack.unpack(urlsafe_b64decode(claims)) + # Decode claims + claims = msgpack.unpackb(encoded_claims) + + return claims def update_settings(username, newdata): @@ -412,7 +372,6 @@ def delete_account(username, purge=False): "quote": None, "pswd": None, "mfa_recovery_code": None, - "tokens": None, "flags": account["flags"], "permissions": None, "ban": None, diff --git a/sessions.py b/sessions.py new file mode 100644 index 0000000..c107c47 --- /dev/null +++ b/sessions.py @@ -0,0 +1,112 @@ +from typing import Optional, TypedDict +import uuid, time, msgpack + +from database import db, rdb +import security, errors + + +class AccSessionDB(TypedDict): + _id: str + user: str + ip: str + user_agent: str + created_at: int + refreshed_at: int + + +class AccSessionV0(TypedDict): + _id: str + ip: str + location: str + user_agent: str + created_at: int + refreshed_at: int + + +class AccSession: + def __init__(self, data: AccSessionDB): + self._db = data + + @classmethod + def create(cls: "AccSession", user: str, ip: str, user_agent: str) -> "AccSession": + data: AccSessionDB = { + "_id": str(uuid.uuid4()), + "user": user, + "ip": ip, + "user_agent": user_agent, + "created_at": int(time.time()), + "refreshed_at": int(time.time()) + } + db.acc_sessions.insert_one(data) + return cls(data) + + @classmethod + def get_by_id(cls: "AccSession", session_id: str) -> "AccSession": + data: Optional[AccSessionDB] = db.acc_sessions.find_one({"_id": session_id}) + if not data: + raise errors.SessionNotFound + + return cls(data) + + @classmethod + def get_by_token(cls: "AccSession", token: str) -> "AccSession": + session_id, _ = security.extract_token(token, "acc") + return cls.get_by_id(session_id) + + @classmethod + def get_username_by_token(cls: "AccSession", token: str) -> str: + session_id, _ = security.extract_token(token, "acc") + username = rdb.get(f"u{session_id}") + if username: + return username.decode() + else: + session = cls.get_by_id(session_id) + username = session.username + rdb.set(f"u{session_id}", username, ex=300) + return username + + @classmethod + def get_all(cls: "AccSession", user: str) -> list["AccSession"]: + return [cls(data) for data in db.acc_sessions.find({"user": user})] + + @property + def token(self) -> str: + return security.create_token("acc", [self._db["_id"], self._db["refreshed_at"]]) + + @property + def username(self): + return self._db["user"] + + @property + def v0(self) -> AccSessionV0: + return { + "_id": self._db["_id"], + "ip": self._db["ip"], + "location": "", + "user_agent": self._db["user_agent"], + "created_at": self._db["created_at"], + "refreshed_at": self._db["refreshed_at"] + } + + def refresh(self, ip: str, user_agent: str, check_token: Optional[str] = None): + if check_token: + # token re-use prevention + _, refreshed_at = security.extract_token(check_token, "acc") + if refreshed_at != self._db["refreshed_at"]: + return self.revoke() + + self._db.update({ + "ip": ip, + "user_agent": user_agent, + "refreshed_at": int(time.time()) + }) + db.acc_sessions.update_one({"_id": self._db["_id"]}, {"$set": self._db}) + + def revoke(self): + db.acc_sessions.delete_one({"_id": self._db["_id"]}) + rdb.delete(f"u{self._db['_id']}") + rdb.publish("admin", msgpack.packb({ + "op": "revoke_acc_session", + "user": self._db["user"], + "sid": self._db["_id"] + })) diff --git a/supporter.py b/supporter.py index bd220fa..9dacc86 100644 --- a/supporter.py +++ b/supporter.py @@ -128,8 +128,13 @@ def listen_for_admin_pubsub(self): pubsub.subscribe("admin") for msg in pubsub.listen(): try: - msg = msgpack.loads(msg["data"]) + msg = msgpack.unpackb(msg["data"]) match msg.pop("op"): + case "revoke_acc_session": + for c in self.cl.usernames.get(msg["user"], []): + if "sid" in msg and msg["sid"] != c.acc_session_id: + continue + c.kick() case "alert_user": self.create_post("inbox", msg["user"], msg["content"]) case "ban_user": @@ -163,7 +168,7 @@ def listen_for_admin_pubsub(self): # Logout user (can't kick because of async stuff) for c in self.cl.usernames.get(username, []): - c.logout() + c.kick() except: continue