Skip to content

Commit

Permalink
Merge pull request #293 from meower-media/tnix-emails
Browse files Browse the repository at this point in the history
Account emails
  • Loading branch information
tnix100 authored Sep 10, 2024
2 parents 91c07b3 + 792c26f commit 69176d2
Show file tree
Hide file tree
Showing 27 changed files with 1,239 additions and 270 deletions.
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,24 @@ 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=

EMAIL_SMTP_HOST=
EMAIL_SMTP_PORT=
EMAIL_SMTP_TLS=
EMAIL_SMTP_USERNAME=
EMAIL_SMTP_PASSWORD=
EMAIL_FROM_NAME=
EMAIL_FROM_ADDRESS=
EMAIL_PLATFORM_NAME="Meower"
EMAIL_PLATFORM_LOGO=""
EMAIL_PLATFORM_BRAND="Meower Media"
EMAIL_PLATFORM_FRONTEND="https://meower.org"
EMAIL_PLATFORM_SUPPORT="[email protected]"

GRPC_AUTH_ADDRESS="0.0.0.0:5000"
GRPC_AUTH_TOKEN=

Expand Down
23 changes: 13 additions & 10 deletions cloudlink.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ def __init__(self):
#"Kicked": "E:020 | Kicked", -- deprecated
#"ChatFull": "E:023 | Chat full", -- deprecated
#"LoggedOut": "I:024 | Logged out", -- deprecated
"Deleted": "E:025 | Deleted"
"Deleted": "E:025 | Deleted",
"AccountLocked": "E:026 | Account Locked"
}
self.commands: dict[str, function] = {
# Core commands
Expand Down Expand Up @@ -226,7 +227,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])
Expand Down Expand Up @@ -255,7 +257,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()

Expand All @@ -265,6 +267,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)
Expand All @@ -275,6 +278,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"],
Expand Down Expand Up @@ -307,6 +311,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
Expand All @@ -321,8 +326,6 @@ def proxy_api_request(
return resp
else:
match resp["type"]:
case "repairModeEnabled":
self.kick()
case "ipBlocked"|"registrationBlocked":
self.send_statuscode("Blocked", listener)
case "badRequest":
Expand All @@ -335,6 +338,8 @@ def proxy_api_request(
self.send_statuscode("2FARequired", listener)
case "accountDeleted":
self.send_statuscode("Deleted", listener)
case "accountLocked":
self.send_statuscode("AccountLocked", listener)
case "accountBanned":
self.send_statuscode("Banned", listener)
case "tooManyRequests":
Expand All @@ -353,10 +358,8 @@ def send(self, cmd: str, val: Any, extra: Optional[dict] = None, listener: Optio
def send_statuscode(self, statuscode: str, listener: Optional[str] = None):
return self.send("statuscode", self.server.statuscodes[statuscode], listener=listener)

def kick(self):
async def _kick():
await self.websocket.close()
asyncio.create_task(_kick())
async def kick(self):
await self.websocket.close()

class CloudlinkCommands:
@staticmethod
Expand Down Expand Up @@ -389,7 +392,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)
Expand Down
102 changes: 66 additions & 36 deletions database.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import pymongo
import pymongo.errors
import redis
import os
import secrets
import time
from radix import Radix
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...")
Expand Down Expand Up @@ -41,7 +45,9 @@
# 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)
try: db.usersv0.create_index([("email", pymongo.ASCENDING)], name="email", unique=True)
except: pass
try: db.usersv0.create_index([("normalized_email_hash", pymongo.ASCENDING)], name="normalized_email_hash", unique=True)
except: pass
try: db.usersv0.create_index([("created", pymongo.DESCENDING)], name="recent_users")
except: pass
Expand All @@ -60,24 +66,24 @@
try: db.authenticators.create_index([("user", pymongo.ASCENDING)], name="user")
except: pass

# Create data exports indexes
try: db.data_exports.create_index([("user", pymongo.ASCENDING)], name="user")
# Create account sessions indexes
try: db.acc_sessions.create_index([("user", pymongo.ASCENDING)], name="user")
except: pass

# Create relationships indexes
try: db.relationships.create_index([("_id.from", pymongo.ASCENDING)], name="from")
try: db.acc_sessions.create_index([("ip", pymongo.ASCENDING)], name="ip")
except: pass

# Create netinfo indexes
try: db.netinfo.create_index([("last_refreshed", pymongo.ASCENDING)], name="last_refreshed")
try: db.acc_sessions.create_index([("refreshed_at", pymongo.ASCENDING)], name="refreshed_at")
except: pass

# Create netlog indexes
try: db.netlog.create_index([("_id.ip", pymongo.ASCENDING)], name="ip")
# Create security log indexes
try: db.security_log.create_index([("user", pymongo.ASCENDING)], name="user")
except: pass
try: db.netlog.create_index([("_id.user", pymongo.ASCENDING)], name="user")

# Create data exports indexes
try: db.data_exports.create_index([("user", pymongo.ASCENDING)], name="user")
except: pass
try: db.netlog.create_index([("last_used", pymongo.ASCENDING)], name="last_used")

# Create relationships indexes
try: db.relationships.create_index([("_id.from", pymongo.ASCENDING)], name="from")
except: pass

# Create posts indexes
Expand Down Expand Up @@ -179,39 +185,30 @@


# Create default database items
for username in ["Server", "Deleted", "Meower", "Admin", "username"]:
try:
db.usersv0.insert_one({
"_id": username,
"lower_username": username.lower(),
"uuid": None,
"created": None,
"pfp_data": None,
"avatar": None,
"avatar_color": None,
"quote": None,
"pswd": None,
"tokens": None,
"flags": 1,
"permissions": None,
"ban": None,
"last_seen": None,
"delete_after": None
})
except: pass
try:
db.config.insert_one({
"_id": "migration",
"database": 1
})
except: pass
except pymongo.errors.DuplicateKeyError: pass
try:
db.config.insert_one({
"_id": "status",
"repair_mode": False,
"registration": True
})
except: pass
except pymongo.errors.DuplicateKeyError: pass
try:
db.config.insert_one({
"_id": "signing_keys",
"acc": secrets.token_bytes(64),
"email": secrets.token_bytes(64)
})
except pymongo.errors.DuplicateKeyError: pass


# Load signing keys
signing_keys = db.config.find_one({"_id": "signing_keys"})


# Load netblocks
Expand Down Expand Up @@ -306,6 +303,39 @@ def get_total_pages(collection: str, query: dict, page_size: int = 25) -> int:
"mfa_recovery_code": user["mfa_recovery_code"][:10]
}})

# Delete system users from DB
log("[Migrator] Deleting system users from DB")
db.usersv0.delete_many({"_id": {"$in": ["Server", "Deleted", "Meower", "Admin", "username"]}})

# Emails
log("[Migrator] Adding email addresses")
db.usersv0.update_many({"email": {"$exists": False}}, {"$set": {
"email": "",
"normalized_email_hash": ""
}})

# New sessions
log("[Migrator] Adding new sessions")
for user in db.usersv0.find({
"tokens": {"$exists": True},
"last_seen": {"$ne": None, "$gt": int(time.time())-(86400*21)},
}, projection={"_id": 1, "tokens": 1}):
if user["tokens"]:
for token in user["tokens"]:
rdb.set(
urlsafe_b64encode(sha256(token.encode()).digest()),
user["_id"],
ex=86400*21 # 21 days
)
db.usersv0.update_many({}, {"$unset": {"tokens": ""}})
try: db.usersv0.drop_index("tokens")
except: pass

# No more netinfo and netlog
log("[Migrator] Removing netinfo and netlog")
db.netinfo.drop()
db.netlog.drop()

db.config.update_one({"_id": "migration"}, {"$set": {"database": CURRENT_DB_VERSION}})
log(f"[Migrator] Finished Migrating DB to version {CURRENT_DB_VERSION}")

Expand Down
25 changes: 25 additions & 0 deletions email_templates/_base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<body>
<div style="width: 720px; margin-left: auto; margin-right: auto; color: black;">
<div style="width: max-content; margin-left: auto; margin-right: auto; padding-bottom: 25px;">
<img src="{{ env['EMAIL_PLATFORM_LOGO'] }}" alt="{{ env['EMAIL_PLATFORM_NAME'] }} Logo" />
</div>
<table style="width: 100%; background-color: #f2f2f2; padding: 20px; border-radius: 6px; border-top: 7px solid #f29e2e; font-family: Arial, Helvetica, sans-serif; font-size: 16px; box-shadow: 0 0 5px rgba(0,0,0,.1);">
<tr>
<td style="font-size: 32px; font-weight: 600; padding-bottom: 12px;">{{ subject }}</td>
</tr>

<tr>
<td style="padding-bottom: 12px;">Hey {{ name }}!</td>
</tr>

{% block body %}{% endblock %}

<tr>
<td style="padding-bottom: 12px;">- {{ env['EMAIL_PLATFORM_BRAND'] }}</td>
</tr>
</table>
</div>
</body>
</html>
5 changes: 5 additions & 0 deletions email_templates/_base.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Hey {{ name }}!

{% block body %}{% endblock %}

- {{ env['EMAIL_PLATFORM_BRAND'] }}
26 changes: 26 additions & 0 deletions email_templates/locked.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{% extends "_base.html" %}
{% block body %}
<tr>
<td style="padding-bottom: 12px;">
Your {{ env['EMAIL_PLATFORM_NAME'] }} account has been locked because we believe it may have been compromised. This can happen if your {{ env['EMAIL_PLATFORM_NAME'] }} password is weak, you used the same password on another website and that website was hacked, or you accidentally gave an access token to someone else.
</td>
</tr>

<tr>
<td style="padding-bottom: 12px;">
You will be required to reset your password using this email address (<a href="mailto:{{ address }}" target="_blank" style="color: black;">{{ address }}</a>) before logging back in to {{ env['EMAIL_PLATFORM_NAME'] }}.
</td>
</tr>

<tr>
<td style="padding-bottom: 24px;">
If you had multi-factor authentication enabled, it has been temporarily disabled as a precaution, in case it was modified by someone attempting to lock you out of your account.
</td>
</tr>

<tr>
<td style="padding-bottom: 24px;">
If you have any questions, please reach out to <a href="mailto:{{ env['EMAIL_PLATFORM_SUPPORT'] }}" target="_blank" style="color: black;">{{ env['EMAIL_PLATFORM_SUPPORT'] }}</a>.
</td>
</tr>
{% endblock %}
10 changes: 10 additions & 0 deletions email_templates/locked.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% extends "_base.txt" %}
{% block body %}
Your {{ env['EMAIL_PLATFORM_NAME'] }} account has been locked because we believe it may have been compromised. This can happen if your {{ env['EMAIL_PLATFORM_NAME'] }} password is weak, you used the same password on another website and that website was hacked, or you accidentally gave an access token to someone else.

You will be required to reset your password using this email address ({{ address }}) before logging back in to {{ env['EMAIL_PLATFORM_NAME'] }}.

If you had multi-factor authentication enabled, it has been temporarily disabled as a precaution, in case it was modified by someone attempting to lock you out of your account.

If you have any questions, please reach out to {{ env['EMAIL_PLATFORM_SUPPORT'] }}.
{% endblock %}
30 changes: 30 additions & 0 deletions email_templates/recover.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{% extends "_base.html" %}
{% block body %}
<tr>
<td style="padding-bottom: 12px;">
To reset your {{ env['EMAIL_PLATFORM_NAME'] }} account password, please click the button below.
</td>
</tr>

<tr>
<td style="padding-bottom: 24px;">
If you didn't request this, please ignore this email, no further action is required.
</td>
</tr>

<tr>
<td style="padding-bottom: 12px;"><i>This link will expire in 30 minutes.</i></td>
</tr>

<tr>
<td style="padding-bottom: 28px;">
<a
href="{{ env['EMAIL_PLATFORM_FRONTEND'] }}/emails/recover#{{ token }}"
target="_blank"
style="padding: 12px; background-color: #f29e2e; border-radius: 6px; text-decoration: none; color: white; max-width: fit-content;"
>
<b>Reset Password</b>
</a>
</td>
</tr>
{% endblock %}
6 changes: 6 additions & 0 deletions email_templates/recover.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{% extends "_base.txt" %}
{% block body %}
To reset your {{ env['EMAIL_PLATFORM_NAME'] }} account password, please follow this link (this link will expire in 30 minutes): {{ env['EMAIL_PLATFORM_FRONTEND'] }}/emails/recover#{{ token }}

If you didn't request this, please ignore this email, no further action is required.
{% endblock %}
Loading

0 comments on commit 69176d2

Please sign in to comment.