feat: implement OAuth2 login with Discord and automatic server detection and assignment

This commit is contained in:
mmacierzanka committed 2026-10-01 02:40:00 +02:00
1 parent 9fbfce8511
commit 71958e5995
10 files changed
+206 -20

No files matched your search

+41 -9
View File
@@ -10,11 +10,14 @@ pip install -r requirements.txt
python main.py # albo: python -m zyrarbot
```
Wymagane zmienne środowiskowe (plik `.env`):
Zmienne środowiskowe (plik `.env`):
| Zmienna | Opis |
| --------------- | ----------------------------------------------------------- |
| `DISCORD_TOKEN` | Token bota Discord. |
| `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | Dane aplikacji OAuth2 z Discord Developer Portal. |
| `DISCORD_REDIRECT_URI` | Publiczny adres callbacku, np. `https://bot.example.com/api/auth/discord/callback`. |
| `DISCORD_ADMIN_IDS` | Opcjonalne, przecinkami oddzielone Discord User ID administratorów panelu. |
| `GUILD_ID` | Opcjonalnie - synchronizuje komendy tylko z jednym serwerem. |
| `POSTGRES_PASSWORD` | Hasło użytkownika bazy; domyślnie `zyrarbot`, zalecana zmiana w `.env`. |
| `POSTGRES_DB` / `POSTGRES_USER` | Opcjonalna nazwa bazy i użytkownika; domyślnie `zyrarbot`. |
@@ -24,17 +27,46 @@ jest dostępny pod `http://localhost` po wykonaniu `docker compose up --build`.
### Konta i dostępy panelu
Panel wymaga logowania. Utwórz pierwszego administratora przez Makefile:
Panel obsługuje logowanie hasłem oraz Discord OAuth2. Przy logowaniu przez Discord
dostęp do sterowania na danym serwerze dostaje
użytkownik, który ma tam uprawnienie `Manage Server` (lub Administratora), o ile bot
również jest na tym serwerze. Dostęp odświeża się przy każdym logowaniu. Administratorów
panelu wskaż przez `DISCORD_ADMIN_IDS` (Discord User ID, oddzielone przecinkami); mają
dostęp do `/admin` i wszystkich serwerów, na których działa bot. Uprawnienie Administratora
na serwerze daje dostęp do sterowania tym serwerem, ale samo nie czyni użytkownika
administratorem panelu. Logowanie hasłem nadal działa. Przycisk Discord pojawia się tylko,
gdy ustawione są wszystkie trzy zmienne OAuth: Client ID, Client Secret i Redirect URI.
```bash
make create-admin USERNAME=admin
Dodaj do `.env` wartości aplikacji OAuth2 (nie publikuj Client Secret):
```dotenv
DISCORD_CLIENT_ID=123456789012345678
DISCORD_CLIENT_SECRET=wklej_client_secret
DISCORD_REDIRECT_URI=https://bot.example.com/api/auth/discord/callback
DISCORD_ADMIN_IDS=123456789012345678
```
Komenda poprosi o niepuste hasło. Domyślna nazwa użytkownika to `admin`, jeśli nie
podasz `USERNAME`. Administrator ma dostęp do wszystkich
serwerów i strony `/admin`, gdzie może tworzyć konta użytkowników, przypisywać im
dostęp do serwerów oraz restartować bota. Zwykły użytkownik widzi wyłącznie
przypisane mu serwery. Konta i sesje są przechowywane w PostgreSQL.
#### Konfiguracja Discord Developer Portal
1. Wybierz lub utwórz aplikację w [Discord Developer Portal](https://discord.com/developers/applications).
2. W **OAuth2 → General** skopiuj **Client ID** i wygeneruj **Client Secret**.
3. W **OAuth2 → General → Redirects** dodaj dokładnie wartość `DISCORD_REDIRECT_URI`,
np. `https://bot.example.com/api/auth/discord/callback`. Adres musi być osiągalny
przez przeglądarkę użytkownika. Lokalnie z Docker Compose użyj
`http://localhost/api/auth/discord/callback`; przy uruchomieniu bezpośrednio
na porcie 8000 użyj `http://localhost:8000/api/auth/discord/callback`.
4. Ustaw `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` i `DISCORD_REDIRECT_URI` w `.env`.
`DISCORD_ADMIN_IDS` jest opcjonalne; wpisz tam własny User ID, jeśli konto ma
zarządzać panelem. Aby skopiować ID, włącz Developer Mode w Discordzie, kliknij
swój profil prawym przyciskiem i wybierz **Copy User ID**. Kolejne ID oddzielaj
przecinkami.
5. Aby dodać bota na serwery, w **Installation** włącz instalację dla serwerów i zakresy
`bot` oraz `applications.commands`; przyznaj wymagane uprawnienia bota (co najmniej
View Channels, Connect, Speak i Send Messages). OAuth logowania używa zakresów
`identify` i `guilds`; nie wymaga dodatkowych intentów Gateway.
6. Zrestartuj usługę: `docker compose up -d --build`. Otwórz panel i wybierz
**Zaloguj przez Discord**. Discord poprosi o zakresy `identify` i `guilds`;
zaakceptuj je, aby panel mógł sprawdzić konto i serwery użytkownika.
Panel obsługuje wiele serwerów Discorda. Wybór serwera w nagłówku przełącza jego
osobną kolejkę, historię, bazę plików i ustawienia odtwarzania. Ustawienia, historia
+4
View File
@@ -23,6 +23,10 @@ services:
container_name: zyrarbot
environment:
- DISCORD_TOKEN=${DISCORD_TOKEN}
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}
- DISCORD_REDIRECT_URI=${DISCORD_REDIRECT_URI:-}
- DISCORD_ADMIN_IDS=${DISCORD_ADMIN_IDS:-}
- GUILD_ID=${GUILD_ID:-}
- POSTGRES_DB=${POSTGRES_DB:-zyrarbot}
- POSTGRES_USER=${POSTGRES_USER:-zyrarbot}
+29 -9
View File
@@ -17,6 +17,7 @@ const { status, connect, disconnect, selectGuild } = useBotStatus()
const { refresh } = useUploads()
const user = ref<{ id: number; username: string; is_admin: boolean } | null>(null)
const authLoading = ref(true)
const discordLoginEnabled = ref(false)
const loginName = ref('')
const loginPassword = ref('')
const loginError = ref('')
@@ -33,15 +34,33 @@ async function loadUser() {
}
}
async function loadAuthOptions() {
try {
const response = await fetch('/api/auth/options')
if (response.ok) {
const options = await response.json() as { discord_oauth_enabled: boolean }
discordLoginEnabled.value = options.discord_oauth_enabled
}
} catch {
discordLoginEnabled.value = false
}
}
async function login() {
loginError.value = ''
const response = await fetch('/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: loginName.value, password: loginPassword.value }),
})
if (!response.ok) { loginError.value = 'Nieprawidłowa nazwa użytkownika lub hasło'; return }
const loggedInUser = await response.json() as { id: number; username: string; is_admin: boolean }
user.value = loggedInUser
try {
const response = await fetch('/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: loginName.value, password: loginPassword.value }),
})
if (!response.ok) {
loginError.value = 'Nieprawidłowa nazwa użytkownika lub hasło'
return
}
user.value = await response.json() as { id: number; username: string; is_admin: boolean }
} catch {
loginError.value = 'Nie udało się połączyć z serwerem'
}
}
async function logout() {
@@ -95,7 +114,7 @@ async function changeGuild(guildId: string) {
await refresh()
}
onMounted(() => { void loadUser() })
onMounted(() => { void loadUser(); void loadAuthOptions() })
watch(user, (value) => {
if (value && !(value.is_admin && onAdminRoute)) { connect(); void refresh() }
@@ -111,6 +130,7 @@ watch(user, (value) => {
<label>Hasło<input v-model="loginPassword" type="password" autocomplete="current-password" required></label>
<p v-if="loginError" class="auth-error">{{ loginError }}</p>
<button class="login-button" type="submit">Zaloguj</button>
<a v-if="discordLoginEnabled" class="login-button discord-login-button" href="/api/auth/discord">Zaloguj przez Discord</a>
</form>
</main>
<AdminLayout v-else-if="user.is_admin && onAdminRoute" :username="user.username" :user-id="user.id" />
@@ -157,5 +177,5 @@ watch(user, (value) => {
</template>
<style scoped>
.auth-loading{min-height:60vh;display:grid;place-items:center;color:#a9b1c4;font-size:14px}.auth-page{width:520px;max-width:calc(100vw - 40px);margin:8vh auto;padding:24px}.auth-card{display:grid;gap:16px;padding:30px;border:1px solid #303449;border-radius:16px;background:#171a29;box-shadow:0 20px 60px #0006}.auth-card h1{margin:0}.auth-card label{display:grid;gap:7px;color:#d9dcec;font-size:13px;font-weight:600}.auth-card input{width:100%;height:44px;padding:0 12px;border-radius:9px;border:1px solid #42475e;background:#101321;color:#f6f7fc;font:inherit}.auth-card input:focus{outline:2px solid #9b83ff66;border-color:#9b83ff}.login-button{height:44px;border:1px solid #8c70f2;border-radius:9px;background:#7657e8;color:#fff;font:600 14px Inter,ui-sans-serif,system-ui,sans-serif;cursor:pointer}.login-button:hover{background:#886df1}.auth-error{color:#ff4d5e;font-weight:600}.admin-shortcut{display:flex;justify-content:flex-end;padding:0 0 12px}.admin-shortcut a{color:#c8baff}
.auth-loading{min-height:60vh;display:grid;place-items:center;color:#a9b1c4;font-size:14px}.auth-page{width:520px;max-width:calc(100vw - 40px);margin:8vh auto;padding:24px}.auth-card{display:grid;gap:16px;padding:30px;border:1px solid #303449;border-radius:16px;background:#171a29;box-shadow:0 20px 60px #0006}.auth-card h1{margin:0}.auth-card label{display:grid;gap:7px;color:#d9dcec;font-size:13px;font-weight:600}.auth-card input{width:100%;height:44px;padding:0 12px;border-radius:9px;border:1px solid #42475e;background:#101321;color:#f6f7fc;font:inherit}.auth-card input:focus{outline:2px solid #9b83ff66;border-color:#9b83ff}.login-button{display:grid;place-items:center;height:44px;border:1px solid #8c70f2;border-radius:9px;background:#7657e8;color:#fff;text-decoration:none;font:600 14px Inter,ui-sans-serif,system-ui,sans-serif;cursor:pointer}.login-button:hover{background:#886df1}.discord-login-button{border-color:#5865f2;background:#4752c4}.discord-login-button:hover{background:#5865f2}.auth-error{color:#ff4d5e;font-weight:600}.admin-shortcut{display:flex;justify-content:flex-end;padding:0 0 12px}.admin-shortcut a{color:#c8baff}
</style>
+1
View File
@@ -3,6 +3,7 @@ python-dotenv
yt-dlp
PyNaCl
fastapi
httpx
uvicorn
websockets
python-multipart
+2 -1
View File
@@ -81,7 +81,8 @@ class Application:
@self.web.middleware("http")
async def require_panel_login(request, call_next):
if request.url.path.startswith("/api/") and request.url.path not in {
"/api/health", "/api/auth/login"
"/api/health", "/api/auth/login", "/api/auth/options",
"/api/auth/discord", "/api/auth/discord/callback"
}:
if self.web.state.auth.user_for_token(request.cookies.get("zyrarbot_session")) is None:
from fastapi.responses import JSONResponse
+8
View File
@@ -21,6 +21,10 @@ class Settings:
history_limit: int = 50
min_plays: int = 1
max_plays: int = 999
discord_client_id: str | None = None
discord_client_secret: str | None = None
discord_redirect_uri: str | None = None
discord_admin_ids: tuple[int, ...] = ()
@classmethod
def from_env(cls) -> Settings:
@@ -29,6 +33,10 @@ class Settings:
return cls(
discord_token=os.getenv("DISCORD_TOKEN"),
guild_id=int(raw_guild_id) if raw_guild_id else None,
discord_client_id=os.getenv("DISCORD_CLIENT_ID"),
discord_client_secret=os.getenv("DISCORD_CLIENT_SECRET"),
discord_redirect_uri=os.getenv("DISCORD_REDIRECT_URI"),
discord_admin_ids=tuple(int(value) for value in os.getenv("DISCORD_ADMIN_IDS", "").split(",") if value.strip()),
)
@property
@@ -0,0 +1,16 @@
from __future__ import annotations
import psycopg
class DiscordOAuthMigration:
version = 4
name = "discord_oauth_identity"
def apply(self, connection: psycopg.Connection) -> None:
connection.execute("ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL")
connection.execute("ALTER TABLE users ADD COLUMN discord_id BIGINT UNIQUE")
def rollback(self, connection: psycopg.Connection) -> None:
connection.execute("ALTER TABLE users DROP COLUMN discord_id")
connection.execute("ALTER TABLE users ALTER COLUMN password_hash SET NOT NULL")
@@ -9,5 +9,6 @@ UsersMigration = import_module(".0002_users", __name__).UsersMigration
DisableLogChannelMigration = import_module(
".0003_disable_log_channel", __name__
).DisableLogChannelMigration
DiscordOAuthMigration = import_module(".0004_discord_oauth", __name__).DiscordOAuthMigration
MIGRATIONS = (InitialSchemaMigration(), UsersMigration(), DisableLogChannelMigration())
MIGRATIONS = (InitialSchemaMigration(), UsersMigration(), DisableLogChannelMigration(), DiscordOAuthMigration())
+29
View File
@@ -47,6 +47,35 @@ class AuthRepository:
)
return token, {"id": row[0], "username": row[1], "is_admin": row[3]}
def login_discord(self, profile: dict, eligible_guild_ids: list[int], admin_ids: tuple[int, ...]) -> tuple[str, dict]:
discord_id = int(profile["id"])
username = str(profile.get("global_name") or profile.get("username") or discord_id)
is_admin = discord_id in admin_ids
with self.database.connect() as connection:
row = connection.execute(
"SELECT id FROM users WHERE discord_id=%s", (discord_id,)
).fetchone()
if row:
user_id = row[0]
connection.execute("UPDATE users SET is_admin=%s WHERE id=%s", (is_admin, user_id))
else:
row = connection.execute(
"INSERT INTO users(username,password_hash,is_admin,discord_id) VALUES (%s,NULL,%s,%s) RETURNING id",
(f"{username} ({discord_id})", is_admin, discord_id),
).fetchone()
user_id = row[0]
connection.execute("DELETE FROM user_guild_access WHERE user_id=%s", (user_id,))
for guild_id in eligible_guild_ids:
connection.execute("INSERT INTO user_guild_access(user_id,guild_id) VALUES (%s,%s)",
(user_id, guild_id))
token = secrets.token_urlsafe(32)
connection.execute(
"INSERT INTO user_sessions(token_hash,user_id,expires_at) VALUES (%s,%s,%s)",
(hashlib.sha256(token.encode()).hexdigest(), user_id,
datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)),
)
return token, {"id": user_id, "username": username, "is_admin": is_admin}
def user_for_token(self, token: str | None) -> dict | None:
if not token:
return None
+74
View File
@@ -1,11 +1,20 @@
from __future__ import annotations
import asyncio
import secrets
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field
from zyrarbot.ui.api.auth import current_user, require_admin
router = APIRouter(prefix="/api/auth", tags=["auth"])
DISCORD_AUTHORIZE = "https://discord.com/oauth2/authorize"
DISCORD_TOKEN = "https://discord.com/api/oauth2/token"
DISCORD_API = "https://discord.com/api/v10"
class Credentials(BaseModel):
@@ -25,6 +34,71 @@ class UserRoleUpdate(BaseModel):
is_admin: bool
@router.get("/discord")
async def discord_login(request: Request) -> RedirectResponse:
settings = request.app.state.settings
if not all((settings.discord_client_id, settings.discord_client_secret, settings.discord_redirect_uri)):
raise HTTPException(status_code=503, detail="Logowanie Discord nie jest skonfigurowane")
state = secrets.token_urlsafe(32)
response = RedirectResponse(f"{DISCORD_AUTHORIZE}?" + urlencode({
"client_id": settings.discord_client_id,
"redirect_uri": settings.discord_redirect_uri,
"response_type": "code",
"scope": "identify guilds",
"state": state,
}))
response.set_cookie("zyrarbot_oauth_state", state, httponly=True, samesite="lax",
secure=request.url.scheme == "https", max_age=600, path="/")
return response
@router.get("/options")
async def auth_options(request: Request) -> dict[str, bool]:
settings = request.app.state.settings
return {"discord_oauth_enabled": bool(
settings.discord_client_id and settings.discord_client_secret and settings.discord_redirect_uri
)}
@router.get("/discord/callback")
async def discord_callback(request: Request, code: str | None = None, state: str | None = None) -> RedirectResponse:
settings = request.app.state.settings
if not state or not secrets.compare_digest(state, request.cookies.get("zyrarbot_oauth_state", "")):
raise HTTPException(status_code=400, detail="Nieprawidłowy stan logowania Discord")
if not code:
raise HTTPException(status_code=400, detail="Discord nie zwrócił kodu autoryzacji")
async with httpx.AsyncClient(timeout=15) as client:
token_response = await client.post(DISCORD_TOKEN, data={
"client_id": settings.discord_client_id,
"client_secret": settings.discord_client_secret,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": settings.discord_redirect_uri,
}, headers={"Content-Type": "application/x-www-form-urlencoded"})
if token_response.is_error:
raise HTTPException(status_code=401, detail="Nie udało się zalogować przez Discord")
access_token = token_response.json()["access_token"]
headers = {"Authorization": f"Bearer {access_token}"}
profile_response, guild_response = await asyncio.gather(
client.get(f"{DISCORD_API}/users/@me", headers=headers),
client.get(f"{DISCORD_API}/users/@me/guilds", headers=headers),
)
if profile_response.is_error or guild_response.is_error:
raise HTTPException(status_code=401, detail="Discord nie zwrócił danych użytkownika")
bot_guild_ids = {guild.id for guild in request.app.state.guilds.guilds()}
manageable = [int(item["id"]) for item in guild_response.json()
if int(item["id"]) in bot_guild_ids
and (int(item["permissions"]) & (0x8 | 0x20)) != 0]
token, _user = request.app.state.auth.login_discord(
profile_response.json(), manageable, settings.discord_admin_ids
)
response = RedirectResponse("/", status_code=303)
response.delete_cookie("zyrarbot_oauth_state", path="/")
response.set_cookie("zyrarbot_session", token, httponly=True, samesite="lax",
secure=request.url.scheme == "https", max_age=14 * 86400, path="/")
return response
@router.post("/login")
async def login(body: Credentials, request: Request, response: Response) -> dict:
result = request.app.state.auth.login(body.username, body.password)