-
Notifications
You must be signed in to change notification settings - Fork 0
Implement authentication. #15
Comments
After the first webqueue2 demo, Dave expressed interest in moving forward with CAS due to a need for two factor authentication. This should be followed up on. |
https://www.purdue.edu/securepurdue/identity-access/authentication-options.php SAML and CAS both support BoilerKey. |
Initial research suggests that using ITaP's SAML interface to BoilerKey may be the easiest method of two factor authentication. More experimentation is needed. Direct ActiveDirectory authentication is also an option. I need to ask @seth what he uses to interact with ActiveDirectory to see how that works under the hood. |
@seth Authenticates against BoilerAD directly at boilerad.purdue.edu |
For our beta release we need authentication. For the short term this can be acheived by authenticating with a shared username and password and authorizing with JWTs. Later implementations of authentication will be 2FA via SAML or BoilerKey. The authentication/authorization workflow should work as follows:
{
"username": form.username,
"password": sha256(form.password)
}
username = request.json.username
password = sha256(request.json.password + salt)
To achieve these the following things need to happen in this order: Implment .env File Support In The APIJWTs require a secret key for encryption. This should not be stored in version control. Instead, the JWT secret key and other sensitive values can be stored in dotenv is a Python package that will load entries from Implement JWT support in the APIWe are currently using Flask and FlaskRESTful to create our API. Within this environment we can add JWT support via a low level library like pyjwt and use an authentication decorator as shown in this YouTube video. Important: This videos suggests storing the JWT private key directly in source code. This is insecure as it would expose the key via version control. The key should be stored in an environment variable file outside of version control with separate keys for development and production. The generated JWT is sent back to the client as dictionary with a key of { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" } Flask specific JWT libraries could be considered such as Flask JWT Extended or Flask-Praetorian. Note: This article suggests that there are to be two types of tokens:
At this time, it appears that a short term access token alone will be sufficent but refresh tokens should be implemented at a later date. Build A Login PageWe are currently using react-router to control which items get displayed in the frontend. There are plans to implement cookie based queue storage using react-cookie. With JWT support in the API, we can retrieve a JWT by sending a valid username and SHA 256 password. This can be acheived by a controlled login form sending the authentication data then storing the JWT in a cookie. Its important that the cookie path be set to root Make react-router Authentication AwareWe are currently using react-router to control which items get displayed in the frontend. react-router needs to be made aware of authentication so that the following things happen:
This can be acheived by:
Bother of these are dicussed in this article. |
Support for Getting and (temporarily) setting environment variables during runtime is already available within Python scrips via the os library's import os
# Get the USER environment variable
print( os.environ.get("USER") ) # campb303
# Set the LOL_BUTTS environment variable to nyan-cat
# Setting this variable does not persist outside of this script
os.environ["LOL_BUTTS"] = "nyan-cat"
print( os.environ.get("LOL_BUTTS") ) # nyan-cat To set and load out own environment variables, we can create a file (typically named # ./.env
LOL_BUTTS=nyan-cat
test=123 # ./script.py
import os, dotenv
# Load environment variables from .env
dotenv.load_dotenv()
# Get the LOL_BUTTS environment variable
print( os.environ.get("LOL_BUTTS") ) # nyan-cat
# Get the test environment variable
print( os.environ.get("test") ) # 123 dotenv's Any part of out Python codebase can utilize |
FUll JWT support has been added to the API. There are two types of tokens:
JWT usage depends on the API endpoint. There are four types of API endpoints:
Interacting with the API using a JWT based workflow would work like this: First Time Login (Authentication):Send a POST request to the fetch(
"/login",
{
method: "POST",
headers: { 'Content-Type': 'application/json'},
body: '{
"username": username,
"password": password
}'
}
); The API will validate the username and password then return an access token in the body of the response. {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2MDQ3Nzk2MDUsIm5iZiI6MTYwNDc3OTYwNSwianRpIjoiY2EzMWMxMzAtNDU5OC00MTBkLThhNzEtM2JmMzhkODc5OTljIiwiZXhwIjoxNjA0NzgwNTA1LCJzdWIiOiJ3cTJCZXRhIiwiZnJlc2giOmZhbHNlLCJ0eXBlIjoiYWNjZXNzIiwiY3NyZiI6IjYzZTBjOWI3LTY5NzgtNDIwMy1iMjVmLTNhMTIzNWU3YzAzMSJ9.RsuZe0C8CMZvNlcLHRptWXwmj0RGNhW6YYylNbRMjco"
} The API will also attach two cookies to the response:
Refreshing Access TokensAfter the first login, new access tokens should be retrieved before the old access token expires to avoid errors. Send a POST request to the fetch(
"/tokens/refresh",
{
method: "POST",
headers: {"X-CSRF-TOKEN": "8a32d817-8f77-42d1-94f2-e6876cb436a4"}
}
); The API will validate the refresh token with the CSRF string and return a new access token: {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2MDQ3Nzk2MDUsIm5iZiI6MTYwNDc3OTYwNSwianRpIjoiY2EzMWMxMzAtNDU5OC00MTBkLThhNzEtM2JmMzhkODc5OTljIiwiZXhwIjoxNjA0NzgwNTA1LCJzdWIiOiJ3cTJCZXRhIiwiZnJlc2giOmZhbHNlLCJ0eXBlIjoiYWNjZXNzIiwiY3NyZiI6IjYzZTBjOWI3LTY5NzgtNDIwMy1iMjVmLTNhMTIzNWU3YzAzMSJ9.RsuZe0C8CMZvNlcLHRptWXwmj0RGNhW6YYylNbRMjco"
} Access Token Usage (Authorization)When interacting with access token restricted endpoints, a unexpired access token must be sent in an authorization header: fetch(
"/ce/100",
{
method: "POST",
headers: {"Authorization": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2MDQ3Nzk2MDUsIm5iZiI6MTYwNDc3OTYwNSwianRpIjoiY2EzMWMxMzAtNDU5OC00MTBkLThhNzEtM2JmMzhkODc5OTljIiwiZXhwIjoxNjA0NzgwNTA1LCJzdWIiOiJ3cTJCZXRhIiwiZnJlc2giOmZhbHNlLCJ0eXBlIjoiYWNjZXNzIiwiY3NyZiI6IjYzZTBjOWI3LTY5NzgtNDIwMy1iMjVmLTNhMTIzNWU3YzAzMSJ9.RsuZe0C8CMZvNlcLHRptWXwmj0RGNhW6YYylNbRMjco"}
}
); The API will validate the access token and respond with the requested data: {
"queue": "ce",
"number": 100,
"lastUpdated": "2020-09-28T13:26:00-0400",
"headers": [ ],
"content": [ ],
"isLocked": "ce 100 is locked by knewell using qvi",
"userEmail": "campb303@purdue.edu",
"userName": "Justin Campbell",
"userAlias": "campb303",
"assignedTo": "campb303",
"subject": "Beepboop",
"status": "Dont Delete",
"priority": "",
"department": "",
"building": "",
"dateReceived": "2020-06-23T13:25:51-0400"
} Renewing Refresh TokensWhen a refresh token expires, another login must happen. |
Using authentication within the frontend use state variables require extraneous prop drilling (passing state variables through intermediate components for access down the tree) and would become brittle for refactoring with every prop reference needing to be changed. A better way to manage this would be with a React Context. React Context allows data to be arbitrarily passed through a component tree without prop drilling. Contexts are made of three pieces:
Making use of a context requires three steps:
(Codevolution on YouTube has a three part video series that visualizes the use of a context well. See Part 1, Part 2 and Part 3) Example: import React, { createContext, useContext } from "react";
export default function App(){
// Create a context
const AuthContext = createContext();
// Create components for component tree
const CompA = _ => "CompA";
const CompB = _ => "CompB";
const CompC = _ => "CompC";
return (
// Use the AuthContext provider with a `true` value to represent a user being logged in
<AuthContext.Provider value={true}>
<CompA>
<CompB>
<CompC>
// Use the AuthContext consumer to reference the value
<AuthContext.Consumer>
{
// To use the value inside a provider we must use render prop style rendering
// See: https://reactjs.org/docs/render-props.html
(value) => {
const isLoggedIn = value;
isLoggedIn
? <AdminArea />
: <Login />
}
}
</AuthContext.Consumer>
</CompC>
</CompB>
</CompA>
</AuthContext.Provider>
);
} In the example above we created an AuthContext and used its Provider to pass a value down a component tree to be used inside its consumer. As this article points out there are ways to make this more convenient allow for complicated logic to be controlled by the context. Using suggestions from this article, we can create helper components from the context Provider and Consumer as well as utilize custom hooks that wrap This is the method we'll use for managing authentication in the frontend. |
A timed event to refresh the access token needs to be implemented. |
Fixed in #130 . Closing. |
ITaP's authentication options says that I2A2 based systems have been deprecated since Jan 2019. The current webqueue authenticates against I2A2. The other three options are:
At this point, it seems SAML is the best option moving forward as it provides all features and is recommended in place of the other two. More information on how it can be used is needed. |
Notes on how Shibboleth/SAML works: What is Shibboleth/SAML?Security Assertion Markup Language (SAML) is used to exchange authorization and authentication information between an Identity Provider (IdP) and Service Provider (SP). An IdP is any source of truth for users and info about them. One popular IdP is Shibboleth. An SP is typically some application or service, like a banking website or Google Drive. The use of SAML with an IdP and SP presents two particular benefits:
How Does Shibboleth/SAML Work?There are generally three components in play when using Shibboleth/SAML for authentication and/or authorization:
There are two common workflows for Shibboleth/SAML based authentication/authorization routines:
For webqueue2, we'll focus on the SP-Init process where webqueue2 is the service provider and Purdue's SAML interface is the Identity Provider. It works like this:
|
After a talk w/ Sundeep, the decision was made to fall back to I2A2. We'll move webqueue2 to CAS or SAML if/when I2A2 dies. |
@benf Mentioned LDAP as a possibility. @seth Is already using LDAP for his applications without issue. @kellyst confirmed that LDAP can be interacted with anyone on the domain without issue. @sundeep approved the new direction. Proceeding with LDAP testing using my own account. If i works, we should create an Active Directory account for webqueue2 to do auth independent of a user's account. |
Initial testing using EasyAD were inconclusive. I can't seem to log in while providing valid credentials for my own account. However, there isn't much logging available on my side. I asked @seth about how he authenticates and the configuration seems similar though there's a possibility that I need to make everything lowercase. I've also reached out to Sean Kellyto double-check configuration settings as he is more familiar with BoilerAD than I am. |
EasyAD requires the following packages to be installed on the host machine:
With these packages installed on Ubuntu 18.04 and while connected to the WebVPN, I was able to successfully authenticate against BoilerAD. (With myself, @kellyst and @seth looking at the code, we're not sure why both the AD Server and AD Domain need to be from easyad import EasyAD
config = dict(AD_SERVER="boilerad.purdue.edu",
AD_DOMAIN="boilerad.purdue.edu")
ad = EasyAD(config)
user = ad.authenticate_user("campb303", password_redacted, json_safe=True) Next steps are to:
|
As @seth pointed out, EasyAD's By removing all but an LDAP display name which corresponds to the career account user name (sAMMAccountName), user lookup is less flexible but 400x times faster at <0.02 seconds. Since we're only looking users up by their career account username the loss in flexibility should be fine. # Default EasyAD Filter String
# Takes about 8 seconds for user lookup
filter_string = "(&(objectClass=user)(|(userPrincipalName={0})(sAMAccountName={0})(uid={0})(mail={0})" \
"(distinguishedName={0})(proxyAddresses=SMTP:{0})))".format(escape_filter_chars(user_string))
# Optimized EasyAD Filter String:
# Takes about 0.02 seconds for user lookup
filter_string = "(&(objectClass=user)(|(sAMAccountName={0})))".format(escape_filter_chars(user_string)) There is no way to override the default filter string when using EasyAD's @seth also pointed out that the use of a Python equivalent to C#'s secure string would reduce the risk of plaintext passwords getting dumped in case of the Python interpreting crashing while a password is in memory. This should be looked into. I also spoke with @kellyst and @sundeep about security implications of arbitrary user logins via webqueue2. We don't want administrative accounts to bind or authenticate to avoid potential unintended privilege escalation. Next Steps
|
After further conversation with @cs and @kellyst, the logic for preventing admin account login has switched from using regex to block usernames ending in 'adm' or 'admx' in the API. Instead, @kellyst created a new Active Direcory group
This group cannot contain username's that end in adm or admx therefore it does the same checking but it is enforced on Active Directory's side instead of webqueue2's. |
Auth code is ready. Need to check with @cs on getting dependencies installed on templeton. from easyad import EasyAD
from ldap.filter import escape_filter_chars
from ldap import INVALID_CREDENTIALS as LDAP_INVALID_CREDENTIALS
def user_is_valid(username: str, password: str) -> bool:
"""Checks if user is valid and in webqueue2 login group.
Args:
username (str): Career account username.
password (str): Career account passphrase.
Returns:
bool: True if user is valid, otherwise False.
"""
# Check for empty arguments
if (username == "" or password == ""):
return False
# Initialize EasyAD
config = {
"AD_SERVER": "boilerad.purdue.edu",
"AD_DOMAIN": "boilerad.purdue.edu"
}
ad = EasyAD(config)
# Prepare search critiera for Active Directory
credentials = {
"username": escape_filter_chars(username),
"password": password
}
attributes = [ 'cn', "memberOf" ]
filter_string = f'(&(objectClass=user)(|(sAMAccountName={username})))'
# Do user search
try:
user = ad.search(credentials=credentials, attributes=attributes, filter_string=filter_string)[0]
# pylint says this is an error but it works so ¯\_(ツ)_/¯
except LDAP_INVALID_CREDENTIALS:
return False
# Isolate group names
# Example:
# 'CN=00000227-ECNStuds,OU=BoilerADGroups,DC=BoilerAD,DC=Purdue,DC=edu' becomes
# `00000227-ECNStuds`
user_groups = [ group.split(',')[0].split('=')[1] for group in user["memberOf"] ]
# Check group membership
webqueue_login_group = "00000227-ECN-webqueue"
if webqueue_login_group not in user_groups:
return False
return True |
Request sent to software group. |
Full Active Directory login support has been implemented in the API using EasyAD. This required building a custom version of PyLDAP that didn't require unused SASL libraries and automating this process in the venv-manager as described in this comment. This is now being tracked in #169 . Closing. |
webqueue2 does not have any form of authentication at this time. The original webqueue had HTTP protocol level authentication at the browser level that looks like this:
This type of authentication cannot be accessed by screen readers or password managers. To address this, one of two options should be chosen and moved forward with:
The text was updated successfully, but these errors were encountered: