Allow auth plugins

remotes/origin/enhancement/email-actions
Jesús Espino 2015-02-09 17:08:37 +01:00
parent dd5cff35cf
commit c517a8519c
11 changed files with 163 additions and 304 deletions

View File

@ -9,3 +9,4 @@ pytest-pythonpath==0.3
coverage==3.7.1
coveralls==0.4.2
django-slowdown==0.0.1
taiga-contrib-github-auth==0.0.2

View File

@ -24,7 +24,10 @@ CELERY_ENABLED = False
MEDIA_ROOT = "/tmp"
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
INSTALLED_APPS = INSTALLED_APPS + ["tests"]
INSTALLED_APPS = INSTALLED_APPS + [
"tests",
"taiga_contrib_github_auth",
]
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {
"anon": None,

View File

@ -27,7 +27,6 @@ from rest_framework import serializers
from taiga.base.api import viewsets
from taiga.base.decorators import list_route
from taiga.base import exceptions as exc
from taiga.base.connectors import github
from taiga.users.services import get_and_validate_user
from .serializers import PublicRegisterSerializer
@ -37,8 +36,8 @@ from .serializers import PrivateRegisterForNewUserSerializer
from .services import private_register_for_existing_user
from .services import private_register_for_new_user
from .services import public_register
from .services import github_register
from .services import make_auth_response_data
from .services import get_auth_plugins
from .permissions import AuthPermission
@ -135,36 +134,15 @@ class AuthViewSet(viewsets.ViewSet):
return self._private_register(request)
raise exc.BadRequest(_("invalid register type"))
def _login(self, request):
username = request.DATA.get('username', None)
password = request.DATA.get('password', None)
user = get_and_validate_user(username=username, password=password)
data = make_auth_response_data(user)
return Response(data, status=status.HTTP_200_OK)
def _github_login(self, request):
code = request.DATA.get('code', None)
token = request.DATA.get('token', None)
email, user_info = github.me(code)
user = github_register(username=user_info.username,
email=email,
full_name=user_info.full_name,
github_id=user_info.id,
bio=user_info.bio,
token=token)
data = make_auth_response_data(user)
return Response(data, status=status.HTTP_200_OK)
# Login view: /api/v1/auth
def create(self, request, **kwargs):
self.check_permissions(request, 'create', None)
auth_plugins = get_auth_plugins()
login_type = request.DATA.get("type", None)
if login_type in auth_plugins:
return auth_plugins[login_type]['login_func'](request)
type = request.DATA.get("type", None)
if type == "normal":
return self._login(request)
elif type == "github":
return self._github_login(request)
raise exc.BadRequest(_("invalid login type"))

View File

@ -29,6 +29,9 @@ from django.db import transaction as tx
from django.db import IntegrityError
from django.utils.translation import ugettext as _
from rest_framework.response import Response
from rest_framework import status
from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail
from taiga.base import exceptions as exc
@ -39,6 +42,19 @@ from taiga.base.utils.slug import slugify_uniquely
from .tokens import get_token_for_user
from .signals import user_registered as user_registered_signal
auth_plugins = {}
def register_auth_plugin(name, login_func):
auth_plugins[name] = {
"login_func": login_func,
}
def get_auth_plugins():
return auth_plugins
def send_register_email(user) -> bool:
"""
Given a user, send register welcome email
@ -169,47 +185,6 @@ def private_register_for_new_user(token:str, username:str, email:str,
return user
@tx.atomic
def github_register(username:str, email:str, full_name:str, github_id:int, bio:str, token:str=None):
"""
Register a new user from github.
This can raise `exc.IntegrityError` exceptions in
case of conflics found.
:returns: User
"""
user_model = apps.get_model("users", "User")
try:
# Github user association exist?
user = user_model.objects.get(github_id=github_id)
except user_model.DoesNotExist:
try:
# Is a user with the same email as the github user?
user = user_model.objects.get(email=email)
user.github_id = github_id
user.save(update_fields=["github_id"])
except user_model.DoesNotExist:
# Create a new user
username_unique = slugify_uniquely(username, user_model, slugfield="username")
user = user_model.objects.create(email=email,
username=username_unique,
github_id=github_id,
full_name=full_name,
bio=bio)
send_register_email(user)
user_registered_signal.send(sender=user.__class__, user=user)
if token:
membership = get_membership_by_token(token)
membership.user = user
membership.save(update_fields=["user"])
return user
def make_auth_response_data(user) -> dict:
"""
Given a domain and user, creates data structure
@ -220,3 +195,15 @@ def make_auth_response_data(user) -> dict:
data = dict(serializer.data)
data["auth_token"] = get_token_for_user(user, "authentication")
return data
def normal_login_func(request):
username = request.DATA.get('username', None)
password = request.DATA.get('password', None)
user = get_and_validate_user(username=username, password=password)
data = make_auth_response_data(user)
return Response(data, status=status.HTTP_200_OK)
register_auth_plugin("normal", normal_login_func);

View File

@ -21,7 +21,3 @@ from django.utils.translation import ugettext_lazy as _
class ConnectorBaseException(BaseException):
status_code = 400
default_detail = _("Connection error.")
class GitHubApiError(ConnectorBaseException):
pass

View File

@ -1,166 +0,0 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import requests
import json
from collections import namedtuple
from urllib.parse import urljoin
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from . import exceptions as exc
######################################################
## Data
######################################################
CLIENT_ID = getattr(settings, "GITHUB_API_CLIENT_ID", None)
CLIENT_SECRET = getattr(settings, "GITHUB_API_CLIENT_SECRET", None)
URL = getattr(settings, "GITHUB_URL", "https://github.com/")
API_URL = getattr(settings, "GITHUB_API_URL", "https://api.github.com/")
API_RESOURCES_URLS = {
"login": {
"authorize": "login/oauth/authorize",
"access-token": "login/oauth/access_token"
},
"user": {
"profile": "user",
"emails": "user/emails"
}
}
HEADERS = {"Accept": "application/json",}
AuthInfo = namedtuple("AuthInfo", ["access_token"])
User = namedtuple("User", ["id", "username", "full_name", "bio"])
Email = namedtuple("Email", ["email", "is_primary"])
######################################################
## utils
######################################################
def _build_url(*args, **kwargs) -> str:
"""
Return a valid url.
"""
resource_url = API_RESOURCES_URLS
for key in args:
resource_url = resource_url[key]
if kwargs:
resource_url = resource_url.format(**kwargs)
return urljoin(API_URL, resource_url)
def _get(url:str, headers:dict) -> dict:
"""
Make a GET call.
"""
response = requests.get(url, headers=headers)
data = response.json()
if response.status_code != 200:
raise exc.GitHubApiError({"status_code": response.status_code,
"error": data.get("error", "")})
return data
def _post(url:str, params:dict, headers:dict) -> dict:
"""
Make a POST call.
"""
response = requests.post(url, params=params, headers=headers)
data = response.json()
if response.status_code != 200 or "error" in data:
raise exc.GitHubApiError({"status_code": response.status_code,
"error": data.get("error", "")})
return data
######################################################
## Simple calls
######################################################
def login(access_code:str, client_id:str=CLIENT_ID, client_secret:str=CLIENT_SECRET,
headers:dict=HEADERS):
"""
Get access_token fron an user authorized code, the client id and the client secret key.
(See https://developer.github.com/v3/oauth/#web-application-flow).
"""
if not CLIENT_ID or not CLIENT_SECRET:
raise exc.GitHubApiError({"error_message": _("Login with github account is disabled. Contact "
"with the sysadmins. Maybe they're snoozing in a "
"secret hideout of the data center.")})
url = urljoin(URL, "login/oauth/access_token")
params={"code": access_code,
"client_id": client_id,
"client_secret": client_secret,
"scope": "user:emails"}
data = _post(url, params=params, headers=headers)
return AuthInfo(access_token=data.get("access_token", None))
def get_user_profile(headers:dict=HEADERS):
"""
Get authenticated user info.
(See https://developer.github.com/v3/users/#get-the-authenticated-user).
"""
url = _build_url("user", "profile")
data = _get(url, headers=headers)
return User(id=data.get("id", None),
username=data.get("login", None),
full_name=(data.get("name", None) or ""),
bio=(data.get("bio", None) or ""))
def get_user_emails(headers:dict=HEADERS) -> list:
"""
Get a list with all emails of the authenticated user.
(See https://developer.github.com/v3/users/emails/#list-email-addresses-for-a-user).
"""
url = _build_url("user", "emails")
data = _get(url, headers=headers)
return [Email(email=e.get("email", None), is_primary=e.get("primary", False))
for e in data]
######################################################
## Convined calls
######################################################
def me(access_code:str) -> tuple:
"""
Connect to a github account and get all personal info (profile and the primary email).
"""
auth_info = login(access_code)
headers = HEADERS.copy()
headers["Authorization"] = "token {}".format(auth_info.access_token)
user = get_user_profile(headers=headers)
emails = get_user_emails(headers=headers)
primary_email = next(filter(lambda x: x.is_primary, emails))
return primary_email.email, user

View File

@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.conf import settings
import django_pgjson.fields
def migrate_github_id(apps, schema_editor):
AuthData = apps.get_model("users", "AuthData")
User = apps.get_model("users", "User")
for user in User.objects.all():
if user.github_id:
AuthData.objects.create(user=user, key="github", value=user.github_id, extra={})
class Migration(migrations.Migration):
dependencies = [
('users', '0006_auto_20141030_1132'),
]
operations = [
migrations.CreateModel(
name='AuthData',
fields=[
('id', models.AutoField(primary_key=True, verbose_name='ID', serialize=False, auto_created=True)),
('key', models.SlugField()),
('value', models.CharField(max_length=300)),
('extra', django_pgjson.fields.JsonField()),
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
],
options={
},
bases=(models.Model,),
),
migrations.AlterUniqueTogether(
name='authdata',
unique_together=set([('key', 'value')]),
),
migrations.RunPython(migrate_github_id),
migrations.RemoveField(
model_name='user',
name='github_id',
),
]

View File

@ -32,6 +32,7 @@ from django.utils import timezone
from django.utils.encoding import force_bytes
from django.template.defaultfilters import slugify
from django_pgjson.fields import JsonField
from djorm_pgarray.fields import TextArrayField
from taiga.auth.tokens import get_token_for_user
@ -129,7 +130,6 @@ class User(AbstractBaseUser, PermissionsMixin):
new_email = models.EmailField(_('new email address'), null=True, blank=True)
github_id = models.IntegerField(null=True, blank=True, verbose_name=_("github ID"), db_index=True)
is_system = models.BooleanField(null=False, blank=False, default=False)
USERNAME_FIELD = 'username'
@ -170,9 +170,9 @@ class User(AbstractBaseUser, PermissionsMixin):
self.default_timezone = ""
self.colorize_tags = True
self.token = None
self.github_id = None
self.set_unusable_password()
self.save()
self.auth_data.all().delete()
class Role(models.Model):
name = models.CharField(max_length=200, null=False, blank=False,
@ -211,6 +211,16 @@ class Role(models.Model):
return self.name
class AuthData(models.Model):
user = models.ForeignKey('users.User', related_name="auth_data")
key = models.SlugField(max_length=50)
value = models.CharField(max_length=300)
extra = JsonField()
class Meta:
unique_together = ["key", "value"]
# On Role object is changed, update all membership
# related to current role.
@receiver(models.signals.post_save, sender=Role,

View File

@ -33,9 +33,9 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ("id", "username", "full_name", "full_name_display", "email",
"github_id", "color", "bio", "default_language",
"color", "bio", "default_language",
"default_timezone", "is_active", "photo", "big_photo")
read_only_fields = ("id", "email", "github_id")
read_only_fields = ("id", "email")
def validate_username(self, attrs, source):
value = attrs[source]

View File

@ -24,11 +24,12 @@ from django.core import mail
from .. import factories
from taiga.base.connectors import github
from taiga.front import resolve as resolve_front_url
from taiga.users import models
from taiga.auth.tokens import get_token_for_user
from taiga_contrib_github_auth import connector as github_connector
pytestmark = pytest.mark.django_db
@ -95,9 +96,11 @@ def test_response_200_in_registration_with_github_account(client, settings):
form = {"type": "github",
"code": "xxxxxx"}
with patch("taiga.base.connectors.github.me") as m_me:
auth_data_model = apps.get_model("users", "AuthData")
with patch("taiga_contrib_github_auth.connector.me") as m_me:
m_me.return_value = ("mmcfly@bttf.com",
github.User(id=1955,
github_connector.User(id=1955,
username="mmcfly",
full_name="martin seamus mcfly",
bio="time traveler"))
@ -109,7 +112,7 @@ def test_response_200_in_registration_with_github_account(client, settings):
assert response.data["email"] == "mmcfly@bttf.com"
assert response.data["full_name"] == "martin seamus mcfly"
assert response.data["bio"] == "time traveler"
assert response.data["github_id"] == 1955
assert auth_data_model.objects.filter(user__username="mmcfly", key="github", value="1955").count() == 1
def test_response_200_in_registration_with_github_account_and_existed_user_by_email(client, settings):
settings.PUBLIC_REGISTER_ENABLED = False
@ -117,12 +120,11 @@ def test_response_200_in_registration_with_github_account_and_existed_user_by_em
"code": "xxxxxx"}
user = factories.UserFactory()
user.email = "mmcfly@bttf.com"
user.github_id = None
user.save()
with patch("taiga.base.connectors.github.me") as m_me:
with patch("taiga_contrib_github_auth.connector.me") as m_me:
m_me.return_value = ("mmcfly@bttf.com",
github.User(id=1955,
github_connector.User(id=1955,
username="mmcfly",
full_name="martin seamus mcfly",
bio="time traveler"))
@ -134,19 +136,21 @@ def test_response_200_in_registration_with_github_account_and_existed_user_by_em
assert response.data["email"] == user.email
assert response.data["full_name"] == user.full_name
assert response.data["bio"] == user.bio
assert response.data["github_id"] == 1955
assert user.auth_data.filter(key="github", value="1955").count() == 1
def test_response_200_in_registration_with_github_account_and_existed_user_by_github_id(client, settings):
settings.PUBLIC_REGISTER_ENABLED = False
form = {"type": "github",
"code": "xxxxxx"}
user = factories.UserFactory()
user.github_id = 1955
user.save()
user = factories.UserFactory.create()
with patch("taiga.base.connectors.github.me") as m_me:
auth_data_model = apps.get_model("users", "AuthData")
auth_data_model.objects.create(user=user, key="github", value="1955", extra={})
with patch("taiga_contrib_github_auth.connector.me") as m_me:
m_me.return_value = ("mmcfly@bttf.com",
github.User(id=1955,
github_connector.User(id=1955,
username="mmcfly",
full_name="martin seamus mcfly",
bio="time traveler"))
@ -158,7 +162,6 @@ def test_response_200_in_registration_with_github_account_and_existed_user_by_gi
assert response.data["email"] != "mmcfly@bttf.com"
assert response.data["full_name"] != "martin seamus mcfly"
assert response.data["bio"] != "time traveler"
assert response.data["github_id"] == user.github_id
def test_response_200_in_registration_with_github_account_and_change_github_username(client, settings):
settings.PUBLIC_REGISTER_ENABLED = False
@ -168,9 +171,11 @@ def test_response_200_in_registration_with_github_account_and_change_github_user
user.username = "mmcfly"
user.save()
with patch("taiga.base.connectors.github.me") as m_me:
auth_data_model = apps.get_model("users", "AuthData")
with patch("taiga_contrib_github_auth.connector.me") as m_me:
m_me.return_value = ("mmcfly@bttf.com",
github.User(id=1955,
github_connector.User(id=1955,
username="mmcfly",
full_name="martin seamus mcfly",
bio="time traveler"))
@ -182,7 +187,7 @@ def test_response_200_in_registration_with_github_account_and_change_github_user
assert response.data["email"] == "mmcfly@bttf.com"
assert response.data["full_name"] == "martin seamus mcfly"
assert response.data["bio"] == "time traveler"
assert response.data["github_id"] == 1955
assert auth_data_model.objects.filter(user__username="mmcfly-1", key="github", value="1955").count() == 1
def test_response_200_in_registration_with_github_account_in_a_project(client, settings):
settings.PUBLIC_REGISTER_ENABLED = False
@ -192,9 +197,9 @@ def test_response_200_in_registration_with_github_account_in_a_project(client, s
"code": "xxxxxx",
"token": membership.token}
with patch("taiga.base.connectors.github.me") as m_me:
with patch("taiga_contrib_github_auth.connector.me") as m_me:
m_me.return_value = ("mmcfly@bttf.com",
github.User(id=1955,
github_connector.User(id=1955,
username="mmcfly",
full_name="martin seamus mcfly",
bio="time traveler"))
@ -210,9 +215,9 @@ def test_response_404_in_registration_with_github_in_a_project_with_invalid_toke
"code": "xxxxxx",
"token": "123456"}
with patch("taiga.base.connectors.github.me") as m_me:
with patch("taiga_contrib_github_auth.connector.me") as m_me:
m_me.return_value = ("mmcfly@bttf.com",
github.User(id=1955,
github_connector.User(id=1955,
username="mmcfly",
full_name="martin seamus mcfly",
bio="time traveler"))

View File

@ -18,8 +18,7 @@
import pytest
from unittest.mock import patch, Mock
from taiga.base.connectors import github
from taiga.base.connectors import exceptions as exc
from taiga_contrib_github_auth import connector as github
def test_url_builder():
@ -34,8 +33,8 @@ def test_url_builder():
def test_login_without_settings_params():
with pytest.raises(exc.GitHubApiError) as e, \
patch("taiga.base.connectors.github.requests") as m_requests:
with pytest.raises(github.GitHubApiError) as e, \
patch("taiga_contrib_github_auth.connector.requests") as m_requests:
m_requests.post.return_value = m_response = Mock()
m_response.status_code = 200
m_response.json.return_value = {"access_token": "xxxxxxxx"}
@ -46,9 +45,9 @@ def test_login_without_settings_params():
def test_login_success():
with patch("taiga.base.connectors.github.requests") as m_requests, \
patch("taiga.base.connectors.github.CLIENT_ID") as CLIENT_ID, \
patch("taiga.base.connectors.github.CLIENT_SECRET") as CLIENT_SECRET:
with patch("taiga_contrib_github_auth.connector.requests") as m_requests, \
patch("taiga_contrib_github_auth.connector.CLIENT_ID") as CLIENT_ID, \
patch("taiga_contrib_github_auth.connector.CLIENT_SECRET") as CLIENT_SECRET:
CLIENT_ID = "*CLIENT_ID*"
CLIENT_SECRET = "*CLIENT_SECRET*"
m_requests.post.return_value = m_response = Mock()
@ -67,10 +66,10 @@ def test_login_success():
def test_login_whit_errors():
with pytest.raises(exc.GitHubApiError) as e, \
patch("taiga.base.connectors.github.requests") as m_requests, \
patch("taiga.base.connectors.github.CLIENT_ID") as CLIENT_ID, \
patch("taiga.base.connectors.github.CLIENT_SECRET") as CLIENT_SECRET:
with pytest.raises(github.GitHubApiError) as e, \
patch("taiga_contrib_github_auth.connector.requests") as m_requests, \
patch("taiga_contrib_github_auth.connector.CLIENT_ID") as CLIENT_ID, \
patch("taiga_contrib_github_auth.connector.CLIENT_SECRET") as CLIENT_SECRET:
CLIENT_ID = "*CLIENT_ID*"
CLIENT_SECRET = "*CLIENT_SECRET*"
m_requests.post.return_value = m_response = Mock()
@ -84,7 +83,7 @@ def test_login_whit_errors():
def test_get_user_profile_success():
with patch("taiga.base.connectors.github.requests") as m_requests:
with patch("taiga_contrib_github_auth.connector.requests") as m_requests:
m_requests.get.return_value = m_response = Mock()
m_response.status_code = 200
m_response.json.return_value = {"id": 1955,
@ -103,8 +102,8 @@ def test_get_user_profile_success():
def test_get_user_profile_whit_errors():
with pytest.raises(exc.GitHubApiError) as e, \
patch("taiga.base.connectors.github.requests") as m_requests:
with pytest.raises(github.GitHubApiError) as e, \
patch("taiga_contrib_github_auth.connector.requests") as m_requests:
m_requests.get.return_value = m_response = Mock()
m_response.status_code = 401
m_response.json.return_value = {"error": "Invalid credentials"}
@ -116,7 +115,7 @@ def test_get_user_profile_whit_errors():
def test_get_user_emails_success():
with patch("taiga.base.connectors.github.requests") as m_requests:
with patch("taiga_contrib_github_auth.connector.requests") as m_requests:
m_requests.get.return_value = m_response = Mock()
m_response.status_code = 200
m_response.json.return_value = [{"email": "darth-vader@bttf.com", "primary": False},
@ -134,8 +133,8 @@ def test_get_user_emails_success():
def test_get_user_emails_whit_errors():
with pytest.raises(exc.GitHubApiError) as e, \
patch("taiga.base.connectors.github.requests") as m_requests:
with pytest.raises(github.GitHubApiError) as e, \
patch("taiga_contrib_github_auth.connector.requests") as m_requests:
m_requests.get.return_value = m_response = Mock()
m_response.status_code = 401
m_response.json.return_value = {"error": "Invalid credentials"}
@ -147,9 +146,9 @@ def test_get_user_emails_whit_errors():
def test_me():
with patch("taiga.base.connectors.github.login") as m_login, \
patch("taiga.base.connectors.github.get_user_profile") as m_get_user_profile, \
patch("taiga.base.connectors.github.get_user_emails") as m_get_user_emails:
with patch("taiga_contrib_github_auth.connector.login") as m_login, \
patch("taiga_contrib_github_auth.connector.get_user_profile") as m_get_user_profile, \
patch("taiga_contrib_github_auth.connector.get_user_emails") as m_get_user_emails:
m_login.return_value = github.AuthInfo(access_token="xxxxxxxx")
m_get_user_profile.return_value = github.User(id=1955,
username="mmcfly",