feat: OpenID auth via vas3k.club
This commit is contained in:
parent
67ff3cd490
commit
fe7b4da22d
|
@ -1,16 +1,21 @@
|
|||
import logging
|
||||
|
||||
import requests
|
||||
from authlib.integrations.django_client import OAuth
|
||||
from django.conf import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
oauth = OAuth()
|
||||
oauth.register(**settings.CLUB_OPENID_CONFIG)
|
||||
|
||||
def parse_membership(user_slug, jwt_token):
|
||||
|
||||
def parse_membership(token):
|
||||
try:
|
||||
return requests.get(
|
||||
url=f"https://vas3k.club/user/{user_slug}.json",
|
||||
params={
|
||||
"jwt": jwt_token
|
||||
url=f"{oauth.club.api_base_url}/user/me.json",
|
||||
headers={
|
||||
"Authorization": f"{token['token_type']} {token['access_token']}"
|
||||
}
|
||||
).json()
|
||||
except Exception as ex:
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import logging
|
||||
from urllib.parse import urlencode, quote, urlparse
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import jwt
|
||||
from authlib.integrations.base_client import OAuthError
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import logout, login
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import render, redirect
|
||||
from django.urls import reverse
|
||||
from requests import HTTPError
|
||||
|
||||
from authn import club, patreon
|
||||
from authn.club import oauth
|
||||
from authn.exceptions import PatreonException
|
||||
from users.models import User
|
||||
|
||||
|
@ -28,33 +30,37 @@ def login_club(request):
|
|||
if request.user.is_authenticated:
|
||||
return redirect("profile")
|
||||
|
||||
goto = request.GET.get("goto")
|
||||
params = urlencode({
|
||||
"app_id": "vas3k_blog",
|
||||
"redirect": f"https://{request.get_host()}/auth/club_callback/" + (f"?goto={quote(goto)}" if goto else "")
|
||||
})
|
||||
return redirect(f"{settings.CLUB_AUTH_URL}?{params}")
|
||||
# TODO: implement
|
||||
# goto = request.GET.get("goto")
|
||||
|
||||
redirect_uri = request.build_absolute_uri(reverse("club_callback"))
|
||||
return oauth.club.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
def club_callback(request):
|
||||
token = request.GET.get("jwt")
|
||||
if not token:
|
||||
try:
|
||||
token = oauth.club.authorize_access_token(request)
|
||||
except OAuthError as ex:
|
||||
return render(request, "error.html", {
|
||||
"title": "Ошибка OAuth",
|
||||
"message": f"Что-то проебалось при авторизации: {ex}"
|
||||
})
|
||||
except HTTPError as ex:
|
||||
return render(request, "error.html", {
|
||||
"title": "Ошибка Клуба",
|
||||
"message": f"Что-то сломалось или сайт упал, попробуйте еще раз: {ex}"
|
||||
})
|
||||
|
||||
userinfo = token.get("userinfo")
|
||||
|
||||
if not token or not userinfo:
|
||||
return render(request, "error.html", {
|
||||
"title": "Что-то пошло не так",
|
||||
"message": "При авторизации потерялся токен. Попробуйте войти еще раз."
|
||||
"message": "При авторизации потерялся токен юзера. Попробуйте войти еще раз."
|
||||
})
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_PUBLIC_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||
except (jwt.DecodeError, jwt.ExpiredSignatureError) as ex:
|
||||
log.error(f"JWT token error: {ex}")
|
||||
return render(request, "error.html", {
|
||||
"title": "Что-то сломалось",
|
||||
"message": "Неправильный ключ. Наверное, что-то сломалось. Либо ты ХАКИР!!11"
|
||||
})
|
||||
|
||||
user_slug = payload["user_slug"]
|
||||
club_profile = club.parse_membership(user_slug, token)
|
||||
user_slug = userinfo["sub"]
|
||||
club_profile = club.parse_membership(token)
|
||||
if not club_profile or not club_profile.get("user"):
|
||||
return render(request, "error.html", {
|
||||
"message": f"Член Клуба с именем {user_slug} не найден. "
|
||||
|
@ -64,25 +70,25 @@ def club_callback(request):
|
|||
|
||||
if club_profile["user"]["payment_status"] != "active":
|
||||
return render(request, "error.html", {
|
||||
"message": "Ваша подписка истекла. "
|
||||
"message": "Ваша подписка на Клуб истекла. "
|
||||
"<a href=\"https://vas3k.club\">Продлите</a> её здесь."
|
||||
})
|
||||
|
||||
user = User.objects.filter(Q(email=payload["user_email"]) | Q(vas3k_club_slug=payload["user_slug"])).first()
|
||||
user = User.objects.filter(Q(email=userinfo["email"]) | Q(vas3k_club_slug=userinfo["sub"])).first()
|
||||
if user:
|
||||
user.avatar = club_profile["user"]["avatar"]
|
||||
user.vas3k_club_slug = payload["user_slug"]
|
||||
user.vas3k_club_slug = userinfo["sub"]
|
||||
if not user.email:
|
||||
user.email = payload["user_email"]
|
||||
user.email = userinfo["email"]
|
||||
user.country = club_profile["user"]["country"]
|
||||
user.city = club_profile["user"]["city"]
|
||||
user.save()
|
||||
else:
|
||||
user = User.objects.create_user(
|
||||
vas3k_club_slug=payload["user_slug"],
|
||||
vas3k_club_slug=userinfo["sub"],
|
||||
avatar=club_profile["user"]["avatar"],
|
||||
username=club_profile["user"]["full_name"][:20],
|
||||
email=payload["user_email"],
|
||||
email=userinfo["email"],
|
||||
)
|
||||
|
||||
login(request, user)
|
||||
|
|
|
@ -16,7 +16,7 @@ services:
|
|||
- SENTRY_DSN
|
||||
- PATREON_CLIENT_ID
|
||||
- PATREON_CLIENT_SECRET
|
||||
- JWT_SECRET
|
||||
- CLUB_OPENID_CONFIG_SECRET
|
||||
- EMAIL_HOST
|
||||
- EMAIL_HOST_USER
|
||||
- EMAIL_HOST_PASSWORD
|
||||
|
|
|
@ -68,9 +68,20 @@ tests = ["attrs", "zope.interface"]
|
|||
tests-no-zope = ["hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist", "cloudpickle", "mypy (>=0.971,<0.990)", "pytest-mypy-plugins"]
|
||||
tests_no_zope = ["hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist", "cloudpickle", "mypy (>=0.971,<0.990)", "pytest-mypy-plugins"]
|
||||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.2.0"
|
||||
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
cryptography = ">=3.2"
|
||||
|
||||
[[package]]
|
||||
name = "beautifulsoup4"
|
||||
version = "4.11.1"
|
||||
version = "4.11.2"
|
||||
description = "Screen-scraping library"
|
||||
category = "main"
|
||||
optional = false
|
||||
|
@ -112,14 +123,11 @@ pycparser = "*"
|
|||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "2.1.1"
|
||||
version = "3.0.1"
|
||||
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6.0"
|
||||
|
||||
[package.extras]
|
||||
unicode_backport = ["unicodedata2"]
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
|
@ -142,7 +150,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7
|
|||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "39.0.0"
|
||||
version = "39.0.2"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
category = "main"
|
||||
optional = false
|
||||
|
@ -152,12 +160,14 @@ python-versions = ">=3.6"
|
|||
cffi = ">=1.12"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1,!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"]
|
||||
docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
|
||||
docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
|
||||
pep8test = ["black", "ruff"]
|
||||
pep8test = ["black", "ruff", "mypy", "types-pytz", "types-requests", "check-manifest"]
|
||||
sdist = ["setuptools-rust (>=0.11.4)"]
|
||||
ssh = ["bcrypt (>=3.1.5)"]
|
||||
test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
|
||||
test = ["pytest (>=6.2.0)", "pytest-shard (>=0.1.2)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
|
||||
test-randomorder = ["pytest-randomly"]
|
||||
tox = ["tox"]
|
||||
|
||||
[[package]]
|
||||
name = "cssselect"
|
||||
|
@ -181,7 +191,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8
|
|||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "4.1.5"
|
||||
version = "4.1.7"
|
||||
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
|
||||
category = "main"
|
||||
optional = false
|
||||
|
@ -392,7 +402,7 @@ tests = ["pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)"]
|
|||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "7.2.0"
|
||||
version = "7.2.2"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
|
@ -412,11 +422,11 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.
|
|||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "0.21.0"
|
||||
version = "1.0.0"
|
||||
description = "Read key-value pairs from a .env file and set them as environment variables"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.8"
|
||||
|
||||
[package.extras]
|
||||
cli = ["click (>=5.0)"]
|
||||
|
@ -457,7 +467,7 @@ socks = ["pysocks"]
|
|||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2022.7"
|
||||
version = "2022.7.1"
|
||||
description = "World timezone definitions, modern and historical"
|
||||
category = "main"
|
||||
optional = false
|
||||
|
@ -484,7 +494,7 @@ python-versions = ">=3.6"
|
|||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.28.1"
|
||||
version = "2.28.2"
|
||||
description = "Python HTTP for Humans."
|
||||
category = "main"
|
||||
optional = false
|
||||
|
@ -492,7 +502,7 @@ python-versions = ">=3.7, <4"
|
|||
|
||||
[package.dependencies]
|
||||
certifi = ">=2017.4.17"
|
||||
charset-normalizer = ">=2,<3"
|
||||
charset-normalizer = ">=2,<4"
|
||||
idna = ">=2.5,<4"
|
||||
urllib3 = ">=1.21.1,<1.27"
|
||||
|
||||
|
@ -502,7 +512,7 @@ use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"]
|
|||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "1.12.1"
|
||||
version = "1.16.0"
|
||||
description = "Python client for Sentry (https://sentry.io)"
|
||||
category = "main"
|
||||
optional = false
|
||||
|
@ -514,6 +524,7 @@ urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""}
|
|||
|
||||
[package.extras]
|
||||
aiohttp = ["aiohttp (>=3.5)"]
|
||||
arq = ["arq (>=0.23)"]
|
||||
beam = ["apache-beam (>=2.12)"]
|
||||
bottle = ["bottle (>=0.12.13)"]
|
||||
celery = ["celery (>=3)"]
|
||||
|
@ -523,7 +534,8 @@ falcon = ["falcon (>=1.4)"]
|
|||
fastapi = ["fastapi (>=0.79.0)"]
|
||||
flask = ["flask (>=0.11)", "blinker (>=1.1)"]
|
||||
httpx = ["httpx (>=0.16.0)"]
|
||||
opentelemetry = ["opentelemetry-distro (>=0.350b0)"]
|
||||
huey = ["huey (>=2)"]
|
||||
opentelemetry = ["opentelemetry-distro (>=0.35b0)"]
|
||||
pure_eval = ["pure-eval", "executing", "asttokens"]
|
||||
pymongo = ["pymongo (>=3.1)"]
|
||||
pyspark = ["pyspark (>=2.4.4)"]
|
||||
|
@ -532,6 +544,7 @@ rq = ["rq (>=0.6)"]
|
|||
sanic = ["sanic (>=0.8)"]
|
||||
sqlalchemy = ["sqlalchemy (>=1.2)"]
|
||||
starlette = ["starlette (>=0.19.1)"]
|
||||
starlite = ["starlite (>=1.48)"]
|
||||
tornado = ["tornado (>=5)"]
|
||||
|
||||
[[package]]
|
||||
|
@ -552,11 +565,11 @@ python-versions = ">=3.7"
|
|||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.3.2.post1"
|
||||
version = "2.4"
|
||||
description = "A modern CSS selector implementation for Beautiful Soup."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
|
@ -616,7 +629,7 @@ test = ["pytest-mock (>=3.3)", "pytest (>=4.3)"]
|
|||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "1.26.13"
|
||||
version = "1.26.14"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
category = "main"
|
||||
optional = false
|
||||
|
@ -684,13 +697,14 @@ python-versions = ">=3.7"
|
|||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "3aa71cf2f09ffd6d788b35fdcb946394589eddab3f0909433d4ac4e8b65035b1"
|
||||
content-hash = "ca4d7604e33136016efe2245aafdbf8354e5b70792e74d41e7a7e9ef4ec1114a"
|
||||
|
||||
[metadata.files]
|
||||
anyio = []
|
||||
apscheduler = []
|
||||
asgiref = []
|
||||
attrs = []
|
||||
authlib = []
|
||||
beautifulsoup4 = []
|
||||
cachetools = []
|
||||
certifi = []
|
||||
|
|
|
@ -23,6 +23,7 @@ cryptography = "^39.0.0"
|
|||
gunicorn = "^20.1.0"
|
||||
uvicorn = {extras = ["standard"], version = "^0.20.0"}
|
||||
sentry-sdk = "^1.12.1"
|
||||
Authlib = "^1.2.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^7.2"
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
import os
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from random import randint
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
# Build paths inside the project like this: BASE_DIR / "subdir".
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
|
@ -155,7 +154,15 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|||
|
||||
# Auth
|
||||
|
||||
CLUB_AUTH_URL = "https://vas3k.club/auth/external/"
|
||||
CLUB_BASE_URL = "https://vas3k.club"
|
||||
CLUB_OPENID_CONFIG = {
|
||||
"name": "club",
|
||||
"client_id": "vas3k_blog",
|
||||
"client_secret": os.getenv("CLUB_OPENID_CONFIG_SECRET") or "vas3k_blog",
|
||||
"api_base_url": CLUB_BASE_URL,
|
||||
"server_metadata_url": f"{CLUB_BASE_URL}/.well-known/openid-configuration",
|
||||
"client_kwargs": {"scope": "openid"},
|
||||
}
|
||||
|
||||
PATREON_AUTH_URL = "https://www.patreon.com/oauth2/authorize"
|
||||
PATREON_TOKEN_URL = "https://www.patreon.com/api/oauth2/token"
|
||||
|
@ -164,23 +171,6 @@ PATREON_CLIENT_ID = os.getenv("PATREON_CLIENT_ID")
|
|||
PATREON_CLIENT_SECRET = os.getenv("PATREON_CLIENT_SECRET")
|
||||
PATREON_SCOPE = "identity identity[email]"
|
||||
|
||||
JWT_ALGORITHM = "RS256"
|
||||
JWT_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
|
||||
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvEDEGKL0b+okI6QBBMiu
|
||||
3GOHOG/Ml4KJ13tWyPnl5yGswf9rUGOLo0T0dXxSwxp/6g1ZeYqDR7jckuP6A3Rv
|
||||
DPdKYc44eG3YB/bO2Yeq57Kx1rxvFvWZap2jTyu2wbALmmeg0ne3wkXPExTy/EQ4
|
||||
LDft8nraSJuW7c+qrah+F94qKGVNvilf20V5S186iGpft2j/UAl9s81kzZKBwk7M
|
||||
B+u4jSH8E3KHZVb28CVNOpnYYcLBNLsjGwZk6qbiuq1PEq4AZ5TN3EdoVP9nbIGY
|
||||
BZAMwoNxP4YQN+mDRa6BU2Mhy+c9ea+fuCKRxNi3+nYjF00D28fErFFcA+BEe4A1
|
||||
Hhq25PsVfUgOYvpv1F/ImPJBl8q728DEzDcj1QzL0flbPUMBV6Bsq+l2X3OdrVtQ
|
||||
GXiwJfJRWIVRVDuJzdH+Te2bvuxk2d0Sq/H3uzXYd/IQU5Jw0ZZRTKs+Rzdpb8ui
|
||||
eoDmq2uz6Q2WH2gPwyuVlRfatJOHCUDjd6dE93lA0ibyJmzxo/G35ns8sZoZaJrW
|
||||
rVdFROm3nmAIATC/ui9Ex+tfuOkScYJ5OV1H1qXBckzRVwfOHF0IiJQP4EblLlvv
|
||||
6CEL2VBz0D2+gE4K4sez6YSn3yTg9TkWGhXWCJ7vomfwIfHIdZsItqay156jMPaV
|
||||
c+Ha7cw3U+n6KI4idHLiwa0CAwEAAQ==
|
||||
-----END PUBLIC KEY-----"""
|
||||
JWT_EXP_TIMEDELTA = timedelta(days=120)
|
||||
|
||||
# Email
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
|
|
Loading…
Reference in New Issue