From 3c2f4a0445a42065eee53c624a1702fb5773a58b Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Tue, 13 Oct 2015 22:52:30 -0500 Subject: [PATCH 01/22] first pass, non-working --- realms/config/__init__.py | 2 +- realms/modules/auth/ldap/__init__.py | 0 realms/modules/auth/local/__init__.py | 0 realms/modules/auth/{ => local}/commands.py | 3 +- realms/modules/auth/{ => local}/forms.py | 0 realms/modules/auth/{ => local}/hooks.py | 0 realms/modules/auth/local/models.py | 107 ++++++++++++++++++++ realms/modules/auth/local/views.py | 51 ++++++++++ realms/modules/auth/models.py | 104 +++++++++---------- realms/modules/auth/oauth/__init__.py | 0 realms/modules/auth/oauth/models.py | 36 +++++++ realms/modules/auth/oauth/views.py | 30 ++++++ realms/modules/auth/views.py | 66 ++---------- realms/templates/auth/local/login.html | 5 + realms/templates/auth/login.html | 8 +- realms/templates/auth/register.html | 2 +- realms/templates/layout.html | 2 +- 17 files changed, 295 insertions(+), 121 deletions(-) create mode 100644 realms/modules/auth/ldap/__init__.py create mode 100644 realms/modules/auth/local/__init__.py rename realms/modules/auth/{ => local}/commands.py (94%) rename realms/modules/auth/{ => local}/forms.py (100%) rename realms/modules/auth/{ => local}/hooks.py (100%) create mode 100644 realms/modules/auth/local/models.py create mode 100644 realms/modules/auth/local/views.py create mode 100644 realms/modules/auth/oauth/__init__.py create mode 100644 realms/modules/auth/oauth/models.py create mode 100644 realms/modules/auth/oauth/views.py create mode 100644 realms/templates/auth/local/login.html diff --git a/realms/config/__init__.py b/realms/config/__init__.py index 0c740a9..c9cf115 100644 --- a/realms/config/__init__.py +++ b/realms/config/__init__.py @@ -161,4 +161,4 @@ if ENV != "DEV": ASSETS_DEBUG = False SQLALCHEMY_ECHO = False -MODULES = ['wiki', 'auth', 'search'] +MODULES = ['wiki', 'search', 'auth', 'auth.local', 'auth.oauth'] diff --git a/realms/modules/auth/ldap/__init__.py b/realms/modules/auth/ldap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/realms/modules/auth/local/__init__.py b/realms/modules/auth/local/__init__.py new file mode 100644 index 0000000..e69de29 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..d758e82 --- /dev/null +++ b/realms/modules/auth/local/models.py @@ -0,0 +1,107 @@ +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 +import bcrypt + + +@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 + + @staticmethod + def hash_password(password): + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(12)) + + @staticmethod + def check_password(password, hashed): + return bcrypt.hashpw(password.encode('utf-8'), hashed.encode('utf-8')) == hashed + + @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..2c0ed7f 100644 --- a/realms/modules/auth/models.py +++ b/realms/modules/auth/models.py @@ -1,39 +1,41 @@ 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 realms import login_manager from realms.lib.util import gravatar_url from itsdangerous import URLSafeSerializer, BadSignature from hashlib import sha256 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 get_auth_user(auth_type): + print auth_type + mod = importlib.import_module('realms.modules.auth.%s.models' % auth_type) + return mod.User - # User key *could* be stored in payload to avoid user lookup in db - user = User.get_by_id(payload.get('id')) + @staticmethod + def load_user(auth_id): + print auth_id + auth_type, user_id = auth_id.split("/") + return Auth.get_auth_user(auth_type).load_user(user_id) - if not user: - return None - - try: - if User.signer(sha256(user.password).hexdigest()).loads(token): - return user - else: - return None - except BadSignature: - return None + @staticmethod + def login_forms(): + forms = [] + # TODO be dynamic + for t in ['local']: + forms.append(Auth.get_auth_user(t).login_form()) + return forms class AnonUser(AnonymousUserMixin): @@ -42,40 +44,42 @@ class AnonUser(AnonymousUserMixin): admin = False -class User(Model, UserMixin): - __tablename__ = 'users' - 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 +class BaseUser(UserMixin): + id = None + email = None + username = None + type = 'base' - hidden_fields = ['password'] - readonly_fields = ['email', 'password'] + def get_id(self): + return unicode("%s/%s" % (self.type, self.id)) def get_auth_token(self): - key = sha256(self.password).hexdigest() - return User.signer(key).dumps(dict(id=self.id)) + key = sha256(self.auth_token_id).hexdigest() + return BaseUser.signer(key).dumps(dict(id=self.id)) + + @property + def auth_token_id(self): + raise NotImplementedError @property def avatar(self): return gravatar_url(self.email) @staticmethod - def create(username, email, password): - u = User() - u.username = username - u.email = email - u.password = User.hash_password(password) - u.save() + def load_user(*args, **kwargs): + raise NotImplementedError + + @staticmethod + def create(*args, **kwargs): + pass @staticmethod def get_by_username(username): - return User.query().filter_by(username=username).first() + pass @staticmethod def get_by_email(email): - return User.query().filter_by(email=email).first() + pass @staticmethod def signer(salt): @@ -83,19 +87,7 @@ class User(Model, UserMixin): @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 + raise NotImplementedError() @staticmethod def hash_password(password): @@ -109,4 +101,8 @@ class User(Model, UserMixin): def logout(cls): logout_user() + @staticmethod + def login_form(): + pass + login_manager.anonymous_user = AnonUser diff --git a/realms/modules/auth/oauth/__init__.py b/realms/modules/auth/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/realms/modules/auth/oauth/models.py b/realms/modules/auth/oauth/models.py new file mode 100644 index 0000000..217e7b1 --- /dev/null +++ b/realms/modules/auth/oauth/models.py @@ -0,0 +1,36 @@ +from flask import render_template +from flask_oauthlib.client import OAuth +from realms import config +from ..models import BaseUser + +oauth = OAuth() + + +class OAuthUser(BaseUser): + # OAuth remote app + app = None + + +class TwitterUser(OAuthUser): + + app = oauth.remote_app( + 'twitter', + base_url='https://api.twitter.com/1/', + request_token_url='https://api.twitter.com/oauth/request_token', + access_token_url='https://api.twitter.com/oauth/access_token', + authorize_url='https://api.twitter.com/oauth/authenticate', + consumer_key=config.TWITTER_KEY, + consumer_secret=config.TWITTER_SECRET) + + def __init__(self, id_, username, email=None): + self.id = id_ + self.username = username + self.email = email + + @staticmethod + def load_user(*args, **kwargs): + return TwitterUser(args[0]) + + @staticmethod + def login_form(): + return render_template('auth/oauth/twitter.html') diff --git a/realms/modules/auth/oauth/views.py b/realms/modules/auth/oauth/views.py new file mode 100644 index 0000000..efe7434 --- /dev/null +++ b/realms/modules/auth/oauth/views.py @@ -0,0 +1,30 @@ +from flask import Blueprint, url_for, request, flash, redirect +from .models import TwitterUser + +blueprint = Blueprint('auth.oauth', __name__) + + +def oauth_failed(next_url): + flash(u'You denied the request to sign in.') + return redirect(next_url) + +@blueprint.route("/login/twitter") +def login_twitter(): + return TwitterUser.app.authorize(callback=url_for('twitter_callback', + next=request.args.get('next') or request.referrer or None)) + +@blueprint.route('/login/twitter/callback') +def twitter_callback(): + next_url = request.args.get('next') or url_for('index') + resp = TwitterUser.app.authorized_response() + if resp is None: + return oauth_failed(next_url) + + session['twitter_token'] = ( + resp['oauth_token'], + resp['oauth_token_secret'] + ) + session['twitter_user'] = resp['screen_name'] + + flash('You were signed in as %s' % resp['screen_name']) + return redirect(next_url) \ No newline at end of file diff --git a/realms/modules/auth/views.py b/realms/modules/auth/views.py index 6afdc54..8ca607f 100644 --- a/realms/modules/auth/views.py +++ b/realms/modules/auth/views.py @@ -1,72 +1,22 @@ from flask import current_app, render_template, request, redirect, Blueprint, flash, url_for -from realms.modules.auth.models import User -from realms.modules.auth.forms import LoginForm, RegistrationForm +from flask.ext.login import logout_user +from realms.modules.auth.models import Auth blueprint = Blueprint('auth', __name__) -@blueprint.route("/logout") -def logout_page(): - User.logout() - flash("You are now logged out") - return redirect(url_for(current_app.config['ROOT_ENDPOINT'])) - - @blueprint.route("/login", methods=['GET', 'POST']) def login(): - form = LoginForm() - - if request.method == "POST": - 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')) - - return render_template("auth/login.html", form=form) + return render_template("auth/login.html", forms=Auth.login_forms()) -@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.register')) - - if User.get_by_username(request.form['username']): - flash('Username is taken', 'warning') - return redirect(url_for('auth.register')) - - if User.get_by_email(request.form['email']): - flash('Email is taken', 'warning') - return redirect(url_for('auth.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) +@blueprint.route("/logout") +def logout(): + logout_user() + flash("You are now logged out") + return redirect(url_for(current_app.config['ROOT_ENDPOINT'])) @blueprint.route("/settings", methods=['GET', 'POST']) def settings(): return render_template("auth/settings.html") - - -@blueprint.route("/logout") -def logout(): - User.logout() - return redirect(url_for(current_app.config['ROOT_ENDPOINT'])) diff --git a/realms/templates/auth/local/login.html b/realms/templates/auth/local/login.html new file mode 100644 index 0000000..65d55dc --- /dev/null +++ b/realms/templates/auth/local/login.html @@ -0,0 +1,5 @@ +{% from 'macros.html' import render_form, render_field %} +{% call render_form(form, action_url=url_for('auth.local.login'), action_text='Login', btn_class='btn btn-primary') %} +{{ render_field(form.email, placeholder='Email', type='email', required=1) }} +{{ render_field(form.password, placeholder='Password', type='password', required=1) }} +{% endcall %} diff --git a/realms/templates/auth/login.html b/realms/templates/auth/login.html index 034f256..adde149 100644 --- a/realms/templates/auth/login.html +++ b/realms/templates/auth/login.html @@ -1,8 +1,6 @@ {% extends 'layout.html' %} -{% from 'macros.html' import render_form, render_field %} {% block body %} - {% call render_form(form, action_url=url_for('auth.login'), action_text='Login', btn_class='btn btn-primary') %} - {{ render_field(form.email, placeholder='Email', type='email', required=1) }} - {{ render_field(form.password, placeholder='Password', type='password', required=1) }} - {% endcall %} + {% for form in forms %} + {{ form|safe }} + {% endfor %} {% endblock %} diff --git a/realms/templates/auth/register.html b/realms/templates/auth/register.html index 2e82fbd..1e7f28a 100644 --- a/realms/templates/auth/register.html +++ b/realms/templates/auth/register.html @@ -1,7 +1,7 @@ {% extends 'layout.html' %} {% from 'macros.html' import render_form, render_field %} {% block body %} - {% call render_form(form, action_url=url_for('auth.register'), action_text='Register', btn_class='btn btn-primary') %} + {% call render_form(form, action_url=url_for('auth.local.register'), action_text='Register', btn_class='btn btn-primary') %} {{ render_field(form.username, placeholder='Username', type='username', **{"required": 1, "data-parsley-type": "alphanum"}) }} {{ render_field(form.email, placeholder='Email', type='email', required=1) }} {{ render_field(form.password, placeholder='Password', type='password', **{"required": 1, "data-parsley-minlength": "6"}) }} diff --git a/realms/templates/layout.html b/realms/templates/layout.html index 03faaf0..85bbef6 100644 --- a/realms/templates/layout.html +++ b/realms/templates/layout.html @@ -74,7 +74,7 @@ {% else %}
  •  Login
  • {% if config.REGISTRATION_ENABLED %} -
  •  Register
  • +
  •  Register
  • {% endif %} {% endif %} From 2eaf09dc783eb8a4d2c68cf8b06e1a5324652dcc Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Wed, 14 Oct 2015 17:36:22 -0500 Subject: [PATCH 02/22] ldap first pass --- install.sh | 8 +++---- realms/__init__.py | 3 +++ realms/config/__init__.py | 34 ++++++++++++++++++++++++++- realms/modules/auth/ldap/__init__.py | 4 ++++ realms/modules/auth/ldap/forms.py | 7 ++++++ realms/modules/auth/ldap/models.py | 31 ++++++++++++++++++++++++ realms/modules/auth/ldap/views.py | 18 ++++++++++++++ realms/modules/auth/models.py | 6 ++--- realms/modules/auth/oauth/models.py | 26 ++++++++++++-------- realms/modules/auth/oauth/views.py | 12 +++++----- realms/templates/auth/ldap/login.html | 21 +++++++++++++++++ realms/templates/auth/login.html | 1 + setup.py | 2 ++ 13 files changed, 148 insertions(+), 25 deletions(-) create mode 100644 realms/modules/auth/ldap/forms.py create mode 100644 realms/modules/auth/ldap/models.py create mode 100644 realms/modules/auth/ldap/views.py create mode 100644 realms/templates/auth/ldap/login.html diff --git a/install.sh b/install.sh index ab194e2..8edca84 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 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..4b51853 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 @@ -163,6 +164,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) @@ -202,6 +204,7 @@ db = SQLAlchemy() cache = Cache() assets = Assets() search = Search() +ldap = LDAPLoginManager() assets.register('main.js', 'vendor/jquery/dist/jquery.js', diff --git a/realms/config/__init__.py b/realms/config/__init__.py index c9cf115..6895e1c 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': 'ldap://localhost:8389', + + # This BIND_DN/BIND_PASSORD 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 @@ -161,4 +193,4 @@ if ENV != "DEV": ASSETS_DEBUG = False SQLALCHEMY_ECHO = False -MODULES = ['wiki', 'search', 'auth', 'auth.local', 'auth.oauth'] +MODULES = ['wiki', 'search', 'auth', 'auth.local', 'auth.oauth', 'auth.ldap'] diff --git a/realms/modules/auth/ldap/__init__.py b/realms/modules/auth/ldap/__init__.py index e69de29..cb45955 100644 --- a/realms/modules/auth/ldap/__init__.py +++ b/realms/modules/auth/ldap/__init__.py @@ -0,0 +1,4 @@ +from flask_ldap_login import LDAPLoginManager + +ldap_mgr = LDAPLoginManager() + diff --git a/realms/modules/auth/ldap/forms.py b/realms/modules/auth/ldap/forms.py new file mode 100644 index 0000000..ddbc54c --- /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): + email = StringField('Email', [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..3cd8767 --- /dev/null +++ b/realms/modules/auth/ldap/models.py @@ -0,0 +1,31 @@ +from flask import current_app, render_template +from flask.ext.login import login_user +from realms import ldap +from flask_ldap_login import LDAPLoginForm +from ..models import BaseUser +import bcrypt + +users = {} + +@ldap.save_user +def save_user(username, userdata): + users[username] = User(username, userdata) + return users[username] + +class User(BaseUser): + type = 'ldap' + + def __init__(self, username, data): + self.id = username + self.username = username + self.data = data + + @staticmethod + def login_form(): + form = LDAPLoginForm() + return render_template('auth/ldap/login.html', form=form) + + @staticmethod + def auth(*args): + login_user(args[0].user, remember=True) + return True diff --git a/realms/modules/auth/ldap/views.py b/realms/modules/auth/ldap/views.py new file mode 100644 index 0000000..5d2a32f --- /dev/null +++ b/realms/modules/auth/ldap/views.py @@ -0,0 +1,18 @@ +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): + 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/models.py b/realms/modules/auth/models.py index 2c0ed7f..9b4c40f 100644 --- a/realms/modules/auth/models.py +++ b/realms/modules/auth/models.py @@ -19,13 +19,11 @@ class Auth(object): @staticmethod def get_auth_user(auth_type): - print auth_type mod = importlib.import_module('realms.modules.auth.%s.models' % auth_type) return mod.User @staticmethod def load_user(auth_id): - print auth_id auth_type, user_id = auth_id.split("/") return Auth.get_auth_user(auth_type).load_user(user_id) @@ -33,7 +31,7 @@ class Auth(object): def login_forms(): forms = [] # TODO be dynamic - for t in ['local']: + for t in ['local', 'ldap']: forms.append(Auth.get_auth_user(t).login_form()) return forms @@ -87,7 +85,7 @@ class BaseUser(UserMixin): @staticmethod def auth(email, password): - raise NotImplementedError() + raise NotImplementedError @staticmethod def hash_password(password): diff --git a/realms/modules/auth/oauth/models.py b/realms/modules/auth/oauth/models.py index 217e7b1..d67ce18 100644 --- a/realms/modules/auth/oauth/models.py +++ b/realms/modules/auth/oauth/models.py @@ -8,25 +8,31 @@ oauth = OAuth() class OAuthUser(BaseUser): # OAuth remote app - app = None + remote_app = None class TwitterUser(OAuthUser): - app = oauth.remote_app( - 'twitter', - base_url='https://api.twitter.com/1/', - request_token_url='https://api.twitter.com/oauth/request_token', - access_token_url='https://api.twitter.com/oauth/access_token', - authorize_url='https://api.twitter.com/oauth/authenticate', - consumer_key=config.TWITTER_KEY, - consumer_secret=config.TWITTER_SECRET) - def __init__(self, id_, username, email=None): self.id = id_ self.username = username self.email = email + @classmethod + def app(cls): + if cls.remote_app: + return cls.remote_app + + cls.remote_app = oauth.remote_app( + 'twitter', + base_url='https://api.twitter.com/1/', + request_token_url='https://api.twitter.com/oauth/request_token', + access_token_url='https://api.twitter.com/oauth/access_token', + authorize_url='https://api.twitter.com/oauth/authenticate', + consumer_key=config.OAUTH['twitter']['key'], + consumer_secret=config.OAUTH['twitter']['secret']) + return cls.remote_app + @staticmethod def load_user(*args, **kwargs): return TwitterUser(args[0]) diff --git a/realms/modules/auth/oauth/views.py b/realms/modules/auth/oauth/views.py index efe7434..fa2b5cf 100644 --- a/realms/modules/auth/oauth/views.py +++ b/realms/modules/auth/oauth/views.py @@ -1,22 +1,22 @@ -from flask import Blueprint, url_for, request, flash, redirect +from flask import Blueprint, url_for, request, flash, redirect, session from .models import TwitterUser blueprint = Blueprint('auth.oauth', __name__) def oauth_failed(next_url): - flash(u'You denied the request to sign in.') + flash('You denied the request to sign in.') return redirect(next_url) @blueprint.route("/login/twitter") def login_twitter(): - return TwitterUser.app.authorize(callback=url_for('twitter_callback', - next=request.args.get('next') or request.referrer or None)) + return TwitterUser.app().authorize(callback=url_for('twitter_callback', + next=request.args.get('next') or request.referrer or None)) @blueprint.route('/login/twitter/callback') def twitter_callback(): next_url = request.args.get('next') or url_for('index') - resp = TwitterUser.app.authorized_response() + resp = TwitterUser.app().authorized_response() if resp is None: return oauth_failed(next_url) @@ -27,4 +27,4 @@ def twitter_callback(): session['twitter_user'] = resp['screen_name'] flash('You were signed in as %s' % resp['screen_name']) - return redirect(next_url) \ No newline at end of file + return redirect(next_url) diff --git a/realms/templates/auth/ldap/login.html b/realms/templates/auth/ldap/login.html new file mode 100644 index 0000000..045040b --- /dev/null +++ b/realms/templates/auth/ldap/login.html @@ -0,0 +1,21 @@ +{% from 'macros.html' import render_form, render_field %} + + + \ No newline at end of file diff --git a/realms/templates/auth/login.html b/realms/templates/auth/login.html index adde149..ef129b1 100644 --- a/realms/templates/auth/login.html +++ b/realms/templates/auth/login.html @@ -2,5 +2,6 @@ {% block body %} {% for form in forms %} {{ form|safe }} +
    {% endfor %} {% endblock %} diff --git a/setup.py b/setup.py index 1812098..193c596 100644 --- a/setup.py +++ b/setup.py @@ -28,12 +28,14 @@ setup(name='realms-wiki', 'Flask-Cache==0.13.1', 'Flask-Elastic==0.2', 'Flask-Login==0.2.11', + 'Flask-OAuthlib==0.9.1', 'Flask-SQLAlchemy==2.0', 'Flask-WTF==0.10.2', 'PyYAML==3.11', 'bcrypt==1.0.2', 'beautifulsoup4==4.3.2', 'click==3.3', + 'flask-ldap-login==0.3.0', 'gevent==1.0.2', 'ghdiff==0.4', 'gittle==0.4.0', From 838eb3cb060fa1ba15a86309cc7020a4628f8d87 Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Wed, 14 Oct 2015 23:08:56 -0500 Subject: [PATCH 03/22] 2nd pass on oauth, making more generic --- realms/modules/auth/oauth/models.py | 44 +++++++++++------------------ realms/modules/auth/oauth/views.py | 20 ++++++------- 2 files changed, 27 insertions(+), 37 deletions(-) diff --git a/realms/modules/auth/oauth/models.py b/realms/modules/auth/oauth/models.py index d67ce18..1777123 100644 --- a/realms/modules/auth/oauth/models.py +++ b/realms/modules/auth/oauth/models.py @@ -5,38 +5,28 @@ from ..models import BaseUser oauth = OAuth() +users = {} -class OAuthUser(BaseUser): - # OAuth remote app - remote_app = None - - -class TwitterUser(OAuthUser): - - def __init__(self, id_, username, email=None): - self.id = id_ - self.username = username - self.email = email - - @classmethod - def app(cls): - if cls.remote_app: - return cls.remote_app - - cls.remote_app = oauth.remote_app( - 'twitter', +providers = { + 'twitter': { + 'oauth': dict( base_url='https://api.twitter.com/1/', request_token_url='https://api.twitter.com/oauth/request_token', access_token_url='https://api.twitter.com/oauth/access_token', - authorize_url='https://api.twitter.com/oauth/authenticate', - consumer_key=config.OAUTH['twitter']['key'], - consumer_secret=config.OAUTH['twitter']['secret']) - return cls.remote_app + authorize_url='https://api.twitter.com/oauth/authenticate') + } +} - @staticmethod - def load_user(*args, **kwargs): - return TwitterUser(args[0]) + +class User(BaseUser): + + @classmethod + def get_app(cls, provider): + return oauth.remote_app(provider, + consumer_key=config.OAUTH.get(provider, {}).get('key'), + consumer_secret=config.OAUTH.get(provider, {}).get('secret'), + **providers[provider]['oauth']) @staticmethod def login_form(): - return render_template('auth/oauth/twitter.html') + pass diff --git a/realms/modules/auth/oauth/views.py b/realms/modules/auth/oauth/views.py index fa2b5cf..bb6990b 100644 --- a/realms/modules/auth/oauth/views.py +++ b/realms/modules/auth/oauth/views.py @@ -1,5 +1,5 @@ from flask import Blueprint, url_for, request, flash, redirect, session -from .models import TwitterUser +from .models import User blueprint = Blueprint('auth.oauth', __name__) @@ -8,23 +8,23 @@ def oauth_failed(next_url): flash('You denied the request to sign in.') return redirect(next_url) -@blueprint.route("/login/twitter") -def login_twitter(): - return TwitterUser.app().authorize(callback=url_for('twitter_callback', + +@blueprint.route("/login/oauth/") +def oauth_login(provider): + return User.get_app(provider).authorize(callback=url_for('oauth_callback', provider=provider, next=request.args.get('next') or request.referrer or None)) -@blueprint.route('/login/twitter/callback') -def twitter_callback(): + +@blueprint.route('/login/oauth//callback') +def oauth_callback(provider): next_url = request.args.get('next') or url_for('index') - resp = TwitterUser.app().authorized_response() + resp = User.get_app(provider).authorized_response() if resp is None: return oauth_failed(next_url) - session['twitter_token'] = ( + session[provider + '_token'] = ( resp['oauth_token'], resp['oauth_token_secret'] ) - session['twitter_user'] = resp['screen_name'] - flash('You were signed in as %s' % resp['screen_name']) return redirect(next_url) From e9709b6c8f927cf03ddc9b0d231812dea8d69a14 Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Thu, 15 Oct 2015 15:55:38 -0500 Subject: [PATCH 04/22] ldap second pass, working state --- realms/__init__.py | 6 ++++- realms/modules/auth/ldap/__init__.py | 3 --- realms/modules/auth/ldap/models.py | 39 +++++++++++++++++++++------- realms/modules/auth/ldap/views.py | 2 +- realms/modules/auth/local/models.py | 9 ------- realms/modules/auth/models.py | 6 +---- 6 files changed, 37 insertions(+), 28 deletions(-) diff --git a/realms/__init__.py b/realms/__init__.py index 4b51853..808bf41 100644 --- a/realms/__init__.py +++ b/realms/__init__.py @@ -110,6 +110,10 @@ class Assets(Environment): return super(Assets, self).register(name, Bundle(*args, filters=filters, output=output)) +class MyLDAPLoginManager(LDAPLoginManager): + @property + def attrlist(self): + return None class RegexConverter(BaseConverter): """ Enables Regex matching on endpoints @@ -204,7 +208,7 @@ db = SQLAlchemy() cache = Cache() assets = Assets() search = Search() -ldap = LDAPLoginManager() +ldap = MyLDAPLoginManager() assets.register('main.js', 'vendor/jquery/dist/jquery.js', diff --git a/realms/modules/auth/ldap/__init__.py b/realms/modules/auth/ldap/__init__.py index cb45955..8b13789 100644 --- a/realms/modules/auth/ldap/__init__.py +++ b/realms/modules/auth/ldap/__init__.py @@ -1,4 +1 @@ -from flask_ldap_login import LDAPLoginManager - -ldap_mgr = LDAPLoginManager() diff --git a/realms/modules/auth/ldap/models.py b/realms/modules/auth/ldap/models.py index 3cd8767..269512d 100644 --- a/realms/modules/auth/ldap/models.py +++ b/realms/modules/auth/ldap/models.py @@ -1,24 +1,38 @@ -from flask import current_app, render_template +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 -import bcrypt + users = {} @ldap.save_user def save_user(username, userdata): - users[username] = User(username, userdata) - return users[username] + user = User(userdata.get('username'), userdata.get('email')) + users[user.id] = user + return user class User(BaseUser): type = 'ldap' - def __init__(self, username, data): + def __init__(self, username, email='null@localhost.local', password=None): self.id = username self.username = username - self.data = data + 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(): @@ -26,6 +40,13 @@ class User(BaseUser): return render_template('auth/ldap/login.html', form=form) @staticmethod - def auth(*args): - login_user(args[0].user, remember=True) - return True + 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 index 5d2a32f..ccb0414 100644 --- a/realms/modules/auth/ldap/views.py +++ b/realms/modules/auth/ldap/views.py @@ -12,7 +12,7 @@ def login(): flash('Form invalid', 'warning') return redirect(url_for('auth.login')) - if User.auth(form.user): + 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/models.py b/realms/modules/auth/local/models.py index d758e82..695ce63 100644 --- a/realms/modules/auth/local/models.py +++ b/realms/modules/auth/local/models.py @@ -6,7 +6,6 @@ from ..models import BaseUser from .forms import LoginForm from itsdangerous import URLSafeSerializer, BadSignature from hashlib import sha256 -import bcrypt @login_manager.token_loader @@ -88,14 +87,6 @@ class User(Model, BaseUser): # Password check failed return False - @staticmethod - def hash_password(password): - return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(12)) - - @staticmethod - def check_password(password, hashed): - return bcrypt.hashpw(password.encode('utf-8'), hashed.encode('utf-8')) == hashed - @classmethod def logout(cls): logout_user() diff --git a/realms/modules/auth/models.py b/realms/modules/auth/models.py index 9b4c40f..a8fa888 100644 --- a/realms/modules/auth/models.py +++ b/realms/modules/auth/models.py @@ -1,5 +1,5 @@ from flask import current_app -from flask.ext.login import UserMixin, logout_user, login_user, AnonymousUserMixin +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 @@ -83,10 +83,6 @@ class BaseUser(UserMixin): def signer(salt): return URLSafeSerializer(current_app.config['SECRET_KEY'] + salt) - @staticmethod - def auth(email, password): - raise NotImplementedError - @staticmethod def hash_password(password): return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(12)) From a0124baf1de84db84eaa40ba2fb801a8623d40db Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Thu, 15 Oct 2015 17:36:47 -0500 Subject: [PATCH 05/22] oauth work --- realms/config/__init__.py | 2 +- realms/modules/auth/models.py | 12 ------ realms/modules/auth/oauth/models.py | 60 +++++++++++++++++++++++++---- realms/modules/auth/oauth/views.py | 20 ++++++---- 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/realms/config/__init__.py b/realms/config/__init__.py index 6895e1c..2f9fd58 100644 --- a/realms/config/__init__.py +++ b/realms/config/__init__.py @@ -193,4 +193,4 @@ if ENV != "DEV": ASSETS_DEBUG = False SQLALCHEMY_ECHO = False -MODULES = ['wiki', 'search', 'auth', 'auth.local', 'auth.oauth', 'auth.ldap'] +MODULES = ['wiki', 'search', 'auth', 'auth.local', 'auth.oauth', 'auth.ldap', 'auth.oauth'] diff --git a/realms/modules/auth/models.py b/realms/modules/auth/models.py index a8fa888..ba9ccd7 100644 --- a/realms/modules/auth/models.py +++ b/realms/modules/auth/models.py @@ -67,18 +67,6 @@ class BaseUser(UserMixin): def load_user(*args, **kwargs): raise NotImplementedError - @staticmethod - def create(*args, **kwargs): - pass - - @staticmethod - def get_by_username(username): - pass - - @staticmethod - def get_by_email(email): - pass - @staticmethod def signer(salt): return URLSafeSerializer(current_app.config['SECRET_KEY'] + salt) diff --git a/realms/modules/auth/oauth/models.py b/realms/modules/auth/oauth/models.py index 1777123..252756e 100644 --- a/realms/modules/auth/oauth/models.py +++ b/realms/modules/auth/oauth/models.py @@ -1,5 +1,6 @@ -from flask import render_template +from flask_login import login_user from flask_oauthlib.client import OAuth + from realms import config from ..models import BaseUser @@ -10,23 +11,66 @@ users = {} providers = { 'twitter': { 'oauth': dict( - base_url='https://api.twitter.com/1/', + base_url='https://api.twitter.com/1.1/', request_token_url='https://api.twitter.com/oauth/request_token', access_token_url='https://api.twitter.com/oauth/access_token', - authorize_url='https://api.twitter.com/oauth/authenticate') + authorize_url='https://api.twitter.com/oauth/authenticate', + access_token_method='GET'), + 'button': ' Twitter' } } class User(BaseUser): + type = 'oauth' + provider = None + + def __init__(self, provider, username, token): + self.provider = provider + self.username = username + self.id = username + self.token = token + + @property + def auth_token_id(self): + return self.token + + @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 auth(username, provider, token): + user = User(provider, username, User.hash_password(token)) + users[user.id] = user + if user: + login_user(user, remember=True) + return True + else: + return False @classmethod def get_app(cls, provider): - return oauth.remote_app(provider, - consumer_key=config.OAUTH.get(provider, {}).get('key'), - consumer_secret=config.OAUTH.get(provider, {}).get('secret'), - **providers[provider]['oauth']) + if oauth.remote_apps.get(provider): + return oauth.remote_apps.get(provider) + return oauth.remote_app( + provider, + consumer_key=config.OAUTH.get(provider, {}).get('key'), + consumer_secret=config.OAUTH.get(provider, {}).get( + 'secret'), + **providers[provider]['oauth']) + + def get_id(self): + return unicode("%s/%s/%s" % (self.type, self.provider, self.id)) @staticmethod def login_form(): - pass + buttons = '' + for k, v in providers.items(): + buttons += v.get('button') + + return buttons diff --git a/realms/modules/auth/oauth/views.py b/realms/modules/auth/oauth/views.py index bb6990b..b51c60c 100644 --- a/realms/modules/auth/oauth/views.py +++ b/realms/modules/auth/oauth/views.py @@ -10,17 +10,23 @@ def oauth_failed(next_url): @blueprint.route("/login/oauth/") -def oauth_login(provider): - return User.get_app(provider).authorize(callback=url_for('oauth_callback', provider=provider, - next=request.args.get('next') or request.referrer or None)) +def login(provider): + return User.get_app(provider).authorize(callback=url_for('auth.oauth.callback', provider=provider)) @blueprint.route('/login/oauth//callback') -def oauth_callback(provider): +def callback(provider): next_url = request.args.get('next') or url_for('index') - resp = User.get_app(provider).authorized_response() - if resp is None: - return oauth_failed(next_url) + try: + resp = User.get_app(provider).authorized_response() + if resp is None: + flash('You denied the request to sign in.', 'error') + flash('Reason: ' + request.args['error_reason'] + + ' ' + request.args['error_description'], 'error') + return redirect(next_url) + except Exception as e: + flash('Access denied: %s' % e.message) + return redirect(next_url) session[provider + '_token'] = ( resp['oauth_token'], From 4fede50136316d34d835912a944d13e681de6af0 Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Tue, 20 Oct 2015 17:18:43 -0500 Subject: [PATCH 06/22] enable oauth, set correct default oauth success endpoint --- realms/modules/auth/models.py | 2 +- realms/modules/auth/oauth/views.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/realms/modules/auth/models.py b/realms/modules/auth/models.py index ba9ccd7..a75aa50 100644 --- a/realms/modules/auth/models.py +++ b/realms/modules/auth/models.py @@ -31,7 +31,7 @@ class Auth(object): def login_forms(): forms = [] # TODO be dynamic - for t in ['local', 'ldap']: + for t in ['local', 'ldap', 'oauth']: forms.append(Auth.get_auth_user(t).login_form()) return forms diff --git a/realms/modules/auth/oauth/views.py b/realms/modules/auth/oauth/views.py index b51c60c..9c52cf4 100644 --- a/realms/modules/auth/oauth/views.py +++ b/realms/modules/auth/oauth/views.py @@ -1,4 +1,4 @@ -from flask import Blueprint, url_for, request, flash, redirect, session +from flask import Blueprint, url_for, request, flash, redirect, session, current_app from .models import User blueprint = Blueprint('auth.oauth', __name__) @@ -16,7 +16,7 @@ def login(provider): @blueprint.route('/login/oauth//callback') def callback(provider): - next_url = request.args.get('next') or url_for('index') + next_url = request.args.get('next') or current_app.config['ROOT_ENDPOINT'] try: resp = User.get_app(provider).authorized_response() if resp is None: From 4375db4f8c107a73a5ee861e6ad0f9d5a7da02e2 Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Wed, 21 Oct 2015 09:09:42 -0500 Subject: [PATCH 07/22] add github support, fix redirect on oauth --- realms/modules/auth/oauth/models.py | 26 ++++++++++++++++++-------- realms/modules/auth/oauth/views.py | 2 +- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/realms/modules/auth/oauth/models.py b/realms/modules/auth/oauth/models.py index 252756e..3a24840 100644 --- a/realms/modules/auth/oauth/models.py +++ b/realms/modules/auth/oauth/models.py @@ -17,6 +17,16 @@ providers = { authorize_url='https://api.twitter.com/oauth/authenticate', access_token_method='GET'), 'button': ' Twitter' + }, + 'github': { + 'oauth': dict( + request_token_params={'scope': 'user:email'}, + base_url='https://api.github.com/', + request_token_url=None, + access_token_method='POST', + access_token_url='https://github.com/login/oauth/access_token', + authorize_url='https://github.com/login/oauth/authorize'), + 'button': ' Github' } } @@ -58,19 +68,19 @@ class User(BaseUser): if oauth.remote_apps.get(provider): return oauth.remote_apps.get(provider) return oauth.remote_app( - provider, - consumer_key=config.OAUTH.get(provider, {}).get('key'), - consumer_secret=config.OAUTH.get(provider, {}).get( - 'secret'), - **providers[provider]['oauth']) + provider, + consumer_key=config.OAUTH.get(provider, {}).get('key'), + consumer_secret=config.OAUTH.get(provider, {}).get( + 'secret'), + **providers[provider]['oauth']) def get_id(self): return unicode("%s/%s/%s" % (self.type, self.provider, self.id)) @staticmethod def login_form(): - buttons = '' + buttons = [] for k, v in providers.items(): - buttons += v.get('button') + buttons.append(v.get('button')) - return buttons + return " ".join(buttons) diff --git a/realms/modules/auth/oauth/views.py b/realms/modules/auth/oauth/views.py index 9c52cf4..fc4970f 100644 --- a/realms/modules/auth/oauth/views.py +++ b/realms/modules/auth/oauth/views.py @@ -16,7 +16,7 @@ def login(provider): @blueprint.route('/login/oauth//callback') def callback(provider): - next_url = request.args.get('next') or current_app.config['ROOT_ENDPOINT'] + next_url = request.args.get('next') or url_for(current_app.config['ROOT_ENDPOINT']) try: resp = User.get_app(provider).authorized_response() if resp is None: From c92cc46db29398a1ff10865fd51dd9206331f6a8 Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Wed, 21 Oct 2015 17:34:20 -0500 Subject: [PATCH 08/22] add more oauth providers, really authenticate --- realms/modules/auth/models.py | 5 ++- realms/modules/auth/oauth/models.py | 55 ++++++++++++++++++++++++----- realms/modules/auth/oauth/views.py | 2 ++ realms/templates/auth/login.html | 5 +-- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/realms/modules/auth/models.py b/realms/modules/auth/models.py index a75aa50..eca945d 100644 --- a/realms/modules/auth/models.py +++ b/realms/modules/auth/models.py @@ -33,7 +33,7 @@ class Auth(object): # TODO be dynamic for t in ['local', 'ldap', 'oauth']: forms.append(Auth.get_auth_user(t).login_form()) - return forms + return "
    ".join(forms) class AnonUser(AnonymousUserMixin): @@ -61,6 +61,9 @@ class BaseUser(UserMixin): @property def avatar(self): + if not self.email: + # TODO return default avatar + return "" return gravatar_url(self.email) @staticmethod diff --git a/realms/modules/auth/oauth/models.py b/realms/modules/auth/oauth/models.py index 3a24840..82e1afe 100644 --- a/realms/modules/auth/oauth/models.py +++ b/realms/modules/auth/oauth/models.py @@ -16,7 +16,10 @@ providers = { access_token_url='https://api.twitter.com/oauth/access_token', authorize_url='https://api.twitter.com/oauth/authenticate', access_token_method='GET'), - 'button': ' Twitter' + 'button': ' Twitter', + 'field_map': { + 'username': 'screen_name' + } }, 'github': { 'oauth': dict( @@ -27,6 +30,30 @@ providers = { access_token_url='https://github.com/login/oauth/access_token', authorize_url='https://github.com/login/oauth/authorize'), 'button': ' Github' + }, + 'facebook': { + 'oauth': dict( + request_token_params={'scope': 'email'}, + base_url='https://graph.facebook.com', + request_token_url=None, + access_token_url='/oauth/access_token', + access_token_method='GET', + authorize_url='https://www.facebook.com/dialog/oauth' + ), + 'button': ' Facebook' + }, + 'google': { + 'oauth': dict( + request_token_params={ + 'scope': 'https://www.googleapis.com/auth/userinfo.email' + }, + base_url='https://www.googleapis.com/oauth2/v1/', + request_token_url=None, + access_token_method='POST', + access_token_url='https://accounts.google.com/o/oauth2/token', + authorize_url='https://accounts.google.com/o/oauth2/auth', + ), + 'button': ' Google' } } @@ -40,6 +67,7 @@ class User(BaseUser): self.username = username self.id = username self.token = token + self.auth_id = "%s-%s" % (provider, username) @property def auth_token_id(self): @@ -54,9 +82,18 @@ class User(BaseUser): return users.get(user_id) @staticmethod - def auth(username, provider, token): - user = User(provider, username, User.hash_password(token)) - users[user.id] = user + def auth(provider, resp): + field_map = providers.get(provider).get('field_map') + if not field_map: + raise NotImplementedError + + fields = {} + for k, v in field_map.items(): + fields[k] = resp[v] + + user = User(provider, fields['username'], User.hash_password(resp['oauth_token'])) + users[user.auth_id] = user + if user: login_user(user, remember=True) return True @@ -75,12 +112,14 @@ class User(BaseUser): **providers[provider]['oauth']) def get_id(self): - return unicode("%s/%s/%s" % (self.type, self.provider, self.id)) + return unicode("%s/%s" % (self.type, self.auth_id)) @staticmethod def login_form(): buttons = [] - for k, v in providers.items(): - buttons.append(v.get('button')) + for name, val in providers.items(): + if not config.OAUTH.get(name, {}).get('key') or not config.OAUTH.get(name, {}).get('secret'): + continue + buttons.append(val.get('button')) - return " ".join(buttons) + return "

    Social Login

    " + " ".join(buttons) diff --git a/realms/modules/auth/oauth/views.py b/realms/modules/auth/oauth/views.py index fc4970f..5c80402 100644 --- a/realms/modules/auth/oauth/views.py +++ b/realms/modules/auth/oauth/views.py @@ -33,4 +33,6 @@ def callback(provider): resp['oauth_token_secret'] ) + User.auth(provider, resp) + return redirect(next_url) diff --git a/realms/templates/auth/login.html b/realms/templates/auth/login.html index ef129b1..79553a4 100644 --- a/realms/templates/auth/login.html +++ b/realms/templates/auth/login.html @@ -1,7 +1,4 @@ {% extends 'layout.html' %} {% block body %} - {% for form in forms %} - {{ form|safe }} -
    - {% endfor %} + {{ forms|safe }} {% endblock %} From e635be896176c74c1939fa36b14d19242badc8a2 Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Mon, 26 Oct 2015 16:47:32 -0500 Subject: [PATCH 09/22] oauth field map to include lists to access nested response dicts oauth authorize callback to use absolute url, needed by Facebook --- realms/modules/auth/oauth/models.py | 34 ++++++++++++++++++++++++----- realms/modules/auth/oauth/views.py | 2 +- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/realms/modules/auth/oauth/models.py b/realms/modules/auth/oauth/models.py index 82e1afe..46a07ba 100644 --- a/realms/modules/auth/oauth/models.py +++ b/realms/modules/auth/oauth/models.py @@ -18,6 +18,7 @@ providers = { access_token_method='GET'), 'button': ' Twitter', 'field_map': { + 'id': 'user_id', 'username': 'screen_name' } }, @@ -29,7 +30,12 @@ providers = { access_token_method='POST', access_token_url='https://github.com/login/oauth/access_token', authorize_url='https://github.com/login/oauth/authorize'), - 'button': ' Github' + 'button': ' Github', + 'field_map': { + 'id': ['user', 'id'], + 'username': ['user', 'login'], + 'email': ['user', 'email'] + } }, 'facebook': { 'oauth': dict( @@ -40,7 +46,12 @@ providers = { access_token_method='GET', authorize_url='https://www.facebook.com/dialog/oauth' ), - 'button': ' Facebook' + 'button': ' Facebook', + 'field_map': { + 'id': 'id', + 'username': 'name', + 'email': 'email' + } }, 'google': { 'oauth': dict( @@ -62,10 +73,10 @@ class User(BaseUser): type = 'oauth' provider = None - def __init__(self, provider, username, token): + def __init__(self, provider, user_id, username, token): self.provider = provider self.username = username - self.id = username + self.id = user_id self.token = token self.auth_id = "%s-%s" % (provider, username) @@ -87,11 +98,22 @@ class User(BaseUser): if not field_map: raise NotImplementedError + def get_value(d, key): + if isinstance(key, basestring): + return d.get(key) + # key should be list here + val = d.get(key.pop(0)) + if len(key) == 0: + # if empty we have our value + return val + # keep digging + return get_value(val, key) + fields = {} for k, v in field_map.items(): - fields[k] = resp[v] + fields[k] = get_value(resp, v) - user = User(provider, fields['username'], User.hash_password(resp['oauth_token'])) + user = User(provider, fields['id'], fields['username'], User.hash_password(resp['oauth_token'])) users[user.auth_id] = user if user: diff --git a/realms/modules/auth/oauth/views.py b/realms/modules/auth/oauth/views.py index 5c80402..31fd281 100644 --- a/realms/modules/auth/oauth/views.py +++ b/realms/modules/auth/oauth/views.py @@ -11,7 +11,7 @@ def oauth_failed(next_url): @blueprint.route("/login/oauth/") def login(provider): - return User.get_app(provider).authorize(callback=url_for('auth.oauth.callback', provider=provider)) + return User.get_app(provider).authorize(callback=url_for('auth.oauth.callback', provider=provider, _external=True)) @blueprint.route('/login/oauth//callback') From 0b1c55f6a59459c79d71c26b8848374c5062a39f Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Fri, 6 Nov 2015 17:44:48 -0600 Subject: [PATCH 10/22] fallback to default avatar if email is not set auth submodules are registered with initialized check if auth.local is loaded before accessing registration route check DB_URI before attempt to create db --- realms/__init__.py | 8 +++++--- realms/config/__init__.py | 2 +- realms/lib/util.py | 3 ++- realms/modules/auth/__init__.py | 1 + realms/modules/auth/ldap/__init__.py | 2 ++ realms/modules/auth/local/__init__.py | 3 +++ realms/modules/auth/models.py | 11 ++++++----- realms/modules/auth/oauth/__init__.py | 3 +++ realms/templates/layout.html | 2 +- 9 files changed, 24 insertions(+), 11 deletions(-) diff --git a/realms/__init__.py b/realms/__init__.py index 808bf41..59899cc 100644 --- a/realms/__init__.py +++ b/realms/__init__.py @@ -113,6 +113,7 @@ class Assets(Environment): class MyLDAPLoginManager(LDAPLoginManager): @property def attrlist(self): + # the parent method doesn't always work return None class RegexConverter(BaseConverter): @@ -188,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 diff --git a/realms/config/__init__.py b/realms/config/__init__.py index 2f9fd58..ffe8050 100644 --- a/realms/config/__init__.py +++ b/realms/config/__init__.py @@ -86,7 +86,7 @@ DB_URI = 'sqlite:////tmp/wiki.db' LDAP = { 'URI': 'ldap://localhost:8389', - # This BIND_DN/BIND_PASSORD default to '', this is shown here for demonstrative purposes + # 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': '', 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 index 8b13789..f3d364d 100644 --- a/realms/modules/auth/ldap/__init__.py +++ b/realms/modules/auth/ldap/__init__.py @@ -1 +1,3 @@ +from ..models import Auth +Auth.register('ldap') diff --git a/realms/modules/auth/local/__init__.py b/realms/modules/auth/local/__init__.py index e69de29..8c939c1 100644 --- a/realms/modules/auth/local/__init__.py +++ b/realms/modules/auth/local/__init__.py @@ -0,0 +1,3 @@ +from ..models import Auth + +Auth.register('local') diff --git a/realms/modules/auth/models.py b/realms/modules/auth/models.py index eca945d..f62b736 100644 --- a/realms/modules/auth/models.py +++ b/realms/modules/auth/models.py @@ -4,6 +4,7 @@ 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 @@ -17,6 +18,10 @@ auth_users = {} class Auth(object): + @staticmethod + def register(module): + modules.add(module) + @staticmethod def get_auth_user(auth_type): mod = importlib.import_module('realms.modules.auth.%s.models' % auth_type) @@ -30,8 +35,7 @@ class Auth(object): @staticmethod def login_forms(): forms = [] - # TODO be dynamic - for t in ['local', 'ldap', 'oauth']: + for t in modules: forms.append(Auth.get_auth_user(t).login_form()) return "
    ".join(forms) @@ -61,9 +65,6 @@ class BaseUser(UserMixin): @property def avatar(self): - if not self.email: - # TODO return default avatar - return "" return gravatar_url(self.email) @staticmethod diff --git a/realms/modules/auth/oauth/__init__.py b/realms/modules/auth/oauth/__init__.py index e69de29..7a84b08 100644 --- a/realms/modules/auth/oauth/__init__.py +++ b/realms/modules/auth/oauth/__init__.py @@ -0,0 +1,3 @@ +from ..models import Auth + +Auth.register('oauth') diff --git a/realms/templates/layout.html b/realms/templates/layout.html index 85bbef6..59ea243 100644 --- a/realms/templates/layout.html +++ b/realms/templates/layout.html @@ -73,7 +73,7 @@ {% else %}
  •  Login
  • - {% if config.REGISTRATION_ENABLED %} + {% if config.REGISTRATION_ENABLED and 'auth.local' in config.MODULES %}
  •  Register
  • {% endif %} {% endif %} From 96b583d63b88ae41dbd63a60c15a2124fa639987 Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Fri, 6 Nov 2015 22:05:02 -0600 Subject: [PATCH 11/22] update scripts and readme with dependencies --- README.md | 8 ++++---- docker/Dockerfile | 2 +- install.sh | 2 +- setup.py | 1 + 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f223cb2..afa19d0 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 @@ -292,7 +292,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 8edca84..93077e2 100755 --- a/install.sh +++ b/install.sh @@ -25,7 +25,7 @@ sudo apt-get update sudo apt-get install -y python build-essential pkg-config git \ python-pip python-virtualenv python-dev zlib1g-dev libldap2-dev libsasl2-dev \ -libffi-dev libyaml-dev libssl-dev nodejs +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/setup.py b/setup.py index 193c596..15be6d5 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,7 @@ setup(name='realms-wiki', 'gunicorn==19.3', 'itsdangerous==0.24', 'markdown2==2.3.0', + 'python-ldap==2.4.22', 'simplejson==3.6.3' ], entry_points={ From 6edaec3876fa1b02ce5778c5eb8d60c661969456 Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Thu, 12 Nov 2015 17:19:26 -0600 Subject: [PATCH 12/22] get oauth data from providers --- realms/config/__init__.py | 2 +- realms/modules/auth/oauth/models.py | 40 +++++++++++++++++++++++------ realms/modules/auth/oauth/views.py | 11 ++++---- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/realms/config/__init__.py b/realms/config/__init__.py index ffe8050..72e4a4d 100644 --- a/realms/config/__init__.py +++ b/realms/config/__init__.py @@ -84,7 +84,7 @@ DB_URI = 'sqlite:////tmp/wiki.db' # DB_URI = 'crate://' LDAP = { - 'URI': 'ldap://localhost:8389', + '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 diff --git a/realms/modules/auth/oauth/models.py b/realms/modules/auth/oauth/models.py index 46a07ba..b1e6288 100644 --- a/realms/modules/auth/oauth/models.py +++ b/realms/modules/auth/oauth/models.py @@ -17,10 +17,12 @@ providers = { authorize_url='https://api.twitter.com/oauth/authenticate', access_token_method='GET'), 'button': ' Twitter', + 'profile': None, 'field_map': { 'id': 'user_id', 'username': 'screen_name' - } + }, + 'token_name': 'oauth_token' }, 'github': { 'oauth': dict( @@ -31,11 +33,13 @@ providers = { access_token_url='https://github.com/login/oauth/access_token', authorize_url='https://github.com/login/oauth/authorize'), 'button': ' Github', + 'profile': 'user', 'field_map': { 'id': ['user', 'id'], 'username': ['user', 'login'], 'email': ['user', 'email'] - } + }, + 'token_name': 'access_token' }, 'facebook': { 'oauth': dict( @@ -47,11 +51,13 @@ providers = { authorize_url='https://www.facebook.com/dialog/oauth' ), 'button': ' Facebook', + 'profile': '/me', 'field_map': { 'id': 'id', 'username': 'name', 'email': 'email' - } + }, + 'token_name': 'access_name' }, 'google': { 'oauth': dict( @@ -64,7 +70,14 @@ providers = { access_token_url='https://accounts.google.com/o/oauth2/token', authorize_url='https://accounts.google.com/o/oauth2/auth', ), - 'button': ' Google' + 'button': ' Google', + 'profile': 'userinfo', + 'field_map': { + 'id': 'id', + 'username': 'name', + 'email': 'email' + }, + 'token_name': 'access_token' } } @@ -73,9 +86,10 @@ class User(BaseUser): type = 'oauth' provider = None - def __init__(self, provider, user_id, username, token): + def __init__(self, provider, user_id, username=None, token=None, email=None): self.provider = provider self.username = username + self.email = email self.id = user_id self.token = token self.auth_id = "%s-%s" % (provider, username) @@ -93,7 +107,8 @@ class User(BaseUser): return users.get(user_id) @staticmethod - def auth(provider, resp): + def auth(provider, data, resp): + oauth_token = resp.get(User.get_provider_value(provider, 'token_name')) field_map = providers.get(provider).get('field_map') if not field_map: raise NotImplementedError @@ -111,9 +126,10 @@ class User(BaseUser): fields = {} for k, v in field_map.items(): - fields[k] = get_value(resp, v) + fields[k] = get_value(data, v) - user = User(provider, fields['id'], fields['username'], User.hash_password(resp['oauth_token'])) + user = User(provider, fields['id'], username=fields.get('username'), email=fields.get('email'), + token=User.hash_password(oauth_token)) users[user.auth_id] = user if user: @@ -133,6 +149,14 @@ class User(BaseUser): 'secret'), **providers[provider]['oauth']) + @classmethod + def get_provider_value(cls, provider, key): + return providers.get(provider, {}).get(key) + + @classmethod + def get_token(cls, provider, resp): + return resp.get(cls.get_provider_value(provider, 'token_name')) + def get_id(self): return unicode("%s/%s" % (self.type, self.auth_id)) diff --git a/realms/modules/auth/oauth/views.py b/realms/modules/auth/oauth/views.py index 31fd281..b80f00e 100644 --- a/realms/modules/auth/oauth/views.py +++ b/realms/modules/auth/oauth/views.py @@ -18,7 +18,8 @@ def login(provider): def callback(provider): next_url = request.args.get('next') or url_for(current_app.config['ROOT_ENDPOINT']) try: - resp = User.get_app(provider).authorized_response() + remote_app = User.get_app(provider) + resp = remote_app.authorized_response() if resp is None: flash('You denied the request to sign in.', 'error') flash('Reason: ' + request.args['error_reason'] + @@ -28,11 +29,9 @@ def callback(provider): flash('Access denied: %s' % e.message) return redirect(next_url) - session[provider + '_token'] = ( - resp['oauth_token'], - resp['oauth_token_secret'] - ) + profile = User.get_provider_value(provider, 'profile') + data = remote_app.get(profile) if profile else resp - User.auth(provider, resp) + User.auth(provider, data, resp) return redirect(next_url) From 7178a6190efa20b9027c228aa2b2dba730057d9e Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Fri, 13 Nov 2015 16:55:42 -0600 Subject: [PATCH 13/22] define tokegetter --- realms/modules/auth/oauth/models.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/realms/modules/auth/oauth/models.py b/realms/modules/auth/oauth/models.py index b1e6288..1004d7e 100644 --- a/realms/modules/auth/oauth/models.py +++ b/realms/modules/auth/oauth/models.py @@ -1,3 +1,4 @@ +from flask import session from flask_login import login_user from flask_oauthlib.client import OAuth @@ -81,6 +82,9 @@ providers = { } } +@oauth.tokengetter +def get_token(provider): + return session.get(provider + "_token") class User(BaseUser): type = 'oauth' @@ -109,6 +113,7 @@ class User(BaseUser): @staticmethod def auth(provider, data, resp): oauth_token = resp.get(User.get_provider_value(provider, 'token_name')) + session[provider + "_token"] = (oauth_token, '') field_map = providers.get(provider).get('field_map') if not field_map: raise NotImplementedError From c3ac176c156f74ca3dcc7c908d863047f8ae085f Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Fri, 13 Nov 2015 17:03:58 -0600 Subject: [PATCH 14/22] set token in session before making oauth request --- realms/modules/auth/oauth/models.py | 4 +--- realms/modules/auth/oauth/views.py | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/realms/modules/auth/oauth/models.py b/realms/modules/auth/oauth/models.py index 1004d7e..e8a9492 100644 --- a/realms/modules/auth/oauth/models.py +++ b/realms/modules/auth/oauth/models.py @@ -111,9 +111,7 @@ class User(BaseUser): return users.get(user_id) @staticmethod - def auth(provider, data, resp): - oauth_token = resp.get(User.get_provider_value(provider, 'token_name')) - session[provider + "_token"] = (oauth_token, '') + def auth(provider, data, oauth_token): field_map = providers.get(provider).get('field_map') if not field_map: raise NotImplementedError diff --git a/realms/modules/auth/oauth/views.py b/realms/modules/auth/oauth/views.py index b80f00e..7daec3a 100644 --- a/realms/modules/auth/oauth/views.py +++ b/realms/modules/auth/oauth/views.py @@ -29,9 +29,11 @@ def callback(provider): flash('Access denied: %s' % e.message) return redirect(next_url) + oauth_token = resp.get(User.get_provider_value(provider, 'token_name')) + session[provider + "_token"] = (oauth_token, '') profile = User.get_provider_value(provider, 'profile') data = remote_app.get(profile) if profile else resp - User.auth(provider, data, resp) + User.auth(provider, data, oauth_token) return redirect(next_url) From 027abd065865dde576e78b73bb5b1ace605f9cb0 Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Fri, 13 Nov 2015 17:04:47 -0600 Subject: [PATCH 15/22] remove tokengetter callback --- realms/modules/auth/oauth/models.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/realms/modules/auth/oauth/models.py b/realms/modules/auth/oauth/models.py index e8a9492..3295db8 100644 --- a/realms/modules/auth/oauth/models.py +++ b/realms/modules/auth/oauth/models.py @@ -82,10 +82,6 @@ providers = { } } -@oauth.tokengetter -def get_token(provider): - return session.get(provider + "_token") - class User(BaseUser): type = 'oauth' provider = None From 1e62997272f6cc86198083e4e149a27f2c1d6704 Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Fri, 13 Nov 2015 17:25:55 -0600 Subject: [PATCH 16/22] change github fieldmap define tokengetter for each provider --- realms/modules/auth/oauth/models.py | 10 ++++++---- realms/modules/auth/oauth/views.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/realms/modules/auth/oauth/models.py b/realms/modules/auth/oauth/models.py index 3295db8..8320293 100644 --- a/realms/modules/auth/oauth/models.py +++ b/realms/modules/auth/oauth/models.py @@ -36,9 +36,9 @@ providers = { 'button': ' Github', 'profile': 'user', 'field_map': { - 'id': ['user', 'id'], - 'username': ['user', 'login'], - 'email': ['user', 'email'] + 'id': 'id', + 'username': 'login', + 'email': 'email' }, 'token_name': 'access_token' }, @@ -141,12 +141,14 @@ class User(BaseUser): def get_app(cls, provider): if oauth.remote_apps.get(provider): return oauth.remote_apps.get(provider) - return oauth.remote_app( + app = oauth.remote_app( provider, consumer_key=config.OAUTH.get(provider, {}).get('key'), consumer_secret=config.OAUTH.get(provider, {}).get( 'secret'), **providers[provider]['oauth']) + app.tokengetter(lambda: session.get(provider + "_token")) + return app @classmethod def get_provider_value(cls, provider, key): diff --git a/realms/modules/auth/oauth/views.py b/realms/modules/auth/oauth/views.py index 7daec3a..3eb99e4 100644 --- a/realms/modules/auth/oauth/views.py +++ b/realms/modules/auth/oauth/views.py @@ -32,7 +32,7 @@ def callback(provider): oauth_token = resp.get(User.get_provider_value(provider, 'token_name')) session[provider + "_token"] = (oauth_token, '') profile = User.get_provider_value(provider, 'profile') - data = remote_app.get(profile) if profile else resp + data = remote_app.get(profile).data if profile else resp User.auth(provider, data, oauth_token) From 4063754e12340b86d44c21ffa8fea5a43c890c47 Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Sun, 22 Nov 2015 11:19:11 -0600 Subject: [PATCH 17/22] update readme --- README.md | 67 ++++++++++++++++++++++++++++- realms/modules/auth/ldap/forms.py | 2 +- realms/modules/auth/ldap/models.py | 2 + realms/modules/auth/ldap/views.py | 1 + realms/modules/auth/oauth/models.py | 1 + realms/modules/search/views.py | 2 +- 6 files changed, 72 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index afa19d0..4fb4e8e 100644 --- a/README.md +++ b/README.md @@ -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,71 @@ 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. + +### 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 diff --git a/realms/modules/auth/ldap/forms.py b/realms/modules/auth/ldap/forms.py index ddbc54c..71dd215 100644 --- a/realms/modules/auth/ldap/forms.py +++ b/realms/modules/auth/ldap/forms.py @@ -3,5 +3,5 @@ from wtforms import StringField, PasswordField, validators class LoginForm(Form): - email = StringField('Email', [validators.DataRequired()]) + 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 index 269512d..0d97ef2 100644 --- a/realms/modules/auth/ldap/models.py +++ b/realms/modules/auth/ldap/models.py @@ -7,12 +7,14 @@ 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' diff --git a/realms/modules/auth/ldap/views.py b/realms/modules/auth/ldap/views.py index ccb0414..7ab82f4 100644 --- a/realms/modules/auth/ldap/views.py +++ b/realms/modules/auth/ldap/views.py @@ -4,6 +4,7 @@ from flask_ldap_login import LDAPLoginForm blueprint = Blueprint('auth.ldap', __name__) + @blueprint.route("/login/ldap", methods=['POST']) def login(): form = LDAPLoginForm() diff --git a/realms/modules/auth/oauth/models.py b/realms/modules/auth/oauth/models.py index 8320293..72de533 100644 --- a/realms/modules/auth/oauth/models.py +++ b/realms/modules/auth/oauth/models.py @@ -82,6 +82,7 @@ providers = { } } + class User(BaseUser): type = 'oauth' provider = None diff --git a/realms/modules/search/views.py b/realms/modules/search/views.py index d462348..f94d27e 100644 --- a/realms/modules/search/views.py +++ b/realms/modules/search/views.py @@ -1,4 +1,4 @@ -from flask import abort, g, render_template, request, redirect, Blueprint, flash, url_for, current_app +from flask import render_template, request, Blueprint from realms import search as search_engine blueprint = Blueprint('search', __name__) From 04b5e045938da92c8d11dfd46c352c99b3d3aa30 Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Sun, 22 Nov 2015 17:12:26 -0600 Subject: [PATCH 18/22] Dynamically enable modules based on config values --- README.md | 68 ++++++++++++++-------------- realms/config/__init__.py | 72 +++++++++++++++++------------- realms/modules/auth/ldap/models.py | 3 +- 3 files changed, 75 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 4fb4e8e..c8df786 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,10 @@ If your language is not supported, Realms will fall back to a simple text analyz ### 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) @@ -290,53 +294,47 @@ Use these examples as a guide and place it in your realms-wiki.json config. 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" + "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, + "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": "" + "OAUTH": { + "twitter": { + "key": "", + "secret": "" + }, + "github": { + "key": "", + "secret": "" + } } -} -``` - ## Running diff --git a/realms/config/__init__.py b/realms/config/__init__.py index 72e4a4d..1d06cde 100644 --- a/realms/config/__init__.py +++ b/realms/config/__init__.py @@ -83,37 +83,37 @@ DB_URI = 'sqlite:////tmp/wiki.db' # DB_URI = 'oracle://scott:tiger@127.0.0.1:1521/sidname' # DB_URI = 'crate://' -LDAP = { - 'URI': '', +# 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', +# } +# } - # 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': '' - } -} +# OAUTH = { +# 'twitter': { +# 'key': '', +# 'secret': '' +# }, +# 'github': { +# 'key': '', +# 'secret': '' +# } +# } CACHE_TYPE = 'simple' @@ -153,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 @@ -193,4 +194,13 @@ if ENV != "DEV": ASSETS_DEBUG = False SQLALCHEMY_ECHO = False -MODULES = ['wiki', 'search', 'auth', 'auth.local', 'auth.oauth', 'auth.ldap', 'auth.oauth'] +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/modules/auth/ldap/models.py b/realms/modules/auth/ldap/models.py index 0d97ef2..cd605c2 100644 --- a/realms/modules/auth/ldap/models.py +++ b/realms/modules/auth/ldap/models.py @@ -38,8 +38,7 @@ class User(BaseUser): @staticmethod def login_form(): - form = LDAPLoginForm() - return render_template('auth/ldap/login.html', form=form) + return render_template('auth/ldap/login.html', form=LDAPLoginForm()) @staticmethod def auth(user, password): From b3c4db0cf9582271ce46e8a25456fad3cb62b275 Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Sun, 22 Nov 2015 17:13:29 -0600 Subject: [PATCH 19/22] Change ldap login view --- realms/templates/auth/ldap/login.html | 42 ++++++++++++++++----------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/realms/templates/auth/ldap/login.html b/realms/templates/auth/ldap/login.html index 045040b..6460f74 100644 --- a/realms/templates/auth/ldap/login.html +++ b/realms/templates/auth/ldap/login.html @@ -1,21 +1,29 @@ {% from 'macros.html' import render_form, render_field %} - +{% if config.get('AUTH_LOCAL_ENABLE') %} + -