1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-17 15:53:28 +00:00

[api] use device specific token

This commit is contained in:
lins05
2014-03-25 11:23:31 +08:00
parent bbdfa51e49
commit 28bd1f9d85
10 changed files with 494 additions and 28 deletions

View File

@@ -1,7 +1,21 @@
import datetime
import logging
from rest_framework.authentication import BaseAuthentication from rest_framework.authentication import BaseAuthentication
from models import Token
from seahub.base.accounts import User from seahub.base.accounts import User
from seahub.api2.models import Token, TokenV2
from seahub.api2.utils import get_client_ip
logger = logging.getLogger(__name__)
def within_ten_min(d1, d2):
'''Return true if two datetime.datetime object differs less than ten minutes'''
delta = d2 - d1
interval = 60 * 10
return abs(delta.total_seconds()) < interval
HEADER_CLIENT_VERSION = 'HTTP_SEAFILE_CLEINT_VERSION'
HEADER_PLATFORM_VERSION = 'HTTP_SEAFILE_PLATFORM_VERSION'
class TokenAuthentication(BaseAuthentication): class TokenAuthentication(BaseAuthentication):
""" """
@@ -11,10 +25,7 @@ class TokenAuthentication(BaseAuthentication):
HTTP header, prepended with the string "Token ". For example: HTTP header, prepended with the string "Token ". For example:
Authorization: Token 401f7ac837da42b97f613d789819ff93537bee6a Authorization: Token 401f7ac837da42b97f613d789819ff93537bee6a
"""
model = Token
"""
A custom token model may be used, but must have the following properties. A custom token model may be used, but must have the following properties.
* key -- The string identifying the token * key -- The string identifying the token
@@ -23,17 +34,70 @@ class TokenAuthentication(BaseAuthentication):
def authenticate(self, request): def authenticate(self, request):
auth = request.META.get('HTTP_AUTHORIZATION', '').split() auth = request.META.get('HTTP_AUTHORIZATION', '').split()
key = None
if len(auth) == 2 and auth[0].lower() == "token": if len(auth) == 2 and auth[0].lower() == "token":
key = auth[1] key = auth[1]
try:
token = self.model.objects.get(key=key) if not key:
except self.model.DoesNotExist:
return None return None
ret = self.authenticate_v2(request, key)
if ret:
return ret
return self.authenticate_v1(request, key)
def authenticate_v1(self, request, key):
try:
token = Token.objects.get(key=key)
except Token.DoesNotExist:
return None
try: try:
user = User.objects.get(email=token.user) user = User.objects.get(email=token.user)
except User.DoesNotExist: except User.DoesNotExist:
return None return None
if user.is_active: if user.is_active:
return (user, token) return (user, token)
def authenticate_v2(self, request, key):
try:
token = TokenV2.objects.get(key=key)
except TokenV2.DoesNotExist:
return None
try:
user = User.objects.get(email=token.user)
except User.DoesNotExist:
return None
if user.is_active:
need_save = False
# We update the device's last_login_ip, client_version, platform_version if changed
ip = get_client_ip(request)
if ip and ip != token.last_login_ip:
token.last_login_ip = ip
need_save = True
client_version = request.META.get(HEADER_CLIENT_VERSION, '')
if client_version and client_version != token.client_version:
token.client_version = client_version
need_save = True
platform_version = request.META.get(HEADER_PLATFORM_VERSION, '')
if platform_version and platform_version != token.platform_version:
token.platform_version = platform_version
need_save = True
if not within_ten_min(token.last_accessed, datetime.datetime.now()):
# We only need 10min precision for the last_accessed field
need_save = True
if need_save:
try:
token.save()
except:
logger.exception('error when save token v2:')
return (user, token)

View File

@@ -3,9 +3,10 @@ import hmac
from hashlib import sha1 from hashlib import sha1
from django.db import models from django.db import models
from seahub.base.accounts import User
from seahub.base.fields import LowerCaseCharField from seahub.base.fields import LowerCaseCharField
DESKTOP_PLATFORMS = ('windows', 'linux', 'mac')
class Token(models.Model): class Token(models.Model):
""" """
The default authorization token model. The default authorization token model.
@@ -26,4 +27,127 @@ class Token(models.Model):
def __unicode__(self): def __unicode__(self):
return self.key return self.key
class TokenV2Manager(models.Manager):
def get_user_devices(self, username):
'''List user devices, most recently used first'''
devices = super(TokenV2Manager, self).filter(user=username)
platform_priorities = {
'windows': 0,
'linux': 0,
'mac': 0,
'android': 1,
'ios': 1,
}
def sort_devices(d1, d2):
'''Desktop clients are listed before mobile clients. Devices of
the same category are listed by most recently used first
'''
ret = cmp(platform_priorities[d1.platform], platform_priorities[d2.platform])
if ret != 0:
return ret
return cmp(d2.last_accessed, d1.last_accessed)
return [ d.as_dict() for d in sorted(devices, sort_devices) ]
def _get_token_by_user_device(self, username, platform, device_id):
try:
return super(TokenV2Manager, self).get(user=username,
platform=platform,
device_id=device_id)
except TokenV2.DoesNotExist:
return None
def get_or_create_token(self, username, platform, device_id, device_name,
client_version, platform_version, last_login_ip):
token = self._get_token_by_user_device(username, platform, device_id)
if token:
if token.client_version != client_version or token.platform_version != platform_version \
or token.device_name != device_name:
token.client_version = client_version
token.platform_version = platform_version
token.device_name = device_name
token.save()
return token
token = TokenV2(user=username,
platform=platform,
device_id=device_id,
device_name=device_name,
client_version=client_version,
platform_version=platform_version,
last_login_ip=last_login_ip)
token.save()
return token
def delete_device_token(self, username, platform, device_id):
super(TokenV2Manager, self).filter(user=username, platform=platform, device_id=device_id).delete()
class TokenV2(models.Model):
"""
Device specific token
"""
key = models.CharField(max_length=40, primary_key=True)
user = LowerCaseCharField(max_length=255)
# windows/linux/mac/ios/android
platform = LowerCaseCharField(max_length=32)
# ccnet id, android secure id, etc.
device_id = models.CharField(max_length=40)
# lin-laptop
device_name = models.CharField(max_length=40)
# platform version
platform_version = LowerCaseCharField(max_length=16)
# seafile client/app version
client_version = LowerCaseCharField(max_length=16)
# most recent activity
last_accessed = models.DateTimeField(auto_now=True)
last_login_ip = models.GenericIPAddressField(null=True, default=None)
objects = TokenV2Manager()
class Meta:
unique_together = (('user', 'platform', 'device_id'),)
def save(self, *args, **kwargs):
if not self.key:
self.key = self.generate_key()
return super(TokenV2, self).save(*args, **kwargs)
def generate_key(self):
unique = str(uuid.uuid4())
return hmac.new(unique, digestmod=sha1).hexdigest()
def __unicode__(self):
return "TokenV2{user=%(user)s,device=%(device_name)s}" % \
dict(user=self.user,device_name=self.device_name)
def is_desktop_client(self):
return str(self.platform) in ('windows', 'linux', 'mac')
def as_dict(self):
return dict(key=self.key,
user=self.user,
platform=self.platform,
device_id=self.device_id,
device_name=self.device_name,
client_version=self.client_version,
platform_version=self.platform_version,
last_accessed=self.last_accessed,
last_login_ip=self.last_login_ip)

View File

@@ -1,28 +1,103 @@
from rest_framework import serializers from rest_framework import serializers
from seahub.auth import authenticate from seahub.auth import authenticate
from seahub.api2.models import Token, TokenV2, DESKTOP_PLATFORMS
from seahub.api2.utils import get_client_ip
def all_none(values):
for value in values:
if value is not None:
return False
return True
def all_not_none(values):
for value in values:
if value is None:
return False
return True
class AuthTokenSerializer(serializers.Serializer): class AuthTokenSerializer(serializers.Serializer):
username = serializers.CharField() username = serializers.CharField()
password = serializers.CharField() password = serializers.CharField()
# There fields are used by TokenV2
platform = serializers.CharField(required=False)
device_id = serializers.CharField(required=False)
device_name = serializers.CharField(required=False)
# These fields may be needed in the future
client_version = serializers.CharField(required=False)
platform_version = serializers.CharField(required=False)
def validate(self, attrs): def validate(self, attrs):
username = attrs.get('username') username = attrs.get('username')
password = attrs.get('password') password = attrs.get('password')
platform = attrs.get('platform', None)
device_id = attrs.get('device_id', None)
device_name = attrs.get('device_name', None)
client_version = attrs.get('client_version', None)
platform_version = attrs.get('platform_version', None)
v2_fields = (platform, device_id, device_name, client_version, platform_version)
# Decide the version of token we need
if all_none(v2_fields):
v2 = False
elif all_not_none(v2_fields):
v2 = True
else:
raise serializers.ValidationError('invalid params')
# first check password
if username and password: if username and password:
user = authenticate(username=username, password=password) user = authenticate(username=username, password=password)
if user: if user:
if not user.is_active: if not user.is_active:
raise serializers.ValidationError('User account is disabled.') raise serializers.ValidationError('User account is disabled.')
attrs['user'] = user
return attrs
else: else:
raise serializers.ValidationError('Unable to login with provided credentials.') raise serializers.ValidationError('Unable to login with provided credentials.')
else: else:
raise serializers.ValidationError('Must include "username" and "password"') raise serializers.ValidationError('Must include "username" and "password"')
# Now user is authenticated
if v2:
token = self.get_token_v2(username, platform, device_id, device_name,
client_version, platform_version)
else:
token = self.get_token_v1(username)
return token.key
def get_token_v1(self, username):
token, created = Token.objects.get_or_create(user=username)
return token
def get_token_v2(self, username, platform, device_id, device_name,
client_version, platform_version):
if platform in DESKTOP_PLATFORMS:
# desktop device id is the peer id, so it must be 40 chars
if len(device_id) != 40:
raise serializers.ValidationError('invalid device id')
elif platform == 'android':
# android device id is the 64bit secure id, so it must be 16 chars in hex representation
if len(device_id) != 16:
raise serializers.ValidationError('invalid device id')
elif platform == 'ios':
pass
else:
raise serializers.ValidationError('invalid platform')
request = self.context['request']
last_login_ip = get_client_ip(request)
return TokenV2.objects.get_or_create_token(username, platform, device_id, device_name,
client_version, platform_version, last_login_ip)
class AccountSerializer(serializers.Serializer): class AccountSerializer(serializers.Serializer):
email = serializers.EmailField() email = serializers.EmailField()
password = serializers.CharField() password = serializers.CharField()

View File

@@ -449,3 +449,12 @@ def api_group_check(func):
return api_error(status.HTTP_403_FORBIDDEN, 'Forbid to access this group.') return api_error(status.HTTP_403_FORBIDDEN, 'Forbid to access this group.')
return _decorated return _decorated
def get_client_ip(request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR', '')
return ip

View File

@@ -25,14 +25,13 @@ from django.template.loader import render_to_string
from django.shortcuts import render_to_response from django.shortcuts import render_to_response
from django.utils import timezone from django.utils import timezone
from models import Token
from authentication import TokenAuthentication from authentication import TokenAuthentication
from serializers import AuthTokenSerializer, AccountSerializer from serializers import AuthTokenSerializer, AccountSerializer
from utils import is_repo_writable, is_repo_accessible, calculate_repo_info, \ from utils import is_repo_writable, is_repo_accessible, calculate_repo_info, \
api_error, get_file_size, prepare_starred_files, \ api_error, get_file_size, prepare_starred_files, \
get_groups, get_group_and_contacts, prepare_events, \ get_groups, get_group_and_contacts, prepare_events, \
get_person_msgs, api_group_check, get_email, get_timetamp, \ get_person_msgs, api_group_check, get_email, get_timetamp, \
get_group_message_json, get_group_msgs, get_group_msgs_json get_group_message_json, get_group_msgs, get_group_msgs_json, get_client_ip
from seahub.base.accounts import User from seahub.base.accounts import User
from seahub.base.models import FileDiscuss, UserStarredFiles, \ from seahub.base.models import FileDiscuss, UserStarredFiles, \
DirFilesLastModifiedInfo, DeviceToken DirFilesLastModifiedInfo, DeviceToken
@@ -131,13 +130,13 @@ class ObtainAuthToken(APIView):
permission_classes = () permission_classes = ()
parser_classes = (parsers.FormParser, parsers.MultiPartParser, parsers.JSONParser,) parser_classes = (parsers.FormParser, parsers.MultiPartParser, parsers.JSONParser,)
renderer_classes = (renderers.JSONRenderer,) renderer_classes = (renderers.JSONRenderer,)
model = Token
def post(self, request): def post(self, request):
serializer = AuthTokenSerializer(data=request.DATA) context = { 'request': request }
serializer = AuthTokenSerializer(data=request.DATA, context=context)
if serializer.is_valid(): if serializer.is_valid():
token, created = Token.objects.get_or_create(user=serializer.object['user'].username) key = serializer.object
return Response({'token': token.key}) return Response({'token': key})
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -0,0 +1,95 @@
{% extends "home_base.html" %}
{% load seahub_tags avatar_tags i18n %}
{% block sub_title %}{% trans "Devices" %} - {% endblock %}
{% block cur_devices %}tab-cur{% endblock %}
{% block right_panel %}
<h3 class="hd">{% trans "Devices" %}</h3>
{% if devices %}
<table class="client-list">
<tr>
<th width="10%">{% trans "Platform" %}</th>
<th width="25%">{% trans "Device Name" %}</th>
<th width="20%">{% trans "IP" %}</th>
<th width="15%">{% trans "Last Access" %}</th>
<th width="20%">{% trans "Libraries" %}</th>
<th width="10%">{% trans "Operation" %}</th>
</tr>
{% for device in devices %}
<tr data-platform="{{ device.platform }}" data-device-id="{{ device.device_id }}">
<td>{{ device.platform }}</td>
<td>{{ device.device_name }}</td>
<!-- <td>{{ device.client_version }}</td> -->
<td>{{ device.last_login_ip }}</td>
<td>{{ device.last_accessed | translate_seahub_time }}</td>
<td>
<ul>
{% for repo in device.synced_repos %}
<li>
<a name="{{ repo.sync_time }}" href="{% url 'repo' repo.repo_id %}">{{ repo.repo_name }}</a>
</li>
{% endfor %}
</ul>
</td>
<td>
<div><a href="#" class="unlink-device op vh">{% trans "Unlink" %}</a></div>
</td>
</tr>
{% endfor %}
</table>
<div id="unlink-device-confirm" class="hide">
<p>{% trans "Really want to unlink this device? It will immediately stop syncing." %}</p>
<button class="yes">{% trans "Yes" %}</button>
<button class="no simplemodal-close">{% trans "No" %}</button>
</div>
{% else %}
<div class="empty-tips">
<h2 class="alc">{% trans "You do not have connected devices" %}</h2>
<p>{% trans "Your clients (Desktop/Anroid/iOS) would be listed here." %}</p>
</div>
{% endif %}
{% endblock %}
{% block extra_script %}{{block.super}}
<script type="text/javascript">
function send_unlink_request(tr) {
var post_data = {
'platform': tr.data('platform'),
'device_id': tr.data('device-id')
};
console.log('send_unlink_request called, ' + post_data);
$.ajax({
url: '{% url 'unlink_device' %}',
type: 'POST',
dataType: 'json',
beforeSend: prepareCSRFToken,
data: post_data,
success: function(data) {
$.modal.close();
tr.remove();
feedback("{% trans "Successfully unlinked." %}", 'success');
},
error: function(xhr, textStatus, errorThrown) {
$.modal.close();
var error = $.parseJSON(xhr.responseText).error;
feedback("{% trans "Failed to unlink the device." %}", 'error');
}
});
}
$('.unlink-device').click(function() {
var op = $(this);
var tr = op.parents('tr');
var form = $('#unlink-device-confirm');
form.modal({appendTo: "#main", focus:false});
$($('.yes', form)).click(function() {
send_unlink_request(tr);
return false;
});
});
</script>
{% endblock %}

View File

@@ -20,7 +20,7 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<li class="tab {% block cur_messages %}{% endblock %}"><a href="{% url 'message_list' %}" class="msgs">{% trans "Messages" %}</a></li> <li class="tab {% block cur_messages %}{% endblock %}"><a href="{% url 'message_list' %}" class="msgs">{% trans "Messages" %}</a></li>
<li class="tab {% block cur_clients %}{% endblock %}"><a href="{% url 'client_mgmt' %}" class="clients">{% trans "Clients" %}</a></li> <li class="tab {% block cur_devices %}{% endblock %}"><a href="{% url 'devices' %}" class="clients">{% trans "Devices" %}</a></li>
<li class="tab {% block cur_contacts %}{% endblock %}"><a href="{% url 'contacts' %}" class="contacts">{% trans "Contacts" %}</a></li> <li class="tab {% block cur_contacts %}{% endblock %}"><a href="{% url 'contacts' %}" class="contacts">{% trans "Contacts" %}</a></li>
</ul> </ul>

View File

@@ -53,8 +53,10 @@ urlpatterns = patterns('',
url(r'^home/wiki_page_edit/(?P<page_name>[^/]+)$', personal_wiki_page_edit, name='personal_wiki_page_edit'), url(r'^home/wiki_page_edit/(?P<page_name>[^/]+)$', personal_wiki_page_edit, name='personal_wiki_page_edit'),
url(r'^home/wiki_page_delete/(?P<page_name>[^/]+)$', personal_wiki_page_delete, name='personal_wiki_page_delete'), url(r'^home/wiki_page_delete/(?P<page_name>[^/]+)$', personal_wiki_page_delete, name='personal_wiki_page_delete'),
url(r'^home/clients/$', client_mgmt, name='client_mgmt'), # url(r'^home/clients/$', client_mgmt, name='client_mgmt'),
url(r'^home/clients/unsync/$', client_unsync, name='client_unsync'), # url(r'^home/clients/unsync/$', client_unsync, name='client_unsync'),
url(r'^devices/$', devices, name='devices'),
url(r'^home/devices/unlink/$', unlink_device, name='unlink_device'),
# url(r'^home/public/reply/(?P<msg_id>[\d]+)/$', innerpub_msg_reply, name='innerpub_msg_reply'), # url(r'^home/public/reply/(?P<msg_id>[\d]+)/$', innerpub_msg_reply, name='innerpub_msg_reply'),
# url(r'^home/owner/(?P<owner_name>[^/]+)/$', ownerhome, name='ownerhome'), # url(r'^home/owner/(?P<owner_name>[^/]+)/$', ownerhome, name='ownerhome'),

68
seahub/utils/devices.py Normal file
View File

@@ -0,0 +1,68 @@
import logging
from seaserv import seafile_api
from seahub.api2.models import TokenV2, DESKTOP_PLATFORMS
logger = logging.getLogger(__name__)
__all__ = [
'get_user_devices',
'do_unlink_device',
]
def get_user_devices(username):
devices = TokenV2.objects.get_user_devices(username)
peer_repos_map = get_user_synced_repo_infos(username)
for device in devices:
if device['platform'] in DESKTOP_PLATFORMS:
peer_id = device['device_id']
device['synced_repos'] = peer_repos_map.get(peer_id, [])
return devices
def get_user_synced_repo_infos(username):
'''Return a (client_ccnet_peer_id, synced_repos_on_that_client) dict'''
tokens = []
try:
tokens = seafile_api.list_repo_tokens_by_email(username)
except:
return {}
def sort_by_sync_time_descending(a, b):
if isinstance(a, dict):
return cmp(b['sync_time'], a['sync_time'])
else:
return cmp(b.sync_time, a.sync_time)
tokens.sort(sort_by_sync_time_descending, reverse=True)
peer_repos_map = {}
for token in tokens:
peer_id = token.peer_id
repo_id = token.repo_id
if peer_id not in peer_repos_map:
peer_repos_map[peer_id] = {}
peer_repos_map[peer_id][repo_id] = {
'repo_id': token.repo_id,
'repo_name': token.repo_name,
'sync_time': token.sync_time
}
ret = {}
for peer_id, repos in peer_repos_map.iteritems():
ret[peer_id] = sorted(repos.values(), sort_by_sync_time_descending)
return ret
def do_unlink_device(username, platform, device_id):
if platform in DESKTOP_PLATFORMS:
# For desktop client, we also remove the sync tokens
if seafile_api.delete_repo_tokens_by_peer_id(username, device_id) < 0:
logger.warning('failed to delete_repo_tokens_by_peer_id')
raise Exception('failed to delete_repo_tokens_by_peer_id')
TokenV2.objects.delete_device_token(username, platform, device_id)

View File

@@ -15,6 +15,7 @@ import datetime as dt
from datetime import datetime from datetime import datetime
from math import ceil from math import ceil
from urllib import quote from urllib import quote
from django.utils.datastructures import SortedDict
from django.core.cache import cache from django.core.cache import cache
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
@@ -89,6 +90,7 @@ from seahub.utils.star import get_dir_starred_files
from seahub.views.modules import MOD_PERSONAL_WIKI, \ from seahub.views.modules import MOD_PERSONAL_WIKI, \
enable_mod_for_user, disable_mod_for_user enable_mod_for_user, disable_mod_for_user
from seahub.utils import HAS_OFFICE_CONVERTER from seahub.utils import HAS_OFFICE_CONVERTER
from seahub.utils.devices import get_user_devices, do_unlink_device
if HAS_OFFICE_CONVERTER: if HAS_OFFICE_CONVERTER:
from seahub.utils import prepare_converted_html, OFFICE_PREVIEW_MAX_SIZE, OFFICE_PREVIEW_MAX_PAGES from seahub.utils import prepare_converted_html, OFFICE_PREVIEW_MAX_SIZE, OFFICE_PREVIEW_MAX_PAGES
@@ -1063,6 +1065,34 @@ def starred(request):
"starred_files": starred_files, "starred_files": starred_files,
}, context_instance=RequestContext(request)) }, context_instance=RequestContext(request))
@login_required
def devices(request):
"""List user devices"""
username = request.user.username
user_devices = get_user_devices(username)
return render_to_response('devices.html', {
"devices": user_devices,
}, context_instance=RequestContext(request))
@login_required
def unlink_device(request):
if not request.is_ajax():
raise Http404
content_type = 'application/json; charset=utf-8'
platform = request.POST.get('platform', '')
device_id = request.POST.get('device_id', '')
if not platform or not device_id:
return HttpResponseBadRequest(content_type=content_type)
do_unlink_device(request.user.username, platform, device_id)
return HttpResponse(json.dumps({'success': True}),
content_type=content_type)
@login_required @login_required
@user_mods_check @user_mods_check
def client_mgmt(request): def client_mgmt(request):