| @@ -0,0 +1,294 @@ | ||
| # -*- coding: utf-8 -*- | ||
| import json | ||
| from functools import partial | ||
| from uuid import uuid1, uuid4, UUID | ||
| from annotator import auth | ||
| from hem.interfaces import IDBSession | ||
| from hem.db import get_session | ||
| from horus import interfaces | ||
| from horus.models import ( | ||
| BaseModel, | ||
| ActivationMixin, | ||
| GroupMixin, | ||
| UserMixin, | ||
| UserGroupMixin, | ||
| ) | ||
| from horus.strings import UIStringsBase | ||
| from pyramid_basemodel import Base, Session | ||
| from pyramid.settings import asbool | ||
| from pyramid.threadlocal import get_current_request | ||
| import sqlalchemy as sa | ||
| from sqlalchemy import func, or_ | ||
| from sqlalchemy.dialects import postgresql as pg | ||
| from sqlalchemy.ext.declarative import declared_attr | ||
| from sqlalchemy.schema import Column | ||
| from sqlalchemy.types import Integer, TypeDecorator, CHAR, VARCHAR | ||
| import transaction | ||
| from zope.interface import Interface | ||
| class JSONEncodedDict(TypeDecorator): | ||
| """Represents an immutable structure as a json-encoded string. | ||
| Usage:: | ||
| JSONEncodedDict(255) | ||
| """ | ||
| # pylint: disable=too-many-public-methods | ||
| impl = VARCHAR | ||
| def process_bind_param(self, value, dialect): | ||
| if value is not None: | ||
| value = json.dumps(value) | ||
| return value | ||
| def process_result_value(self, value, dialect): | ||
| if value is not None: | ||
| value = json.loads(value) | ||
| return value | ||
| def python_type(self): | ||
| return dict | ||
| class GUID(TypeDecorator): | ||
| """Platform-independent GUID type. | ||
| From http://docs.sqlalchemy.org/en/latest/core/types.html | ||
| Copyright (C) 2005-2011 the SQLAlchemy authors and contributors | ||
| Uses Postgresql's UUID type, otherwise uses | ||
| CHAR(32), storing as stringified hex values. | ||
| """ | ||
| # pylint: disable=too-many-public-methods | ||
| impl = CHAR | ||
| def load_dialect_impl(self, dialect): | ||
| if dialect.name == 'postgresql': | ||
| return dialect.type_descriptor(pg.UUID()) | ||
| else: | ||
| return dialect.type_descriptor(CHAR(32)) | ||
| def process_bind_param(self, value, dialect): | ||
| if value is None: | ||
| return value | ||
| elif dialect.name == 'postgresql': | ||
| return str(value) | ||
| else: | ||
| if not isinstance(value, UUID): | ||
| return "%.32x" % UUID(value) | ||
| else: | ||
| # hexstring | ||
| return "%.32x" % value | ||
| def process_result_value(self, value, dialect): | ||
| if value is None: | ||
| return value | ||
| else: | ||
| return UUID(value) | ||
| def python_type(self): | ||
| return UUID | ||
| class Activation(ActivationMixin, Base): | ||
| def __init__(self, *args, **kwargs): | ||
| super(Activation, self).__init__(*args, **kwargs) | ||
| # XXX: Horus currently has a bug where the Activation model isn't | ||
| # flushed before the email is generated, causing the link to be | ||
| # broken (hypothesis/h#1156). | ||
| # | ||
| # Fixed in horus@90f838cef12be249a9e9deb5f38b37151649e801 | ||
| request = get_current_request() | ||
| db = get_session(request) | ||
| db.add(self) | ||
| db.flush() | ||
| class ConsumerMixin(BaseModel): | ||
| """ | ||
| API Consumer | ||
| The annotator-store :py:class:`annotator.auth.Authenticator` uses this | ||
| function in the process of authenticating requests to verify the secrets of | ||
| the JSON Web Token passed by the consumer client. | ||
| """ | ||
| key = Column(GUID, default=partial(uuid1, clock_seq=id(Base)), index=True) | ||
| secret = Column(GUID, default=uuid4) | ||
| ttl = Column(Integer, default=auth.DEFAULT_TTL) | ||
| def __init__(self, **kwargs): | ||
| super(ConsumerMixin, self).__init__() | ||
| self.__dict__.update(kwargs) | ||
| def __repr__(self): | ||
| return '<Consumer %r>' % self.key | ||
| @classmethod | ||
| def get_by_key(cls, request, key): | ||
| return get_session(request).query(cls).filter(cls.key == key).first() | ||
| class IConsumerClass(Interface): | ||
| pass | ||
| class Consumer(ConsumerMixin, Base): | ||
| pass | ||
| class Group(GroupMixin, Base): | ||
| pass | ||
| class User(UserMixin, Base): | ||
| # pylint: disable=too-many-public-methods | ||
| @classmethod | ||
| def get_by_username(cls, request, username): | ||
| session = get_session(request) | ||
| lhs = func.replace(cls.username, '.', '') | ||
| rhs = username.replace('.', '') | ||
| return session.query(cls).filter( | ||
| func.lower(lhs) == rhs.lower() | ||
| ).first() | ||
| @classmethod | ||
| def get_by_username_or_email(cls, request, username, email): | ||
| session = get_session(request) | ||
| lhs = func.replace(cls.username, '.', '') | ||
| rhs = username.replace('.', '') | ||
| return session.query(cls).filter( | ||
| or_( | ||
| func.lower(lhs) == rhs.lower(), | ||
| cls.email == email | ||
| ) | ||
| ).first() | ||
| @property | ||
| def email_confirmed(self): | ||
| return bool((self.status or 0) & 0b001) | ||
| @email_confirmed.setter | ||
| def email_confirmed(self, value): | ||
| if value: | ||
| self.status = (self.status or 0) | 0b001 | ||
| else: | ||
| self.status = (self.status or 0) & ~0b001 | ||
| @property | ||
| def optout(self): | ||
| return bool((self.status or 0) & 0b010) | ||
| @optout.setter | ||
| def optout(self, value): | ||
| if value: | ||
| self.status = (self.status or 0) | 0b010 | ||
| else: | ||
| self.status = (self.status or 0) & ~0b010 | ||
| @property | ||
| def subscriptions(self): | ||
| return bool((self.status or 0) & 0b100) | ||
| @subscriptions.setter | ||
| def subscriptions(self, value): | ||
| if value: | ||
| self.status = (self.status or 0) | 0b100 | ||
| else: | ||
| self.status = (self.status or 0) & ~0b100 | ||
| class UserGroup(UserGroupMixin, Base): | ||
| pass | ||
| class UserSubscriptionsMixin(BaseModel): | ||
| # pylint: disable=no-self-use | ||
| @declared_attr | ||
| def username(self): | ||
| return sa.Column( | ||
| sa.Unicode(30), | ||
| sa.ForeignKey( | ||
| '%s.%s' % (UserMixin.__tablename__, 'username'), | ||
| onupdate='CASCADE', | ||
| ondelete='CASCADE' | ||
| ), | ||
| nullable=False | ||
| ) | ||
| @declared_attr | ||
| def query(self): | ||
| return sa.Column(JSONEncodedDict(4096), nullable=False) | ||
| @declared_attr | ||
| def template(self): | ||
| return sa.Column( | ||
| sa.Enum('reply_notification', 'custom_search', | ||
| name='subscription_template'), | ||
| nullable=False, | ||
| default='custom_search' | ||
| ) | ||
| @declared_attr | ||
| def description(self): | ||
| return sa.Column(sa.VARCHAR(256), default="") | ||
| @declared_attr | ||
| def type(self): | ||
| return sa.Column( | ||
| sa.Enum('system', 'user', name='subscription_type'), | ||
| nullable=False, | ||
| default='user' | ||
| ) | ||
| @declared_attr | ||
| def active(self): | ||
| return sa.Column(sa.BOOLEAN, default=True, nullable=False) | ||
| class UserSubscriptions(UserSubscriptionsMixin, Base): | ||
| pass | ||
| def includeme(config): | ||
| registry = config.registry | ||
| settings = registry.settings | ||
| config.include('pyramid_basemodel') | ||
| config.include('pyramid_tm') | ||
| models = [ | ||
| (interfaces.IActivationClass, Activation), | ||
| (interfaces.IUserClass, User), | ||
| (interfaces.IUIStrings, UIStringsBase), | ||
| (IConsumerClass, Consumer), | ||
| (IDBSession, Session) | ||
| ] | ||
| for iface, imp in models: | ||
| if not registry.queryUtility(iface): | ||
| registry.registerUtility(imp, iface) | ||
| if asbool(settings.get('basemodel.should_create_all', True)): | ||
| key = settings['api.key'] | ||
| secret = settings.get('api.secret') | ||
| ttl = settings.get('api.ttl', auth.DEFAULT_TTL) | ||
| session = Session() | ||
| consumer = session.query(Consumer).filter(Consumer.key == key).first() | ||
| if not consumer: | ||
| with transaction.manager: | ||
| consumer = Consumer(key=key, secret=secret, ttl=ttl) | ||
| session.add(consumer) | ||
| session.flush() |
| @@ -0,0 +1,134 @@ | ||
| # -*- coding: utf-8 -*- | ||
| import re | ||
| from urllib import quote | ||
| from annotator import auth | ||
| from oauthlib.oauth2 import BackendApplicationServer, RequestValidator | ||
| from pyramid.authentication import RemoteUserAuthenticationPolicy | ||
| from h.auth.local.models import Consumer, User | ||
| def generate_token(request): | ||
| message = { | ||
| 'consumerKey': request.client.key, | ||
| 'ttl': request.client.ttl, | ||
| } | ||
| if request.extra_credentials is not None: | ||
| message.update(request.extra_credentials) | ||
| return auth.encode_token(message, request.client.secret) | ||
| def get_consumer(request, key): | ||
| inst = Consumer.get_by_key(request, key) | ||
| # Coerce types so elasticsearch doesn't choke on the UUIDs. | ||
| # TODO: Can we add magic to .models.GUID to take care of this? | ||
| result = auth.Consumer(str(key)) | ||
| result.secret = str(inst.secret) | ||
| result.ttl = inst.ttl | ||
| return result | ||
| class RequestValidator(RequestValidator): | ||
| def __init__(self, request, *args, **kwargs): | ||
| super(RequestValidator, self).__init__(*args, **kwargs) | ||
| # bw compat | ||
| if request.params.get('grant_type') is None: | ||
| persona = request.GET.get('persona') | ||
| if persona is not None: | ||
| request.GET['persona'] = quote(persona) | ||
| request.GET['grant_type'] = 'client_credentials' | ||
| self.request = request | ||
| def authenticate_client(self, request): | ||
| key = self.request.registry.settings['api.key'] | ||
| request.client = get_consumer(self.request, key) | ||
| request.client.client_id = key | ||
| request.client_id = key | ||
| return True | ||
| def get_default_redirect_uri(self, client_id, request): | ||
| return self.request.registry.settings.get('horus.login_redirect', '/') | ||
| def get_default_scopes(self, client, request): | ||
| return ['annotations'] | ||
| def save_bearer_token(self, token, request): | ||
| return self.get_default_redirect_uri(request.client_id, request) | ||
| def validate_bearer_token(self, token, scopes, request): | ||
| self.authenticate_client(request) | ||
| try: | ||
| credentials = auth.decode_token(token, request.client.secret) | ||
| request.user = credentials.get('userId') | ||
| return True | ||
| except: | ||
| pass | ||
| return False | ||
| def validate_grant_type(self, client_id, grant_type, client, request): | ||
| if grant_type == 'client_credentials': | ||
| return client_id == client.client_id | ||
| return False | ||
| def validate_scopes(self, client_id, scopes, client, request): | ||
| return scopes == ['annotations'] | ||
| class LocalAuthenticationPolicy(RemoteUserAuthenticationPolicy): | ||
| def unauthenticated_userid(self, request): | ||
| validator = RequestValidator(request) | ||
| token_generator = generate_token | ||
| server = BackendApplicationServer(validator, token_generator) | ||
| token = request.environ.get(self.environ_key) | ||
| if token is not None: | ||
| request.authorization = 'Bearer %s' % token | ||
| valid, r = server.verify_request( | ||
| request.url, | ||
| request.method, | ||
| None, | ||
| request.headers, | ||
| ['annotations'], | ||
| ) | ||
| if valid: | ||
| return r.user | ||
| return None | ||
| def groupfinder(userid, request): | ||
| username = re.match('acct:([^@]+)@', userid).group(1) | ||
| user = User.get_by_username(request, username) | ||
| groups = [] | ||
| if user: | ||
| groups = [userid] | ||
| for group in user.groups: | ||
| groups.append('group:%s' % group.name) | ||
| return groups | ||
| def includeme(config): | ||
| registry = config.registry | ||
| settings = registry.settings | ||
| authn_debug = settings.get('pyramid.debug_authorization') \ | ||
| or settings.get('debug_authorization') | ||
| authn_policy = LocalAuthenticationPolicy( | ||
| environ_key='HTTP_X_ANNOTATOR_AUTH_TOKEN', | ||
| callback=groupfinder, | ||
| debug=authn_debug, | ||
| ) | ||
| config.set_authentication_policy(authn_policy) |
| @@ -0,0 +1,42 @@ | ||
| from pyramid.events import subscriber | ||
| from pyramid.settings import asbool | ||
| from horus.events import ( | ||
| NewRegistrationEvent, | ||
| RegistrationActivatedEvent, | ||
| PasswordResetEvent, | ||
| ) | ||
| from h.events import LoginEvent | ||
| @subscriber(NewRegistrationEvent, autologin=True) | ||
| @subscriber(PasswordResetEvent, autologin=True) | ||
| @subscriber(RegistrationActivatedEvent) | ||
| def login(event): | ||
| request = event.request | ||
| user = 'acct:%s@%s' % (event.user.username, request.server_name) | ||
| event = LoginEvent(request, user) | ||
| request.registry.notify(event) | ||
| class AutoLogin(object): | ||
| # pylint: disable=too-few-public-methods | ||
| def __init__(self, val, config): | ||
| self.val = val | ||
| def text(self): | ||
| return 'autologin = %s' % (self.val,) | ||
| phash = text | ||
| def __call__(self, event): | ||
| request = event.request | ||
| settings = request.registry.settings | ||
| autologin = asbool(settings.get('horus.autologin', False)) | ||
| return self.val == autologin | ||
| def includeme(config): | ||
| config.add_subscriber_predicate('autologin', AutoLogin) | ||
| config.scan(__name__) |
| @@ -0,0 +1,211 @@ | ||
| # -*- coding: utf-8 -*- | ||
| import json | ||
| from urllib import unquote | ||
| import colander | ||
| import deform | ||
| import horus.views | ||
| from horus.lib import FlashMessage | ||
| from pyramid import httpexceptions | ||
| from pyramid.view import view_config, view_defaults | ||
| from h import events, views | ||
| from h.auth.local import forms, models, oauth, schemas | ||
| from h.models import _ | ||
| def ajax_form(request, result): | ||
| errors = [] | ||
| if isinstance(result, httpexceptions.HTTPRedirection): | ||
| request.response.headers.extend(result.headers) | ||
| result = {'status': 'okay'} | ||
| elif isinstance(result, httpexceptions.HTTPError): | ||
| request.response.status_code = result.code | ||
| result = {'status': 'failure', 'reason': str(result)} | ||
| else: | ||
| errors = result.pop('errors', []) | ||
| if errors: | ||
| request.response.status_code = 400 | ||
| result['status'] = 'failure' | ||
| result['reason'] = _('Please check your input.') | ||
| else: | ||
| result['status'] = 'okay' | ||
| for e in errors: | ||
| if isinstance(e, colander.Invalid): | ||
| result.setdefault('errors', {}) | ||
| result['errors'].update(e.asdict()) | ||
| return result | ||
| class AsyncFormViewMapper(object): | ||
| def __init__(self, **kw): | ||
| self.attr = kw['attr'] | ||
| def __call__(self, view): | ||
| def wrapper(context, request): | ||
| if request.method == 'POST': | ||
| data = request.json_body | ||
| data.update(request.params) | ||
| request.content_type = 'application/x-www-form-urlencoded' | ||
| request.POST.clear() | ||
| request.POST.update(data) | ||
| inst = view(request) | ||
| meth = getattr(inst, self.attr) | ||
| result = meth() | ||
| result = ajax_form(request, result) | ||
| result['flash'] = views.pop_flash(request) | ||
| result['model'] = views.model(request) | ||
| result.pop('form', None) | ||
| return result | ||
| return wrapper | ||
| @view_defaults(accept='text/html', renderer='h:templates/auth.pt') | ||
| @view_config(attr='login', route_name='login') | ||
| @view_config(attr='logout', route_name='logout') | ||
| class AuthController(horus.views.AuthController): | ||
| def login(self): | ||
| request = self.request | ||
| result = super(AuthController, self).login() | ||
| if request.user: | ||
| # XXX: Horus should maybe do this for us | ||
| user = 'acct:%s@%s' % (request.user.username, request.server_name) | ||
| event = events.LoginEvent(request, user) | ||
| request.registry.notify(event) | ||
| return result | ||
| def logout(self): | ||
| request = self.request | ||
| result = super(AuthController, self).logout() | ||
| # XXX: Horus should maybe do this for us | ||
| event = events.LogoutEvent(request) | ||
| request.registry.notify(event) | ||
| return result | ||
| @view_defaults(accept='application/json', name='app', renderer='json') | ||
| @view_config(attr='login', request_param='__formid__=login') | ||
| @view_config(attr='logout', request_param='__formid__=logout') | ||
| class AsyncAuthController(AuthController): | ||
| __view_mapper__ = AsyncFormViewMapper | ||
| @view_defaults(accept='text/html', renderer='h:templates/auth.pt') | ||
| @view_config(attr='forgot_password', route_name='forgot_password') | ||
| @view_config(attr='reset_password', route_name='reset_password') | ||
| class ForgotPasswordController(horus.views.ForgotPasswordController): | ||
| pass | ||
| @view_defaults(accept='application/json', name='app', renderer='json') | ||
| @view_config(attr='forgot_password', request_param='__formid__=forgot') | ||
| class AsyncForgotPasswordController(ForgotPasswordController): | ||
| __view_mapper__ = AsyncFormViewMapper | ||
| @view_defaults(accept='text/html', renderer='h:templates/auth.pt') | ||
| @view_config(attr='register', route_name='register') | ||
| @view_config(attr='activate', route_name='activate') | ||
| class RegisterController(horus.views.RegisterController): | ||
| pass | ||
| @view_defaults(accept='application/json', name='app', renderer='json') | ||
| @view_config(attr='register', request_param='__formid__=register') | ||
| @view_config(attr='activate', request_param='__formid__=activate') | ||
| class AsyncRegisterController(RegisterController): | ||
| __view_mapper__ = AsyncFormViewMapper | ||
| def activate(self): | ||
| """Activate a user and set a password given an activation code. | ||
| This view is different from the activation view in horus because it | ||
| does not require the user id to be passed. It trusts the activation | ||
| code and updates the password. | ||
| """ | ||
| request = self.request | ||
| Str = self.Str | ||
| schema = schemas.ActivationSchema.bind(request=request) | ||
| form = forms.ActivateForm(schema) | ||
| appstruct = None | ||
| try: | ||
| appstruct = form.validate(request.POST.items()) | ||
| except deform.ValidationFailure as e: | ||
| return dict(errors=e.error.children) | ||
| code = appstruct['code'] | ||
| activation = models.Activation.get_by_code(request, code) | ||
| user = None | ||
| if activation: | ||
| user = self.User.get_by_activation(request, activation) | ||
| if user is None: | ||
| return dict(errors=[_('This activation code is not valid.')]) | ||
| user.password = appstruct['password'] | ||
| self.db.delete(activation) | ||
| self.db.add(user) | ||
| FlashMessage(request, Str.reset_password_done, kind='success') | ||
| # XXX: Horus should maybe do this for us | ||
| event = events.RegistrationActivatedEvent(request, user, activation) | ||
| request.registry.notify(event) | ||
| return {} | ||
| def access_token(request): | ||
| validator = oauth.RequestValidator(request) | ||
| token_generator = oauth.generate_token | ||
| server = oauth.BackendApplicationServer(validator, token_generator) | ||
| persona = unquote(request.params.get('persona')) | ||
| personas = request.session.get('personas', []) | ||
| try: | ||
| credentials = dict(userId=next(p for p in personas if p == persona)) | ||
| except StopIteration: | ||
| credentials = None | ||
| headers, body, status = server.create_token_response( | ||
| request.url, | ||
| request.method, | ||
| request.body, | ||
| request.headers, | ||
| credentials, | ||
| ) | ||
| request.response.headers.update(headers) | ||
| request.response.status_int = status | ||
| request.response.content_type = 'application/json' | ||
| request.response.charset = 'UTF-8' | ||
| request.response.body = body | ||
| return request.response | ||
| @view_config(renderer='string', route_name='token') | ||
| def token(request): | ||
| return json.loads(access_token(request).body).get('access_token', '') | ||
| def includeme(config): | ||
| registry = config.registry | ||
| settings = registry.settings | ||
| token_url = settings.get('auth.token_endpoint', '/api/token').strip('/') | ||
| config.add_route('token', token_url) | ||
| config.include('horus') | ||
| config.scan(__name__) |