login via twitter.com

This commit is contained in:
Your Name 2022-04-01 03:07:56 -04:00
parent 4924f5f248
commit 489fe17ebb
7 changed files with 256 additions and 192 deletions

View file

@ -7,7 +7,6 @@ from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Q, Count
from markdownx.models import MarkdownxField
from users.models import User
from mastodon.api import get_relationships, get_cross_site_id
from django.utils import timezone
from django.conf import settings

View file

@ -1,5 +1,7 @@
$(document).ready( function() {
$("#userInfoCard .mast-brief").text($("<div>"+$("#userInfoCard .mast-brief").text().replace(/\<br/g,'\n<br').replace(/\<p/g,'\n<p')+"</div>").text());
$("#userInfoCard .mast-brief").html($("#userInfoCard .mast-brief").html().replace(/\n/g,'<br/>'));
let token = $("#oauth2Token").text();
let mast_uri = $("#mastodonURI").text();
@ -7,111 +9,112 @@ $(document).ready( function() {
mast_domain = mast_domain.hostname;
let id = $("#userMastodonID").text();
let userInfoSpinner = $("#spinner").clone().removeAttr("hidden");
let followersSpinner = $("#spinner").clone().removeAttr("hidden");
let followingSpinner = $("#spinner").clone().removeAttr("hidden");
$("#userInfoCard").append(userInfoSpinner);
$("#followings h5").after(followingSpinner);
$("#followers h5").after(followersSpinner);
$(".mast-following-more").hide();
$(".mast-followers-more").hide();
getUserInfo(
id,
mast_uri,
token,
function(userData) {
let userName;
if (userData.display_name) {
userName = translateEmojis(userData.display_name, userData.emojis, true);
} else {
userName = userData.username;
}
//$("#userInfoCard .mast-acct").text(userData.acct);
$("#userInfoCard .mast-acct").attr("href", userData.url);
$("#userInfoCard .mast-avatar").attr("src", userData.avatar);
$("#userInfoCard .mast-displayname").html(userName);
$("#userInfoCard .mast-brief").text($("<div>"+userData.note.replace(/\<br/g,'\n<br').replace(/\<p/g,'\n<p')+"</div>").text());
$("#userInfoCard .mast-brief").html($("#userInfoCard .mast-brief").html().replace(/\n/g,'<br/>'));
$(userInfoSpinner).remove();
}
);
getFollowers(
id,
mast_uri,
token,
function(userList, request) {
if (userList.length == 0) {
$(".mast-followers").hide();
$(".mast-followers").before('<div style="margin-bottom: 20px;">暂无</div>');
} else {
if (userList.length > 4){
userList = userList.slice(0, 4);
$(".mast-followers-more").show();
if (id && id != 'None' && mast_domain != 'twitter.com') {
let userInfoSpinner = $("#spinner").clone().removeAttr("hidden");
let followersSpinner = $("#spinner").clone().removeAttr("hidden");
let followingSpinner = $("#spinner").clone().removeAttr("hidden");
// $("#userInfoCard").append(userInfoSpinner);
$("#followings h5").after(followingSpinner);
$("#followers h5").after(followersSpinner);
$(".mast-following-more").hide();
$(".mast-followers-more").hide();
getUserInfo(
id,
mast_uri,
token,
function(userData) {
let userName;
if (userData.display_name) {
userName = translateEmojis(userData.display_name, userData.emojis, true);
} else {
userName = userData.username;
}
let template = $(".mast-followers li").clone();
$(".mast-followers").html("");
userList.forEach(data => {
temp = $(template).clone();
temp.find("img").attr("src", data.avatar);
if (data.display_name) {
temp.find(".mast-displayname").html(translateEmojis(data.display_name, data.emojis));
} else {
temp.find(".mast-displayname").text(data.username);
}
let url;
if (data.acct.includes('@')) {
url = $("#userPageURL").text().replace('0', data.acct);
} else {
url = $("#userPageURL").text().replace('0', data.acct + '@' + mast_domain);
}
temp.find("a").attr('href', url);
$(".mast-followers").append(temp);
});
//$("#userInfoCard .mast-acct").text(userData.acct);
$("#userInfoCard .mast-acct").attr("href", userData.url);
$("#userInfoCard .mast-avatar").attr("src", userData.avatar);
$("#userInfoCard .mast-displayname").html(userName);
$("#userInfoCard .mast-brief").text($("<div>"+userData.note.replace(/\<br/g,'\n<br').replace(/\<p/g,'\n<p')+"</div>").text());
$("#userInfoCard .mast-brief").html($("#userInfoCard .mast-brief").html().replace(/\n/g,'<br/>'));
$(userInfoSpinner).remove();
}
$(followersSpinner).remove();
}
);
);
getFollowing(
id,
mast_uri,
token,
function(userList, request) {
if (userList.length == 0) {
$(".mast-following").hide();
$(".mast-following").before('<div style="margin-bottom: 20px;">暂无</div>');
} else {
if (userList.length > 4){
userList = userList.slice(0, 4);
$(".mast-following-more").show();
getFollowers(
id,
mast_uri,
token,
function(userList, request) {
if (userList.length == 0) {
$(".mast-followers").hide();
$(".mast-followers").before('<div style="margin-bottom: 20px;">暂无</div>');
} else {
if (userList.length > 4){
userList = userList.slice(0, 4);
$(".mast-followers-more").show();
}
let template = $(".mast-followers li").clone();
$(".mast-followers").html("");
userList.forEach(data => {
temp = $(template).clone();
temp.find("img").attr("src", data.avatar);
if (data.display_name) {
temp.find(".mast-displayname").html(translateEmojis(data.display_name, data.emojis));
} else {
temp.find(".mast-displayname").text(data.username);
}
let url;
if (data.acct.includes('@')) {
url = $("#userPageURL").text().replace('0', data.acct);
} else {
url = $("#userPageURL").text().replace('0', data.acct + '@' + mast_domain);
}
temp.find("a").attr('href', url);
$(".mast-followers").append(temp);
});
}
let template = $(".mast-following li").clone();
$(".mast-following").html("");
userList.forEach(data => {
temp = $(template).clone()
temp.find("img").attr("src", data.avatar);
if (data.display_name) {
temp.find(".mast-displayname").html(translateEmojis(data.display_name, data.emojis));
} else {
temp.find(".mast-displayname").text(data.username);
}
let url;
if (data.acct.includes('@')) {
url = $("#userPageURL").text().replace('0', data.acct);
} else {
url = $("#userPageURL").text().replace('0', data.acct + '@' + mast_domain);
}
temp.find("a").attr('href', url);
$(".mast-following").append(temp);
});
$(followersSpinner).remove();
}
$(followingSpinner).remove();
);
}
);
getFollowing(
id,
mast_uri,
token,
function(userList, request) {
if (userList.length == 0) {
$(".mast-following").hide();
$(".mast-following").before('<div style="margin-bottom: 20px;">暂无</div>');
} else {
if (userList.length > 4){
userList = userList.slice(0, 4);
$(".mast-following-more").show();
}
let template = $(".mast-following li").clone();
$(".mast-following").html("");
userList.forEach(data => {
temp = $(template).clone()
temp.find("img").attr("src", data.avatar);
if (data.display_name) {
temp.find(".mast-displayname").html(translateEmojis(data.display_name, data.emojis));
} else {
temp.find(".mast-displayname").text(data.username);
}
let url;
if (data.acct.includes('@')) {
url = $("#userPageURL").text().replace('0', data.acct);
} else {
url = $("#userPageURL").text().replace('0', data.acct + '@' + mast_domain);
}
temp.find("a").attr('href', url);
$(".mast-following").append(temp);
});
}
$(followingSpinner).remove();
}
);
}
// mobile dropdown
$(".relation-dropdown__button").data("collapse", true);

View file

@ -45,13 +45,15 @@ API_CREATE_APP = '/api/v1/apps'
# GET
API_SEARCH = '/api/v2/search'
TWITTER_DOMAIN = 'twitter.com'
TWITTER_API = 'api.twitter.com'
get = functools.partial(requests.get, timeout=settings.MASTODON_TIMEOUT)
post = functools.partial(requests.post, timeout=settings.MASTODON_TIMEOUT)
# low level api below
def get_relationships(site, id_list, token):
def get_relationships(site, id_list, token): # no longer in use
url = 'https://' + site + API_GET_RELATIONSHIPS
payload = {'id[]': id_list}
headers = {
@ -63,30 +65,41 @@ def get_relationships(site, id_list, token):
def post_toot(site, content, visibility, token, local_only=False):
url = 'https://' + site + API_PUBLISH_TOOT
headers = {
'User-Agent': 'NeoDB/1.0',
'Authorization': f'Bearer {token}',
'Idempotency-Key': random_string_generator(16)
}
payload = {
'status': content,
'visibility': visibility,
'local_only': True,
}
if not local_only:
del payload['local_only']
response = post(url, headers=headers, data=payload)
if site == TWITTER_DOMAIN:
url = 'https://api.twitter.com/2/tweets'
payload = {
'text': content if len(content) <= 150 else content[0:150] + '...'
}
response = post(url, headers=headers, json=payload)
else:
url = 'https://' + site + API_PUBLISH_TOOT
payload = {
'status': content,
'visibility': visibility,
}
if local_only:
payload['local_only'] = True
response = post(url, headers=headers, data=payload)
if response.status_code == 201:
response.status_code = 200
return response
def get_instance_domain(domain_name):
if domain_name.lower().strip() == TWITTER_DOMAIN:
return TWITTER_DOMAIN
try:
response = get(f'https://{domain_name}/api/v1/instance', headers={'User-Agent': 'NeoDB/1.0'})
return response.json()['uri'].lower().split('//')[-1].split('/')[0]
except:
return domain_name
def create_app(domain_name):
# naive protocal strip
is_http = False
@ -154,6 +167,8 @@ def get_cross_site_id(target_user, target_site, token):
"""
if target_site == target_user.mastodon_site:
return target_user.mastodon_id
if target_site == TWITTER_DOMAIN:
return None
try:
cross_site_info = CrossSiteUserInfo.objects.get(
@ -182,14 +197,36 @@ def random_string_generator(n):
def verify_account(site, token):
if site == TWITTER_DOMAIN:
url = 'https://' + TWITTER_API + '/2/users/me?user.fields=id,username,name,description,profile_image_url,created_at,protected'
try:
response = get(url, headers={'User-Agent': 'NeoDB/1.0', 'Authorization': f'Bearer {token}'})
if response.status_code != 200:
print(url)
print(response.status_code)
print(response.text)
return response.status_code, None
r = response.json()['data']
r['display_name'] = r['name']
r['note'] = r['description']
r['avatar'] = r['profile_image_url']
r['avatar_static'] = r['profile_image_url']
r['locked'] = r['protected']
r['url'] = f'https://{TWITTER_DOMAIN}/{r["username"]}'
return 200, r
except Exception:
return -1, None
url = 'https://' + site + API_VERIFY_ACCOUNT
try:
response = get(url, headers={'User-Agent': 'NeoDB/1.0', 'Authorization': f'Bearer {token}'})
return response.status_code, response.json() if response.status_code == 200 else None
return response.status_code, (response.json() if response.status_code == 200 else None)
except Exception:
return -1, None
def get_related_acct_list(site, token, api):
if site == TWITTER_DOMAIN:
return []
url = 'https://' + site + api
results = []
while url:

View file

@ -3,74 +3,115 @@ from django.shortcuts import reverse
from .api import *
from .models import MastodonApplication
from django.conf import settings
from urllib.parse import quote
def get_mastodon_application(domain):
app = MastodonApplication.objects.filter(domain_name=domain).first()
if app is not None:
return app, ''
if domain == TWITTER_DOMAIN:
return None, 'Twitter未配置'
error_msg = None
try:
response = create_app(domain)
except (requests.exceptions.Timeout, ConnectionError):
error_msg = _("联邦网络请求超时。")
except Exception as e:
error_msg = str(e)
else:
# fill the form with returned data
if response.status_code != 200:
error_msg = "实例连接错误,代码: " + str(response.status_code)
print(f'Error connecting {domain}: {response.status_code} {response.content.decode("utf-8")}')
else:
try:
data = response.json()
except Exception as e:
error_msg = "实例返回内容无法识别"
print(f'Error connecting {domain}: {response.status_code} {response.content.decode("utf-8")} {e}')
else:
app = MastodonApplication.objects.create(domain_name=domain, app_id=data['id'], client_id=data['client_id'],
client_secret=data['client_secret'], vapid_key=data['vapid_key'] if 'vapid_key' in data else '')
return app, error_msg
def get_mastodon_login_url(app, login_domain, request):
url = request.scheme + "://" + request.get_host() + reverse('users:OAuth2_login')
if login_domain == TWITTER_DOMAIN:
return f"https://twitter.com/i/oauth2/authorize?response_type=code&client_id={app.client_id}&redirect_uri={quote(url)}&scope={quote(settings.TWITTER_CLIENT_SCOPE)}&state=state&code_challenge=challenge&code_challenge_method=plain"
return "https://" + login_domain + "/oauth/authorize?client_id=" + app.client_id + "&scope=" + quote(settings.MASTODON_CLIENT_SCOPE) + "&redirect_uri=" + url + "&response_type=code"
def obtain_token(site, request, code):
""" Returns token if success else None. """
mast_app = MastodonApplication.objects.get(domain_name=site)
redirect_uri = request.scheme + "://" + request.get_host() + reverse('users:OAuth2_login')
payload = {
'client_id': mast_app.client_id,
'client_secret': mast_app.client_secret,
'redirect_uri': f"https://{request.get_host()}{reverse('users:OAuth2_login')}",
'redirect_uri': redirect_uri,
'grant_type': 'authorization_code',
'code': code,
'scope': 'read write'
'code_verifier': 'challenge'
}
if settings.DEBUG:
payload['redirect_uri'] = f"http://{request.get_host()}{reverse('users:OAuth2_login')}",
headers = {'User-Agent': 'NeoDB/1.0'}
auth = None
if mast_app.is_proxy:
url = 'https://' + mast_app.proxy_to + API_OBTAIN_TOKEN
elif site == TWITTER_DOMAIN:
url = 'https://api.twitter.com/2/oauth2/token'
auth = (mast_app.client_id, mast_app.client_secret)
del payload['client_secret']
else:
url = 'https://' + mast_app.domain_name + API_OBTAIN_TOKEN
response = post(url, data=payload, headers={'User-Agent': 'NeoDB/1.0'})
response = post(url, data=payload, headers=headers, auth=auth)
# {"token_type":"bearer","expires_in":7200,"access_token":"VGpkOEZGR3FQRDJ5NkZ0dmYyYWIwS0dqeHpvTnk4eXp0NV9nWDJ2TEpmM1ZTOjE2NDg3ODMxNTU4Mzc6MToxOmF0OjE","scope":"block.read follows.read offline.access tweet.write users.read mute.read","refresh_token":"b1pXbGEzeUF1WE5yZHJOWmxTeWpvMTBrQmZPd0czLU0tQndZQTUyU3FwRDVIOjE2NDg3ODMxNTU4Mzg6MToxOnJ0OjE"}
if response.status_code != 200:
return
print(url)
print(response.status_code)
print(response.text)
return None, None
data = response.json()
return data.get('access_token'), data.get('refresh_token', '')
def refresh_access_token(site, refresh_token):
if site != TWITTER_DOMAIN:
return None
mast_app = MastodonApplication.objects.get(domain_name=site)
url = 'https://api.twitter.com/2/oauth2/token'
payload = {
'client_id': mast_app.client_id,
'refresh_token': refresh_token,
'grant_type': 'refresh_token',
}
headers = {'User-Agent': 'NeoDB/1.0'}
auth = (mast_app.client_id, mast_app.client_secret)
response = post(url, data=payload, headers=headers, auth=auth)
if response.status_code != 200:
print(url)
print(response.status_code)
print(response.text)
return None
data = response.json()
return data.get('access_token')
def get_user_data(site, token):
url = 'https://' + site + API_VERIFY_ACCOUNT
headers = {
'User-Agent': 'NeoDB/1.0',
'Authorization': f'Bearer {token}'
}
response = get(url, headers=headers)
if response.status_code != 200:
return None
return response.json()
def revoke_token(site, token):
mast_app = MastodonApplication.objects.get(domain_name=site)
payload = {
'client_id': mast_app.client_id,
'client_secret': mast_app.client_secret,
'scope': token
'token': token
}
if mast_app.is_proxy:
url = 'https://' + mast_app.proxy_to + API_REVOKE_TOKEN
else:
url = 'https://' + site + API_REVOKE_TOKEN
response = post(url, data=payload, headers={'User-Agent': 'NeoDB/1.0'})
def verify_token(site, token):
""" Check if the token is valid and is of local instance. """
url = 'https://' + site + API_VERIFY_ACCOUNT
headers = {
'User-Agent': 'NeoDB/1.0',
'Authorization': f'Bearer {token}'
}
response = get(url, headers=headers)
if response.status_code == 200:
res_data = response.json()
# check if is local instance user
if res_data['acct'] == res_data['username']:
return True
return False
post(url, data=payload, headers={'User-Agent': 'NeoDB/1.0'})
class OAuth2Backend(ModelBackend):
@ -78,22 +119,23 @@ class OAuth2Backend(ModelBackend):
# "authenticate() should check the credentials it gets and returns
# a user object that matches those credentials."
# arg request is an interface specification, not used in this implementation
def authenticate(self, request, token=None, username=None, site=None, **kwargs):
def authenticate(self, request, token=None, username=None, site=None, **kwargs):
""" when username is provided, assume that token is newly obtained and valid """
if token is None or site is None:
return
if username is None:
user_data = get_user_data(site, token)
if user_data:
username = user_data['username']
code, user_data = verify_account(site, token)
if code == 200:
userid = user_data['id']
else:
# aquiring user data fail means token is invalid thus auth fail
return None
# when username is provided, assume that token is newly obtained and valid
try:
user = UserModel._default_manager.get(username=username, mastodon_site=site)
user = UserModel._default_manager.get(mastodon_id=userid, mastodon_site=site)
except UserModel.DoesNotExist:
return None
else:

View file

@ -26,6 +26,7 @@ class User(AbstractUser):
# mastodon domain name, eg donotban.com
mastodon_site = models.CharField(max_length=100, blank=False)
mastodon_token = models.CharField(max_length=100, default='')
mastodon_refresh_token = models.CharField(max_length=100, default='')
mastodon_locked = models.BooleanField(default=False)
mastodon_followers = models.JSONField(default=list)
mastodon_following = models.JSONField(default=list)

View file

@ -639,13 +639,13 @@
<div class="user-profile" id="userInfoCard">
<div class="user-profile__header">
<!-- <img src="" class="user-profile__avatar mast-avatar" alt="{{ user.username }}"> -->
<img src="" class="user-profile__avatar mast-avatar">
<img src="{{ user.mastodon_account.avatar }}" class="user-profile__avatar mast-avatar">
<a href="{% url 'users:home' user.mastodon_username %}">
<h5 class="user-profile__username mast-displayname"></h5>
<h5 class="user-profile__username mast-displayname">{{ user.mastodon_account.display_name }}</h5>
</a>
</div>
<p><a class="user-profile__link mast-acct" target="_blank" href="https://{{ user.mastodon_site }}/@{{ user.username }}">@{{ user.username }}@{{ user.mastodon_site }}</a></p>
<p class="user-profile__bio mast-brief"></p>
<p><a class="user-profile__link mast-acct" target="_blank" href="{{ user.mastodon_account.url }}">@{{ user.username }}@{{ user.mastodon_site }}</a></p>
<p class="user-profile__bio mast-brief">{{ user.mastodon_account.note }}</p>
<!-- <a href="#" class="follow">{% trans '关注TA' %}</a> -->
{% if request.user != user %}

View file

@ -43,7 +43,7 @@ from collection.models import Collection
# Views
########################################
def swap_login(request, token, site):
def swap_login(request, token, site, refresh_token):
del request.session['swap_login']
del request.session['swap_domain']
code, data = verify_account(site, token)
@ -58,9 +58,12 @@ def swap_login(request, token, site):
messages.add_message(request, messages.ERROR, _(f'该身份 {username}@{site} 已被用于其它账号。'))
except ObjectDoesNotExist:
current_user.username = username
current_user.mastodon_id = data['id']
current_user.mastodon_site = site
current_user.mastodon_token = token
current_user.save(update_fields=['username', 'mastodon_site', 'mastodon_token'])
current_user.mastodon_refresh_token = refresh_token
current_user.mastodon_account = data
current_user.save(update_fields=['username', 'mastodon_id', 'mastodon_site', 'mastodon_token', 'mastodon_refresh_token', 'mastodon_account'])
django_rq.get_queue('mastodon').enqueue(refresh_mastodon_data_task, current_user, token)
messages.add_message(request, messages.INFO, _(f'账号身份已更新为 {username}@{site}'))
else:
@ -78,12 +81,12 @@ def OAuth2_login(request):
# Network IO
try:
token = obtain_token(site, request, code)
token, refresh_token = obtain_token(site, request, code)
except ObjectDoesNotExist:
return HttpResponseBadRequest("Mastodon site not registered")
if token:
if request.session.get('swap_login', False) and request.user.is_authenticated: # swap login for existing user
return swap_login(request, token, site)
return swap_login(request, token, site, refresh_token)
user = authenticate(request, token=token, site=site)
if user:
auth_login(request, user, token)
@ -98,6 +101,7 @@ def OAuth2_login(request):
else:
# will be passed to register page
request.session['new_user_token'] = token
request.session['new_user_refresh_token'] = refresh_token
return redirect(reverse('users:register'))
else:
return render(
@ -142,38 +146,11 @@ def connect(request):
login_domain = request.session['swap_domain'] if request.session.get('swap_login') else request.GET.get('domain')
login_domain = login_domain.strip().lower().split('//')[-1].split('/')[0].split('@')[-1]
domain = get_instance_domain(login_domain)
app = MastodonApplication.objects.filter(domain_name=domain).first()
app, error_msg = get_mastodon_application(domain)
if app is None:
try:
response = create_app(domain)
except (requests.exceptions.Timeout, ConnectionError):
error_msg = _("联邦网络请求超时。")
except Exception as e:
error_msg = str(e)
else:
# fill the form with returned data
if response.status_code != 200:
error_msg = "实例连接错误,代码: " + str(response.status_code)
print(f'Error connecting {domain}: {response.status_code} {response.content.decode("utf-8")}')
else:
try:
data = response.json()
except Exception as e:
error_msg = "实例返回内容无法识别"
print(f'Error connecting {domain}: {response.status_code} {response.content.decode("utf-8")}')
else:
app = MastodonApplication.objects.create(domain_name=domain, app_id=data['id'], client_id=data['client_id'],
client_secret=data['client_secret'], vapid_key=data['vapid_key'] if 'vapid_key' in data else '')
if app is None:
return render(request,
'common/error.html',
{
'msg': error_msg,
'secondary_msg': "",
}
)
return render(request, 'common/error.html', {'msg': error_msg, 'secondary_msg': "", })
else:
login_url = "https://" + login_domain + "/oauth/authorize?client_id=" + app.client_id + "&scope=" + quote(settings.MASTODON_CLIENT_SCOPE) + "&redirect_uri=" + request.scheme + "://" + request.get_host() + reverse('users:OAuth2_login') + "&response_type=code"
login_url = get_mastodon_login_url(app, login_domain, request)
resp = redirect(login_url)
resp.set_cookie("mastodon_domain", domain)
return resp
@ -216,8 +193,9 @@ def register(request):
return HttpResponseBadRequest()
elif request.method == 'POST':
token = request.session['new_user_token']
user_data = get_user_data(request.COOKIES['mastodon_domain'], token)
if user_data is None:
refresh_token = request.session['new_user_refresh_token']
code, user_data = verify_account(request.COOKIES['mastodon_domain'], token)
if code != 200 or user_data is None:
return render(
request,
'common/error.html',
@ -229,9 +207,13 @@ def register(request):
username=user_data['username'],
mastodon_id=user_data['id'],
mastodon_site=request.COOKIES['mastodon_domain'],
mastodon_token=token,
mastodon_refresh_token=refresh_token,
mastodon_account=user_data,
)
new_user.save()
del request.session['new_user_token']
del request.session['new_user_refresh_token']
auth_login(request, new_user, token)
response = redirect(reverse('common:home'))
response.delete_cookie('mastodon_domain')