Skip to content

Commit

Permalink
Merge pull request #17 from OSDG-IIITH/ankith26-new-verify
Browse files Browse the repository at this point in the history
Refactor and simplify verification process
  • Loading branch information
ankith26 authored Jun 5, 2024
2 parents 8f6356c + 2b6cce0 commit f0e06cd
Show file tree
Hide file tree
Showing 12 changed files with 738 additions and 5,937 deletions.
15 changes: 10 additions & 5 deletions .env.template
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# To configure the project, rename this file to .env and fill in your secrets
# configuration variables needed both ./bot and ./portal

# the private IP address of the container running the /portal code
PORTAL_PRIVATE_IP="172.20.0.5"

# the private IP address of the container running the /bot code
BOT_PRIVATE_IP="172.20.0.2"

# configuration variables needed both ./bot and ./portal

# this parameter must be either http or https. For local testing this can be
# http, but on production it must be https
Expand All @@ -18,15 +25,13 @@ PORT=""
# subpath is used.
SUBPATH=""

MONGO_DATABASE="casbot"
MONGO_URI="mongodb://127.0.0.1:100"

# needed by ./bot
DISCORD_TOKEN=""
MONGO_DATABASE="casbot"
MONGO_URI="mongodb://127.0.0.1:100"
BOT_ADMINS=""

# needed by ./portal
CAS_LINK="https://cas.my-org.com/login"
DISCORD_CLIENT_ID=""
DISCORD_SECRET=""
SECRET=""
14 changes: 3 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ information:
1. Roll number
1. DiscordID.

1,2,3 are obtained from CAS, and 4 is obtained from Discord OAuth.
1,2,3 are obtained from CAS, and 4 is obtained from Discord.

This data is used strictly for authentication only, and not visible publicly.
It is only visible to the server host.
Expand Down Expand Up @@ -70,14 +70,13 @@ You can create a new "Bot" role in your server and give it to our bot. This role

1. The bot will only give the user role A if the bot's topmost role is above role A.
2. To change the nickname for a user, the user's highest role should be lower than the bot's highest role.
3. The bot should have read access to the channel to read `.verify` commands
4. The bot should have write access to the channel to give feedback on verification (Success/Failure).

(If you're new to Discord roles, read the FAQ: [link](https://support.discord.com/hc/en-us/articles/214836687-Role-Management-101))

### Notes

1. `.verify` might not work for server-admins. That indicates your role setup does not follow the criteria above.
1. `/verify` might not work for server-admins. That indicates your role setup does not follow the criteria above.

If at any point you face any difficulty, please raise a new issue in this GitHub repository.

Expand All @@ -96,14 +95,7 @@ This instance is intended to be used only by IIIT-H related discord servers. See

## Hosting the project

This project is made up of two parts, the web portal and the discord bot.

The web portal does four things:

1. Authenticates you against Discord OAuth2.
2. Authenticates you against a CAS portal (feel free to change this to SAML, OAuth2, or whatever your organization prefers to use).
3. Stores this data in MongoDB.
4. Checks if the server is allowed in `server_config.ini`
This project is made up of two parts, the web portal (in the [/portal](/portal) directory) and the discord bot (in the [/bot](/bot) directory)

You may need to change the source code corresponding to your organization's CAS attributes; specifically, the `/cas` express endpoint has settings for which attributes to fetch from the CAS server response.

Expand Down
4 changes: 2 additions & 2 deletions bot/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# cache dependencies
FROM python:3.11 as python_cache
FROM python:3.12-slim as python_cache
ENV VIRTUAL_ENV=/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /cache/
Expand All @@ -8,7 +8,7 @@ RUN python -m venv /venv
RUN pip install -r requirements.txt

# build and start
FROM python:3.11-slim as build
FROM python:3.12-slim as build
EXPOSE 80
WORKDIR /app/
ENV VIRTUAL_ENV=/venv
Expand Down
136 changes: 109 additions & 27 deletions bot/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@
- `is_verified()`: If a user is present in DB or not
- `check_bot_admin()`: Check if a user is a bot admin or not
- `get_realname_from_discordid()`: Get a user's real name from their Discord ID.
- `send_link()`: Send link for reattempting authentication.
- `create_roles_if_missing()`: Adds missing roles to a server.
- `assign_role()`: Adds specified roles to the given user post-verification.
- `delete_role()`: Removes specified roles from the given user post-verification.
- `set_nickname()`: Sets nickname of the given user to real name if server specifies.
- `post_verification()`: Handle role add/delete and nickname set post-verification of given user.
- `verify_user()`: Implements `.verify`.
- `verify_user()`: Implements `/verify`.
- `backend_info()`: Logs server details for debug purposes
- `backend_info_error()`: If the author of the message is not a bot admin then reply accordingly.
- `check_is_academic_mod()`: Checks if server is for academic use.
Expand All @@ -30,7 +29,11 @@
import sys
import asyncio
import platform
import secrets
import time
from typing import TypedDict
from aiohttp import web

from dotenv import load_dotenv

import discord
Expand Down Expand Up @@ -60,20 +63,70 @@

BOT_ADMINS = {int(id) for id in os.getenv("BOT_ADMINS", "").split(",") if id}

BOT_PRIVATE_IP = os.getenv("BOT_PRIVATE_IP")

VERIFY_TIMEOUT_SECONDS = 300

intent = discord.Intents.default()
intent.message_content = True
bot = commands.Bot(command_prefix=".", intents=intent)
# to get message privelege

db: database.Database | None = None # assigned in main function

# Yes, global variable. Not the most ideal thing but is efficient
token_to_id: dict[str, tuple[int, float]] = {}


class DBEntry(TypedDict):
discordId: str
name: str
email: str
rollno: str
view: bool


async def webserver():
"""
Launch an aiohttp web server.
This is an internal server used for the JS code in /portal to communicate
with the code here. This server MUST NOT be exposed to the public.
"""

async def authenticate(request: web.Request):
if db is None:
# should not happen, but if it somehow does, it's a server error
return web.Response(status=500)

token = request.match_info["token"]
try:
discord_id = str(token_to_id.pop(token)[0])
except KeyError:
# the token has already expired
return web.Response(status=404)

data = await request.post()
search = {"discordId": discord_id}
try:
updated = {
"discordId": discord_id,
"name": data["name"],
"email": data["email"],
"rollno": data["rollno"],
}
except KeyError:
# client sent bad request
return web.Response(status=400)

db.users.update_one(search, {"$set": updated}, upsert=True)
return web.Response()

app = web.Application()
app.add_routes([web.post("/{token}", authenticate)])

# run app async
runner = web.AppRunner(app)
await runner.setup()
await web.TCPSite(runner, BOT_PRIVATE_IP, 80).start()


def get_users_from_discordid(user_id: int):
Expand Down Expand Up @@ -105,13 +158,6 @@ def get_realname_from_discordid(user_id: int):
return users[0]["name"]


async def send_link(ctx: commands.Context):
"""Sends the base url for users to reattempt sign-in."""
await ctx.reply(
f"<{BASE_URL}>\nSign in through our portal, and try again.", ephemeral=True
)


def get_first_channel_with_permission(guild: discord.Guild):
for channel in guild.text_channels:
if channel.permissions_for(guild.me).send_messages:
Expand Down Expand Up @@ -206,27 +252,61 @@ async def post_verification(
@bot.hybrid_command(name="verify")
async def verify_user(ctx: commands.Context):
"""
Runs when the user types `.verify` in the server. First tries to find the user in the DB.
If present, performs post-verification actions. If not verified, Sends the link to authenticate
and waits for a minute. xits if the user still is not found after that and
tells user to run `.verify` again.
Verify yourself with a CAS login.
Runs when the user does `/verify` in the server. First tries to find the user.
If present, performs post-verification actions. If not verified, creates and sends
a unique verification link. Handles the link timeout and also performs post-verify
actions if the user could verify within the timeout.
"""
if not ctx.interaction:
await ctx.reply("This command has been removed, please use /verify instead.")
return

author = ctx.message.author
for i in range(2):
verification = is_verified(author.id)
if is_verified(author.id):
# user has already previously verified
await post_verification(ctx, author)
return

if verification:
await post_verification(ctx, author)
old_link = True
for loop_token, (loop_discord_id, loop_expire_time) in token_to_id.items():
if loop_discord_id == author.id:
# user already has an active link
token = loop_token
expire_time = loop_expire_time
break
if i == 0:
await send_link(ctx)
await asyncio.sleep(60)
else:
await ctx.reply(
f"Sorry {author.mention}, could not auto-detect your verification. \
Please run `.verify` again.",
ephemeral=True,
)
else:
old_link = False
token = secrets.token_urlsafe()
expire_time = time.time() + VERIFY_TIMEOUT_SECONDS
token_to_id[token] = (author.id, expire_time)

# it is important that is is ephemeral. It has a secret link that only the
# invoker must see.
await ctx.send(
f"[This](<{BASE_URL}/cas?token={token}>) is your verification link, click "
"it to login and verify yourself.\n"
"IMPORTANT NOTE: Above link is secret, do not share with anyone! "
f"This link expires <t:{int(expire_time)}:R>.",
ephemeral=True,
)

if old_link:
return

while time.time() < expire_time and token in token_to_id:
await asyncio.sleep(1)

if token not in token_to_id and is_verified(author.id):
await post_verification(ctx, author)
return

# time-out has happened, and no success. pop token to expire link
token_to_id.pop(token, None)
await ctx.reply(
f"{author.mention}, you haven't been CAS-verified, you may retry to /verify"
)


@bot.hybrid_command(name="backend_info")
Expand Down Expand Up @@ -381,6 +461,8 @@ async def on_ready():
except Exception as e:
print(e)

bot.loop.create_task(webserver())


def main():
"""
Expand Down
7 changes: 6 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ services:
build:
context: ./bot
dockerfile: Dockerfile
networks:
portal_network:
# this IP must be private and NOT EXPOSED via any proxy to the
# public
ipv4_address: ${BOT_PRIVATE_IP}
env_file:
- ./.env
volumes:
Expand All @@ -23,7 +28,7 @@ services:
dockerfile: Dockerfile
networks:
portal_network:
ipv4_address: 172.20.0.5
ipv4_address: ${PORTAL_PRIVATE_IP}
env_file:
- ./.env
volumes:
Expand Down
Loading

0 comments on commit f0e06cd

Please sign in to comment.