Implementing 2 Factor Authentication for Web Security
Self-education is, I firmly believe, the only kind of education there is.
, Isaac Asimov
8 Jan 2018
Introduction
The login and authentication mechanism of a web application plays a key role in securing user data. Modern web-based applications increasingly rely on multi-factor authentication. Two of the following methods are generally used: what a user knows (password), what a user has (physical token generation device), or who a user is (biometric). This article shows how to build a two factor authentication login for a Google App Engine Java application. The application uses password and a time-based token from Google Authenticator, a mobile application that runs on a smartphone.
Security principles are followed when building the application. For instance, passwords are properly salted and hashed using Password-Based Key Derivation 2 (PBKDF2), user accounts are locked after a number of failed login attempts, untrusted user inputs are handled appropriately to prevent SQL and script injection attacks. Various web security headers and secure cookie flags are set as well. Logging is enabled to warn about potential malicious attacks.
For simplicity, the application uses standard JSP and Servlet. This reduces the risk of potential vulnerabilties from external third party libraries and frameworks; reducing the attack surface.
Design and Approach
The application runs on Google App Engine Java 8 Standard Environment and consists mainly of Servlets, JSP pages and Java classes. App Engine offers a Cloud-based Platform as a Service environment that doesn't require infrastructure management. It is easy and convenient for developers to quickly build serverless applications.
The 2nd factor authentication relies on time-based tokens generated from the Google Authenticator mobile application on an Android smartphone. Google Authenticator is configured with a secret key for generating time-based tokens. These tokens are used as additional verification codes to authenticate a user.
A Model-View-Controller (MVC) architecture is adopted for the 2 factor authentication application. JSP and html pages rendered the view, while Java Servlets functioned as the controllers. A number of Java classes served as Data Access Objects, providing access to the backend datastore. The application uses Google Cloud Datastore for storing data. Google Cloud Datastore is a NoSQL database that is easy to use and suitable for the 2fa application.
The following diagram shows the components of the 2fa application that handle user login.
Index.jsp displays the login form and a user can enter their userid and password here. The submitted user credentials are sent to the LoginControllerServlet for processing. The LoginControllerServlet utilizes the LoginDAO (Data Access Object) to query and access Google Cloud Datastore for the stored user credentials. If the credentials are valid, the LoginControllerServlet displays the OTP form (otp.jsp).
The user then enters the OTP token obtained from the Google Authenticator Mobile Application for the 2nd layer of verification. The OTPControllerServlet processes the submitted token. The OTP Controller utilizes the OTPDAO to access the datastore. If the OTP token is ok, the controller redirects the user to success.jsp. Success.jsp displays the fullname of the logged in user.
Some of the helper and utility classes are not shown in the diagram above, these will be discussed later in the article. The error handling flow is not shown as well. For example, if an invalid user credentials are submitted, the LoginControllerServlet will redirect back to the login form (index.jsp).
The application stores user account using a user entity object in the Google Cloud datastore. The following illustrates a user entity and its properties.
AccountLock is a boolean flag indicating whether the user account is locked. FailLogin is a counter storing the number of unsuccessful login attempts. If it exceeds a configured threshold (default of 5), the account will be locked. First Name and Last Name are self explanatory. The password property stores a hexadecimal string based on PBKDF2. Salt is a hexadecimal string used for adding randomness to the password and TOTP is a hexadecimal string holding the secret key for deriving time-based OTP tokens. The base32 encoded version of the same secret key is entered into the Google Authenticator Mobile App.
Each entity requires a unique key. The User entity uses email address as a unique key. The email also serves as the userid for logging in. Google Cloud Datastore requires a "Kind" to be defined for an entity type. Think of "Kind" as a category for classifying entities. In this case, the "Kind" for our User Entity is simply user. The properties for an entity can be indexed to facilitate Datastore queries. For our User Entity, password, salt and TOTP are not indexed.
Threat Model for the Application
The 2 factor authentication mechanism prevents and mitigates against user account hijacking by requiring an additional verification code from a mobile app (what you have). It improves application and user data security. However, the 2fa authentication mechanism itself, faces a number of security threats. This section highlights some of the threats that are considered when developing the application.
The following table lists the threat vectors and the counter measures that are implemented. The list is not prioritized.
Target/Asset | Threat Vector Description | Counter Measures |
---|---|---|
Login form at index.jsp and LoginControllerServlet | Brute force attempts at logging in. | A fail login counter is maintained and the user account is locked after 5 fail login attempts. Logging is enabled to warn about failed login attempts and accounts being locked. |
Login form at index.jsp and LoginControllerServlet | Attempts to discover valid userids by brute force harvesting. | The application prevents information leakage by redirecting back to the login form when an invalid userid, invalid password or a locked account is submitted. |
Otp form at otp.jsp and OTPControllerServlet | Attempts to brute force One time passwords. | A fail login counter is maintained and the user account is locked after 5 invalid OTP submissions. Logging is enabled to warn of invalid otp submissions and user account being locked. |
Login form at index.jsp and LoginControllerServlet Otp form at otp.jsp and OTPControllerServlet | SQL Injection and Cross-Site Scripting (XSS) injection Attacks | The application uses Google NoSQL datastore and user input is not inter-mixed with application control code. This prevents both SQL and NoSQL injections. The application does not reflect user input back to the user browser, hence preventing reflected Cross-Site Scripting (XSS) attacks. Special html characters in user data that is displayed as html content are escaped properly. The application does not store user input, this together with the escaping of special html characters will prevent stored Cross-Site Scripting attacks. Additionally, HTTP security headers like Content Security Policy (CSP) is set by the application to prevent inline javascript. Session cookie is configured with HTTPOnly flag, preventing any client side javascript from accesing the session cookie. |
success.jsp | Cross-Site Request Forgery (CSRF) Attacks | An anti CSRF attribute is set by the OTPControllerServlet upon successful OTP validation. Success.jsp will only display user data if this attribute is present, otherwise a revalidation of OTP is required. This attribute is removed by success.jsp each time user data is displayed. Any reload of success.jsp will require a OTP validation, hence preventing CSRF attacks. The session cookie is set with SameSite=Strict, which can help to prevent CSRF if the browser support this flag. |
Login form at index.jsp and LoginControllerServlet Otp form at otp.jsp and OTPControllerServlet | Session Fixation attack where an attacker obtains an existing session id and then tricks the user into logging in using the same session id. The attacker hence gain access. | The application invalidates the old session and create a new one upon successful login. Similarly the old session is invalidated a new one created upon successful OTP verification. |
Login form at index.jsp and Otp form at otp.jsp | Browser auto filling form fields. | autocomplete="off" is configured in the two forms to tell the browser not to autofill forms. Password managers in browsers may still prompt users to save their credentials. The time-based OTP token provides a second layer of verification and protection. The OTP token is only valid for a window of 30 seconds. |
Application User Data page at success.jsp | Attempts to bypass login and otp authentication by directly accessing the Application User Data page. | The application checks that authentication is performed successfully before granting access to the application user data. |
Application Logout at logout.jsp | Session is not properly terminated at logout and user information can still be accessed after logging out. | The application invalidates the session at logout and prevents access to user information until the user logs in again. |
Entire Application | Attempts to access the application using insecure transport such as HTTP. | A security constraint is configured in web.xml to require confidential secure transport. The appengine-web.xml is set to enable SSL. HTTP Security header, HSTS (HTTP Strict Transport Security) is set by the application. The cookie storing the session id is configured with Secure flag and will not be sent over an unencrypted connection. |
Entire Application | After logging in, the session is persistent and can be reused for long periods of time. | The application maintains a session time out of 15 minutes. Application user data cannot be accessed without logging in again, after the existing session has timed out. The cookie storing the session id is valid only for the current browsing session and is purged when the browser is completely closed. |
Entire Application | Sensitive data leakage through intermediate caches. | The application set HTTP Cache-Control headers as no-store. This tells the browser and intermediate caches not to store the responses. |
Entire Application | Sensitive information leakage through error messages. | The application does not display sensitive debugging messages to the user when error occurs. A sanitized error.html is configured to be displayed when errors occur. |
Entire Application | Brute Forcing session identifiers. | The application relies on Google App Engine Java platform to set strong session identifiers with sufficient entropy such that it cannot be bruted force easily. |
Entire Application | Sensitive Query Parameters in URLs that can be leaked through browser history or server logs. | The submission of user credentials and OTP token uses HTTP POST method and doesn't have any URL query parameters. |
Entire Application | Clickjacking Attack that tries to put the application into a html frame and then hijacks the clicks or key strokes to steal sensitive information. | The application sets the X-Frame-Options header to DENY to prevent being put into an html frame by other websites. |
The threat model doesn't cover infrastructure issues. For example, the application is a maven project and relies on maven functionality in Eclipse IDE to download dependencies such as the Google App Engine API SDK. The assumption is that the downloading and verification of such dependencies are secure. It also assumes that Google Cloud setup is configured properly and securely. The data stored in Google cloud are not encrypted by the application. It is assumed that the Google Cloud Datastore is secure and the default data at rest encryption by Google Cloud Datastore is sufficient.
Enterprises building cloud projects, need to take such issues into consideration and ensure an entire chain of security. From the basic infrastructure components, platforms security, software dependencies, all the way to the application code and user data.
Description of Source Code Layout
The source code are organized according to the Maven directory structure.
src/main/java | Contains the java classes such as Servlets, Data Access Object classes, utility and helper classes. |
src/main/webapp | The web application directory, containing the JSP files, html files, css stylesheets and the various configuration files. |
The application classes in src/main/java are arranged into 2 java packages
- sg.nighthour.app Contains the Servlet classes, helper classes used by the Servlets, Data Access Object classes and a Servlet filter class.
- sg.nighthour.crypto Contains a CryptoUtil class for handling PBKDF2, and a TimeBaseOTP class for generating OTP tokens.
The following describes the classes in sg.nighthour.app package
AppConstants.java | Holds the static constants used in the application, such as the default fail login threshold before an account is locked, the number of iterations for PBKDF2. |
AppSecurityHeadersFilter.java | The Servlet Filter that adds HTTP Security headers for the entire application. For example, it sets the Content Security Policy header, the HTTP Strict-Transport-Security header etc... |
AuthSession.java | A helper class that provides method to check whether a user is authenticated successfully as well as whether the application can proceed to the OTP form. |
HtmlEscape.java | A helper class that provides method to escape special html characters into its corresponding entities that can be safely displayed as content within html elements. Note the method should not be used to escape content used in html attributes, javascript etc... |
LoginControllerServlet.java | The servlet that handles the submitted user credentials. It uses LoginDAO to access user entities stored in the Cloud Datastore. If the user credentials are ok, it sets a userid2fa attribute into the session and display the OTP form. It redirects back to the login form if user credentials are invalid or non-existence. 5 failed login attempts will cause an account to be locked, preventing further logins. |
LoginDAO.java | The Data Access Object class used by the LoginControllerServlet for accessing the user entities stored in the Cloud Datastore. The class uses sg.nighthour.crypto.CryptoUtil for PBKDF2 functionalities. |
OTPControllerServlet.java | The servlet handles the OTP token submission. It uses OTPDAO to access user entities stored in the Cloud Datastore, as well as sg.nighthour.crypto.TimeBaseOTP class for OTP functionalities. If the submitted OTP token is valid, it redirects the user to success.jsp (Application User Data Page). If the OTP token is invalid, it redirects back to the OTP form. 5 failed OTP submissions will cause an account to be locked, preventing further logins. |
OTPDAO.java | The Data Access Object class used by the OTPControllerServlet for accessing the user entities stored in the Cloud Datastore. |
UserDAO.java | The Data Access Object class used by the success.jsp for accessing the authenticated user first and last name, stored in the Cloud Datastore. |
There are only two classes in the sg.nighthour.crypto package and we will not list it out. As mentioned earlier, CryptoUtil class is for handling PBKDF2, and TimeBaseOTP class is for generating OTP tokens.
Besides these java classes, there are the JSP files. The JSP files are index.jsp, otp.jsp, success.jsp and logout.jsp. Index.jsp is the first login form prompting for userid and password. otp.jsp displays the form for inputting the OTP token. Success.jsp will display the user's fullname if authentication is successful. Logout.jsp is for logging out and terminating the user session.
Other files in the application include html files (error.html, locked.html, footer.html), css file (main.css) and the application configuration files. Refer to the full source code in the Github link at the bottom of the article.
Implementation
Let's run through the source code for some of the key functionalities of the application. The full source code is available at the github link at the bottom of the article.
The following is the source code for index.jsp. It displays the user login form. The form sends the user credentials to the LoginControllerServlet at /login via HTTP POST. Autocomplete is set to off in the html form.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | <%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%
response.setHeader("Cache-Control", "no-store");
%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" type="text/css" href="styles/main.css">
<title>Login Page</title>
</head>
<body>
<div class="webformdiv">
<div class="formheader">
<h3>Application Sign In<br><span class="ch">应用程序登录</span></h3>
</div>
<form method="POST" autocomplete="off" accept-charset="utf-8" action="/login">
<ul class="form-ul" >
<li>
<label>Username</label>
<input type="email" required autofocus name="userid">
</li>
<li>
<label>Password</label>
<input type="password" required name="password">
</li>
<li>
<input type="submit" value="Login" >
</li>
</ul>
</form>
</div>
<%@include file="templates/footer.html" %>
</body>
</html>
|
The following is the source code for LoginControllerServlet.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 | /*
* MIT License
*
*Copyright (c) 2018 Ng Chiang Lin
*
*Permission is hereby granted, free of charge, to any person obtaining a copy
*of this software and associated documentation files (the "Software"), to deal
*in the Software without restriction, including without limitation the rights
*to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
*copies of the Software, and to permit persons to whom the Software is
*furnished to do so, subject to the following conditions:
*
*The above copyright notice and this permission notice shall be included in all
*copies or substantial portions of the Software.
*
*THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
*IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
*FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
*AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
*LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
*OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
*SOFTWARE.
*
*/
package sg.nighthour.app;
import java.io.IOException;
import java.util.logging.Logger;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
* Servlet implementation class LoginServlet
*/
@WebServlet("/login")
public class LoginControllerServlet extends HttpServlet
{
private static final long serialVersionUID = 1L;
private static final Logger log = Logger.getLogger(LoginControllerServlet.class.getName());
/**
* @see HttpServlet#HttpServlet()
*/
public LoginControllerServlet()
{
super();
}
/**
* @see HttpServlet#doPost(HttpServletRequest request, HttpServletResponse
* response)
*/
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException
{
request.setCharacterEncoding("UTF-8");
response.setContentType("text/plain;charset=UTF-8");
response.setHeader("Cache-Control", "no-store");
HttpSession session = request.getSession(false);
if(session == null)
{//no existing session
log.warning("Error: Session not present redirect to login.jsp " + request.getRemoteAddr());
response.sendRedirect("/index.jsp");
return;
}
String userid = request.getParameter("userid");
String password = request.getParameter("password");
if (userid == null || password == null)
{
log.warning("Error: Invalid userid or password " + request.getRemoteAddr());
response.sendRedirect("/index.jsp");
return;
}
if (LoginDAO.validateUser(userid, password, request.getRemoteAddr()))
{
password = null;
//Prevent Session fixation, invalidate and assign a new session
session.invalidate();
session = request.getSession(true);
session.setAttribute("userid2fa", userid);
//Set the session id cookie with HttpOnly, secure and samesite flags
String custsession = "JSESSIONID=" + session.getId() + ";Path=/;Secure;HttpOnly;SameSite=Strict";
response.setHeader("Set-Cookie", custsession);
RequestDispatcher rd = request.getRequestDispatcher("otp.jsp");
rd.forward(request, response);
}
else
{//If user credential is invalid, account is locked or user doesn't exist redirect back to login page
if(LoginDAO.isAccountLocked(userid, request.getRemoteAddr()))
{
log.warning("Error: Account is locked " + userid + " " + request.getRemoteAddr());
}
else
{
//For Log in functionality, don't log non-existent userid or
//invalid credentials to better secure the logs
//An actual user may accidentially key their password in the userid field
log.warning("Error: Invalid userid or password " + request.getRemoteAddr());
}
response.sendRedirect("/index.jsp");
}
}
}
|
The LoginControllerServlet receives the user credentials and uses LoginDAO to validate the credentials against the relevant user entity in the Cloud Datastore. LoginControllerServlet displays the OTP form if the credentials are valid.
The servlet ensures that the request received is interpreted as UTF-8 and set the response encoding to be UTF-8 as well. It prevents session fixation attacks by invalidating and assigning a new session when authentication is successful. The session cookie is set using HttpOnly, Secure and SameSite=Strict security flags. To prevent information leakage, it redirects back to the login form when credentials are invalid, user doesn't exist or the user account is locked. Logging is enabled for security monitoring.
Notice that for the login functionality, non-existent userids are not logged. A user may accidentally key in his/her password or parts of the password into the userid field. The logging tries to avoid capturing such sensitive information. Userids are logged in other parts of the application where it is relevant, to enable troubleshooting and for detecting potential attacks.
The following is the code listing for otp.jsp.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 | <%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@page import="sg.nighthour.app.AuthSession" %>
<%
response.setHeader("Cache-Control", "no-store");
if(!AuthSession.check2FASession(request, response, "/index.jsp"))
{
return;
}
%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" type="text/css" href="styles/main.css">
<title>2 Factor Authentication Page</title>
</head>
<body>
<div class="webformdiv">
<div class="formheader">
<h3>2 Factor Authentication</h3>
</div>
<form method="POST" autocomplete="off" accept-charset="utf-8" action="/otpctl">
<ul class="form-ul" >
<li>
<label>Enter OTP</label>
</li>
<li>
<div id="msg" class="settingmsg">
<%
//Check for OTP error message
String otperror = (String) session.getAttribute("otperror");
if (otperror != null)
{
session.removeAttribute("otperror");
out.println("Invalid OTP");
}
%>
</div>
<input type="text" required name="totp" size="25" >
</li>
<li>
<input type="submit" value="Submit" >
</li>
</ul>
</form>
</div>
<%@include file="templates/footer.html" %>
</body>
</html>
|
Otp.jsp displays the OTP form and sends the OTP token to the OTPControllerServlet. The check2FASession() method of AuthSession is called at the top of the jsp page. This method checks whether the initial user login is successful or not. If the initial login is unsuccessful, the user is redirected to the login form at index.jsp.
Next is the code listing for the OTPControllerServlet.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 | /*
* MIT License
*
*Copyright (c) 2018 Ng Chiang Lin
*
*Permission is hereby granted, free of charge, to any person obtaining a copy
*of this software and associated documentation files (the "Software"), to deal
*in the Software without restriction, including without limitation the rights
*to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
*copies of the Software, and to permit persons to whom the Software is
*furnished to do so, subject to the following conditions:
*
*The above copyright notice and this permission notice shall be included in all
*copies or substantial portions of the Software.
*
*THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
*IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
*FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
*AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
*LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
*OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
*SOFTWARE.
*
*/
package sg.nighthour.app;
import sg.nighthour.crypto.TimeBaseOTP;
import sg.nighthour.crypto.CryptoUtil;
import sg.nighthour.app.AuthSession;
import java.io.IOException;
import java.util.logging.Logger;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
* Servlet implementation class OTPControllerServlet
*/
@WebServlet("/otpctl")
public class OTPControllerServlet extends HttpServlet
{
private static final long serialVersionUID = 1L;
private static final Logger log = Logger.getLogger(OTPControllerServlet.class.getName());
/**
* @see HttpServlet#HttpServlet()
*/
public OTPControllerServlet()
{
super();
}
/**
* @see HttpServlet#doPost(HttpServletRequest request, HttpServletResponse
* response)
*/
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException
{
request.setCharacterEncoding("UTF-8");
response.setContentType("text/plain;charset=UTF-8");
response.setHeader("Cache-Control", "no-store");
// Make sure it has a valid 2fa session from login page
// userid2fa session attribute must be set
if(!AuthSession.check2FASession(request, response, "/index.jsp"))
{
return;
}
HttpSession session = request.getSession(false);
String userid = (String) session.getAttribute("userid2fa");
// Remove the userid2fa attribute to prevent multiple submission attempts
session.removeAttribute("userid2fa");
String otpvalue = (String) request.getParameter("totp");
if (otpvalue == null)
{
session.invalidate();
log.warning("Error: Invalid otp value " + request.getRemoteAddr() + " " + userid);
response.sendRedirect("/error.html");
return;
}
String otpsecret = OTPDAO.getOTPSecret(userid, request.getRemoteAddr());
String otpresult = TimeBaseOTP.generateOTP(CryptoUtil.hexStringToByteArray(otpsecret));
otpsecret = null;
if (otpresult == null)
{
session.invalidate();
log.warning("Error: cannot generate otp " + request.getRemoteAddr() + " " + userid);
response.sendRedirect("/error.html");
return;
}
if (otpresult.equals(otpvalue))
{// Correct OTP value
session.invalidate();
session = request.getSession(true);
session.setAttribute("userid", userid);
session.setAttribute("anticsrf_success", "AntiCSRF");
String custsession = "JSESSIONID=" + session.getId() + ";Path=/;Secure;HttpOnly;SameSite=Strict";
response.setHeader("Set-Cookie", custsession);
OTPDAO.resetFailLogin(userid, request.getRemoteAddr());
response.sendRedirect("/success");
}
else
{// Incorrect OTP value
String remoteip = request.getRemoteAddr();
log.warning("Error: Invalid otp value " + request.getRemoteAddr() + " " + userid);
// Update fail login count. If max fail login is exceeded, lock account
OTPDAO.incrementFailLogin(userid, remoteip);
//If account is locked reset session and redirect user
if(OTPDAO.isAccountLocked(userid, remoteip))
{
session.invalidate();
response.sendRedirect("/locked.html");
}
else
{// Send back to the otp input page again
session.setAttribute("userid2fa", userid);
session.setAttribute("otperror", "");
RequestDispatcher rd = request.getRequestDispatcher("otp.jsp");
rd.forward(request, response);
}
}
}
}
|
The controller checks that the initial user login is successful and redirects to error.html otherwise. The controller fetches the secret OTP key from the Datastore and uses TimeBaseOTP class to generate a otp token. It then compares the generated token against the one submitted by the user. If the submitted OTP token is valid, the user will be redirected to the success.jsp page.
The OTPControllerServlet invalidates the current session and create a new one after a successful otp token verification. The new session cookie is configured with HttpOnly, Secure and SameSite=Strict security flags. A final userid session attribute is set to indicate a successful authentication. OTPControllerServlet will also set an anti-CSRF (Cross-Site Request Forgery) attribute in the session. Success.jsp will use this attribute to prevent CSRF attacks.
The following shows the code for success.jsp.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 | <%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@page import="sg.nighthour.app.UserDAO" %>
<%@page import="sg.nighthour.app.AuthSession" %>
<%@page import="sg.nighthour.app.HtmlEscape" %>
<%
response.setHeader("Cache-Control", "no-store");
if(!AuthSession.validate(request, response))
{
return;
}
String userid = (String)session.getAttribute("userid");
if(userid == null)
{
response.sendRedirect("/error.html");
return;
}
//Prevent CSRF by requring OTP validation each time page is displayed.
String anticsrf = (String)session.getAttribute("anticsrf_success");
if(anticsrf == null)
{//token not present redirect back to OTP page for validation again
session.removeAttribute("userid");
session.setAttribute("userid2fa", userid);
userid = null;
RequestDispatcher rd = request.getRequestDispatcher("otp.jsp");
rd.forward(request, response);
return;
}
else
{//token present
//remvoe the token so that subsequent request will require OTP validation
session.removeAttribute("anticsrf_success");
}
String username = UserDAO.getUserName(userid, request.getRemoteAddr());
username = HtmlEscape.escapeHTML(username);
%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" type="text/css" href="styles/main.css">
<title>Success Page</title>
</head>
<body>
<div class="mainbody">
<p>
Welcome
<%
if(username != null)
{
out.print(username);
}
else
{
out.print("Unknown");
}
%>
<br>
</p>
<p>
<a href="/logout.jsp">Logout</a>
</p>
<%@include file="templates/footer.html" %>
</div>
</body>
</html>
|
The validate() method of AuthSession is called to check if the user has been authenticated successfully. The user is redirected to index.jsp if the validation fails. Success.jsp will check for the anti-CSRF attribute, if the attribute is present, success.jsp will remove the attribute and proceed to retrieve and display the user fullname. If the anti-CSRF attribute is missing, the user will be redirected to the OTP form for verification again. This prevents CSRF attacks, as each display of user information requires a valid OTP verification.
Such a security measure may seem onerous to users, but it really depends on the kind of application and data involved. If the application and data is really sensitive, such a measure may be worthwhile. Success.jsp escape the user fullname properly before displaying it. This guards against Cross-Site Scripting (XSS) attacks. In this simple application, the user fullname is set by a trusted administrator and not by the end user. This should normally prevent Cross-Site Scripting (XSS), but it is good practice to escape output properly before displaying it as html content.
For all JSP codes that present data to the user, untrusted user input is never sent back to the user. User data that is displayed (such as fullname retrieved from the datastore) are escaped properly. Additionally, Content Security Policy header is set by a servlet filter to disable inline script. This adds one more layer of defense against Cross-Site Scripting (XSS) attacks.
The following is the source listing for the LoginDAO.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | /*
* MIT License
*
*Copyright (c) 2018 Ng Chiang Lin
*
*Permission is hereby granted, free of charge, to any person obtaining a copy
*of this software and associated documentation files (the "Software"), to deal
*in the Software without restriction, including without limitation the rights
*to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
*copies of the Software, and to permit persons to whom the Software is
*furnished to do so, subject to the following conditions:
*
*The above copyright notice and this permission notice shall be included in all
*copies or substantial portions of the Software.
*
*THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
*IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
*FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
*AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
*LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
*OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
*SOFTWARE.
*
*/
package sg.nighthour.app;
import java.util.logging.Logger;
import javax.servlet.ServletException;
import com.google.appengine.api.datastore.DatastoreService;
import com.google.appengine.api.datastore.DatastoreServiceFactory;
import com.google.appengine.api.datastore.Entity;
import com.google.appengine.api.datastore.EntityNotFoundException;
import com.google.appengine.api.datastore.Key;
import com.google.appengine.api.datastore.KeyFactory;
import com.google.appengine.api.datastore.Transaction;
import sg.nighthour.crypto.CryptoUtil;
public class LoginDAO
{
private static final Logger log = Logger.getLogger(LoginDAO.class.getName());
/**
* Validates user credential
*
* @param userid
* @param password
* @param remoteip client ip address
* @return true if user is valid, false otherwise
*/
public static boolean validateUser(String userid, String password, String remoteip)
{
DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
Key k1 = KeyFactory.createKey(AppConstants.USER_KIND, userid);
Transaction txn = datastore.beginTransaction();
try
{
Entity user = null;
try
{
user = datastore.get(k1);
}
catch (EntityNotFoundException e)
{
//For Login functionality
//don't log non-existent userid to better secure the logs
//An actual user may accidentially key their password in the userid field
log.warning("Error: userid not found " + remoteip);
return false;
}
boolean account_locked = (Boolean) user.getProperty("AccountLock");
if (account_locked)
{
log.warning("Error: Cannot login Account is locked " + userid + " " + remoteip);
return false;
}
String stored_salt = (String) user.getProperty("Salt");
String stored_password = (String) user.getProperty("Password");
byte[] stored_salt_bytes = CryptoUtil.hexStringToByteArray(stored_salt);
char[] user_password_char = password.toCharArray();
byte[] user_derivekey = CryptoUtil.getPasswordKey(user_password_char, stored_salt_bytes,
CryptoUtil.PBE_ITERATION);
if (user_derivekey == null)
{
log.warning("Error: Unable to derive PBKDF2 password using CryptoUtil " + userid + " " + remoteip);
CryptoUtil.zeroCharArray(user_password_char);
password = null;
return false;
}
CryptoUtil.zeroCharArray(user_password_char);
password = null;
String user_derivekey_string = CryptoUtil.byteArrayToHexString(user_derivekey);
CryptoUtil.zeroByteArray(user_derivekey);
if (user_derivekey_string.equals(stored_password))
{
user_derivekey_string = null;
stored_password = null;
user.setProperty("FailLogin", 0);
datastore.put(txn, user);
txn.commit();
return true;
}
else
{
log.warning("Error: Fail Login " + userid + " " + remoteip);
long faillogin = (Long) user.getProperty("FailLogin");
faillogin++;
user.setProperty("FailLogin", faillogin);
if (faillogin >= AppConstants.MAX_FAIL_LOGIN)
{
log.warning("Error: Too many fail logins Account is locked " + userid + " " + remoteip);
user.setProperty("AccountLock", true);
}
datastore.put(txn, user);
txn.commit();
return false;
}
}
finally
{
if (txn.isActive())
{
txn.rollback();
}
}
}
/**
* Check if a user account is locked
*
* @param userid
* @return true if account is locked or false otherwise
* @throws ServletException
*/
public static boolean isAccountLocked(String userid, String remoteip) throws ServletException
{
if (userid == null)
{
log.warning("Error: userid is null");
return true;
}
DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
Key k1 = KeyFactory.createKey(AppConstants.USER_KIND, userid);
try
{
Entity user = datastore.get(k1);
boolean acctlock = (Boolean) user.getProperty("AccountLock");
return acctlock;
}
catch (EntityNotFoundException e)
{
log.warning("Error: user entity not found " + remoteip);
throw new ServletException("Error: user entity not found");
}
}
}
|
The untrusted inputs, userid and password, are cleanly separated from the control path of the code. This prevents injection attacks where untrusted inputs are mixed with the control commands of the code, leading to malicious commands being injected. All Data Access Objects are coded similarly to prevent database injection attacks.
The application also implements a filter that sets a number of HTTP security headers. The following lists the code of the filter.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | /*
* MIT License
*
*Copyright (c) 2018 Ng Chiang Lin
*
*Permission is hereby granted, free of charge, to any person obtaining a copy
*of this software and associated documentation files (the "Software"), to deal
*in the Software without restriction, including without limitation the rights
*to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
*copies of the Software, and to permit persons to whom the Software is
*furnished to do so, subject to the following conditions:
*
*The above copyright notice and this permission notice shall be included in all
*copies or substantial portions of the Software.
*
*THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
*IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
*FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
*AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
*LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
*OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
*SOFTWARE.
*
*/
package sg.nighthour.app;
import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletResponseWrapper;
import javax.servlet.http.HttpServletResponse;
/**
* Servlet Filter implementation class SecureHeadersFilter
*/
@WebFilter("/*")
public class AppSecurityHeadersFilter implements Filter
{
/**
* Default constructor.
*/
public AppSecurityHeadersFilter()
{
}
/**
* @see Filter#destroy()
*/
public void destroy()
{
}
/**
* @see Filter#doFilter(ServletRequest, ServletResponse, FilterChain)
*/
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException
{
ResponseWrapper resp = new ResponseWrapper((HttpServletResponse) response);
// pass the request along the filter chain
chain.doFilter(request, resp);
}
/**
* @see Filter#init(FilterConfig)
*/
public void init(FilterConfig fConfig) throws ServletException
{
}
private class ResponseWrapper extends HttpServletResponseWrapper
{
public ResponseWrapper(HttpServletResponse response)
{
super(response);
/*
* Set the security headers. Note these headers may be overwritten by the
* servlet/resource entity down the chain
*/
response.setHeader("Strict-Transport-Security", "max-age=31536000;includeSubDomains");
response.setHeader("Content-Security-Policy", "default-src 'self';");
response.setHeader("X-Frame-Options", "DENY");
response.setHeader("X-XSS-Protection", "1; mode=block");
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Referrer-Policy", "no-referrer");
response.setHeader("Cache-Control", "no-store");
}
}
}
|
The filter sets HTTP headers such as Strict-Transport-Security (HSTS), Content-Security-Policy (CSP), X-Frame-Options etc... for the entire application. These headers are configured with secure values, enhancing the security of the application. Each header though can be overwritten by a JSP or servlet further down the filter chain.
We will go through one more class, TimeBaseOTP. The following shows the code for TimeBaseOTP.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | /*
* MIT License
*
*Copyright (c) 2018 Ng Chiang Lin
*
*Permission is hereby granted, free of charge, to any person obtaining a copy
*of this software and associated documentation files (the "Software"), to deal
*in the Software without restriction, including without limitation the rights
*to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
*copies of the Software, and to permit persons to whom the Software is
*furnished to do so, subject to the following conditions:
*
*The above copyright notice and this permission notice shall be included in all
*copies or substantial portions of the Software.
*
*THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
*IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
*FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
*AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
*LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
*OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
*SOFTWARE.
*
*/
package sg.nighthour.crypto;
import java.util.Date;
import java.util.logging.Logger;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
public class TimeBaseOTP
{
private static final Logger log = Logger.getLogger(TimeBaseOTP.class.getName());
/**
* Generates a TOTP that can be used with google authenticator based on the
* rfc6238 https://tools.ietf.org/html/rfc6238
*
*/
public static String generateOTP(byte[] secretkey)
{
// Get current time as counter
Date curdate = new Date();
long currenttime = curdate.getTime();
long authtime = (currenttime / 1000) / 30;
String hextime = Long.toHexString(authtime);
// Pad hextime with leading 0 so that hextime becomes a 16
// character hex string
int hexlength = hextime.length();
if (hexlength < 16)
{
StringBuilder buf = new StringBuilder(32);
int padlength = 16 - hexlength;
for (int i = 0; i < padlength; i++)
{
buf.append("0");
}
buf.append(hextime);
hextime = buf.toString();
}
byte timecounter[] = CryptoUtil.hexStringToByteArray(hextime);
try
{
SecretKeySpec key = new SecretKeySpec(secretkey, "HmacSHA1");
Mac hmac_sha1 = Mac.getInstance("HmacSHA1");
hmac_sha1.init(key);
byte[] ret = hmac_sha1.doFinal(timecounter);
// Last 4 bits of the hmac is the index into the next 4 bytes to be
// returned as int digit
byte lastbyte = ret[ret.length -1];
int index = lastbyte & 0x0f;
int otpvalue = 0;
int octet = 0;
int shift = 24;
for(int i=0;i<4;i++)
{
int mask = 0;
if(i==0)
{
// & with 0x7f so that the most significant byte
// is unsigned
mask = 0x7f;
}
else
{
mask = 0xff;
}
octet = 0;
octet = (octet | (ret[index + i] & mask)) << shift;
shift = shift - 8;
otpvalue = otpvalue | octet;
}
// To get 6 digit otp ,get the reminder from modulus 1000000
otpvalue = otpvalue % 1000000;
String otpresult = Integer.toString(otpvalue);
// Pad otpresult with leading 0 if it is less than 6 digit
int otpresultlength = otpresult.length();
if (otpresultlength < 6)
{
StringBuilder buf = new StringBuilder(32);
int padlength = 6 - otpresultlength;
for (int i = 0; i < padlength; i++)
{
buf.append("0");
}
buf.append(otpresult);
otpresult = buf.toString();
}
return otpresult;
}
catch (NoSuchAlgorithmException e)
{
log.warning("Error: TimeBaseOTP " + e);
return null;
}
catch (InvalidKeyException e)
{
log.warning("Error: TimeBaseOTP " + e);
return null;
}
}
}
|
TimeBaseOTP generates a one time token using the method defined in the RFC 6238. The RFC includes a reference java implementation that serves as a guide. Once the method described in the RFC is clear, it is relatively easy to code your own.
Refer to the github link at the bottom of the article for the rest of the source code.
Application Setup
The application can be deployed to Google App Engine using Eclipse IDE with the Google Cloud tools for Eclipse installed. Refer to the following Google link for instructions. A google cloud account is required. You can sign up for a google cloud account at Google Cloud Platform.
After your Google Cloud account and eclipse has been set up, obtain the 2fa application source code from github and import it into eclipse as a maven project. The github link is at the bottom of the article. Log in to the Google Cloud console to create your first Google Cloud project. Initialize the App Engine environment with the language (java) and the region where it will run.
We need to create a user entity for Cloud Datastore. The user entity is the user account for testing the 2 factor authentication application. At the Google Cloud console, select Datastore and then Entities option.
Create a new user entity and its properties. The user entity represents a user account for the application. Properties of a user entity are described earlier in the Design and Approach section. The following shows the creation of a new entity with a unique key "user2@nighthour.sg". This unique key is the userid for logging into the application.
The entity is created under the default namespace. In the screenshot, the AccountLock property is a boolean with a value of false. Add the rest of the user entity properties. For the password, salt and TOTP properties, we will need two simple utility applications to create their values.
The following is the source code listing for the password hash generation utility.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | /*
* MIT License
*
*Copyright (c) 2018 Ng Chiang Lin
*
*Permission is hereby granted, free of charge, to any person obtaining a copy
*of this software and associated documentation files (the "Software"), to deal
*in the Software without restriction, including without limitation the rights
*to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
*copies of the Software, and to permit persons to whom the Software is
*furnished to do so, subject to the following conditions:
*
*The above copyright notice and this permission notice shall be included in all
*copies or substantial portions of the Software.
*
*THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
*IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
*FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
*AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
*LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
*OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
*SOFTWARE.
*
*/
/**
* Simple utility to generate a PBKDF2 password.
* The password and salt are output as hexadecimal string.
*/
import java.io.Console;
import sg.nighthour.crypto.CryptoUtil;
public class GenerateHashPassword
{
public static void main(String[] args)
{
char[] password = null;
Console con = System.console();
if (con == null)
{
System.err.println("Unable to get system console");
System.exit(1);
}
password = con.readPassword("%s", "Enter password:");
if (password == null)
{
System.err.println("Unable to get password");
System.exit(1);
}
byte[] salt = CryptoUtil.generateRandomBytes(CryptoUtil.SALT_SIZE);
byte[] derivekey = CryptoUtil.getPasswordKey(password, salt, CryptoUtil.PBE_ITERATION);
String saltstring = CryptoUtil.byteArrayToHexString(salt);
String keystring = CryptoUtil.byteArrayToHexString(derivekey);
System.out.println("Password key : " + keystring);
System.out.println("Salt : " + saltstring);
}
}
|
The utility requires the sg.nighthour.crypto package. Note that CryptoUtil.PBE_ITERATION should be set to 10000 in the CryptoUtil.java file. Compile and run the utility to create the PBKDF2 password hash and salt for the user account. You will be prompted to enter a password for the user account. Choose a complex password with a minimum length of 14 characters. Use a combination of upper and lowercase alphabets, digits and special characters.
The hexadecimal password key and salt string are the values to be set for password and salt properties of the user entity.
The next utility will generate a random OTP secret key. It prints the output as a hexadecimal string and a base32 string. The hexadecimal string can be entered into the TOTP property of the user entity, while the base32 encoded string is entered to the Google Authenticator Mobile App.
The following is the code listing for the OTP secret key generator.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | /*
* MIT License
*
*Copyright (c) 2018 Ng Chiang Lin
*
*Permission is hereby granted, free of charge, to any person obtaining a copy
*of this software and associated documentation files (the "Software"), to deal
*in the Software without restriction, including without limitation the rights
*to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
*copies of the Software, and to permit persons to whom the Software is
*furnished to do so, subject to the following conditions:
*
*The above copyright notice and this permission notice shall be included in all
*copies or substantial portions of the Software.
*
*THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
*IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
*FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
*AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
*LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
*OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
*SOFTWARE.
*
*/
/*
* Simple utility to generate a secret key for Google Authenticator Mobile App.
* The secret key is printed as a base 32 format which can be entered into Google Authenticator Mobile App.
* The hexadecimal string of the secret key is printed as well.
*/
import java.security.SecureRandom;
import sg.nighthour.crypto.CryptoUtil;
public class GenerateOTPSecret {
/**
* Encode input bytes into base32 string
* @param input
* @return the base32 string
*/
private static String encode32(byte[] input)
{
if ((input.length % 5) != 0)
{// Input array has be divisible by 5
// In base 32 ,every 5 bytes will encode to 8 characters
return null;
}
char table[] = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '2', '3', '4', '5', '6', '7' };
int mask = 0xF8;
byte tmp;
int arrayindex = 0;
StringBuilder ret = new StringBuilder();
while (arrayindex < input.length)
{
tmp = input[arrayindex];
// first byte
int tindex = tmp & mask;
tindex = tindex >>> 3;
ret.append(table[tindex]);
// 3 bits remain , borrows 2 bits from next byte
tmp = (byte) ((input[arrayindex] << 5) | ((input[arrayindex + 1] & 0xff) >>> 3));
// Need to & the next byte by 0xff to ensure that when it is cast to
// int for the right shift operation
// the additional 24 bits to its left are 0. Otherwise >>> will not
// work properly.
tindex = tmp & mask;
tindex = tindex >>> 3;
ret.append(table[tindex]);
// 6 bits remain
tmp = (byte) (input[arrayindex + 1] << 2);
tindex = tmp & mask;
tindex = tindex >>> 3;
ret.append(table[tindex]);
// 1 bit remain, borrows 4 bits from next byte
tmp = (byte) ((input[arrayindex + 1] << 7) | ((input[arrayindex + 2] & 0xff) >>> 1));
tindex = tmp & mask;
tindex = tindex >>> 3;
ret.append(table[tindex]);
// 4 bits remain, borrows 1 bit from next byte
tmp = (byte) ((input[arrayindex + 2] << 4) | ((input[arrayindex + 3] & 0xff) >>> 4));
tindex = tmp & mask;
tindex = tindex >>> 3;
ret.append(table[tindex]);
// 7bits remain
tmp = (byte) (input[arrayindex + 3] << 1);
tindex = tmp & mask;
tindex = tindex >>> 3;
ret.append(table[tindex]);
// 2bits remain, borrows 3 bits from next byte
tmp = (byte) ((input[arrayindex + 3] << 6) | ((input[arrayindex + 4] & 0xff) >>> 2));
tindex = tmp & mask;
tindex = tindex >>> 3;
ret.append(table[tindex]);
// 5bits remain
tmp = (byte) (input[arrayindex + 4] << 3);
tindex = tmp & mask;
tindex = tindex >>> 3;
ret.append(table[tindex]);
arrayindex += 5;
}
return ret.toString();
}
public static void main(String[] args)
{
//Generate a 20 bytes random secret key
SecureRandom rand = new SecureRandom();
byte[] ret = new byte[20];
rand.nextBytes(ret);
String base32str = encode32(ret);
//Pretty format the base32 String, change all to lowercase
//and display a space every 4 chars
base32str = base32str.toLowerCase();
StringBuilder buf = new StringBuilder(64);
int count = 0;
for(int i=0;i< base32str.length(); i++)
{
buf.append(base32str.charAt(i));
count ++;
if(i < 28 && count == 4)
{
buf.append(" ");
count = 0;
}
}
System.out.println("The base32 encoded OTP secret key can be configured in the android");
System.out.println("Google Authenticator application to generate time based one time password");
System.out.println("OTP Secret Key in Hexadecimal : " + CryptoUtil.byteArrayToHexString(ret));
System.out.println("OTP Secret Key in base32 : " + buf.toString());
}
}
|
Compile and run the OTP secret key utility.
Fill in the hexadecimal string secret key into the TOTP property of the User Entity in Cloud Datastore. Note down the base32 secret key that is generated by the utility. This base32 value needs to be entered into the Google Authenticator Mobile App later. The following screenshot shows an example entity, user1@nighthour.sg, in Cloud Datastore with all its properties.
Configuring the Google Authenticator Mobile App
Install the Google Authenticator Mobile App from the Google Playstore into the Android phone. Add the base32 secret key into Google Authenticator.
The Google Authenticator Mobile App should start generating time-based OTP token that can be used for authenticating with the 2FA application.
Deploying to App Engine
Let's proceed to deploy the 2fa application to App Engine. Make sure that Google Cloud tools for eclipse has been installed and configured. Select the imported maven project in eclipse. Right click and select "Deploy to App Engine Standard".
Select the Google Cloud project to install the App Engine application.
If the deployment process is successful, the login page should come up.
Testing the 2 Factor Authentication Application
We can now test the 2 factor authentication application through the window that eclipse provides or start up your favourite browser and access the App Engine url for your project.
Log into the application using the user account (user entity) created earlier. Userid is the key of the entity (email address) and the password is what you have set.
If the login credentials are valid, the OTP form should be displayed. Retrieve the OTP token from the Google Authenticator App on the Android phone and submit it here.
If the OTP token is valid, the user fullname will be displayed. Note that the token generation is time-based; the time on your mobile phone and the Google App Engine needs to be accurate.
The screenshot above shows a successful 2 factor authentication. We have a 2 factor authentication mechanism that can enhance the security for a web application. To complete the testing, we need to test for failure cases. The threat model defined earlier in the article will be helpful here. Each item described in the model can be translated into a test case.
A proxy tool like Burp suite or OWASP ZAP will be useful for testing out the security headers as well as injection attacks. The 2 factor authentication application should handle these cases according to the defined threat model.
Conclusion and Afterthought
A 2 factor authentication such as OTP doesn't replace the need of having strong secure passwords. It is an additional layer of defense against cyber attacks. Modern applications should include such multi-factor authentication mechanism to improve the security of the application and user data. This article shows that it is relatively simple to build such a mechanism.
In the digital age, it is no longer enough to build first and secure later. Security has to be included in an application right from the beginning. From the requirements and design phase, to the implementation and operation phases, and in the decomissioning phase. Security is part of the entire application life cycle.
Useful References
- RFC6238, the document that describes the time-based OTP mechanism for Google Authenticator. It includes a java reference implementation.
- How Google Authenticator Works, A useful article describing how Google Authenticator works.
- Google Authenticator compatible 2-Factor Auth in Java, Another article describing how to implement Google Authenticator 2FA.
- Google App Engine Java 8 Standard Environment Documentation
- Burp Suite, a popular proxy/scanner used by many security professionals and penetration testers for testing application security. A free community version is available for download.
- OWASP ZAP, an opensource security proxy/scanner tool used by many security professionals and penetration testers. ZAP can be automated and integrated into a continuous integration/delivery pipeline.
- OWASP Cross Site Scripting Prevention Cheat Sheet, a useful reference explaining how to prevent Cross Site Scripting attacks.
- OWASP Top 10, the top 10 most critical security vulnerabilities in web application. It is a good reference for security professionals and developers to study and understand the common vulnerabilities that can affect web applications.
- OWASP Application Security Verification Standard, provides a useful standard on the verification of security controls and security requirements of web applications.
- OWASP Testing Project, provides a useful guide for penetration testing of web application and web services.
- The Basics of Web Application Security, a useful article on how to secure web applications.
- Web Application Security Headers, an earlier article at nighthour.sg which explains about various http security headers. These headers are set by the filter servlet in this 2fa application.
The full source code for the 2 Factor Authentication App Engine application is available at the following Github link.
https://github.com/ngchianglin/2faAppEngineJava8
The source code for the 2 utility applications is available at the following Github link. https://github.com/ngchianglin/2faUtility
If you have any feedback, comments, corrections or suggestions to improve this article. You can reach me via the contact/feedback link at the bottom of the page.
Article last updated on Feb 2018.