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 %}