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 "
".join(forms) class AnonUser(AnonymousUserMixin): @@ -42,61 +46,35 @@ 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() - - @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() + def load_user(*args, **kwargs): + raise NotImplementedError @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)) @@ -109,4 +87,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..7a84b08 --- /dev/null +++ b/realms/modules/auth/oauth/__init__.py @@ -0,0 +1,3 @@ +from ..models import Auth + +Auth.register('oauth') diff --git a/realms/modules/auth/oauth/models.py b/realms/modules/auth/oauth/models.py new file mode 100644 index 0000000..eec97ad --- /dev/null +++ b/realms/modules/auth/oauth/models.py @@ -0,0 +1,173 @@ +from flask import session +from flask_login import login_user +from flask_oauthlib.client import OAuth + +from realms import config +from ..models import BaseUser + +oauth = OAuth() + +users = {} + +providers = { + 'twitter': { + 'oauth': dict( + 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', + access_token_method='GET'), + 'button': '', + 'profile': None, + 'field_map': { + 'id': 'user_id', + 'username': 'screen_name' + }, + 'token_name': 'oauth_token' + }, + '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': '', + 'profile': 'user', + 'field_map': { + 'id': 'id', + 'username': 'login', + 'email': 'email' + }, + 'token_name': 'access_token' + }, + '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': '', + 'profile': '/me', + 'field_map': { + 'id': 'id', + 'username': 'name', + 'email': 'email' + }, + 'token_name': 'access_token' + }, + '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': '', + 'profile': 'userinfo', + 'field_map': { + 'id': 'id', + 'username': 'name', + 'email': 'email' + }, + 'token_name': 'access_token' + } +} + + +class User(BaseUser): + type = 'oauth' + provider = None + + 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) + + @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(provider, data, oauth_token): + field_map = providers.get(provider).get('field_map') + 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] = get_value(data, v) + + 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: + login_user(user, remember=True) + return True + else: + return False + + @classmethod + def get_app(cls, provider): + if oauth.remote_apps.get(provider): + return oauth.remote_apps.get(provider) + 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): + 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)) + + @staticmethod + def login_form(): + buttons = [] + 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 "

Social Login

" + " ".join(buttons) diff --git a/realms/modules/auth/oauth/views.py b/realms/modules/auth/oauth/views.py new file mode 100644 index 0000000..3eb99e4 --- /dev/null +++ b/realms/modules/auth/oauth/views.py @@ -0,0 +1,39 @@ +from flask import Blueprint, url_for, request, flash, redirect, session, current_app +from .models import User + +blueprint = Blueprint('auth.oauth', __name__) + + +def oauth_failed(next_url): + flash('You denied the request to sign in.') + return redirect(next_url) + + +@blueprint.route("/login/oauth/") +def login(provider): + return User.get_app(provider).authorize(callback=url_for('auth.oauth.callback', provider=provider, _external=True)) + + +@blueprint.route('/login/oauth//callback') +def callback(provider): + next_url = request.args.get('next') or url_for(current_app.config['ROOT_ENDPOINT']) + try: + 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'] + + ' ' + request.args['error_description'], 'error') + return redirect(next_url) + except Exception as e: + 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).data if profile else resp + + User.auth(provider, data, oauth_token) + + return redirect(next_url) 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/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__) diff --git a/realms/static/css/style.css b/realms/static/css/style.css index 1116438..fffdea6 100644 --- a/realms/static/css/style.css +++ b/realms/static/css/style.css @@ -224,6 +224,26 @@ a.label { margin-top: 40px; } +.btn-facebook { + background-color: #325c99; + color: #ffffff; +} + +.btn-github { + background-color: #4c4c4c; + color: #ffffff; +} + +.btn-google { + background-color: #dd4b39; + color: #ffffff; +} + +.btn-twitter { + background-color: #02acec; + color: #ffffff; +} + @media (max-width:1000px) { .ace_content { padding: 3px; @@ -285,6 +305,5 @@ a.label { background-color: #3498db; color: #fff; } - } diff --git a/realms/templates/auth/ldap/login.html b/realms/templates/auth/ldap/login.html new file mode 100644 index 0000000..6460f74 --- /dev/null +++ b/realms/templates/auth/ldap/login.html @@ -0,0 +1,29 @@ +{% from 'macros.html' import render_form, render_field %} +{% if config.get('AUTH_LOCAL_ENABLE') %} + + + +{% else %} +

 LDAP Login

+ {% call render_form(form, action_url=url_for('auth.ldap.login'), action_text='Login', btn_class='btn btn-primary') %} + {{ render_field(form.username, placeholder='Username', type='text', required=1) }} + {{ render_field(form.password, placeholder='Password', type='password', required=1) }} + {% endcall %} +{% endif %} \ No newline at end of file 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..79553a4 100644 --- a/realms/templates/auth/login.html +++ b/realms/templates/auth/login.html @@ -1,8 +1,4 @@ {% 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 %} + {{ forms|safe }} {% 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..aa8a41a 100644 --- a/realms/templates/layout.html +++ b/realms/templates/layout.html @@ -41,12 +41,12 @@ diff --git a/setup.py b/setup.py index 53aea35..8bbf4aa 100644 --- a/setup.py +++ b/setup.py @@ -28,18 +28,21 @@ 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.5.0', 'gunicorn==19.3', 'itsdangerous==0.24', 'markdown2==2.3.0', + 'python-ldap==2.4.22', 'simplejson==3.6.3' ], entry_points={