commit 6dc68ff8ac5271871bd529f55889f08bcfde8e40 Author: Leo VIALLON-GALINIER Date: Thu Jan 17 19:20:13 2019 +0100 First commit diff --git a/adduser.py b/adduser.py new file mode 100755 index 0000000..f67a1a4 --- /dev/null +++ b/adduser.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*-coding:utf-8 -* + +import sauth +import getpass + +import argparse +parser = argparse.ArgumentParser(description=""" + Add a user with its password +""", formatter_class=argparse.RawDescriptionHelpFormatter) +parser.add_argument('username', help="Username to add") +parser.add_argument('--password', '-p', help="Password. If not provided will be asked", type=str, dest='password') +args = parser.parse_args() + +if args.password is None : + password = getpass.getpass('Password : ') +else: + password = args.password + +sa = sauth.SAuth() +if sa.add_user(args.username, password): + print("User added !") +else: + print("An error occured (user already present for instance)") + + diff --git a/example.py b/example.py new file mode 100755 index 0000000..47065d5 --- /dev/null +++ b/example.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# -*-coding:utf-8 -* + +import bottle +import beaker.middleware +import sauth + +app = bottle.app() + +session_opts = { + 'session.type': 'file', + 'session.data_dir': './sessions/', + 'session.auto': True, + } + +app = beaker.middleware.SessionMiddleware(app, session_opts) +sa = sauth.SAuth() +auth = sa.make_auth_decorator(fail_redirect="/login") + + +def post_get(name, default=''): + return bottle.request.POST.get(name, default).strip() + +def get_get(name, default=''): + return bottle.request.GET.get(name, default).strip() + + +@bottle.route('/') +@bottle.view('home') +def hello(): + usn = sauth.get_username() + usr = sauth.get_roles() + if usn=='': + usn=None + return {'username':usn, 'roles':usr} + +@bottle.route('/login') +@bottle.view('login') +def login_form(): + """Serve login form""" + if get_get('error')=='': + return {'error':False} + else: + return {'error':True} + +@bottle.post('/login') +def login(): + """Authenticate users""" + username = post_get('username') + password = post_get('password') + sa.login(username, password, success_redirect='/private', fail_redirect='/login?error=1') + +@bottle.route('/logout') +def logout(): + sa.logout() + return "LOGOUT DONE Back to home" + +@bottle.route('/password') +@bottle.view('password') +@auth() +def password_form(): + return {'username':sauth.get_username()} + +@bottle.post('/password') +@auth() +def password(): + username = sauth.get_username() + password = post_get('password') + sa.set_password(username, password) + bottle.redirect('/') + +@bottle.route('/private') +@auth() +def private(): + return "Page for Log-in users only. Back to home" + + +################################################################ +# Admin section +################################################################ + +@bottle.route('/admin') +@bottle.view('admin') +@auth(role='admin') +def admin(): + sa.refresh() + luser = [] + for u in sa.get_all_usernames(): + us = sa.get_user(u) + luser.append([u, us]) + return {'user':sauth.get_username(), 'luser':luser, 'lroles':sa.get_all_roles()} + +@bottle.post('/admin/user/add') +@auth(role='admin') +def admin_user_add(): + username = post_get('username') + password = post_get('password') + sa.add_user(username, password) + bottle.redirect('/admin') + return + +@bottle.route('/admin/user//del_role/') +@auth(role='admin') +def admin_user_delrole(user, role): + sa.rm_user_role(user, role) + bottle.redirect('/admin') + return + +@bottle.post('/admin/user//add_role') +@auth(role='admin') +def admin_user_addrole(user): + role = post_get('role') + if not role=="": + sa.add_user_role(user, role) + bottle.redirect('/admin') + return + +@bottle.post('/admin/user//password') +@auth(role='admin') +def admin_user_password(user): + password = post_get('password') + sa.set_password(user, password) + bottle.redirect('/admin') + return + +@bottle.route('/admin/user//rm') +@auth(role='admin') +def admin_user_rm(user): + sa.rm_user(user) + bottle.redirect('/admin') + return + +@bottle.post('/admin/role/add') +@auth(role='admin') +def admin_role_add(): + role = post_get('role') + sa.add_role(role) + bottle.redirect('/admin') + return + +@bottle.route('/admin/role//del_sub/') +@auth(role='admin') +def admin_role_delsub(role, subrole): + sa.rm_role(role, subrole=subrole) + bottle.redirect('/admin') + return + +@bottle.post('/admin/role//add_sub/') +@auth(role='admin') +def admin_role_addsub(role): + subrole = post_get('addrole') + print("1::subrole::{}".format(subrole)) + if not subrole=='': + sa.add_role(role, subrole=subrole) + bottle.redirect('/admin') + return + +@bottle.route('/admin/role//rm/') +@auth(role='admin') +def admin_role_rm(role): + sa.rm_role(role) + bottle.redirect('/admin') + return + +bottle.run(app, host='localhost', port=8080) diff --git a/sauth/__init__.py b/sauth/__init__.py new file mode 100644 index 0000000..d2620b3 --- /dev/null +++ b/sauth/__init__.py @@ -0,0 +1,260 @@ +# -*-coding:utf-8 -* + +import logging +logger = logging.getLogger() + +import bottle +import hashlib +import os + + +def get_session(): + return bottle.request.environ['beaker.session'] + +def get_username(): + session = get_session() + if 'username' in session: + username = get_session()['username'] + if username is None or username=='' : + return None + else: + return username + return None + +def get_roles(): + session = get_session() + if 'roles' in session: + roles = get_session()['roles'] + if roles is None or roles=='' : + return None + else: + return roles + return None + +def _redirect(location): + if location is None : + bottle.abort(401, "Sorry, access denied.") + else: + bottle.redirect(location) + +class SAuth(object): + """ + Simple Auth class with decorators for bottle + """ + + def __init__(self, backend=None, backend_infos={}, hashing_algorithm=None): + """Auth/Authorization/Accounting class + + backend: backend to be used (str) + backend_infos: arguments passed to backend constructor + hashing_algorithm: hashing algorith to use, sha256 by default + """ + if hashing_algorithm in hashlib.algorithms_available : + self.hashing_algorithm = hashing_algorithm + elif 'sha256' in hashlib.algorithms_available : + self.hashing_algorithm = 'sha256' + + # Setup JsonBackend by default + # Currently no other backend available + if backend is None or backend == 'json': + from sauth import json_backend + logger.info("Selected backend : json") + self._backend = json_backend.JsonBackend(**backend_infos) + else: + raise ValueError("Selected backend not found") + + def make_auth_decorator(self, username=None, role=None, fail_redirect='/login'): + ''' + Create a decorator to be used for authentication and authorization + + :param username: A resource can be protected for a specific user + :param role: Minimum role level required for authorization + :param fail_redirect: The URL to redirect to if a login is required. + ''' + session_manager = self + def auth_require(username=username, role=role, fail_redirect=fail_redirect): + def decorator(func): + import functools + + @functools.wraps(func) + def wrapper(*a, **ka): + session_manager._require(username=username, role=role, fail_redirect=fail_redirect) + return func(*a, **ka) + return wrapper + return decorator + return(auth_require) + + def get_user(self, username=None): + """ + return a User object for the current user or a specified username + return None if user nout logged in or not found + """ + if username is None : + user = get_username() + if(user is None): + return None + else: + return self.get_user(username=user) + else: + return self._backend.get_user(username) + + def get_user_roles(self, username=None): + """ + return a list of the roles of user (main roles and herited) + return None if not found + """ + if username is None : + user = get_username() + if(user is None): + return None + else: + return self.get_user_roles(user) + else: + return self._backend.get_user_roles(username) + + def _require(self, username=None, role=None, fail_redirect=None): + """ + Teste authentification for decorator + """ + # Authentication + user = get_username() + + if user is None: + _redirect(fail_redirect) + + if(username is not None): + if not (user==username): + _redirect(fail_redirect) + + if(role is not None): + roles = get_roles() + if not (role in roles): + _redirect(fail_redirect) + + return # success + + + def login(self, username, password, success_redirect=None, fail_redirect=None): + """ + Login function + username : str + password : str + success_redirect and fail_redirect : locations on which to redirect if + login is successful or not + """ + user = self.get_user(username) + ip = bottle.request.environ.get('REMOTE_ADDR') + if(user is not None): + pw=user.password + if pw is None or not len(pw.split('/'))==3 : + logger.warn("Login FAIL user {} from {}".format(user, ip)) + _redirect(fail_redirect) + return False + pw=pw.split('/') + hashing_algorithm = pw[0] + salt = bytes.fromhex(pw[1]) + dk = hashlib.pbkdf2_hmac(hashing_algorithm, password.encode('utf-8'), salt, 100000) + pwtest = dk.hex() + if(pw[2]==pwtest): + get_session()['username']=username + get_session()['roles']=self.get_user_roles(username) + logger.info("Login OK user {} from {}".format(user, ip)) + if(success_redirect is not None): + _redirect(success_redirect) + return True + logger.warn("Login FAIL user {} from {}".format(user, ip)) + _redirect(fail_redirect) + return False + + def logout(self, redirect=None): + """ + Logout function. Destroys session. + """ + usn = get_username() + ip = bottle.request.environ.get('REMOTE_ADDR') + session = get_session() + session['username']=None + session.delete() + logger.info("Logout user {} from {}".format(usn, ip)) + if(redirect is not None): + _redirect(redirect) + return + + def add_user(self, username, password): + """ + Add a new user + username: str (return False if alsready exists) + password: str + """ + if self.get_user(username) is not None: + return False + logger.info("Adduser {}".format(username)) + self._backend.add_user(username) + self.set_password(username, password) + return True + + def add_role(self, role, subrole=None): + """ + Add a role or a subrole + (role given by another) + """ + self._backend.add_role(role, subrole=subrole) + return + + def add_user_role(self, username, role): + """ + Add a role to user + """ + return self._backend.add_user_role(username, role) + + def rm_user(self, username): + """ + Delete a user by name + """ + return self._backend.rm_user(username) + + def rm_role(self, role, subrole=None): + """ + Delete a role. + If subrole is not None, delete the subrole of role + """ + return self._backend.rm_role(role, subrole=subrole) + + def rm_user_role(self, username, role): + """ + Remove a role to user + """ + return self._backend.rm_user_role(username, role) + + def update_user(self, username=None, **kwargs): + if username is None : + username = get_username() + self._backend.update_user(username, **kwargs) + return + + def set_password(self, username, password): + """ + Set a new password to user + username: username of the user (str) + password: new password (str) + """ + logger.info("Change password {}".format(username)) + salt = os.urandom(16) + dk = hashlib.pbkdf2_hmac(self.hashing_algorithm, password.encode('utf-8'), salt, 100000) + ssalt = salt.hex() + spassword = dk.hex() + s = self.hashing_algorithm + "/" + ssalt + "/" + spassword + return self._backend.update_user(username, password=s) + + def get_all_usernames(self): + return self._backend.get_users() + + def get_all_roles(self): + return self._backend.get_roles() + + def refresh(self): + if 'refresh' in dir(self._backend): + self._backend.refresh() + return + return + diff --git a/sauth/json_backend.py b/sauth/json_backend.py new file mode 100644 index 0000000..c8b5b67 --- /dev/null +++ b/sauth/json_backend.py @@ -0,0 +1,133 @@ +# -*-coding:utf-8 -* + +import json +import collections +from sauth import user + +class JsonBackend: + def __init__(self, directory='users', users_fname='users.json', roles_fname='roles.json'): + """ + directory: directory in which configuration files are (str) + users_fname: filename for users (str) + roles_fname: filename for roles (str) + """ + if not (directory[-1]=='/'): + directory = directory + "/" + self.users_f = directory + users_fname + self.roles_f = directory + roles_fname + self.refresh() + + def get_user(self, username): + if username in self._json_users : + data = self._json_users[username] + return user.User(username, **data) + else: + return None + + def get_user_roles(self, username): + if username in self._json_users : + data = self._json_users[username] + us = user.User(username, **data) + for r in us.roles: + if(r in self._json_roles): + for sr in self._json_roles[r]: + if not sr in us.roles: + us.roles.append(sr) + return us.roles + else: + return None + + def get_roles(self): + return self._json_roles + + def get_users(self): + return list(self._json_users.keys()) + + def add_role(self, role, subrole=None): + print("Role :{} subrole:{}".format(role, subrole)) + if role not in self._json_roles: + print("Role added") + self._json_roles[role]=[] + if(subrole is not None): + print("Subrole added") + self._json_roles[role].append(subrole) + self._update_json_roles() + return + + def add_user_role(self, username, role): + if 'roles' in self._json_users[username]: + if not role in self._json_users[username]['roles']: + self._json_users[username]['roles'].append(role) + self._update_json() + return True + else: + self._json_users[username]['roles'] = [role] + self._update_json() + return True + return False + + def rm_user(self, username): + if username in self._json_users : + del self._json_users[username] + self._update_json_roles() + return True + return False + + def rm_role(self, role, subrole=None): + if role in self._json_roles: + if subrole is None: + del self._json_roles[role] + self._update_json_roles() + return True + else: + for i in range(len(self._json_roles[role])): + if self._json_roles[role][i]==subrole : + del self._json_roles[role][i] + self._update_json_roles() + return True + break + else: + return False + + def rm_user_role(self, username, role): + if 'roles' in self._json_users[username] and role in self._json_users[username]['roles']: + for i in range(len(self._json_users[username]['roles'])): + if self._json_users[username]['roles'][i]==role: + del self._json_users[username]['roles'][i] + self._update_json() + return True + break + return False + + def add_user(self, username, **kwargs): + if (username in self._json_users): + return False + self._json_users[username] = {} + self._update_json() + return True + + def update_user(self, username, **kwargs): + if(username in self._json_users): + for e in kwargs: + self._json_users[username][e] = kwargs[e] + self._update_json() + return True + else: + return False + + def _update_json(self): + with open(self.users_f, 'w') as f: + json.dump(self._json_users, f, indent=2, ensure_ascii=False) + return + + def _update_json_roles(self): + with open(self.roles_f, 'w') as f: + json.dump(self._json_roles, f, ensure_ascii=False) + return + + def refresh(self): + with open(self.users_f, 'r') as f: + self._json_users = json.load(f, object_pairs_hook=collections.OrderedDict) + with open(self.roles_f, 'r') as f: + self._json_roles = json.load(f, object_pairs_hook=collections.OrderedDict) + diff --git a/sauth/user.py b/sauth/user.py new file mode 100644 index 0000000..6789f84 --- /dev/null +++ b/sauth/user.py @@ -0,0 +1,19 @@ +# -*-coding:utf-8 -* + +class User: + def __init__(self, username, password=None, roles=[], **kw): + self.username = username + self.password = password + self.roles = roles + self.info = {} + for e in kw: + self.info[e] = kw[e] + + def __str__(self): + s = "User {}".format(self.username) + for e in self.info: + s+= " {} : {}".format(e, self.info[e]) + return s + + def __repr__(self): + return self.__str__() diff --git a/users/roles.json b/users/roles.json new file mode 100644 index 0000000..a80bec5 --- /dev/null +++ b/users/roles.json @@ -0,0 +1 @@ +{"admin": ["user"], "user": []} \ No newline at end of file diff --git a/users/users.json b/users/users.json new file mode 100644 index 0000000..409a982 --- /dev/null +++ b/users/users.json @@ -0,0 +1,15 @@ +{ + "admin": { + "password": "sha256/34467647c75727d973bcef4ca739388d/9c6ee4cfe5091789035f162cf479fe2b853b679c381ebfc5b4174959b1a280e3", + "roles": [ + "admin", + "user" + ] + }, + "user": { + "password": "sha256/13332f080564518570773fdd24b7602d/9b88dba299cc37fe8ab171066c791e4343a0e71853dfbcbcc76e4df43d57f923", + "roles": [ + "user" + ] + } +} \ No newline at end of file diff --git a/views/admin.tpl b/views/admin.tpl new file mode 100644 index 0000000..b948232 --- /dev/null +++ b/views/admin.tpl @@ -0,0 +1,160 @@ + + + + +
+

Administration page

+

Welcome {{user}}

+
+

Users

+

Create new user:

+
+

+

+ +
+ %for u in luser: +
+ {{u[0]}} + Roles : + %if u[1].roles: + %for r in u[1].roles: + {{r}} (X), + %end + %end +
+ Details +/- + +
+ %end +
+
+ +
+

Roles

+

Create new role:

+
+

+ +
+
+ %for r in lroles: +
+ {{r}} + %for sr in lroles[r]: + {{sr}} (X), + %end +
+ Details +/- + +
+ %end + +
+ + +
+ + +
+ + + diff --git a/views/home.tpl b/views/home.tpl new file mode 100644 index 0000000..d8ff78c --- /dev/null +++ b/views/home.tpl @@ -0,0 +1,46 @@ + + + + + + + + +
+
+

Simple Auth for Bottle

+

Demonstration site

+ %if username is None: +

You are not loged in

+ %else: +

You are loged as {{username}}

+

You have these roles : + %for r in roles: + {{r}}, + %end +

+

Logout

+

Change password

+ %end +

Pages of the test site :

+

+

+

+
+
+ + diff --git a/views/login.tpl b/views/login.tpl new file mode 100644 index 0000000..5041f92 --- /dev/null +++ b/views/login.tpl @@ -0,0 +1,44 @@ + + + + + + + + +
+
+

Login

+

Please insert your credentials:

+ %if error: +

Incorrect username/password.

+ %end +
+

Username :

+

Password :

+ +

+ +
+
+
+
+
+ + diff --git a/views/password.tpl b/views/password.tpl new file mode 100644 index 0000000..27500c3 --- /dev/null +++ b/views/password.tpl @@ -0,0 +1,27 @@ + + + + +
+

Password change

+

Please insert your new password:

+
+

+

+
+
+
+