feat: OpenID auth via vas3k.club

This commit is contained in:
Vasily Zubarev 2023-03-08 13:49:42 +01:00
parent 67ff3cd490
commit fe7b4da22d
6 changed files with 92 additions and 76 deletions

View File

@ -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:

View File

@ -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)

View File

@ -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

60
poetry.lock generated
View File

@ -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 = []

View File

@ -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"

View File

@ -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"