Use Django auth framework instead of custom one

- Store users using Django user/group/permission model
- Database is data/plinth.sqlite3 instead of data/user.sqlite3
- Use Django auth context processors in templates
This commit is contained in:
Sunil Mohan Adapa 2014-06-28 13:11:34 +02:00
parent b1406f59d3
commit cff0f1bdf6
26 changed files with 122 additions and 372 deletions

2
.gitignore vendored
View File

@ -24,7 +24,7 @@ TODO
\#*
.#*
*~
data/users.sqlite3
data/plinth.sqlite3
predepend
build/
*.pid

View File

@ -14,13 +14,11 @@ specified and linked otherwise.
- logger.py :: -
- Makefile :: -
- menu.py :: -
- model.py :: -
- NOTES :: -
- plinth :: -
- plinth.config :: -
- plinth.py :: [[file:plinth.py::__license__%20%3D%20"GPLv3%20or%20later"]["GPLv3 or later"]]
- plinth.sample.config :: -
- plugin_mount.py :: [[http://martyalchin.com/2008/jan/10/simple-plugin-framework/][CC-BY-SA 3.0]]
- README :: -
- start.sh :: -
- test.sh :: -
@ -51,9 +49,7 @@ specified and linked otherwise.
- modules/expert_mode/expert_mode.py :: -
- modules/first_boot/first_boot.py :: -
- modules/help/help.py :: -
- modules/lib/auth_page.py :: -
- modules/lib/auth.py :: -
- modules/lib/user_store.py :: -
- modules/owncloud/owncloud.py :: -
- modules/packages/packages.py :: -
- modules/santiago/santiago.py :: -

2
cfg.py
View File

@ -19,7 +19,7 @@ host = None
port = None
debug = False
no_daemon = False
session_key = '_username'
server_dir = ''
main_menu = Menu()

View File

@ -14,11 +14,7 @@ def init():
class Logger(object):
"""By convention, log levels are DEBUG, INFO, WARNING, ERROR and CRITICAL."""
def log(self, msg, level="DEBUG"):
try:
username = cherrypy.session.get(cfg.session_key)
except AttributeError:
username = ''
cherrypy.log.error("%s %s %s" % (username, level, msg), inspect.stack()[2][3], 20)
cherrypy.log.error("%s %s" % (level, msg), inspect.stack()[2][3], 20)
def __call__(self, *args):
self.log(*args)

View File

@ -1,17 +0,0 @@
class User(dict):
""" Every user must have keys for a username, name, passphrase (this
is a bcrypt hash of the password), salt, groups, and an email address.
They can be blank or None, but the keys must exist. """
def __init__(self, dict=None):
super(User, self).__init__()
for key in ['username', 'name', 'passphrase', 'salt', 'email']:
self[key] = ''
for key in ['groups']:
self[key] = []
if dict:
for key in dict:
self[key] = dict[key]
def __getattr__(self, attr):
return None

View File

@ -21,6 +21,7 @@ Plinth module for configuring timezone, hostname etc.
from django import forms
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core import validators
from django.template.response import TemplateResponse
from gettext import gettext as _
@ -29,7 +30,6 @@ import socket
import actions
import cfg
from ..lib.auth import login_required
import util
@ -102,7 +102,7 @@ def index(request):
form = None
is_expert = cfg.users.expert(request=request)
is_expert = request.user.groups.filter(name='Expert').exists()
if request.method == 'POST' and is_expert:
form = ConfigurationForm(request.POST, prefix='configuration')
# pylint: disable-msg=E1101

View File

@ -19,12 +19,12 @@
Plinth module for running diagnostics
"""
from gettext import gettext as _
from django.contrib.auth.decorators import login_required
from django.template.response import TemplateResponse
from gettext import gettext as _
import actions
import cfg
from ..lib.auth import login_required
def init():

View File

@ -1,10 +1,11 @@
from django import forms
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.template.response import TemplateResponse
from gettext import gettext as _
import cfg
from ..lib.auth import login_required
from ..lib.auth import get_group
class ExpertsForm(forms.Form): # pylint: disable-msg=W0232
@ -43,23 +44,21 @@ def index(request):
def get_status(request):
"""Return the current status"""
return {'expert_mode': cfg.users.expert(request=request)}
return {'expert_mode': request.user.groups.filter(name='Expert').exists()}
def _apply_changes(request, new_status):
"""Apply expert mode configuration"""
message = (messages.info, _('Settings unchanged'))
user = cfg.users.current(request=request)
expert_group = get_group('Expert')
if new_status['expert_mode']:
if not 'expert' in user['groups']:
user['groups'].append('expert')
if not expert_group in request.user.groups.all():
request.user.groups.add(expert_group)
message = (messages.success, _('Expert mode enabled'))
else:
if 'expert' in user['groups']:
user['groups'].remove('expert')
if expert_group in request.user.groups.all():
request.user.groups.remove(expert_group)
message = (messages.success, _('Expert mode disabled'))
cfg.users.set(user['username'], user)
message[0](request, message[1])

View File

@ -19,12 +19,12 @@
Plinth module to configure a firewall
"""
from django.contrib.auth.decorators import login_required
from django.template.response import TemplateResponse
from gettext import gettext as _
import actions
import cfg
from ..lib.auth import login_required
import service as service_module

View File

@ -20,9 +20,5 @@ Plinth library modules
"""
from . import auth
from . import auth_page
from . import user_store
__all__ = ['auth',
'auth_page',
'user_store']
__all__ = ['auth']

View File

@ -1,98 +1,29 @@
from django.http.response import HttpResponseRedirect
import functools
from passlib.hash import bcrypt
from passlib.exc import PasswordSizeError
import cfg
from model import User
from django.contrib.auth.models import Group, User
def add_user(username, passphrase, name='', email='', expert=False):
"""Add a new user with specified username and passphrase.
"""
error = None
if not username: error = "Must specify a username!"
if not passphrase: error = "Must specify a passphrase!"
"""Add a new user with specified username and passphrase"""
if not username:
return 'Must specify a username!'
if error is None:
if username in map(lambda x: x[0], cfg.users.get_all()):
error = "User already exists!"
else:
try:
pass_hash = bcrypt.encrypt(passphrase)
except PasswordSizeError:
error = "Password is too long."
if not passphrase:
return 'Must specify a passphrase!'
if error is None:
di = {
'username':username,
'name':name,
'email':email,
'expert':'on' if expert else 'off',
'groups':['expert'] if expert else [],
'passphrase':pass_hash,
'salt':pass_hash[7:29], # for bcrypt
}
new_user = User(di)
cfg.users.set(username,new_user)
user = User.objects.create_user(username, email=email,
password=passphrase)
user.first_name = name
user.save()
if error:
cfg.log(error)
return error
if expert:
user.groups.add(get_group('Expert'))
def check_credentials(username, passphrase):
"""Verifies credentials for username and passphrase.
Returns None on success or a string describing the error on failure.
Handles passwords up to 4096 bytes:
>>> len("A" * 4096)
4096
>>> len(u"u|2603" * 682)
4092
"""
if not username or not passphrase:
error = "No username or password."
cfg.log(error)
return error
bad_authentication = "Bad username or password."
hashed_password = None
if username not in cfg.users or 'passphrase' not in cfg.users[username]:
cfg.log(bad_authentication)
return bad_authentication
hashed_password = cfg.users[username]['passphrase']
def get_group(name):
"""Return an existing or newly created group with given name"""
try:
# time-dependent comparison when non-ASCII characters are used.
if not bcrypt.verify(passphrase, hashed_password):
error = bad_authentication
else:
error = None
except PasswordSizeError:
error = bad_authentication
group = Group.objects.get(name__exact=name)
except Group.DoesNotExist:
group = Group(name=name)
group.save()
if error:
cfg.log(error)
return error
# XXX: Only required until we start using Django authentication system properly
def login_required(func):
"""A decorator to ensure that user is logged in before accessing a view"""
@functools.wraps(func)
def wrapper(request, *args, **kwargs):
"""Check that user is logged in"""
if not request.session.get(cfg.session_key, None):
return HttpResponseRedirect(
cfg.server_dir + "/auth/login?from_page=%s" % request.path)
return func(request, *args, **kwargs)
return wrapper
return group

View File

@ -1,67 +0,0 @@
"""
Controller to provide login and logout actions
"""
import cfg
from django import forms
from django.http.response import HttpResponseRedirect
from django.template.response import TemplateResponse
from gettext import gettext as _
from . import auth
class LoginForm(forms.Form): # pylint: disable-msg=W0232
"""Login form"""
username = forms.CharField(label=_('Username'))
password = forms.CharField(label=_('Passphrase'),
widget=forms.PasswordInput())
def clean(self):
"""Check for valid credentials"""
# pylint: disable-msg=E1101
if 'username' in self._errors or 'password' in self._errors:
return self.cleaned_data
error_msg = auth.check_credentials(self.cleaned_data['username'],
self.cleaned_data['password'])
if error_msg:
raise forms.ValidationError(error_msg, code='invalid_credentials')
return self.cleaned_data
def login(request):
"""Serve the login page"""
form = None
if request.method == 'POST':
form = LoginForm(request.POST, prefix='auth')
# pylint: disable-msg=E1101
if form.is_valid():
username = form.cleaned_data['username']
request.session[cfg.session_key] = username
return HttpResponseRedirect(_get_from_page(request))
else:
form = LoginForm(prefix='auth')
return TemplateResponse(request, 'form.html',
{'title': _('Login'),
'form': form,
'submit_text': _('Login')})
def logout(request):
"""Logout and redirect to origin page"""
try:
del request.session[cfg.session_key]
request.session.flush()
except KeyError:
pass
return HttpResponseRedirect(_get_from_page(request))
def _get_from_page(request):
"""Return the 'from page' of a request"""
return request.GET.get('from_page', cfg.server_dir + '/')

View File

@ -21,9 +21,13 @@ URLs for the Lib module
from django.conf.urls import patterns, url
import cfg
urlpatterns = patterns( # pylint: disable-msg=C0103
'modules.lib.auth_page',
url(r'^auth/login/$', 'login'),
url(r'^auth/logout/$', 'logout')
'',
url(r'^accounts/login/$', 'django.contrib.auth.views.login',
{'template_name': 'login.html'}),
url(r'^accounts/logout/$', 'django.contrib.auth.views.logout',
{'next_page': cfg.server_dir})
)

View File

@ -1,73 +0,0 @@
import cfg
from model import User
from plugin_mount import UserStoreModule
from withsqlite.withsqlite import sqlite_db
class UserStore(UserStoreModule, sqlite_db):
def __init__(self):
super(UserStore, self).__init__()
self.db_file = cfg.user_db
sqlite_db.__init__(self, self.db_file, autocommit=True, check_same_thread=False)
self.__enter__()
def close(self):
self.__exit__(None,None,None)
def current(self, request=None, name=False):
"""Return current user, if there is one, else None.
If name = True, return the username instead of the user."""
if not request:
return None
try:
username = request.session[cfg.session_key]
except KeyError:
return None
if name:
return username
return self.get(username)
def expert(self, username=None, request=None):
"""Return whether the current or provided user is an expert"""
if not username:
if not request:
return False
username = self.current(request=request, name=True)
groups = self.attr(username, 'groups')
if not groups:
return False
return 'expert' in groups
def attr(self, username=None, field=None):
return self.get(username)[field]
def get(self,username=None):
return User(sqlite_db.get(self,username))
def exists(self, username=None):
try:
user = self.get(username)
if not user:
return False
elif user["username"]=='':
return False
return True
except TypeError:
return False
def remove(self,username=None):
self.__delitem__(username)
def get_all(self):
return self.items()
def set(self,username=None,user=None):
sqlite_db.__setitem__(self,username, user)

View File

@ -1,11 +1,11 @@
from django import forms
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.template.response import TemplateResponse
from gettext import gettext as _
import actions
import cfg
from ..lib.auth import login_required
import service

View File

@ -1,11 +1,11 @@
from django import forms
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.template.response import TemplateResponse
from gettext import gettext as _
import actions
import cfg
from ..lib.auth import login_required
def get_modules_available():

View File

@ -21,6 +21,7 @@ Plinth module for configuring PageKite service
from django import forms
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core import validators
from django.template import RequestContext
from django.template.loader import render_to_string
@ -29,7 +30,6 @@ from gettext import gettext as _
import actions
import cfg
from ..lib.auth import login_required
def init():

View File

@ -19,12 +19,12 @@
Plinth module for configuring Tor
"""
from django.contrib.auth.decorators import login_required
from django.template.response import TemplateResponse
from gettext import gettext as _
import actions
import cfg
from ..lib.auth import login_required
def init():

View File

@ -1,5 +1,7 @@
from django import forms
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.core import validators
from django.template import RequestContext
from django.template.loader import render_to_string
@ -7,8 +9,7 @@ from django.template.response import TemplateResponse
from gettext import gettext as _
import cfg
from ..lib.auth import add_user, login_required
from model import User
from ..lib.auth import add_user
def init():
@ -72,7 +73,7 @@ def add(request):
def _add_user(request, data):
"""Add a user"""
if cfg.users.exists(data['username']):
if User.objects.filter(username=data['username']).exists():
messages.error(request, _('User "{username}" already exists').format(
username=data['username']))
return
@ -89,14 +90,11 @@ class UserEditForm(forms.Form): # pylint: disable-msg=W0232
# pylint: disable-msg=E1002
super(forms.Form, self).__init__(*args, **kwargs)
users = cfg.users.get_all()
for uname in users:
user = User(uname[1])
label = '%s (%s)' % (user['name'], user['username'])
for user in User.objects.all():
label = '%s (%s)' % (user.first_name, user.username)
field = forms.BooleanField(label=label, required=False)
# pylint: disable-msg=E1101
self.fields['delete_user_' + user['username']] = field
self.fields['delete_user_' + user.username] = field
@login_required
@ -129,21 +127,21 @@ def _apply_edit_changes(request, data):
username = field.split('delete_user_')[1]
requesting_user = request.session.get(cfg.session_key, None)
requesting_user = request.user.username
cfg.log.info('%s asked to delete %s' %
(requesting_user, username))
if username == cfg.users.current(request=request, name=True):
if username == requesting_user:
messages.error(
request, _('Can not delete current account - "%s"') % username)
continue
if not cfg.users.exists(username):
if not User.objects.filter(username=username).exists():
messages.error(request, _('User "%s" does not exist') % username)
continue
try:
cfg.users.remove(username)
User.objects.filter(username=username).delete()
messages.success(request, _('User "%s" deleted') % username)
except IOError as exception:
messages.error(request, _('Error deleting "%s" - %s') %

View File

@ -1,5 +1,6 @@
from django import forms
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.template import RequestContext
from django.template.loader import render_to_string
from django.template.response import TemplateResponse
@ -7,7 +8,6 @@ from gettext import gettext as _
import actions
import cfg
from ..lib.auth import login_required
import service

View File

@ -1,10 +1,12 @@
#!/usr/bin/env python
import argparse
import os
import sys
import django.conf
import django.core.management
import django.core.wsgi
import os
import stat
import sys
import cherrypy
from cherrypy import _cpserver
@ -12,7 +14,6 @@ from cherrypy.process.plugins import Daemonizer
import cfg
import module_loader
import plugin_mount
import service
import logger
@ -112,7 +113,6 @@ def context_processor(request):
'submenu': cfg.main_menu.active_item(request),
'request_path': request.path,
'basehref': cfg.server_dir,
'username': request.session.get(cfg.session_key, None),
'active_menu_urls': active_menu_urls
}
@ -129,19 +129,36 @@ def configure_django():
'django.contrib.messages.context_processors.messages',
'plinth.context_processor']
data_file = os.path.join(cfg.data_dir, 'plinth.sqlite3')
template_directories = module_loader.get_template_directories()
sessions_directory = os.path.join(cfg.data_dir, 'sessions')
django.conf.settings.configure(
DEBUG=cfg.debug,
ALLOWED_HOSTS=['127.0.0.1', 'localhost'],
TEMPLATE_DIRS=template_directories,
CACHES={'default':
{'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}},
DATABASES={'default':
{'ENGINE': 'django.db.backends.sqlite3',
'NAME': data_file}},
DEBUG=cfg.debug,
INSTALLED_APPS=['bootstrapform',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.messages'],
LOGIN_URL=cfg.server_dir + '/accounts/login/',
LOGIN_REDIRECT_URL=cfg.server_dir + '/',
LOGOUT_URL=cfg.server_dir + '/accounts/logout/',
ROOT_URLCONF='urls',
SESSION_ENGINE='django.contrib.sessions.backends.file',
SESSION_FILE_PATH=sessions_directory,
STATIC_URL=cfg.server_dir + '/static/',
TEMPLATE_CONTEXT_PROCESSORS=context_processors)
TEMPLATE_CONTEXT_PROCESSORS=context_processors,
TEMPLATE_DIRS=template_directories)
if not os.path.isfile(data_file):
cfg.log.info('Creating and initializing data file')
django.core.management.call_command('syncdb', interactive=False)
os.chmod(data_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
def main():
@ -160,8 +177,6 @@ def main():
module_loader.load_modules()
cfg.users = plugin_mount.UserStoreModule.get_plugins()[0]
setup_server()
cherrypy.engine.start()

View File

@ -1,54 +0,0 @@
import cfg
class PluginMount(type):
"""See http://martyalchin.com/2008/jan/10/simple-plugin-framework/ for documentation"""
def __init__(cls, name, bases, attrs):
if not hasattr(cls, 'plugins'):
cls.plugins = []
else:
cls.plugins.append(cls)
def init_plugins(cls, *args, **kwargs):
try:
cls.plugins = sorted(cls.plugins, key=lambda x: x.order, reverse=False)
except AttributeError:
pass
return [p(*args, **kwargs) for p in cls.plugins]
def get_plugins(cls, *args, **kwargs):
return cls.init_plugins(*args, **kwargs)
class MultiplePluginViolation(Exception):
"""Multiple plugins found for a type where only one is expected"""
pass
class PluginMountSingular(PluginMount):
"""Plugin mounter that allows only one plugin of this meta type"""
def __init__(cls, name, bases, attrs):
if not hasattr(cls, 'plugins'):
cls.plugins = []
else:
if len(cls.plugins) > 0:
raise MultiplePluginViolation
cls.plugins.append(cls)
class UserStoreModule(object):
"""
Mount Point for plugins that will manage the user backend storage,
where we keep a hash for each user.
Plugins implementing this reference should provide the following
methods, as described in the doc strings of the default
user_store.py: get, get_all, set, exists, remove, attr, expert.
See source code for doc strings.
This is designed as a plugin so mutiple types of user store can be
supported. But the project is moving towards LDAP for
compatibility with third party software. A future version of
Plinth is likely to require LDAP.
"""
# Singular because we can only use one user store at a time
__metaclass__ = PluginMountSingular

View File

@ -82,18 +82,18 @@
{% endfor %}
</ul>
{% if username %}
{% if user.is_authenticated %}
<p class="navbar-text pull-right">
<i class="icon-user icon-white nav-icon"></i>
Logged in as <a href="{{ username }}">{{ username }}</a>.
<a href="{{ basehref }}/auth/logout" title="Log out">
Logged in as <a href="{{ user.username }}">{{ user.username }}</a>.
<a href="{{ basehref }}/accounts/logout" title="Log out">
Log out</a>.
</p>
{% else %}
<p class="navbar-text pull-right">
Not logged in.
<i class="icon-user icon-white nav-icon"></i>
<a href="{{ basehref }}/auth/login" title="Log in">
<a href="{{ basehref }}/accounts/login" title="Log in">
Log in</a>.
</p>
{% endif %}

View File

@ -22,8 +22,6 @@
{% block main_block %}
{% include 'messages.html' %}
<form class="form" method="post">
{% csrf_token %}

34
templates/login.html Normal file
View File

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% comment %}
#
# This file is part of Plinth.
#
# 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/>.
#
{% endcomment %}
{% load bootstrap %}
{% block main_block %}
<form class="form" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn-primary" value="Login" />
<input type="hidden" name="next" value="{{ next }}" />
</form>
{% endblock %}

View File

@ -20,8 +20,6 @@ Main Plinth views
"""
from django.http.response import HttpResponseRedirect
import os
import stat
import cfg
from withsqlite.withsqlite import sqlite_db
@ -32,10 +30,6 @@ def index(request):
# TODO: Move firstboot handling to firstboot module somehow
with sqlite_db(cfg.store_file, table='firstboot') as database:
if not 'state' in database:
# If we created a new user db, make sure it can't be read by
# everyone
userdb_fname = '{}.sqlite3'.format(cfg.user_db)
os.chmod(userdb_fname, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
# Permanent redirect causes the browser to cache the redirect,
# preventing the user from navigating to /plinth until the
# browser is restarted.
@ -46,7 +40,7 @@ def index(request):
return HttpResponseRedirect(
cfg.server_dir + '/firstboot/state%d' % database['state'])
if request.session.get(cfg.session_key, None):
if request.user.is_authenticated():
return HttpResponseRedirect(cfg.server_dir + '/apps')
return HttpResponseRedirect(cfg.server_dir + '/help/about')