Add initial code for user authentication

Change-Id: Ie0c439244f1ae3af707b73ef64b1a411c2aede20
This commit is contained in:
grace.yu 2014-03-12 14:56:37 -07:00
parent 1bbdf4a918
commit 4c69d120cd
25 changed files with 598 additions and 39 deletions

View File

@ -88,6 +88,10 @@ def start(csv_dir, compass_url):
p.join()
progress_file = '/'.join((csv_dir, 'progress.csv'))
write_progress_to_file(results_q, progress_file)
def write_progress_to_file(results_q, progress_file):
cluster_headers = ['cluster_id', 'progress_url']
host_headers = ['host_id', 'progress_url']

View File

@ -39,6 +39,7 @@ from compass.db.model import Machine
from compass.db.model import Role
from compass.db.model import Switch
from compass.db.model import SwitchConfig
from compass.db.model import User
from compass.tasks.client import celery
from compass.utils import flags
from compass.utils import logsetting
@ -93,6 +94,7 @@ TABLE_MAPPING = {
'cluster': Cluster,
'clusterhost': ClusterHost,
'logprogressinghistory': LogProgressingHistory,
'user': User
}

View File

@ -13,14 +13,35 @@
# limitations under the License.
__all__ = ['Flask', 'SQLAlchemy', 'compass_api']
import datetime
import jinja2
import os
from compass.db.model import SECRET_KEY
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.login import LoginManager
from flask import Flask
app = Flask(__name__)
app.debug = True
app.secret_key = SECRET_KEY
app.config['AUTH_HEADER_NAME'] = 'X-Auth-Token'
app.config['REMEMBER_COOKIE_DURATION'] = datetime.timedelta(minutes=30)
templates_dir = "/".join((os.path.dirname(
os.path.dirname(os.path.realpath(__file__))),
'templates/'))
template_loader = jinja2.ChoiceLoader([
app.jinja_loader,
jinja2.FileSystemLoader(templates_dir),
])
app.jinja_loader = template_loader
login_manager = LoginManager()
login_manager.login_view = 'login'
login_manager.init_app(app)
from compass.api import api as compass_api

View File

@ -17,14 +17,23 @@ import logging
import netaddr
import re
import simplejson as json
import sys
from flask import flash
from flask import redirect
from flask import render_template
from flask import request
from flask import session as app_session
from flask import url_for
from flask.ext.restful import Resource
from flask import request
from sqlalchemy.sql import and_
from sqlalchemy.sql import or_
from compass.api import app
from compass.api import auth
from compass.api import errors
from compass.api import login_manager
from compass.api import util
from compass.db import database
from compass.db.model import Adapter
@ -36,8 +45,129 @@ from compass.db.model import Machine as ModelMachine
from compass.db.model import Role
from compass.db.model import Switch as ModelSwitch
from compass.db.model import SwitchConfig
from compass.db.model import User as ModelUser
from compass.tasks.client import celery
from flask.ext.login import current_user
from flask.ext.login import login_required
from flask.ext.login import login_user
from flask.ext.login import logout_user
from flask.ext.wtf import Form
from wtforms.fields import BooleanField
from wtforms.fields import PasswordField
from wtforms.fields import TextField
from wtforms.validators import Required
@login_manager.header_loader
def load_user_from_token(token):
"""Return a user object from token."""
duration = app.config['REMEMBER_COOKIE_DURATION']
max_age = 0
if sys.version_info > (2, 6):
max_age = duration.total_seconds()
else:
max_age = (duration.microseconds + (
duration.seconds + duration.days * 24 * 3600) * 1e6) / 1e6
user_id = auth.get_user_info_from_token(token, max_age)
if not user_id:
logging.info("No user can be found from the token!")
return None
user = User.query.filter_by(id=user_id)
return user
@login_manager.user_loader
def load_user(user_id):
"""Load user from user ID."""
return ModelUser.query.get(user_id)
@app.route('/restricted')
def restricted():
return render_template('restricted.jinja')
@app.errorhandler(403)
def forbidden_403(exception):
"""Unathenticated user page."""
return render_template('forbidden.jinja'), 403
@app.route('/logout')
@login_required
def logout():
"""User logout."""
logout_user()
flash('You have logged out!')
return redirect(url_for('index'))
@app.route('/')
def index():
"""Index page."""
return render_template('index.jinja')
@app.route('/token', methods=['POST'])
def get_token():
"""Get token from email and passowrd after user authentication."""
data = json.loads(request.data)
email = data['email']
password = data['password']
user = auth.authenticate_user(email, password)
if not user:
error_msg = "User cannot be found or email and password do not match!"
return errors.handle_invalid_user_info(
errors.ObjectDoesNotExist(error_msg)
)
token = user.get_auth_token()
login_user(user)
return util.make_json_response(
200, {"status": "OK", "token": token}
)
class LoginForm(Form):
"""Define login form."""
email = TextField('Email', validators=[Required()])
password = PasswordField('Password', validators=[Required()])
remember = BooleanField('Remember me', default=False)
@app.route("/login", methods=['GET', 'POST'])
def login():
"""User login."""
if current_user.is_authenticated():
return redirect(url_for('index'))
else:
form = LoginForm()
if form.validate_on_submit():
email = form.email.data
password = form.password.data
user = auth.authenticate_user(email, password)
if not user:
flash('Wrong username or password!', 'error')
return render_template('login.jinja', form=form)
if login_user(user, remember=form.remember.data):
# Enable session expiration if user didnot choose to be
# remembered.
app_session.permanent = not form.remember.data
flash('Logged in successfully!', 'success')
return redirect(request.args.get('next') or url_for('index'))
else:
flash('This username is disabled!', 'error')
return render_template('login.jinja', form=form)
class SwitchList(Resource):
"""Query details of switches and poll swithes."""
@ -594,9 +724,7 @@ class Cluster(Resource):
if is_valid:
column = resources[resource]['column']
session.query(
ModelCluster
).filter_by(id=cluster_id).update(
session.query(ModelCluster).filter_by(id=cluster_id).update(
{column: json.dumps(value)}
)
else:
@ -1320,6 +1448,34 @@ class DashboardLinks(Resource):
)
class User(Resource):
ENDPOINT = '/users'
@login_required
def get(self, user_id):
"""Get user's information for specified ID."""
resp = {}
with database.session() as session:
user = session.query(ModelUser).filter_by(id=user_id).first()
if not user:
error_msg = "Cannot find the User which id is %s" % user_id
return errors.handle_not_exist(
errors.ObjectDoesNotExist(error_msg)
)
resp['id'] = user_id
resp['email'] = user.email
resp['link'] = {
"href": "/".join((self.ENDPOINT, str(user_id))),
"rel": "self"
}
return util.make_json_response(
200, {"status": "OK",
"user": resp}
)
TABLES = {
'switch_config': {
'name': SwitchConfig,
@ -1359,6 +1515,7 @@ TABLES = {
}
@login_required
@app.route("/export/<string:tname>", methods=['GET'])
def export_csv(tname):
"""export to csv file."""
@ -1438,6 +1595,9 @@ util.add_resource(HostInstallingProgress,
util.add_resource(ClusterInstallingProgress,
'/clusters/<int:cluster_id>/progress')
util.add_resource(DashboardLinks, '/dashboardlinks')
util.add_resource(User,
'/users',
'/users/<int:user_id>')
if __name__ == '__main__':

47
compass/api/auth.py Normal file
View File

@ -0,0 +1,47 @@
# Copyright 2014 Huawei Technologies Co. Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from itsdangerous import BadData
import logging
from compass.db.model import login_serializer
from compass.db.model import User
def get_user_info_from_token(token, max_age):
"""Return user's ID and hased password from token."""
user_id = None
try:
user_id = login_serializer.loads(token, max_age=max_age)
except BadData as err:
logging.error("[auth][get_user_info_from_token] Exception: %s", err)
return None
return user_id
def authenticate_user(email, pwd):
"""Authenticate a use by email and password."""
try:
user = User.query.filter_by(email=email).first()
if user and user.valid_password(pwd):
return user
except Exception as err:
print '[auth][authenticate_user]Exception: %s' % err
logging.info('[auth][authenticate_user]Exception: %s', err)
return None

View File

@ -67,6 +67,13 @@ class MethodNotAllowed(Exception):
return repr(self.message)
class InvalidUserInfo(Exception):
"""Define the Exception for incorrect user information."""
def __init__(self, message):
super(InvalidUserInfo, self).__init__(message)
self.message = message
@app.errorhandler(ObjectDoesNotExist)
def handle_not_exist(error, failed_objs=None):
"""Handler of ObjectDoesNotExist Exception."""
@ -128,3 +135,11 @@ def handle_not_allowed_method(error):
"message": "The method is not allowed to use"
}
return util.make_json_response(405, message)
@app.errorhandler(InvalidUserInfo)
def handle_invalid_user_info(error):
"""Handler of InvalidUserInfo Exception."""
message = {"status": "Incorrect User Info",
"message": error.message}
return util.make_json_response(401, message)

View File

@ -29,7 +29,7 @@ API = Api(app)
def make_json_response(status_code, data):
"""Wrap json format to the reponse object."""
result = json.dumps(data, indent=4)
result = json.dumps(data, indent=4) + '\r\n'
resp = make_response(result, status_code)
resp.headers['Content-type'] = 'application/json'
return resp

View File

@ -29,6 +29,9 @@ ENGINE = create_engine(setting.SQLALCHEMY_DATABASE_URI, convert_unicode=True)
SESSION = sessionmaker(autocommit=False, autoflush=False)
SESSION.configure(bind=ENGINE)
SCOPED_SESSION = scoped_session(SESSION)
model.BASE.query = SCOPED_SESSION.query_property()
SESSION_HOLDER = local()
model.BASE.query = SCOPED_SESSION.query_property()
@ -99,6 +102,11 @@ def current_session():
def create_db():
"""Create database."""
model.BASE.metadata.create_all(bind=ENGINE)
with session() as _s:
# Initialize default user
user = model.User(email='admin@abc.com', password='admin')
logging.info('Init user: %s, %s' % (user.email, user.get_password()))
_s.add(user)
def drop_db():

View File

@ -13,11 +13,12 @@
# limitations under the License.
"""database model."""
from datetime import datetime
from hashlib import md5
import logging
import simplejson as json
import uuid
from datetime import datetime
from sqlalchemy import Column, ColumnDefault, Integer, String
from sqlalchemy import Float, Enum, DateTime, ForeignKey, Text, Boolean
from sqlalchemy import UniqueConstraint
@ -27,8 +28,51 @@ from sqlalchemy.ext.hybrid import hybrid_property
from compass.utils import util
from flask.ext.login import UserMixin
from itsdangerous import URLSafeTimedSerializer
BASE = declarative_base()
#TODO(grace) SECRET_KEY should be generated when installing compass
#and save to a config file or DB
SECRET_KEY = "abcd"
#This is used for generating a token by user's ID and
#decode the ID from this token
login_serializer = URLSafeTimedSerializer(SECRET_KEY)
class User(BASE, UserMixin):
"""User table."""
__tablename__ = 'user'
id = Column(Integer, primary_key=True)
email = Column(String(80), unique=True)
password = Column(String(225))
active = Column(Boolean, default=True)
def __init__(self, email, password, **kwargs):
self.email = email
self.password = self._set_password(password)
def __repr__(self):
return '<User name: %s>' % self.email
def _set_password(self, password):
return self._hash_password(password)
def get_password(self):
return self.password
def valid_password(self, password):
return self.password == self._hash_password(password)
def get_auth_token(self):
return login_serializer.dumps(self.id)
def is_active(self):
return self.active
def _hash_password(self, password):
return md5(password).hexdigest()
class SwitchConfig(BASE):

9
compass/static/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

6
compass/static/js/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
compass/static/js/jquery-1.8.3.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,10 @@
{% extends 'layout.jinja' %}
{% block title %}403 Forbidden{% endblock %}
{% block content %}
<h1>403 Forbidden</h1>
<p class=text-error>
Access to this page is forbidden for user
<strong>{{ current_user.email }}</strong>.
</p>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends 'layout.jinja' %}
{% block title %}Main Page{% endblock %}
{% block content %}
<p>
{% if current_user.is_anonymous() %}
You are not logged in.
{% else %}
Hi, <strong>{{ current_user.email }}</strong>!
{% endif %}
</p>
{% endblock %}

View File

@ -0,0 +1,39 @@
<!doctype html>
<meta charset=utf-8>
<title>{% block title %}{% endblock %}</title>
<link rel=stylesheet media=screen href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
<div class=container>
<div class=navbar>
<div class=navbar-inner>
<span class=brand>Flask-Login example</span>
<ul class=nav>
{% for endpoint in [
'index',
'restricted',
'login' if current_user.is_anonymous() else 'logout',
] %}
<li {% if endpoint == request.endpoint %}class=active{% endif %}>
<a href="{{ url_for(endpoint) }}">{{ endpoint.capitalize() }}</a>
</li>
{% endfor %}
</ul>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert{{ ' alert-%s' % category if category != 'message' else '' }}">
<button type=button class=close data-dismiss="alert">&times;</button>
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}
{% endblock %}
</div>
<script src="{{ url_for('static', filename='js/jquery-1.8.3.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/bootstrap.min.js') }}"></script>

View File

@ -0,0 +1,19 @@
{% extends 'layout.jinja' %}
{% from 'macros.jinja' import with_errors %}
{% block title %}Login{% endblock %}
{% block content %}
<form class=form-horizontal action="{{ url_for('login', next=request.args.get('next')) }}" method=post>
{{ form.hidden_tag() }}
{{ with_errors(form.email, placeholder='Email') }}
{{ with_errors(form.password, placeholder='Password') }}
<div class=control-group>
<div class=controls>
<label class=checkbox>
<input type=checkbox name=remember> Remember me
</label>
<button type=submit class="btn btn-primary">Login</button>
</div>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% macro with_errors(field) -%}
<div class="control-group{% if field.errors %} error{% endif %}">
{{ field.label(class_='control-label') }}
<div class=controls>
{{ field(**kwargs) }}
{% if field.errors %}
{% for error in field.errors %}
<span class=text-error>{{ error }}</span>
{% endfor %}
{% endif %}
</div>
</div>
{%- endmacro %}

View File

@ -0,0 +1,9 @@
{% extends 'layout.jinja' %}
{% block title %}Restricted Area{% endblock %}
{% block content %}
<h1>Restricted Area</h1>
<p class=text-warning>
This is restricted area!
</p>
{% endblock %}

View File

@ -23,7 +23,6 @@ import os
import simplejson as json
import unittest2
os.environ['COMPASS_IGNORE_SETTING'] = 'true'
@ -32,6 +31,7 @@ reload(setting)
from compass.api import app
from compass.api import login_manager
from compass.db import database
from compass.db.model import Adapter
from compass.db.model import Cluster
@ -46,6 +46,10 @@ from compass.utils import flags
from compass.utils import logsetting
from compass.utils import util
app.config['TESTING'] = True
app.config['WTF_CSRF_ENABLED'] = False
login_manager.init_app(app)
class ApiTestCase(unittest2.TestCase):
"""base api test class."""
@ -61,6 +65,7 @@ class ApiTestCase(unittest2.TestCase):
logsetting.init()
database.init(self.DATABASE_URL)
database.create_db()
self.test_client = app.test_client()
# We do not want to send a real task as our test environment
@ -832,21 +837,15 @@ class ClusterHostAPITest(ApiTestCase):
return_value = self.test_client.get(url)
self.assertEqual(200, return_value.status_code)
config = json.loads(return_value.get_data())['config']
expected_config = copy.deepcopy(test_config_data)
expected_config['hostid'] = 1
expected_config['hostname'] = 'host_01'
expected_config['clusterid'] = 1
expected_config['clustername'] = 'cluster_01'
expected_config['fullname'] = 'host_01.1'
expected_config[
'networking'
][
'interfaces'
][
'management'
][
'mac'
] = "00:27:88:0c:01"
expected_config['networking']['interfaces']['management'][
'mac'] = "00:27:88:0c:01"
expected_config['switch_port'] = ''
expected_config['switch_ip'] = '192.168.1.1'
expected_config['vlan'] = 0
@ -856,24 +855,8 @@ class ClusterHostAPITest(ApiTestCase):
"""test put clusterhost config."""
config = copy.deepcopy(self.test_config_data)
config['roles'] = ['base']
config[
'networking'
][
'interfaces'
][
'management'
][
'ip'
] = '192.168.1.2'
config[
'networking'
][
'interfaces'
][
'tenant'
][
'ip'
] = '10.12.1.2'
config['networking']['interfaces']['management']['ip'] = '192.168.1.2'
config['networking']['interfaces']['tenant']['ip'] = '10.12.1.2'
# 1. Try to put a config of the cluster host which does not exist
url = '/clusterhosts/1000/config'
@ -1594,6 +1577,28 @@ class TestExport(ApiTestCase):
self.assertDictEqual(export_row, expected_row)
self.maxDiff = None
class TestUser(ApiTestCase):
def setUp(self):
super(TestUser, self).setUp()
def tearDown(self):
super(TestUser, self).tearDown()
def test_get_user_by_id(self):
# Success to get a user
url = "/users/1"
return_value = self.test_client.get(url)
data = json.loads(return_value.get_data())
self.assertEqual(1, data['user']['id'])
# Try to get a nonexisting user
url = "/users/1000"
return_value = self.test_client.get(url)
self.assertEqual(404, return_value.status_code)
if __name__ == '__main__':
flags.init()
logsetting.init()

View File

@ -0,0 +1,126 @@
# Copyright 2014 Huawei Technologies Co. Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import simplejson as json
import time
import unittest2
os.environ['COMPASS_IGNORE_SETTING'] = 'true'
from compass.utils import setting_wrapper as setting
reload(setting)
from compass.api import app
from compass.api import auth
from compass.api import login_manager
from compass.db import database
from compass.db.model import User
from compass.utils import flags
from compass.utils import logsetting
login_manager.init_app(app)
class AuthTestCase(unittest2.TestCase):
DATABASE_URL = 'sqlite://'
USER_CREDENTIALS = {"email": "admin@abc.com", "password": "admin"}
def setUp(self):
super(AuthTestCase, self).setUp()
logsetting.init()
database.init(self.DATABASE_URL)
database.create_db()
self.test_client = app.test_client()
def tearDown(self):
database.drop_db()
super(AuthTestCase, self).tearDown()
def test_login_logout(self):
url = '/login'
# a. successfully login
data = self.USER_CREDENTIALS
return_value = self.test_client.post(url, data=data,
follow_redirects=True)
self.assertIn("Logged in successfully!", return_value.get_data())
url = '/logout'
return_value = self.test_client.get(url, follow_redirects=True)
self.assertIn("You have logged out!", return_value.get_data())
def test_login_failed(self):
url = '/login'
# a. Failed to login with incorrect user info
data_list = [{"email": "xxx", "password": "admin"},
{"email": "admin@abc.com", "password": "xxx"}]
for data in data_list:
return_value = self.test_client.post(url, data=data,
follow_redirects=True)
self.assertIn("Wrong username or password!",
return_value.get_data())
# b. Inactive user
User.query.filter_by(email="admin@abc.com").update({"active": False})
data = {"email": "admin@abc.com", "password": "admin"}
return_value = self.test_client.post(url, data=data,
follow_redirects=True)
self.assertIn("This username is disabled!", return_value.get_data())
def test_get_token(self):
url = '/token'
# a. Failed to get token by posting incorrect user email
req_data = json.dumps({"email": "xxx", "password": "admin"})
return_value = self.test_client.post(url, data=req_data)
self.assertEqual(401, return_value.status_code)
# b. Success to get token
req_data = json.dumps(self.USER_CREDENTIALS)
return_value = self.test_client.post(url, data=req_data)
resp = json.loads(return_value.get_data())
self.assertIsNotNone(resp['token'])
def test_header_loader(self):
# Get Token
url = '/token'
req_data = json.dumps(self.USER_CREDENTIALS)
return_value = self.test_client.post(url, data=req_data)
token = json.loads(return_value.get_data())['token']
user_id = auth.get_user_info_from_token(token, 50)
self.assertEqual(1, user_id)
# Test on token expiration.
# Sleep 5 seconds but only allow token lifetime of 2 seconds
time.sleep(5)
user_id = auth.get_user_info_from_token(token, 2)
self.assertIsNone(user_id)
# Get None user from the incorrect token
result = auth.get_user_info_from_token("xxx", 50)
self.assertIsNone(result)
if __name__ == '__main__':
flags.init()
logsetting.init()
unittest2.main()

View File

@ -86,8 +86,8 @@ class ApiTestCase(unittest2.TestCase):
# Initial database
try:
database.init(database_url)
except Exception as e:
print "======>", e
except Exception as error:
print "Exception when initializing database: %s" % error
def tearDown(self):
super(ApiTestCase, self).tearDown()
@ -126,6 +126,7 @@ class TestAPICommand(ApiTestCase):
"""test start deploy from csv."""
Client.deploy_hosts = mock.Mock(
return_value=(202, self.deploy_return_val))
csvdeploy.write_progress_to_file = mock.Mock()
url = "http://127.0.0.1:%d" % self.port
csvdeploy.start(self.CSV_IMPORT_DIR, url)
clusters = csvdeploy.get_csv('cluster.csv',

View File

@ -2,6 +2,7 @@ flask
flask-script
flask-restful
flask-sqlalchemy
flask-login
celery
netaddr
paramiko==1.7.5
@ -9,3 +10,5 @@ simplejson
requests
PyChef
redis
flask-wtf
itsdangerous

View File

@ -76,12 +76,16 @@ setup(
long_description='Open Deployment System for zero touch installation',
author='Compass Dev Group, Huawei Cloud',
author_email='shuo.yang@huawei.com',
url='https://github.com/huawei-cloud/compass',
url='https://github.com/stackforge/compass-core',
download_url='',
# dependency
install_requires=REQUIREMENTS,
packages=find_packages(exclude=['compass.tests']),
include_package_data=True,
#TODO login UI will be replaced by compass's own templates later
package_data={'compass': ['templates/*.jinja', 'static/js/*.js',
'static/css/*.css', 'static/img/*.png']},
classifiers=[
'Development Status :: 5 - Production/Stable',
'Environment :: Console',