Skip to content

Commit

Permalink
Improve CAS authentication
Browse files Browse the repository at this point in the history
Adds support for SAML 1.1 in addition to CAS 1.0 and 2.0

CAS settings simplified (see readme)

Fixes #509
  • Loading branch information
andrew-gardener committed Mar 1, 2017
1 parent c908823 commit 61f2d47
Show file tree
Hide file tree
Showing 13 changed files with 326 additions and 129 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ xAPI statements require an actor (currently logged in user) account information.

`LRS_ACTOR_ACCOUNT_CAS_HOMEPAGE`: Set the homepage of the CAS account

`LRS_ACTOR_ACCOUNT_CAS_IDENTIFIER`: Optionally set a param to set as the actor's unique key for the CAS account. Requires `CAS_ATTRIBUTES_TO_STORE` to be set when not using default setting. (uses CAS username by default)
`LRS_ACTOR_ACCOUNT_CAS_IDENTIFIER`: Optionally set a param to set as the actor's unique key for the CAS account. (uses CAS username by default)

Restart server after making any changes to settings

Expand Down Expand Up @@ -144,9 +144,11 @@ Restart server after making any changes to settings

`CAS_LOGIN_ENABLED`: Enable login via CAS server (default: True)

`CAS_ATTRIBUTES_TO_STORE`: Array of CAS attributes to store in the third_party_user table's param column. (default: empty)
`CAS_SERVER`: Url of the CAS Server (do not include trailing slash)

See [Flask-CAS](https://github.com/cameronbwhite/Flask-CAS) for other CAS settings
`CAS_AUTH_PREFIX`: Prefix to CAS action (default '/cas')

`CAS_USE_SAML`: Determines which authorization endpoint to use. '/serviceValidate' if false (default). '/samlValidate' if true.

Restart server after making any changes to settings

Expand Down
7 changes: 4 additions & 3 deletions compair/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import json
import os
import ssl
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning

from flask import Flask, redirect, session as sess, abort, jsonify, url_for
from flask_login import current_user
from sqlalchemy.orm import joinedload
from werkzeug.routing import BaseConverter

from .authorization import define_authorization
from .core import login_manager, bouncer, db, cas, celery
from .core import login_manager, bouncer, db, celery
from .configuration import config
from .models import User, File
from .activity import log
Expand Down Expand Up @@ -75,6 +77,7 @@ def create_app(conf=config, settings_override=None, skip_endpoints=False, skip_a
else:
# Handle target environment that doesn't support HTTPS verification
ssl._create_default_https_context = _create_unverified_https_context
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

app.logger.debug("Application Configuration: " + str(app.config))

Expand Down Expand Up @@ -110,8 +113,6 @@ def unauthorized():
return response
return abort(401)

cas.init_app(app)

# Flask-Bouncer initialization
bouncer.init_app(app)

Expand Down
116 changes: 70 additions & 46 deletions compair/api/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

from flask import Blueprint, jsonify, request, session as sess, current_app, url_for, redirect, Flask, render_template
from flask_login import current_user, login_required, login_user, logout_user
from compair import cas
from compair.core import db, event
from compair.authorization import get_logged_in_user_permissions
from compair.models import User, LTIUser, LTIResourceLink, LTIUserResourceLink, UserCourse, LTIContext, \
ThirdPartyUser, ThirdPartyType
from compair.cas import get_cas_login_url, validate_cas_ticket, get_cas_logout_url

login_api = Blueprint("login_api", __name__, url_prefix='/api')

Expand Down Expand Up @@ -71,7 +71,7 @@ def logout():
url = jsonify({'redirect': return_url})

elif sess.get('CAS_LOGIN'):
url = jsonify({'redirect': url_for('cas.logout')})
url = jsonify({'redirect': url_for('login_api.cas_logout')})

sess.clear()
return url
Expand All @@ -88,68 +88,92 @@ def session():
def get_permission():
return jsonify(get_logged_in_user_permissions())

@login_api.route('/cas/login')
def cas_login():
if not current_app.config.get('CAS_LOGIN_ENABLED'):
return "", 403

return redirect(get_cas_login_url())

@login_api.route('/auth/cas', methods=['GET'])
def auth_cas():
@login_api.route('/cas/auth', methods=['GET'])
def cas_auth():
"""
CAS Authentication Endpoint. Authenticate user through CAS. If user doesn't exists,
set message in session so that frontend can get the message through /session call
"""
if not current_app.config.get('CAS_LOGIN_ENABLED'):
return "", 403

username = cas.username
url = "/app/#/lti" if sess.get('LTI') else "/"
error_message = None
ticket = request.args.get("ticket")

if username is not None:
thirdpartyuser = ThirdPartyUser.query. \
filter_by(
unique_identifier=username,
third_party_type=ThirdPartyType.cas
) \
.one_or_none()
msg = None

# store additional CAS attributes if needed
additional_params = None
if cas.attributes and len(current_app.config.get('CAS_ATTRIBUTES_TO_STORE')) > 0:
additional_params = {}
for attr_name in current_app.config.get('CAS_ATTRIBUTES_TO_STORE'):
additional_params[attr_name] = cas.attributes.get('cas:'+attr_name)

if not thirdpartyuser or not thirdpartyuser.user:
if sess.get('LTI') and sess.get('oauth_create_user_link'):
sess['CAS_CREATE'] = True
sess['CAS_UNIQUE_IDENTIFIER'] = cas.username
sess['CAS_ADDITIONAL_PARAMS'] = additional_params
url = '/app/#/oauth/create'
else:
current_app.logger.debug("Login failed, invalid username for: " + username)
msg = 'You don\'t have access to this application.'
# check if token isn't present
if not ticket:
error_message = "No token in request"
else:
validation_response = validate_cas_ticket(ticket)

if not validation_response.success:
current_app.logger.debug(
"CAS Server did NOT validate ticket:%s and included this response:%s" % (ticket, validation_response.response)
)
error_message = "Login Failed. CAS ticket was invalid."
elif not validation_response.user:
current_app.logger.debug("CAS Server responded with valid ticket but no user")
error_message = "Login Failed. Expecting CAS username to be set."
else:
authenticate(thirdpartyuser.user, login_method=thirdpartyuser.third_party_type.value)
thirdpartyuser.params = additional_params
current_app.logger.debug(
"CAS Server responded with user:%s and attributes:%s" % (validation_response.user, validation_response.attributes)
)
username = validation_response.user

thirdpartyuser = ThirdPartyUser.query. \
filter_by(
unique_identifier=username,
third_party_type=ThirdPartyType.cas
) \
.one_or_none()

# store additional CAS attributes if needed
if not thirdpartyuser or not thirdpartyuser.user:
if sess.get('LTI') and sess.get('oauth_create_user_link'):
sess['CAS_CREATE'] = True
sess['CAS_UNIQUE_IDENTIFIER'] = username
sess['CAS_PARAMS'] = validation_response.attributes
url = '/app/#/oauth/create'
else:
current_app.logger.debug("Login failed, invalid username for: " + username)
error_message = "You don't have access to this application."
else:
authenticate(thirdpartyuser.user, login_method=thirdpartyuser.third_party_type.value)
thirdpartyuser.params = validation_response.attributes

if sess.get('LTI') and sess.get('oauth_create_user_link'):
lti_user = LTIUser.query.get_or_404(sess['lti_user'])
lti_user.compair_user_id = thirdpartyuser.user_id
sess.pop('oauth_create_user_link')
if sess.get('LTI') and sess.get('oauth_create_user_link'):
lti_user = LTIUser.query.get_or_404(sess['lti_user'])
lti_user.compair_user_id = thirdpartyuser.user_id
sess.pop('oauth_create_user_link')

if sess.get('LTI') and sess.get('lti_context') and sess.get('lti_user_resource_link'):
lti_context = LTIContext.query.get_or_404(sess['lti_context'])
lti_user_resource_link = LTIUserResourceLink.query.get_or_404(sess['lti_user_resource_link'])
lti_context.update_enrolment(thirdpartyuser.user_id, lti_user_resource_link.course_role)
if sess.get('LTI') and sess.get('lti_context') and sess.get('lti_user_resource_link'):
lti_context = LTIContext.query.get_or_404(sess['lti_context'])
lti_user_resource_link = LTIUserResourceLink.query.get_or_404(sess['lti_user_resource_link'])
lti_context.update_enrolment(thirdpartyuser.user_id, lti_user_resource_link.course_role)

db.session.commit()
sess['CAS_LOGIN'] = True
else:
msg = 'Login Failed. Expecting CAS username to be set.'
db.session.commit()
sess['CAS_LOGIN'] = True

if msg is not None:
sess['CAS_AUTH_MSG'] = msg
if error_message is not None:
sess['CAS_AUTH_MSG'] = error_message

return redirect(url)

@login_api.route('/cas/logout', methods=['GET'])
def cas_logout():
if not current_app.config.get('CAS_LOGIN_ENABLED'):
return "", 403

return redirect(get_cas_logout_url())


def authenticate(user, login_method=None):
# username valid, password valid, login successful
Expand Down
2 changes: 1 addition & 1 deletion compair/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ def post(self):
thirdpartyuser = ThirdPartyUser(
third_party_type=ThirdPartyType.cas,
unique_identifier=sess.get('CAS_UNIQUE_IDENTIFIER'),
params=sess.get('CAS_ADDITIONAL_PARAMS'),
params=sess.get('CAS_PARAMS'),
user=user
)
login_method = ThirdPartyType.cas.value
Expand Down
Loading

0 comments on commit 61f2d47

Please sign in to comment.