diff --git a/README.md b/README.md index f223cb2..c8df786 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,11 @@ You will need the following packages to get started: #### Ubuntu - sudo apt-get install -y python-pip python-dev libxml2-dev libxslt1-dev zlib1g-dev libffi-dev libyaml-dev libssl-dev + sudo apt-get install -y python-pip python-dev libxml2-dev libxslt1-dev zlib1g-dev libffi-dev libyaml-dev libssl-dev libsasl2-dev libldap2-dev #### CentOS / RHEL - yum install -y python-pip python-devel.x86_64 libxslt-devel.x86_64 libxml2-devel.x86_64 libffi-devel.x86_64 libyaml-devel.x86_64 libxslt-devel.x86_64 zlib-devel.x86_64 openssl-devel.x86_64 python-pbr gcc + yum install -y python-pip python-devel.x86_64 libxslt-devel.x86_64 libxml2-devel.x86_64 libffi-devel.x86_64 libyaml-devel.x86_64 libxslt-devel.x86_64 zlib-devel.x86_64 openssl-devel.x86_64 openldap2-devel cyrus-sasl-devel python-pbr gcc #### OSX / Windows @@ -68,7 +68,7 @@ The easiest way. Install it using Python Package Index: sudo apt-get install -y software-properties-common python-software-properties sudo add-apt-repository -y ppa:chris-lea/node.js sudo apt-get update - sudo apt-get install -y nodejs python-pip python-dev libxml2-dev libxslt1-dev zlib1g-dev libffi-dev libyaml-dev libssl-dev + sudo apt-get install -y nodejs python-pip python-dev libxml2-dev libxslt1-dev zlib1g-dev libffi-dev libyaml-dev libssl-dev libsasl2-dev libldap2-dev sudo npm install -g bower bower install @@ -113,7 +113,7 @@ You may want to customize your app and the easiest way is the setup command: realms-wiki setup -This will ask you questions and create a `realms-wiki.json` file in where you can find it. +This will ask you questions and create a `realms-wiki.json` file. You can manually edit this file as well. Any config value set in `realms-wiki.json` will override values set in `realms/config/__init__.py`. @@ -273,6 +273,69 @@ WHOOSH_INDEX has to be a path readable and writeable by Realm's user. It will be Whoosh is set up to use language optimization, so set WHOOSH_LANGUAGE to the language used in your wiki. For available languages, check `whoosh.lang.languages`. If your language is not supported, Realms will fall back to a simple text analyzer. +## Authentication + +### Local + +Local default will be done using a backend database as defined in the config. +To disable local authentication, put the following your config. + + "AUTH_LOCAL_ENABLE": false + + +### LDAP (beta) + +Realms uses the following library to authenticate using LDAP. https://github.com/ContinuumIO/flask-ldap-login +It supports direct bind and bind by search. +Use these examples as a guide and place it in your realms-wiki.json config. + + +#### Bind By Search Example + +In this example, BIND_DN and BIND_AUTH are used to search and authenticate. Leaving them blank implies anonymous authentication. + + "LDAP": { + "URI": "ldap://localhost:8389", + "BIND_DN": "", + "BIND_AUTH": "", + "USER_SEARCH": {"base": "dc=realms,dc=io", "filter": "uid=%(username)s"}, + "KEY_MAP": { + "username":"cn", + "email": "mail" + } + } + +#### Direct Bind Example + + "LDAP": { + "URI": "ldap://localhost:8389", + "BIND_DN": "uid=%(username)s,ou=People,dc=realms,dc=io", + "KEY_MAP": { + "username":"cn", + "email": "mail", + }, + "OPTIONS": { + "OPT_PROTOCOL_VERSION": 3, + } + } + + +### OAuth (beta) + +Realms currently supports Github, Twitter, Facebook and Google. Each provider requires a key and secret. +Put them in your `realms-wiki.json` config file. Use the example below. + + "OAUTH": { + "twitter": { + "key": "", + "secret": "" + }, + "github": { + "key": "", + "secret": "" + } + } + ## Running realms-wiki start @@ -292,7 +355,7 @@ After your config is in place use the following commands: sudo restart realms-wiki -### Developement mode +### Development mode This will start the server in the foreground with auto reloaded enabled: diff --git a/docker/Dockerfile b/docker/Dockerfile index c00277e..6b4be36 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,7 +1,7 @@ FROM realms/base # Packages -RUN apt-get update && apt-get install -y build-essential python-pip python-virtualenv python-dev zlib1g-dev libffi-dev libyaml-dev +RUN apt-get update && apt-get install -y build-essential python-pip python-virtualenv python-dev zlib1g-dev libffi-dev libyaml-dev libldap2-dev libsasl2-dev # lxml deps # libxml2-dev libxslt1-dev diff --git a/install.sh b/install.sh index ab194e2..93077e2 100755 --- a/install.sh +++ b/install.sh @@ -17,15 +17,15 @@ if ! type "add-apt-repository" > /dev/null; then fi # Elastic Search -wget -qO - http://packages.elasticsearch.org/GPG-KEY-elasticsearch | sudo apt-key add - -echo 'deb http://packages.elasticsearch.org/elasticsearch/1.4/debian stable main' | sudo tee /etc/apt/sources.list.d/elastic.list +# wget -qO - http://packages.elasticsearch.org/GPG-KEY-elasticsearch | sudo apt-key add - +# echo 'deb http://packages.elasticsearch.org/elasticsearch/1.4/debian stable main' | sudo tee /etc/apt/sources.list.d/elastic.list sudo add-apt-repository -y ppa:chris-lea/node.js sudo apt-get update sudo apt-get install -y python build-essential pkg-config git \ -python-pip python-virtualenv python-dev zlib1g-dev \ -libffi-dev libyaml-dev libssl-dev nodejs openjdk-7-jre-headless elasticsearch +python-pip python-virtualenv python-dev zlib1g-dev libldap2-dev libsasl2-dev \ +libffi-dev libyaml-dev libssl-dev libldap2-dev libsasl2-dev nodejs # Create swap file because ES eats up RAM and 14.04 doesn't have swap by default sudo fallocate -l 1G /swapfile diff --git a/realms/__init__.py b/realms/__init__.py index 6c6c51a..59899cc 100644 --- a/realms/__init__.py +++ b/realms/__init__.py @@ -15,6 +15,7 @@ from flask.ext.cache import Cache from flask.ext.login import LoginManager, current_user from flask.ext.sqlalchemy import SQLAlchemy from flask.ext.assets import Environment, Bundle +from flask_ldap_login import LDAPLoginManager from werkzeug.routing import BaseConverter from werkzeug.exceptions import HTTPException from sqlalchemy.ext.declarative import declarative_base @@ -109,6 +110,11 @@ class Assets(Environment): return super(Assets, self).register(name, Bundle(*args, filters=filters, output=output)) +class MyLDAPLoginManager(LDAPLoginManager): + @property + def attrlist(self): + # the parent method doesn't always work + return None class RegexConverter(BaseConverter): """ Enables Regex matching on endpoints @@ -163,6 +169,7 @@ def create_app(config=None): cache.init_app(app) assets.init_app(app) search.init_app(app) + ldap.init_app(app) db.Model = declarative_base(metaclass=HookModelMeta, cls=HookMixin) @@ -182,16 +189,17 @@ def create_app(config=None): def page_not_found(e): return render_template('errors/404.html'), 404 - if app.config['RELATIVE_PATH']: + if app.config.get('RELATIVE_PATH'): @app.route("/") def root(): - return redirect(url_for(app.config['ROOT_ENDPOINT'])) + return redirect(url_for(app.config.get('ROOT_ENDPOINT'))) app.discover() # This will be removed at some point with app.app_context(): - db.metadata.create_all(db.get_engine(app)) + if app.config.get('DB_URI'): + db.metadata.create_all(db.get_engine(app)) return app @@ -202,6 +210,7 @@ db = SQLAlchemy() cache = Cache() assets = Assets() search = Search() +ldap = MyLDAPLoginManager() assets.register('main.js', 'vendor/jquery/dist/jquery.js', diff --git a/realms/config/__init__.py b/realms/config/__init__.py index 0c740a9..1d06cde 100644 --- a/realms/config/__init__.py +++ b/realms/config/__init__.py @@ -83,6 +83,38 @@ DB_URI = 'sqlite:////tmp/wiki.db' # DB_URI = 'oracle://scott:tiger@127.0.0.1:1521/sidname' # DB_URI = 'crate://' +# LDAP = { +# 'URI': '', +# +# # This BIND_DN/BIND_PASSWORD default to '', this is shown here for demonstrative purposes +# # The values '' perform an anonymous bind so we may use search/bind method +# 'BIND_DN': '', +# 'BIND_AUTH': '', +# +# # Adding the USER_SEARCH field tells the flask-ldap-login that we are using +# # the search/bind method +# 'USER_SEARCH': {'base': 'dc=example,dc=com', 'filter': 'uid=%(username)s'}, +# +# # Map ldap keys into application specific keys +# 'KEY_MAP': { +# 'name': 'cn', +# 'company': 'o', +# 'location': 'l', +# 'email': 'mail', +# } +# } + +# OAUTH = { +# 'twitter': { +# 'key': '', +# 'secret': '' +# }, +# 'github': { +# 'key': '', +# 'secret': '' +# } +# } + CACHE_TYPE = 'simple' # Redis @@ -121,6 +153,7 @@ WIKI_PATH = '/tmp/wiki' # Name of page that will act as home WIKI_HOME = 'home' +AUTH_LOCAL_ENABLE = True ALLOW_ANON = True REGISTRATION_ENABLED = True PRIVATE_WIKI = False @@ -161,4 +194,13 @@ if ENV != "DEV": ASSETS_DEBUG = False SQLALCHEMY_ECHO = False -MODULES = ['wiki', 'auth', 'search'] +MODULES = ['wiki', 'search', 'auth'] + +if globals().get('AUTH_LOCAL_ENABLE'): + MODULES.append('auth.local') + +if globals().get('OAUTH'): + MODULES.append('auth.oauth') + +if globals().get('LDAP'): + MODULES.append('auth.ldap') diff --git a/realms/lib/util.py b/realms/lib/util.py index 0bd115d..5eed026 100644 --- a/realms/lib/util.py +++ b/realms/lib/util.py @@ -119,7 +119,8 @@ def filename_to_cname(filename): def gravatar_url(email): - return "https://www.gravatar.com/avatar/" + hashlib.md5(email).hexdigest() + email = hashlib.md5(email).hexdigest() if email else "default@realms.io" + return "https://www.gravatar.com/avatar/" + email def in_virtualenv(): diff --git a/realms/modules/auth/__init__.py b/realms/modules/auth/__init__.py index 8487cd9..a33ffda 100644 --- a/realms/modules/auth/__init__.py +++ b/realms/modules/auth/__init__.py @@ -2,6 +2,7 @@ from realms import login_manager from flask import request, flash, redirect from flask.ext.login import login_url +modules = set() @login_manager.unauthorized_handler def unauthorized(): diff --git a/realms/modules/auth/ldap/__init__.py b/realms/modules/auth/ldap/__init__.py new file mode 100644 index 0000000..f3d364d --- /dev/null +++ b/realms/modules/auth/ldap/__init__.py @@ -0,0 +1,3 @@ +from ..models import Auth + +Auth.register('ldap') diff --git a/realms/modules/auth/ldap/forms.py b/realms/modules/auth/ldap/forms.py new file mode 100644 index 0000000..71dd215 --- /dev/null +++ b/realms/modules/auth/ldap/forms.py @@ -0,0 +1,7 @@ +from flask_wtf import Form +from wtforms import StringField, PasswordField, validators + + +class LoginForm(Form): + login = StringField('Username', [validators.DataRequired()]) + password = PasswordField('Password', [validators.DataRequired()]) \ No newline at end of file diff --git a/realms/modules/auth/ldap/models.py b/realms/modules/auth/ldap/models.py new file mode 100644 index 0000000..cd605c2 --- /dev/null +++ b/realms/modules/auth/ldap/models.py @@ -0,0 +1,53 @@ +from flask import render_template +from flask.ext.login import login_user +from realms import ldap +from flask_ldap_login import LDAPLoginForm +from ..models import BaseUser + + +users = {} + + +@ldap.save_user +def save_user(username, userdata): + user = User(userdata.get('username'), userdata.get('email')) + users[user.id] = user + return user + + +class User(BaseUser): + type = 'ldap' + + def __init__(self, username, email='null@localhost.local', password=None): + self.id = username + self.username = username + self.email = email + self.password = password + + @property + def auth_token_id(self): + return self.password + + @staticmethod + def load_user(*args, **kwargs): + return User.get_by_id(args[0]) + + @staticmethod + def get_by_id(user_id): + return users.get(user_id) + + @staticmethod + def login_form(): + return render_template('auth/ldap/login.html', form=LDAPLoginForm()) + + @staticmethod + def auth(user, password): + password = User.hash_password(password) + user.password = password + users[user.id] = user + if user: + login_user(user, remember=True) + return True + else: + return False + diff --git a/realms/modules/auth/ldap/views.py b/realms/modules/auth/ldap/views.py new file mode 100644 index 0000000..7ab82f4 --- /dev/null +++ b/realms/modules/auth/ldap/views.py @@ -0,0 +1,19 @@ +from flask import current_app, request, redirect, Blueprint, flash, url_for +from ..ldap.models import User +from flask_ldap_login import LDAPLoginForm + +blueprint = Blueprint('auth.ldap', __name__) + + +@blueprint.route("/login/ldap", methods=['POST']) +def login(): + form = LDAPLoginForm() + + if not form.validate(): + flash('Form invalid', 'warning') + return redirect(url_for('auth.login')) + + if User.auth(form.user, request.form['password']): + return redirect(request.args.get("next") or url_for(current_app.config['ROOT_ENDPOINT'])) + else: + return redirect(url_for('auth.login')) diff --git a/realms/modules/auth/local/__init__.py b/realms/modules/auth/local/__init__.py new file mode 100644 index 0000000..8c939c1 --- /dev/null +++ b/realms/modules/auth/local/__init__.py @@ -0,0 +1,3 @@ +from ..models import Auth + +Auth.register('local') diff --git a/realms/modules/auth/commands.py b/realms/modules/auth/local/commands.py similarity index 94% rename from realms/modules/auth/commands.py rename to realms/modules/auth/local/commands.py index 01e7b23..20183be 100644 --- a/realms/modules/auth/commands.py +++ b/realms/modules/auth/local/commands.py @@ -1,9 +1,10 @@ import click from realms.lib.util import random_string -from realms.modules.auth.models import User +from realms.modules.auth.local.models import User from realms.lib.util import green, red, yellow from realms import flask_cli + @flask_cli.group(short_help="Auth Module") def cli(): pass diff --git a/realms/modules/auth/forms.py b/realms/modules/auth/local/forms.py similarity index 100% rename from realms/modules/auth/forms.py rename to realms/modules/auth/local/forms.py diff --git a/realms/modules/auth/hooks.py b/realms/modules/auth/local/hooks.py similarity index 100% rename from realms/modules/auth/hooks.py rename to realms/modules/auth/local/hooks.py diff --git a/realms/modules/auth/local/models.py b/realms/modules/auth/local/models.py new file mode 100644 index 0000000..695ce63 --- /dev/null +++ b/realms/modules/auth/local/models.py @@ -0,0 +1,98 @@ +from flask import current_app, render_template +from flask.ext.login import logout_user, login_user +from realms import login_manager, db +from realms.lib.model import Model +from ..models import BaseUser +from .forms import LoginForm +from itsdangerous import URLSafeSerializer, BadSignature +from hashlib import sha256 + + +@login_manager.token_loader +def load_token(token): + # Load unsafe because payload is needed for sig + sig_okay, payload = URLSafeSerializer(current_app.config['SECRET_KEY']).loads_unsafe(token) + + if not payload: + return None + + # User key *could* be stored in payload to avoid user lookup in db + user = User.get_by_id(payload.get('id')) + + if not user: + return None + + try: + if BaseUser.signer(sha256(user.password).hexdigest()).loads(token): + return user + else: + return None + except BadSignature: + return None + + +class User(Model, BaseUser): + __tablename__ = 'users' + type = 'local' + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(128), unique=True) + email = db.Column(db.String(128), unique=True) + password = db.Column(db.String(60)) + admin = False + + hidden_fields = ['password'] + readonly_fields = ['email', 'password'] + + @property + def auth_token_id(self): + return self.password + + @staticmethod + def load_user(*args, **kwargs): + return User.get_by_id(args[0]) + + @staticmethod + def create(username, email, password): + u = User() + u.username = username + u.email = email + u.password = User.hash_password(password) + u.save() + + @staticmethod + def get_by_username(username): + return User.query().filter_by(username=username).first() + + @staticmethod + def get_by_email(email): + return User.query().filter_by(email=email).first() + + @staticmethod + def signer(salt): + return URLSafeSerializer(current_app.config['SECRET_KEY'] + salt) + + @staticmethod + def auth(email, password): + user = User.get_by_email(email) + + if not user: + # User doesn't exist + return False + + if User.check_password(password, user.password): + # Password is good, log in user + login_user(user, remember=True) + return user + else: + # Password check failed + return False + + @classmethod + def logout(cls): + logout_user() + + @staticmethod + def login_form(): + form = LoginForm() + return render_template('auth/local/login.html', form=form) + diff --git a/realms/modules/auth/local/views.py b/realms/modules/auth/local/views.py new file mode 100644 index 0000000..c863d0e --- /dev/null +++ b/realms/modules/auth/local/views.py @@ -0,0 +1,51 @@ +from flask import current_app, render_template, request, redirect, Blueprint, flash, url_for +from realms.modules.auth.local.models import User +from realms.modules.auth.local.forms import LoginForm, RegistrationForm + +blueprint = Blueprint('auth.local', __name__) + + +@blueprint.route("/login/local", methods=['POST']) +def login(): + form = LoginForm() + + if not form.validate(): + flash('Form invalid', 'warning') + return redirect(url_for('auth.login')) + + if User.auth(request.form['email'], request.form['password']): + return redirect(request.args.get("next") or url_for(current_app.config['ROOT_ENDPOINT'])) + else: + flash('Email or Password Incorrect', 'warning') + return redirect(url_for('auth.login')) + + +@blueprint.route("/register", methods=['GET', 'POST']) +def register(): + + if not current_app.config['REGISTRATION_ENABLED']: + flash("Registration is disabled") + return redirect(url_for(current_app.config['ROOT_ENDPOINT'])) + + form = RegistrationForm() + + if request.method == "POST": + + if not form.validate(): + flash('Form invalid', 'warning') + return redirect(url_for('auth.local.register')) + + if User.get_by_username(request.form['username']): + flash('Username is taken', 'warning') + return redirect(url_for('auth.local.register')) + + if User.get_by_email(request.form['email']): + flash('Email is taken', 'warning') + return redirect(url_for('auth.local.register')) + + User.create(request.form['username'], request.form['email'], request.form['password']) + User.auth(request.form['email'], request.form['password']) + + return redirect(request.args.get("next") or url_for(current_app.config['ROOT_ENDPOINT'])) + + return render_template("auth/register.html", form=form) diff --git a/realms/modules/auth/models.py b/realms/modules/auth/models.py index d621069..f62b736 100644 --- a/realms/modules/auth/models.py +++ b/realms/modules/auth/models.py @@ -1,39 +1,43 @@ from flask import current_app -from flask.ext.login import UserMixin, logout_user, login_user, AnonymousUserMixin -from realms import login_manager, db -from realms.lib.model import Model +from flask.ext.login import UserMixin, logout_user, AnonymousUserMixin +from realms import login_manager from realms.lib.util import gravatar_url from itsdangerous import URLSafeSerializer, BadSignature from hashlib import sha256 +from . import modules import bcrypt +import importlib @login_manager.user_loader -def load_user(user_id): - return User.get_by_id(user_id) +def load_user(auth_id): + return Auth.load_user(auth_id) + +auth_users = {} -@login_manager.token_loader -def load_token(token): - # Load unsafe because payload is needed for sig - sig_okay, payload = URLSafeSerializer(current_app.config['SECRET_KEY']).loads_unsafe(token) +class Auth(object): - if not payload: - return None + @staticmethod + def register(module): + modules.add(module) - # User key *could* be stored in payload to avoid user lookup in db - user = User.get_by_id(payload.get('id')) + @staticmethod + def get_auth_user(auth_type): + mod = importlib.import_module('realms.modules.auth.%s.models' % auth_type) + return mod.User - if not user: - return None + @staticmethod + def load_user(auth_id): + auth_type, user_id = auth_id.split("/") + return Auth.get_auth_user(auth_type).load_user(user_id) - try: - if User.signer(sha256(user.password).hexdigest()).loads(token): - return user - else: - return None - except BadSignature: - return None + @staticmethod + def login_forms(): + forms = [] + for t in modules: + forms.append(Auth.get_auth_user(t).login_form()) + return "