blob: 921d989f7c2ea4f873d614bd645dd6ea56a715d5 [file] [log] [blame]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
'''
Allura plugins for authentication and project registration
'''
import re
import os
import logging
import subprocess
import string
import crypt
import random
from urllib2 import urlopen
from urlparse import urlparse
from cStringIO import StringIO
from random import randint
from hashlib import sha256
from base64 import b64encode
from datetime import datetime, timedelta
import calendar
try:
import ldap
from ldap import modlist
except ImportError:
ldap = modlist = None
import pkg_resources
import tg
from tg import config, request, redirect, response
from pylons import tmpl_context as c, app_globals as g
from webob import exc, Request
from paste.deploy.converters import asbool, asint
from ming.utils import LazyProperty
from ming.orm import state
from ming.orm import ThreadLocalORMSession, session, Mapper
from allura.lib import helpers as h
from allura.lib import security
from allura.lib import exceptions as forge_exc
from allura.lib import utils
from allura.tasks import activity_tasks
from allura.tasks.index_tasks import solr_del_project_artifacts
log = logging.getLogger(__name__)
class AuthenticationProvider(object):
'''
An interface to provide authentication services for Allura.
To use a new provider, expose an entry point in setup.py::
[allura.auth]
myprovider = foo.bar:MyAuthProvider
Then in your .ini file, set ``auth.method=myprovider``
'''
forgotten_password_process = False
pwd_expired_allowed_urls = [
'/auth/pwd_expired', # form for changing password, must be first here
'/auth/pwd_expired_change',
'/auth/logout',
]
multifactor_allowed_urls = [
'/auth/multifactor',
'/auth/do_multifactor',
]
def __init__(self, request):
self.request = request
@classmethod
def get(cls, request):
'''
returns the AuthenticationProvider instance for this request
:rtype: AuthenticationProvider
'''
try:
result = cls._loaded_ep
except AttributeError:
method = config.get('auth.method', 'local')
result = cls._loaded_ep = g.entry_points['auth'][method]
return result(request)
@LazyProperty
def session(self):
return self.request.environ['beaker.session']
def authenticate_request(self):
from allura import model as M
username = self.session.get('username') or self.session.get('expired-username')
user = M.User.query.get(username=username)
if 'multifactor-username' in self.session and request.path not in self.multifactor_allowed_urls:
# ensure any partially completed multifactor login is not left open, if user goes to any other pages
del self.session['multifactor-username']
self.session.save()
if user is None:
return M.User.anonymous()
if user.disabled or user.pending:
self.logout()
return M.User.anonymous()
session_create_date = datetime.utcfromtimestamp(self.session.created)
if user.is_anonymous():
sessions_need_reauth = False
elif self.get_last_password_updated(user) > session_create_date:
sessions_need_reauth = True
elif (user.get_tool_data('allura', 'multifactor_date') or datetime.min) > session_create_date:
sessions_need_reauth = True
else:
sessions_need_reauth = False
if sessions_need_reauth and user.get_tool_data('allura', 'pwd_reset_preserve_session') != self.session.id:
log.debug('Session logged out: due to user %s pwd change or multifactor enabled', user.username)
self.logout()
return M.User.anonymous()
if self.session.get('pwd-expired') and request.path not in self.pwd_expired_allowed_urls:
if self.request.environ['REQUEST_METHOD'] == 'GET':
return_to = self.request.environ['PATH_INFO']
if self.request.environ.get('QUERY_STRING'):
return_to += '?' + self.request.environ['QUERY_STRING']
location = tg.url(self.pwd_expired_allowed_urls[0], dict(return_to=return_to))
else:
# Don't try to re-post; the body has been lost.
location = tg.url(self.pwd_expired_allowed_urls[0])
redirect(location)
return user
def register_user(self, user_doc):
'''
Register a user.
:param user_doc: a dict with 'username' and 'display_name'. Optionally 'password' and others
:rtype: :class:`User <allura.model.auth.User>`
'''
raise NotImplementedError('register_user')
def _login(self):
'''
Authorize a user, usually using ``self.request.params['username']`` and ``['password']``
:rtype: :class:`User <allura.model.auth.User>`
:raises: HTTPUnauthorized if user not found, or credentials are not valid
'''
raise NotImplementedError('_login')
def after_login(self, user, request):
'''
This is a hook so that custom AuthenticationProviders can do things after a successful login.
'''
pass
def login(self, user=None, multifactor_success=False):
from allura import model as M
if user is None:
try:
user = self._login() # raises exception if auth fails
except exc.HTTPUnauthorized:
h.auditlog_user('Failed login', user=M.User.by_username(self.request.params['username']))
raise
if user.get_pref('multifactor') and not multifactor_success:
self.session['multifactor-username'] = user.username
h.auditlog_user('Multifactor login - password ok, code not entered yet', user=user)
self.session.save()
return None
else:
self.session.pop('multifactor-username', None)
if self.is_password_expired(user):
self.session['pwd-expired'] = True
self.session['expired-username'] = user.username
h.auditlog_user('Successful login; Password expired', user=user)
else:
self.session['username'] = user.username
h.auditlog_user('Successful login', user=user)
self.after_login(user, self.request)
if 'rememberme' in self.request.params:
remember_for = int(config.get('auth.remember_for', 365))
self.session['login_expires'] = datetime.utcnow() + timedelta(remember_for)
else:
self.session['login_expires'] = True
self.session.save()
g.statsUpdater.addUserLogin(user)
user.track_login(self.request)
# set a non-secure cookie with same expiration as session,
# so an http request can know if there is a related session on https
response.set_cookie('allura-loggedin', value='true',
expires=None if self.session['login_expires'] is True else self.session['login_expires'],
secure=False, httponly=True)
return user
def logout(self):
self.session.invalidate()
self.session.save()
response.delete_cookie('allura-loggedin')
response.set_cookie('memorable_forget', '/')
def validate_password(self, user, password):
'''Check that provided password matches actual user password
:rtype: bool
'''
raise NotImplementedError('validate_password')
def disable_user(self, user, **kw):
'''Disable user account'''
raise NotImplementedError('disable_user')
def enable_user(self, user, **kw):
'''Enable user account'''
raise NotImplementedError('enable_user')
def activate_user(self, user, **kw):
'''Activate user after registration'''
raise NotImplementedError('activate_user')
def deactivate_user(self, user, **kw):
'''Deactivate user (== registation not confirmed)'''
raise NotImplementedError('deactivate_user')
def by_username(self, username):
'''
Find a user by username.
:rtype: :class:`User <allura.model.auth.User>` or None
'''
raise NotImplementedError('by_username')
def set_password(self, user, old_password, new_password):
'''
Set a user's password.
A provider implementing this method should store the timestamp of this change, either
on ``user.last_password_updated`` or somewhere else that a custom ``get_last_password_updated`` method uses.
:param user: a :class:`User <allura.model.auth.User>`
:rtype: None
:raises: HTTPUnauthorized if old_password is not valid
'''
raise NotImplementedError('set_password')
def upload_sshkey(self, username, pubkey):
'''
Upload an SSH Key. Providers do not necessarily need to implement this.
:rtype: None
:raises: AssertionError with user message, upon any error
'''
raise NotImplementedError('upload_sshkey')
def account_navigation(self):
return [
{
'tabid': 'account_user_prefs',
'title': 'Preferences',
'target': "/auth/preferences",
'alt': 'Manage Personal Preferences',
},
{
'tabid': 'account_user_info',
'title': 'Personal Info',
'target': "/auth/user_info",
'alt': 'Manage Personal Information',
},
{
'tabid': 'account_subscriptions',
'title': 'Subscriptions',
'target': "/auth/subscriptions",
'alt': 'Manage Subscription Preferences',
},
{
'tabid': 'account_oauth',
'title': 'OAuth',
'target': "/auth/oauth",
'alt': 'Manage OAuth Preferences',
},
]
@LazyProperty
def account_urls(self):
return {m['tabid']: m['target'] for m in self.account_navigation()}
def user_project_shortname(self, user):
'''
:param user: a :class:`User <allura.model.auth.User>`
:rtype: str
'''
raise NotImplementedError('user_project_shortname')
def user_by_project_shortname(self, shortname):
'''
:param str: shortname
:rtype: user: a :class:`User <allura.model.auth.User>`
'''
raise NotImplementedError('user_by_project_shortname')
def update_notifications(self, user):
raise NotImplementedError('update_notifications')
def user_registration_date(self, user):
'''
Returns the date in which a user registered himself/herself on the forge.
:param user: a :class:`User <allura.model.auth.User>`
:rtype: :class:`datetime <datetime.datetime>`
'''
raise NotImplementedError('user_registration_date')
def get_last_password_updated(self, user):
'''
Returns the date when the user updated password for a last time.
:param user: a :class:`User <allura.model.auth.User>`
:rtype: :class:`datetime <datetime.datetime>`
'''
raise NotImplementedError('get_last_password_updated')
def get_primary_email_address(self, user_record):
return user_record.get_pref('email_address') if user_record else None
def user_details(self, user):
'''Returns detailed information about user.
:param user: a :class:`User <allura.model.auth.User>`
'''
return {}
def is_password_expired(self, user):
days = asint(config.get('auth.pwdexpire.days', 0))
before = asint(config.get('auth.pwdexpire.before', 0))
now = datetime.utcnow()
last_updated = self.get_last_password_updated(user)
if days and now - last_updated > timedelta(days=days):
return True
if before and last_updated < datetime.utcfromtimestamp(before):
return True
return False
def index_user(self, user):
"""Put here additional fields for user index in SOLR."""
return {}
def details_links(self, user):
'''Return list of pairs (url, label) with details
about the user.
Links will show up at admin user search page.
'''
return [
('/nf/admin/user/%s' % user.username, 'Details/Edit'),
]
class LocalAuthenticationProvider(AuthenticationProvider):
'''
Stores user passwords on the User model, in mongo. Uses per-user salt and
SHA-256 encryption.
'''
forgotten_password_process = True
def register_user(self, user_doc):
from allura import model as M
u = M.User(**user_doc)
if 'password' in user_doc:
u.set_password(user_doc['password'])
return u
def _login(self):
user = self.by_username(self.request.params['username'])
if not self._validate_password(user, self.request.params['password']):
raise exc.HTTPUnauthorized()
return user
def disable_user(self, user, **kw):
user.disabled = True
session(user).flush(user)
if kw.get('audit', True):
h.auditlog_user(u'Account disabled', user=user)
def enable_user(self, user, **kw):
user.disabled = False
session(user).flush(user)
if kw.get('audit', True):
h.auditlog_user(u'Account enabled', user=user)
def activate_user(self, user, **kw):
user.pending = False
session(user).flush(user)
if kw.get('audit', True):
h.auditlog_user('Account activated', user=user)
def deactivate_user(self, user, **kw):
user.pending = True
session(user).flush(user)
if kw.get('audit', True):
h.auditlog_user('Account changed to pending', user=user)
def validate_password(self, user, password):
return self._validate_password(user, password)
def _validate_password(self, user, password):
if user is None:
return False
if not user.password:
return False
salt = str(user.password[6:6 + user.SALT_LEN])
check = self._encode_password(password, salt)
if check != user.password:
return False
return True
def by_username(self, username):
from allura import model as M
un = re.escape(username)
un = un.replace(r'\_', '[-_]')
un = un.replace(r'\-', '[-_]')
rex = re.compile('^' + un + '$')
return M.User.query.get(username=rex, disabled=False, pending=False)
def set_password(self, user, old_password, new_password):
if old_password is not None and not self.validate_password(user, old_password):
raise exc.HTTPUnauthorized()
else:
user.password = self._encode_password(new_password)
user.last_password_updated = datetime.utcnow()
session(user).flush(user)
def _encode_password(self, password, salt=None):
from allura import model as M
if salt is None:
salt = ''.join(chr(randint(1, 0x7f))
for i in xrange(M.User.SALT_LEN))
hashpass = sha256(salt + password.encode('utf-8')).digest()
return 'sha256' + salt + b64encode(hashpass)
def user_project_shortname(self, user):
return 'u/' + user.username.replace('_', '-')
def user_by_project_shortname(self, shortname):
from allura import model as M
return M.User.query.get(username=shortname, disabled=False, pending=False)
def update_notifications(self, user):
return ''
def user_registration_date(self, user):
if user._id:
return user._id.generation_time
return datetime.utcnow()
def get_last_password_updated(self, user):
d = user.last_password_updated
if d is None:
d = self.user_registration_date(user)
# _id.generation_time returns aware datetime (in UTC)
# but we're using naive UTC time everywhere
d = datetime.utcfromtimestamp(calendar.timegm(d.utctimetuple()))
return d
def index_user(self, user):
fields = super(LocalAuthenticationProvider, self).index_user(user)
return dict(user_registration_date_dt=self.user_registration_date(user), **fields)
def ldap_conn(who=None, cred=None):
'''
Init & bind a connection with the given creds, or the admin creds if not
specified. Remember to unbind the connection when done.
'''
con = ldap.initialize(config['auth.ldap.server'])
con.bind_s(who or config['auth.ldap.admin_dn'],
cred or config['auth.ldap.admin_password'])
return con
def ldap_user_dn(username):
'return a Distinguished Name for a given username'
if not username:
raise ValueError('Empty username')
return 'uid=%s,%s' % (
ldap.dn.escape_dn_chars(username),
config['auth.ldap.suffix'])
class LdapAuthenticationProvider(AuthenticationProvider):
forgotten_password_process = True
def register_user(self, user_doc):
from allura import model as M
result = M.User(**user_doc)
if asbool(config.get('auth.ldap.autoregister', True)):
if asbool(config.get('auth.allow_user_registration', True)):
raise Exception('You should not have both "auth.ldap.autoregister" and '
'"auth.allow_user_registration" set to true')
else:
log.debug('LdapAuth: autoregister is true, so only creating the mongo '
'record (not creating ldap record)')
return result
# full registration into LDAP
uid = str(M.AuthGlobals.get_next_uid())
try:
con = ldap_conn()
uname = user_doc['username'].encode('utf-8')
display_name = user_doc['display_name'].encode('utf-8')
ldif_u = modlist.addModlist(dict(
uid=uname,
userPassword=self._encode_password(user_doc['password']),
objectClass=['account', 'posixAccount'],
cn=display_name,
uidNumber=uid,
gidNumber='10001',
homeDirectory='/home/' + uname,
loginShell='/bin/bash',
gecos=uname,
description='SCM user account'))
try:
con.add_s(ldap_user_dn(user_doc['username']), ldif_u)
except ldap.ALREADY_EXISTS:
log.exception('Trying to create existing user %s', uname)
raise
con.unbind_s()
if asbool(config.get('auth.ldap.use_schroot', True)):
argv = ('schroot -d / -c %s -u root /ldap-userconfig.py init %s' % (
config['auth.ldap.schroot_name'], user_doc['username'])).split()
p = subprocess.Popen(
argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
rc = p.wait()
if rc != 0:
log.error('Error creating home directory for %s',
user_doc['username'])
except:
raise
return result
def upload_sshkey(self, username, pubkey):
if not asbool(config.get('auth.ldap.use_schroot', True)):
raise NotImplementedError('SSH keys are not supported')
argv = ('schroot -d / -c %s -u root /ldap-userconfig.py upload %s' % (
config['auth.ldap.schroot_name'], username)).split() + [pubkey]
p = subprocess.Popen(
argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
rc = p.wait()
if rc != 0:
errmsg = p.stdout.read()
log.exception('Error uploading public SSH key for %s: %s',
username, errmsg)
assert False, errmsg
def _get_salt(self, length):
def random_char():
return random.choice(string.ascii_uppercase + string.digits)
return ''.join(random_char() for i in range(length))
def _encode_password(self, password, salt=None):
cfg_prefix = 'auth.ldap.password.'
salt_len = asint(config.get(cfg_prefix + 'salt_len', 16))
algorithm = config.get(cfg_prefix + 'algorithm', 6)
rounds = asint(config.get(cfg_prefix + 'rounds', 6000))
salt = self._get_salt(salt_len) if salt is None else salt
encrypted = crypt.crypt(
password.encode('utf-8'),
'$%s$rounds=%s$%s' % (algorithm, rounds, salt))
return '{CRYPT}%s' % encrypted
def by_username(self, username):
from allura import model as M
return M.User.query.get(username=username, disabled=False, pending=False)
def set_password(self, user, old_password, new_password):
dn = ldap_user_dn(user.username)
if old_password:
ldap_ident = dn
ldap_pass = old_password.encode('utf-8')
else:
ldap_ident = ldap_pass = None
try:
con = ldap_conn(ldap_ident, ldap_pass)
new_password = self._encode_password(new_password)
con.modify_s(
dn, [(ldap.MOD_REPLACE, 'userPassword', new_password)])
con.unbind_s()
user.last_password_updated = datetime.utcnow()
session(user).flush(user)
except ldap.INVALID_CREDENTIALS:
raise exc.HTTPUnauthorized()
def _login(self):
if ldap is None:
raise Exception('The python-ldap package needs to be installed. '
'Run `pip install python-ldap` in your allura environment.')
from allura import model as M
try:
username = str(self.request.params['username'])
except UnicodeEncodeError:
raise exc.HTTPBadRequest('Unicode is not allowed in usernames')
if not self._validate_password(username, self.request.params['password']):
raise exc.HTTPUnauthorized()
user = M.User.query.get(username=username)
if user is None:
if asbool(config.get('auth.ldap.autoregister', True)):
log.debug('LdapAuth: authorized user {} needs a mongo record registered. '
'Creating...'.format(username))
user = M.User.register({'username': username,
'display_name': LdapUserPreferencesProvider()._get_pref(username, 'display_name'),
})
else:
log.debug('LdapAuth: no user {} found in local mongo'.format(username))
raise exc.HTTPUnauthorized()
elif user.disabled or user.pending:
log.debug('LdapAuth: user {} is disabled or pending in Allura'.format(username))
raise exc.HTTPUnauthorized()
return user
def validate_password(self, user, password):
'''by user'''
return self._validate_password(user.username, password)
def _validate_password(self, username, password):
'''by username'''
password = h.really_unicode(password).encode('utf-8')
try:
ldap_user = ldap_user_dn(username)
except ValueError:
return False
try:
con = ldap_conn(ldap_user, password)
con.unbind_s()
return True
except (ldap.INVALID_CREDENTIALS, ldap.UNWILLING_TO_PERFORM, ldap.NO_SUCH_OBJECT):
log.debug('LdapAuth: could not authenticate {}'.format(username), exc_info=True)
return False
def user_project_shortname(self, user):
return LocalAuthenticationProvider(None).user_project_shortname(user)
def user_by_project_shortname(self, shortname):
return LocalAuthenticationProvider(None).user_by_project_shortname(shortname)
def user_registration_date(self, user):
# could read this from an LDAP field?
return LocalAuthenticationProvider(None).user_registration_date(user)
def update_notifications(self, user):
return LocalAuthenticationProvider(None).update_notifications(user)
def disable_user(self, user, **kw):
return LocalAuthenticationProvider(None).disable_user(user, **kw)
def enable_user(self, user, **kw):
return LocalAuthenticationProvider(None).enable_user(user, **kw)
def activate_user(self, user, **kw):
return LocalAuthenticationProvider(None).activate_user(user, **kw)
def deactivate_user(self, user, **kw):
return LocalAuthenticationProvider(None).deactivate_user(user, **kw)
def get_last_password_updated(self, user):
return LocalAuthenticationProvider(None).get_last_password_updated(user)
class ProjectRegistrationProvider(object):
'''
Project registration services for Allura. This is a full implementation
and the default. Extend this class with your own if you need to add more
functionality.
To use a new provider, expose an entry point in setup.py::
[allura.project_registration]
myprovider = foo.bar:MyAuthProvider
Then in your .ini file, set registration.method=myprovider
The provider should expose an attribute, `shortname_validator` which is
an instance of a FormEncode validator that validates project shortnames.
The `to_python()` method of the validator should accept a `check_allowed`
argument to indicate whether additional checks beyond correctness of the
name should be done, such as whether the name is already in use.
'''
def __init__(self):
from allura.lib.widgets import forms
self.add_project_widget = forms.NeighborhoodAddProjectForm
self.shortname_validator = forms.NeighborhoodProjectShortNameValidator(
)
@classmethod
def get(cls):
'''
:rtype: ProjectRegistrationProvider
'''
from allura.lib import app_globals
method = config.get('registration.method', 'local')
return app_globals.Globals().entry_points['registration'][method]()
def rate_limit(self, user, neighborhood):
"""Check the various config-defined project registration rate
limits, and if any are exceeded, raise ProjectRatelimitError.
"""
if security.has_access(neighborhood, 'admin', user=user)():
return
opt = 'project.rate_limits'
project_count = len(list(user.my_projects()))
# have to have the replace because, the generation_time is offset-aware
# UTC and h.rate_limit uses offset-naive UTC dates
start_date = user._id.generation_time.replace(tzinfo=None)
e = forge_exc.ProjectRatelimitError
h.rate_limit(opt, project_count, start_date, exception=e)
def phone_verified(self, user, neighborhood):
"""
Check if user has completed phone verification.
Returns True if one of the following is true:
- phone verification is disabled
- :param user: has 'admin' access to :param neighborhood:
- :param user: is has 'admin' access for some project, which belongs
to :param neighborhood:
- phone is already verified for a :param user:
Otherwise returns False.
"""
if not asbool(config.get('project.verify_phone')):
return True
if security.has_access(neighborhood, 'admin', user=user)():
return True
admin_in = [p for p in user.my_projects_by_role_name('Admin')
if p.neighborhood_id == neighborhood._id]
if len(admin_in) > 0:
return True
return bool(user.get_tool_data('phone_verification', 'number_hash'))
def verify_phone(self, user, number, allow_reuse=False):
from allura import model as M
ok = {'status': 'ok'}
if not asbool(config.get('project.verify_phone')):
return ok
number = utils.clean_phone_number(number)
number_hash = utils.phone_number_hash(number)
if not allow_reuse and M.User.query.find({'tool_data.phone_verification.number_hash': number_hash}).count():
return {'status': 'error',
'error': 'That phone number has already been used.'}
return g.phone_service.verify(number)
def check_phone_verification(self, user, request_id, pin, number_hash):
ok = {'status': 'ok'}
if not asbool(config.get('project.verify_phone')):
return ok
res = g.phone_service.check(request_id, pin)
if res.get('status') == 'ok':
user.set_tool_data('phone_verification', number_hash=number_hash)
msg = 'Phone verification succeeded. Hash: {}'.format(number_hash)
h.auditlog_user(msg, user=user)
else:
msg = 'Phone verification failed. Hash: {}'.format(number_hash)
h.auditlog_user(msg, user=user)
return res
def register_neighborhood_project(self, neighborhood, users, allow_register=False):
from allura import model as M
shortname = '--init--'
name = 'Home Project for %s' % neighborhood.name
p = M.Project(neighborhood_id=neighborhood._id,
shortname=shortname,
name=name,
short_description='',
description=(
'You can edit this description in the admin page'),
homepage_title = '# ' + name,
last_updated = datetime.utcnow(),
is_nbhd_project=True,
is_root=True)
try:
p.configure_project(
users=users,
is_user_project=False,
apps=[
('Wiki', 'wiki', 'Wiki'),
('admin', 'admin', 'Admin')])
except:
ThreadLocalORMSession.close_all()
log.exception('Error registering project %s' % p)
raise
if allow_register:
role_auth = M.ProjectRole.authenticated(p)
security.simple_grant(p.acl, role_auth._id, 'register')
state(p).soil()
return p
def register_project(self, neighborhood, shortname, project_name, user, user_project, private_project, apps=None):
'''Register a new project in the neighborhood. The given user will
become the project's superuser.
'''
self.validate_project(neighborhood, shortname,
project_name, user, user_project, private_project)
return self._create_project(neighborhood, shortname, project_name, user, user_project, private_project, apps)
def validate_project(self, neighborhood, shortname, project_name, user, user_project, private_project):
'''
Validate that a project can be registered, before it is
'''
from allura import model as M
# Check for private project rights
if neighborhood.features['private_projects'] is False and private_project:
raise ValueError(
"You can't create private projects for %s neighborhood" %
neighborhood.name)
# Check for project limit creation
nb_max_projects = neighborhood.get_max_projects()
if nb_max_projects is not None:
count = M.Project.query.find(dict(
neighborhood_id=neighborhood._id,
deleted=False,
is_nbhd_project=False,
)).count()
if count >= nb_max_projects:
log.exception('Error registering project %s' % project_name)
raise forge_exc.ProjectOverlimitError()
self.rate_limit(user, neighborhood)
if not self.phone_verified(user, neighborhood) and not user_project:
raise forge_exc.ProjectPhoneVerificationError()
if user_project and shortname.startswith('u/'):
check_shortname = shortname.replace('u/', '', 1)
else:
check_shortname = shortname
self.shortname_validator.to_python(check_shortname, neighborhood=neighborhood)
p = M.Project.query.get(shortname=shortname, neighborhood_id=neighborhood._id)
if p:
raise forge_exc.ProjectConflict(
'%s already exists in nbhd %s' % (shortname, neighborhood._id))
def index_project(self, project):
"""
Put here additional fields given project should be indexed by SOLR.
"""
return dict()
def _create_project(self, neighborhood, shortname, project_name, user, user_project, private_project, apps):
'''
Actually create the project, no validation. This should not be called directly
under normal circumstances.
'''
from allura import model as M
project_template = neighborhood.get_project_template()
p = M.Project(neighborhood_id=neighborhood._id,
shortname=shortname,
name=project_name,
short_description='',
description=(
'You can edit this description in the admin page'),
homepage_title=shortname,
last_updated = datetime.utcnow(),
is_nbhd_project=False,
is_root=True)
p.configure_project(
users=[user],
is_user_project=user_project,
is_private_project=private_project or project_template.get(
'private', False),
apps=apps or [] if 'tools' in project_template else None)
# Setup defaults from neighborhood project template if applicable
offset = p.next_mount_point(include_hidden=True)
if 'groups' in project_template:
for obj in project_template['groups']:
name = obj.get('name')
permissions = set(obj.get('permissions', [])) & \
set(p.permissions)
usernames = obj.get('usernames', [])
# Must provide a group name
if not name:
continue
# If the group already exists, we'll add users to it,
# but we won't change permissions on the group
group = M.ProjectRole.by_name(name, project=p)
if not group:
# If creating a new group, *must* specify permissions
if not permissions:
continue
group = M.ProjectRole(project_id=p._id, name=name)
p.acl += [M.ACE.allow(group._id, perm)
for perm in permissions]
for username in usernames:
guser = M.User.by_username(username)
if not (guser and guser._id):
continue
pr = M.ProjectRole.by_user(guser, project=p, upsert=True)
if group._id not in pr.roles:
pr.roles.append(group._id)
if 'tools' in project_template:
for i, tool in enumerate(project_template['tools'].keys()):
tool_config = project_template['tools'][tool]
tool_options = tool_config.get('options', {})
for k, v in tool_options.iteritems():
if isinstance(v, basestring):
tool_options[k] = \
string.Template(v).safe_substitute(
p.__dict__.get('root_project', {}))
if p.app_instance(tool) is None:
app = p.install_app(tool,
mount_label=tool_config['label'],
mount_point=tool_config['mount_point'],
ordinal=i + offset,
**tool_options)
if tool == 'wiki':
from forgewiki import model as WM
text = tool_config.get('home_text',
'[[members limit=20]]\n[[download_button]]')
WM.Page.query.get(
app_config_id=app.config._id).text = text
if 'tool_order' in project_template:
for i, tool in enumerate(project_template['tool_order']):
p.app_config(tool).options.ordinal = i
if 'labels' in project_template:
p.labels = project_template['labels']
if 'trove_cats' in project_template:
for trove_type in project_template['trove_cats'].keys():
troves = getattr(p, 'trove_%s' % trove_type)
for trove_id in project_template['trove_cats'][trove_type]:
troves.append(
M.TroveCategory.query.get(trove_cat_id=trove_id)._id)
if 'icon' in project_template:
icon_file = StringIO(urlopen(project_template['icon']['url']).read())
p.save_icon(project_template['icon']['filename'], icon_file)
if user_project:
# Allow for special user-only tools
p._extra_tool_status = ['user']
# add user project informative text to home
from forgewiki import model as WM
home_app = p.app_instance('wiki')
home_page = WM.Page.query.get(app_config_id=home_app.config._id)
home_page.text = ("This is the personal project of %s."
" This project is created automatically during user registration"
" as an easy place to store personal data that doesn't need its own"
" project such as cloned repositories.") % user.display_name
# clear the RoleCache for the user so this project will
# be picked up by user.my_projects()
g.credentials.clear_user(user._id, None) # unnamed roles for this user
# named roles for this project + user
g.credentials.clear_user(user._id, p._id)
with h.push_config(c, project=p, user=user):
ThreadLocalORMSession.flush_all()
# have to add user to context, since this may occur inside auth code
# for user-project reg, and c.user isn't set yet
g.post_event('project_created')
return p
def register_subproject(self, project, name, user, install_apps, project_name=None):
from allura import model as M
assert h.re_project_name.match(name), 'Invalid subproject shortname'
shortname = project.shortname + '/' + name
ordinal = int(project.ordered_mounts(include_hidden=True)
[-1]['ordinal']) + 1
sp = M.Project(
parent_id=project._id,
neighborhood_id=project.neighborhood_id,
shortname=shortname,
name=project_name or name,
last_updated=datetime.utcnow(),
is_root=False,
ordinal=ordinal,
)
with h.push_config(c, project=sp):
M.AppConfig.query.remove(dict(project_id=c.project._id))
if install_apps:
sp.install_app('admin', 'admin', ordinal=1)
sp.install_app('search', 'search', ordinal=2)
g.post_event('project_created')
return sp
def delete_project(self, project, user):
for sp in project.subprojects:
self.delete_project(sp, user)
project.deleted = True
def undelete_project(self, project, user):
project.deleted = False
for sp in project.subprojects:
self.undelete_project(sp, user)
def purge_project(self, project, disable_users=False, reason=None):
from allura.model import AppConfig
pid = project._id
solr_del_project_artifacts.post(pid)
if disable_users:
# Disable users if necessary BEFORE removing all project-related documents
log.info("Disabling users because we're purging project %s", project.url())
self.disable_project_users(project, reason)
app_config_ids = [ac._id for ac in AppConfig.query.find(dict(project_id=pid))]
for m in Mapper.all_mappers():
mcls = m.mapped_class
if 'project_id' in m.property_index:
# Purge the things directly related to the project
mcls.query.remove(dict(project_id=pid))
elif 'app_config_id' in m.property_index:
# ... and the things related to its apps
mcls.query.remove(dict(app_config_id={'$in': app_config_ids}))
project.delete()
session(project).flush()
g.post_event('project_deleted', project_id=pid, reason=reason)
def disable_project_users(self, project, reason=None):
provider = AuthenticationProvider.get(Request.blank('/'))
users = project.admins() + project.users_with_role('Developer')
for user in users:
if user.disabled:
log.info('User %s is already disabled', user.username)
continue
provider.disable_user(user, audit=False)
msg = u'Account disabled because project {}{} is deleted. Reason: {}'.format(
project.neighborhood.url_prefix,
project.shortname,
reason)
auditlog = h.auditlog_user(msg, user=user)
if auditlog:
session(auditlog).flush(auditlog)
else:
log.error('For some reason no auditlog written in disable_project_users for: %s %s', user, msg)
# `users` can contain duplicates. Make sure changes are visible
# to next iterations, so that `user.disabled` check works.
session(user).expunge(user)
def best_download_url(self, project):
'''This is the url needed to render a download button.
It should be overridden for your specific envirnoment'''
return None
def registration_date(self, project):
'''
Return the datetime the project was created.
'''
# generation_time is offset-aware UTC, but everything else is offset-naive
return project._id.generation_time.replace(tzinfo=None)
def details_links(self, project):
'''Return list of pairs (url, label) with details
about the project.
Links will show up at admin project search page
'''
return [
(project.url() + 'admin/groups/', 'Members'),
(project.url() + 'admin/audit/', 'Audit Trail'),
]
def project_from_url(self, url):
'''Returns a tuple (project, error).
Where project is the Project instane parsed from url or None if project
can't be parsed. In that case error will be a string describing the error.
'''
from allura.model import Project, Neighborhood
if url is None:
return None, u'Empty url'
url = urlparse(url)
url = [u for u in url.path.split('/') if u]
if len(url) == 0:
return None, u'Empty url'
if len(url) == 1:
q = Project.query.find(dict(shortname=url[0]))
cnt = q.count()
if cnt == 0:
return None, u'Project not found'
if cnt == 1:
return q.first(), None
return None, u'Too many matches for project: {}'.format(cnt)
n = Neighborhood.query.get(url_prefix=u'/{}/'.format(url[0]))
if not n:
return None, u'Neighborhood not found'
p = Project.query.get(neighborhood_id=n._id, shortname=n.shortname_prefix + url[1])
if len(url) > 2:
# Maybe subproject
subp = Project.query.get(neighborhood_id=n._id, shortname='{}/{}'.format(*url[1:3]))
if subp:
return (subp, None)
return (p, u'Project not found' if p is None else None)
class ThemeProvider(object):
'''
Theme information for Allura. This is a full implementation
and the default. Extend this class with your own if you need to add more
functionality.
To use a new provider, expose an entry point in setup.py::
[allura.theme]
myprovider = foo.bar:MyThemeProvider
Then in your .ini file, set theme=mytheme
The variables referencing jinja template files can be changed to point at your
own jinja templates. Use the standard templates as a reference, you should
provide matching macros and block names.
For more information, see https://forge-allura.apache.org/p/allura/wiki/Themes%20in%20Allura/
:var icons: a dictionary of sized icons for each tool
'''
master_template = 'allura:templates/jinja_master/master.html'
jinja_macros = 'allura:templates/jinja_master/theme_macros.html'
nav_menu = 'allura:templates/jinja_master/nav_menu.html'
top_nav = 'allura:templates/jinja_master/top_nav.html'
sidebar_menu = 'allura:templates/jinja_master/sidebar_menu.html'
icons = {
'subproject': {
24: 'images/ext_24.png',
32: 'images/ext_32.png',
48: 'images/ext_48.png'
}
}
def require(self):
g.register_theme_css('css/site_style.css', compress=False)
@classmethod
def register_ew_resources(cls, manager, name):
manager.register_directory(
'theme/%s' % name,
pkg_resources.resource_filename(
'allura',
os.path.join('nf', name)))
def href(self, href, theme_name=None):
'''
Build a full URL for a given resource path
:param href: a path like ``css/site_style.css``
:param theme_name: defaults to current theme
:return: a full URL
'''
if theme_name is None:
theme_name = config.get('theme', 'allura')
return g.resource_manager.absurl('theme/%s/%s' % (theme_name, href))
@LazyProperty
def personal_data_form(self):
'''
:return: None, or an easywidgets Form to render on the user preferences page
'''
from allura.lib.widgets.forms import PersonalDataForm
return PersonalDataForm()
@LazyProperty
def add_telnumber_form(self):
'''
:return: None, or an easywidgets Form to render on the user preferences page to
allow adding a telephone number.
'''
from allura.lib.widgets.forms import AddTelNumberForm
return AddTelNumberForm()
@LazyProperty
def add_website_form(self):
'''
:return: None, or an easywidgets Form to render on the user preferences page to
allow adding a personal website url.
'''
from allura.lib.widgets.forms import AddWebsiteForm
return AddWebsiteForm()
@LazyProperty
def skype_account_form(self):
'''
:return: None, or an easywidgets Form to render on the user preferences page to
allow setting the user's Skype account.
'''
from allura.lib.widgets.forms import SkypeAccountForm
return SkypeAccountForm()
@LazyProperty
def remove_textvalue_form(self):
'''
:return: None, or an easywidgets Form to render on the user preferences page to
allow removing a single text value from a list.
'''
from allura.lib.widgets.forms import RemoveTextValueForm
return RemoveTextValueForm()
@LazyProperty
def add_socialnetwork_form(self):
'''
:return: None, or an easywidgets Form to render on the user preferences page to
allow adding a social network account.
'''
from allura.lib.widgets.forms import AddSocialNetworkForm
return AddSocialNetworkForm(action='/auth/preferences/add_social_network')
@LazyProperty
def remove_socialnetwork_form(self):
'''
:return: None, or an easywidgets Form to render on the user preferences page to
allow removing a social network account.
'''
from allura.lib.widgets.forms import RemoveSocialNetworkForm
return RemoveSocialNetworkForm(action='/auth/preferences/remove_social_network')
@LazyProperty
def add_timeslot_form(self):
'''
:return: None, or an easywidgets Form to render on the user preferences page
to allow creating a new availability timeslot
'''
from allura.lib.widgets.forms import AddTimeSlotForm
return AddTimeSlotForm()
@LazyProperty
def remove_timeslot_form(self):
'''
:return: None, or an easywidgets Form to render on the user preferences page
to remove a timeslot
'''
from allura.lib.widgets.forms import RemoveTimeSlotForm
return RemoveTimeSlotForm()
@LazyProperty
def add_inactive_period_form(self):
'''
:return: None, or an easywidgets Form to render on the user preferences page
to allow creating a new period of inactivity
'''
from allura.lib.widgets.forms import AddInactivePeriodForm
return AddInactivePeriodForm()
@LazyProperty
def remove_inactive_period_form(self):
'''
:return: None, or an easywidgets Form to render on the user preferences page
to allow removing an existing period of inactivity
'''
from allura.lib.widgets.forms import RemoveInactivePeriodForm
return RemoveInactivePeriodForm()
@LazyProperty
def add_trove_category(self):
'''
:return: None, or an easywidgets Form to render on the page to create a
new trove_category
'''
from allura.lib.widgets.forms import AddTroveCategoryForm
return AddTroveCategoryForm(action='/categories/create')
@LazyProperty
def remove_trove_category(self):
'''
:return: None, or an easywidgets Form to render on the page to remove
an existing trove_category
'''
from allura.lib.widgets.forms import RemoveTroveCategoryForm
return RemoveTroveCategoryForm(action='/categories/remove')
@LazyProperty
def add_user_skill(self):
'''
:return: None, or an easywidgets Form to render on the page to add a
new skill to a user profile
'''
from allura.lib.widgets.forms import AddUserSkillForm
return AddUserSkillForm(action='/auth/user_info/skills/save_skill')
@LazyProperty
def select_subcategory_form(self):
'''
:return: None, or an easywidgets Form to render on the page to add a
new skill to a user profile, allowing to select a category in
order to see its sub-categories
'''
from allura.lib.widgets.forms import SelectSubCategoryForm
return SelectSubCategoryForm(action='/auth/user_info/skills/')
@LazyProperty
def remove_user_skill(self):
'''
:return: None, or an easywidgets Form to render on the page to remove
an existing skill from a user profile
'''
from allura.lib.widgets.forms import RemoveSkillForm
return RemoveSkillForm(action='/auth/user_info/skills/remove_skill')
@property
def master(self):
return self.master_template
@classmethod
def get(cls):
'''
:rtype: ThemeProvider
'''
name = config.get('theme', 'allura')
return g.entry_points['theme'][name]()
def app_icon_url(self, app, size):
"""returns the default icon for the given app (or non-app thing like 'subproject').
Takes an instance of class Application, or else a string.
Expected to be overriden by derived Themes.
"""
if isinstance(app, unicode):
app = str(app)
if isinstance(app, str):
if app in self.icons and size in self.icons[app]:
return g.theme_href(self.icons[app][size])
elif app in g.entry_points['tool']:
return g.entry_points['tool'][app].icon_url(size)
else:
return None
else:
return app.icon_url(size)
def _get_site_notification(self, url='', user=None, tool_name='', site_notification_cookie_value=''):
from allura.model.notification import SiteNotification
note = SiteNotification.current()
if note is None:
return None
if note.user_role and (not user or user.is_anonymous()):
return None
if note.user_role:
projects = user.my_projects_by_role_name(note.user_role)
if len(projects) == 0 or len(projects) == 1 and projects[0].is_user_project:
return None
if note.page_regex and re.search(note.page_regex, url) is None:
return None
if note.page_tool_type and tool_name.lower() != note.page_tool_type.lower():
return None
cookie = site_notification_cookie_value.split('-')
if len(cookie) == 3 and cookie[0] == str(note._id):
views = asint(cookie[1]) + 1
closed = asbool(cookie[2])
else:
views = 1
closed = False
if closed or note.impressions > 0 and views > note.impressions:
return None
set_cookie = '-'.join(map(str, [note._id, views, closed]))
return note, set_cookie
def get_site_notification(self):
from pylons import request, response
tool_name = c.app.config.tool_name if c.app else ''
r = self._get_site_notification(
request.path_qs,
c.user,
tool_name,
request.cookies.get('site-notification', '')
)
if not r:
return None
note, set_cookie = r
response.set_cookie(
'site-notification',
set_cookie,
max_age=timedelta(days=365))
return note
def use_input_placeholders(self):
return False
class ResponsiveTheme(ThemeProvider):
master_template = 'allura:templates_responsive/jinja_master/master.html'
jinja_macros = 'allura:templates_responsive/jinja_master/theme_macros.html'
nav_menu = 'allura:templates_responsive/jinja_master/nav_menu.html'
top_nav = 'allura:templates_responsive/jinja_master/top_nav.html'
sidebar_menu = 'allura:templates_responsive/jinja_master/sidebar_menu.html'
def require(self):
g.register_theme_css('css/styles.css', compress=False)
class LocalProjectRegistrationProvider(ProjectRegistrationProvider):
pass
class UserPreferencesProvider(object):
'''
An interface for user preferences, like display_name and email_address
To use a new provider, expose an entry point in setup.py::
[allura.user_prefs]
myprefs = foo.bar:MyUserPrefProvider
Then in your .ini file, set user_prefs_storage.method=myprefs
'''
@classmethod
def get(cls):
'''
:rtype: UserPreferencesProvider
'''
method = config.get('user_prefs_storage.method', 'local')
return g.entry_points['user_prefs'][method]()
def get_pref(self, user, pref_name):
'''
:param user: a :class:`User <allura.model.auth.User>`
:param str pref_name:
:return: pref_value
:raises: AttributeError if pref_name not found
'''
raise NotImplementedError('get_pref')
def set_pref(self, user, pref_name, pref_value):
'''
:param user: a :class:`User <allura.model.auth.User>`
:param str pref_name:
:param pref_value:
'''
raise NotImplementedError('set_pref')
def additional_urls(self):
'''
Returns a mapping of additional routes for AuthProvider.
By default, scans the provider for @expose()ed methods, which are
added as pages with the same name as the method. Note that if you
want the new pages to show up in the menu on the various auth pages,
you will also need to add it to the list returned by
`AuthenticationProvider.account_navigation`.
If you want to override this behavior, you can override this method
and manually return a mapping of `{page_name: handler, ...}`. Note,
however, that this could break future subclasses of your providers'
ability to extend the list.
For example: `{'newroute', newroute_handler}` will add 'newroute'
attribute to the auth controller, which will be set to `newroute_handler`.
`newroute_handler` can either be an @expose()ed method, or a controller
that can dispatch further sub-pages.
`newroute_handler` must be decorated with @expose(), but does not have
to live on the provider.
'''
urls = {}
for attr_name in dir(self):
attr_value = getattr(self, attr_name)
decoration = getattr(attr_value, 'decoration', None)
if getattr(decoration, 'exposed', False):
urls[attr_name] = attr_value
return urls
def add_multivalue_pref(self, user, pref_name, entry):
'''
:param user: a :class:`User <allura.model.auth.User>`
:param str pref_name:
:param entry: can be a simple value, or a dict structure
:raises: AttributeError if pref_name not found
'''
self.get_pref(user, pref_name).append(entry)
def remove_multivalue_pref(self, user, pref_name, entry):
'''
:param user: a :class:`User <allura.model.auth.User>`
:param str pref_name:
:param entry: can be a simple value, or a dict structure
:raises: AttributeError if pref_name not found
:raises: ValueError if data not found
'''
self.get_pref(user, pref_name).remove(entry)
class LocalUserPreferencesProvider(UserPreferencesProvider):
'''
The default UserPreferencesProvider, storing preferences on the User object
in mongo.
'''
def get_pref(self, user, pref_name):
if pref_name in user.preferences:
pref_value = user.preferences[pref_name]
elif pref_name == 'display_name':
# get the value directly from ming's internals, bypassing
# FieldPropertyDisplayName which always calls back to this get_pref
# method (infinite recursion)
pref_value = user.__dict__['__ming__'].state.document.display_name
else:
pref_value = getattr(user, pref_name)
if pref_name == 'email_format' and pref_value == 'html':
# html-only is no longer supported
pref_value = 'both'
return pref_value
def set_pref(self, user, pref_name, pref_value):
if pref_name == 'display_name' and asbool(config.get('activitystream.recording.enabled', False)):
activity_tasks.change_user_name.post(user._id, pref_value)
if pref_name in user.preferences:
user.preferences[pref_name] = pref_value
else:
setattr(user, pref_name, pref_value)
class LdapUserPreferencesProvider(UserPreferencesProvider):
'''
Store preferences in LDAP, falling back to LocalUserPreferencesProvider
'''
@LazyProperty
def fields(self):
return h.config_with_prefix(config, 'user_prefs_storage.ldap.fields.')
def get_pref(self, user, pref_name):
from allura import model as M
if pref_name in self.fields and user != M.User.anonymous():
self._get_pref(user.username, pref_name)
else:
return LocalUserPreferencesProvider().get_pref(user, pref_name)
def _get_pref(self, username, pref_name):
con = ldap_conn()
try:
rs = con.search_s(ldap_user_dn(username), ldap.SCOPE_BASE)
except ldap.NO_SUCH_OBJECT:
rs = []
else:
con.unbind_s()
if not rs:
log.warning('LdapUserPref: No user record found for: {}'.format(username))
return ''
user_dn, user_attrs = rs[0]
ldap_attr = self.fields[pref_name]
# assume single-valued list
return user_attrs[ldap_attr][0].decode('utf-8')
def set_pref(self, user, pref_name, pref_value):
if pref_name in self.fields:
con = ldap_conn()
ldap_attr = self.fields[pref_name]
con.modify_s(ldap_user_dn(user.username),
[(ldap.MOD_REPLACE, ldap_attr, pref_value.encode('utf-8'))])
con.unbind_s()
else:
return LocalUserPreferencesProvider().set_pref(user, pref_name, pref_value)
class AdminExtension(object):
"""
A base class for extending the admin areas in Allura.
After extending this, expose the app by adding an entry point in your
setup.py::
[allura.admin]
myadmin = foo.bar.baz:MyCustomAdmin
:ivar dict project_admin_controllers: Mapping of str (url component) to
Controllers. Can be implemented as a ``@property`` function. The str
url components will be mounted at /p/someproject/admin/ext/STR/ and will
invoke the Controller.
"""
project_admin_controllers = {}
def update_project_sidebar_menu(self, sidebar_links):
"""
Implement this function to modify the project sidebar.
Check `c.project` if you want to limit when this displays
(e.g. nbhd project, subproject, etc)
:param sidebar_links: project admin side bar links
:type sidebar_links: list of :class:`allura.app.SitemapEntry`
:rtype: ``None``
"""
pass
class SiteAdminExtension(object):
"""
A base class for extending the site admin area in Allura.
After extending this, expose the extension by adding an entry point in your
setup.py::
[allura.site_admin]
myext = foo.bar.baz:MySiteAdminExtension
:ivar dict controllers: Mapping of str (url component) to
Controllers. Can be implemented as a ``@property`` function. The str
url components will be mounted at /nf/admin/STR/ and will
invoke the Controller.
"""
controllers = {}
def update_sidebar_menu(self, sidebar_links):
"""
Change the site admin sidebar by modifying ``sidebar_links``.
:param sidebar_links: site admin side bar links
:type sidebar_links: list of :class:`allura.app.SitemapEntry`
:rtype: ``None``
"""
pass
class ImportIdConverter(object):
'''
An interface to convert to and from import_id values for indexing,
searching, or displaying.
To provide a new converter, expose an entry point in setup.py:
[allura.import_id_converter]
mysource = foo.bar:SourceIdConverter
Then in your .ini file, set import_id_converter=mysource
'''
@classmethod
def get(cls):
'''
:rtype: ImportIdConverter
'''
converter = config.get('import_id_converter')
if converter:
return g.entry_points['allura.import_id_converter'][converter]()
return cls()
def simplify(self, import_id):
if hasattr(import_id, 'get'):
return import_id.get('source_id')
return None
def expand(self, source_id, app_instance):
import_id = {
'source_id': source_id,
}
import_id.update(app_instance.config.options.get('import_id', {}))
return import_id