feat: implement OAuth2 login with Discord and automatic server detection and assignment
This commit is contained in:
10 files changed
+206
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -3,6 +3,7 @@ python-dotenv
|
||||
yt-dlp
|
||||
PyNaCl
|
||||
fastapi
|
||||
httpx
|
||||
uvicorn
|
||||
websockets
|
||||
python-multipart
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user