Imagine putting on a fake mustache, stepping into a company pretending to be the CEO, and firing everyone. Or, imagine putting on a sexy grin, going into MI6 offices pretending to be James Bond, and getting a new Aston Martin car. Good user account management prevents this chaos from happening in the digital world.
User account management is the most sensitive controller in your application. Its job is to identify users, so that they can have a personalized experience. At the same time, it has to make sure the users of the platform are secure, and that hacker’s can’t compromise their accounts.
If it sounds easy, let me assure you, it is not. Attackers have been hacking Websites, and compromising user accounts since the invention of Web 2.0. All thanks to vulnerabilities in the user account management system. Hackers kept finding new ways to bypass authentication controls, and developers kept up by fixing those issues. With time, companies developed user account management services that any website could use. They are secure because they stood the test of time. They went through iterations that made them bulletproof.
Among the most famous one, we have Keycloak, Auth0, Okta, Azure Active Directory, AWS Cognito and many others.
If, for any reason, you cannot use those services for your website. If you need to implement your own user account management system. Then this guide can help. As a pentester, I curated a collection of attacks that targeted user accounts. In this article, I reverse engineer those attacks to create a secure user account management controller.
Before getting started, there are 2 pre-requisites related to passwords that we need to address.
Password storage
Before anything, you need to decide how you will store the users’ passwords in the database. We have a full guide on secure password storage here with code examples. For the rest of the article, we assume that you implemented a function called “hashPassword(password)”.
Password policy
You also need to define a secure and strict password policy for your application. We have a full guide on secure password policy with code examples. For the rest of the article, we assume that you implemented a function called “checkPasswordPolicy(password)”.
Database structure
We propose in this article a full user account management system using 2 tables. We use one table to store users, the other one to store remember me tokens.
The users table will have the following structure:
CREATE TABLE CuE7zK_users ( user_id INT NOT NULL AUTO_INCREMENT, email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, creation_date DATETIME NOT NULL, activation_token VARCHAR(255) NOT NULL, is_active BOOLEAN NOT NULL, password_reset_token VARCHAR(255) NULL DEFAULT NULL, password_reset_token_expiration_date DATETIME NOT NULL, 2fa_seed VARCHAR(255) NULL DEFAULT NULL, 2fa_enabled VARCHAR(255) NOT NULL DEFAULT 'false', PRIMARY KEY (user_id) )
As for the remember me table, we will give it the following structure:
CREATE TABLE CuE7zK_remember_me ( token_id INT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, selector VARCHAR(255) NOT NULL, token VARCHAR(255) NOT NULL, token_expiration_date DATETIME NOT NULL, PRIMARY KEY (token_id) )
That prefix CuE7zK is there to add some randomness to the table names. In case of an SQL injection, we make it harder to exploit by using non-predictable table names. This will not protect you from SQL injections though. So make sure you use a secure ORM or prepared queries on your application.
Let us now dive into the individual API endpoint that constitute our user account management service with an implementation for each endpoint.
Create users
The job of this endpoint is to create a new user record in the database. What this record contains will depend on the application’s needs. But, for most applications, the 9 columns we described in the previous section are the bare minimum.
Your application might require other information about the user. Like his address, phone number, preferred language, favorite animal…
The user creation endpoint’s most important role is to check the user’s input before creating the database record. If any check fails, you need to abort the operation and send back an error message:
- The user shouldn’t be logged in;
- The email address the user defined has to have a valid email format;
- The password the user defined must have the same value as the “Repeat password” field;
- The password the user defined has to respect the password policy;
- If you ask the user for other information, check them here. Use common sense.
Once we check all these points, we have to perform 4 actions:
- Hash the user’s password by calling the function “hashPassword(password)”
- Generate a random activation token; It has to be at least 16 bytes long.
- Create a new user record in the database with the user’s email, password_hash, creation_date=NOW() and the activation_token we generated
- Send the user a welcome email, asking him to click the link to activate his account. The link will point to the “Verify email” endpoint. It will have the user’s email and the generated activation token as GET parameters.
If your application has multiple user profiles (simple user, manager, admin, …), then you should define the privilege you give the user here. A good practice would be to set all new users as simple users by default.
Below is an example implementation of this endpoint:
@Autowired private UserRepository user_repo; @RequestMapping(value = "/signup", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> signup( @RequestBody SignupRequestDTO userInfo, HttpServletRequest request, HttpServletResponse response) { HttpSession session = request.getSession(); if(session.getAttribute("user")!=null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You are already logged in"); // Convert email to lowercase userInfo.setEmail(userInfo.getEmail().toLowerCase); // Check that the email the user sent is a valid email address boolean valid_email = Pattern.compile("^(.+)@(.+)$") .matcher(userInfo.getEmail()) .matches(); if (! valid_email) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("The email sent is no a valid email"); // If we already have a user with that email, send him an email User user = user_repo.findByEmail(userInfo.getEmail()); if(user!=null) { if ( ! user.isActive()) { // The user exists but isn't active. Send him an email to remind him to activate String url="https://www.website.com/activate/?email="+userInfo.getEmail()+"&token="+user.getActivationToken(); String message="To confirm your email address, please click on the following link: <a href='"+url+"'>"+url+"</a>"; MailSender ms=new MailSender(); ms.send_email(userInfo.getEmail(), "Website - activate account", message); } else { // The user exists but isn't active. Send him an email to remind him he already is a user String message = "Someone tried to create an account with your email address.<br/>"; message+= "If it wasn't you, please ignore this email <br/>"; message+= "If it was you, this is a reminder that you already have an account with us :) <br/>"; MailSender ms=new MailSender(); ms.send_email(userInfo.getEmail(), "Website - account creation", message); } return ResponseEntity.ok(null); } // Make sure the password and the repeat password are identical if(!userInfo.getPassword().equals(userInfo.getPassword2())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Password and repeat don't match"); // Make sure the password that the user provided respects the password policy if (! PasswordManager.checkPasswordPolicy(userInfo.getPassword())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Password not strong enough"); // Generate a random activation token byte[] random_token_binary = new byte[16]; new Random().nextBytes(random_token_binary); String random_token_hex = Hex.encodeHexString(random_token_binary); // Calculate the password's hash, and create a new user in the database PasswordManager pass_manager = new PasswordManager(); String new_user_password_hash = pass_manager.hashPassword(userInfo.getPassword()); User new_user = new User(); new_user.setEmail(userInfo.getEmail()); new_user.setPassword(new_user_password_hash); new_user.setActive(false); new_user.setActivationToken(random_token_hex); new_user.setCreationDate(new Date()); user_repo.save(new_user); // Send the activation link to the user via email String url="https://www.website.com/activate/?email="+userInfo.getEmail()+"&token="+random_token_hex; String message="To confirm your email address, please click on the following link: <a href='"+url+"'>"+url+"</a>"; MailSender ms=new MailSender(); ms.send_email(userInfo.getEmail(), "Website - activate account", message); return ResponseEntity.ok(null); }
FAQ
Why check the password policy?
If a user chooses a weak password, his account could get compromised. In that case you, as the website owner, will still be blamed for that—Your brand image will take a hit and the customer will complain about you. Your job is to protect your users, even from their own bad decisions.
Why ask the user to activate his account?
The goal is to make sure the user has access to the email address he used to login. We do that for 2 reasons:
- To avoid having bots who create mass user accounts without needing a valid email. This increases a tiny bit the barrier of entry, thus reducing spam activity on the website.
- The email later allows the user to reset his password. If the user doesn’t have access to the email he provided, then someone else with access to it could reset his password and take over his account. We want to avoid that scenario.
Why generate a random activation token?
We wouldn’t want a hacker to bypass this activation process. We use a long and random token so that bruteforce attacks will become inefficient.
Verify email
As stated earlier, a new user has to activate his account. He does that by clicking a special link that we sent him via email. This allows us to verify that the user has access to the email address that he submitted.
The “verify email” controller is simple. It retrieves the user’s activation token from the database, and compares it to the token present in the URL parameter. If they match, we update the user record to reflect that this is now an activated account.
Here is an example implementation of this endpoint:
@Autowired private UserRepository user_repo; @RequestMapping(value = "/activate", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> activate( @RequestBody ActivateAccountRequest activateData, HttpServletRequest request, HttpServletResponse response) { HttpSession session = request.getSession(); // Check that the user isn't already logged in if(session.getAttribute("user")!=null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You are already logged in"); // Search the user record in the database User user_record = user_repo.findByEmail(activateData.getEmail()); // Check that a user exists in the database with the searched email if (user_record==null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid activation link"); // Check that the token the user sent is the same that is stored in the database if (! user_record.getActivationToken().equals(activateData.getToken())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid activation link"); // Enable the user account and update his database record user_record.setActive(true); user_repo.save(user_record); return ResponseEntity.ok(null); }
Login
The job of the login endpoint is to check 3 things:
- The user exists in the database;
- The user account is activated;
- The password the user submitted is the same as the one stored in the database. For this, we will compare password hashes.
To make things simple, we will give a stateful implementation in this article. This means that once logged in, we create a session for the user where we store his information. This solution makes it simple to manage. But, switching to a multi server or multi container architecture later needs tweaking—You would need to find a way to share session information between servers/services. If you go the stateless route, make sure you use secure web sessions.
Alternatively, you can make the login stateless by using signed JWT tokens. You can also use random authentication tokens. But, you will need to store the latter ones in the database and manage their expiration dates.
This is the most important endpoint in any user account management service. So you should take your time to make it as secure as can be.
Bruteforce protection
Login is the feature that hackers target the most. They will try different username and password combinations to find valid credentials. This would be most helpful on applications where you can’t do much without a valid account.
The process of bruteforce is simple. Hackers perform automatic login requests while varying the username and password. They will try the most common passwords, and passwords they find in data leaks—these are real passwords used by people on websites that got hacked.
We need to protect our login endpoint from these automated attacks. The simplest way of doing that is to add a captcha to the login page. Since programs can’t solve these Captcha challenges, the hackers wouldn’t be able to automate the requests anymore.
We will use the Google invisible Captcha on our login page. It is also possible to put in place a failed login counter. If someone performs 3 failed login attempts for a certain username, we lock that account for a certain amount of time.
Full implementation
Here is an example implementation of a stateful login:
We will use this helper class to check Google ReCaptcha tokens :
import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.util.UriComponentsBuilder; public class CaptchaManager { public static boolean checkGoogleCaptchaToken(String captchaToken) { String url = "https://www.google.com/recaptcha/api/siteverify"; String secret = "YOUR_SERVER_KEY"; try { RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); headers.set("Accept", MediaType.APPLICATION_JSON_VALUE); UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url) .queryParam("secret", secret) .queryParam("response", captchaToken); HttpEntity<String> entity = new HttpEntity<>(headers); ResponseEntity<RecaptchaResponse> response = restTemplate .exchange(builder.build().encode().toUri(), HttpMethod.GET, entity,RecaptchaResponse.class); RecaptchaResponse rs = response.getBody(); if (response.getStatusCode().value() == 200 && rs.isSuccess()) { return true; } else { return false; } } catch (Exception e) { return false; } } }
The actual controller would look like this:
@Autowired private UserRepository user_repo; @RequestMapping(value = "/login", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> login(@RequestBody LoginRequestDTO credsDTO, HttpServletRequest request, HttpServletResponse response) { HttpSession session = request.getSession(); // If the user is already logged in, there is no point in doing that again if(session.getAttribute("user")!=null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You are already logged in"); // Generate a new session ID to avoid session fixation request.changeSessionId(); session = request.getSession(); // Check that the user passed the Captcha Check if ( ! CaptchaManager.checkGoogleCaptchaToken(credsDTO.getCaptchaToken())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Incorrect username or password"); // Convert email to lowercase credsDTO.setEmail(userInfo.getEmail().toLowerCase); User user = user_repo.findByEmail(credsDTO.getEmail()); // We didn't find any users with the email address provided if(user==null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Incorrect username or password"); PasswordManager pass_manager = new PasswordManager(); // The password the user provided is not the same as the one in the database if (! pass_manager.checkPassword(credsDTO.getPassword(), user.getPassword())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Incorrect username or password"); // The user didn't activate his account yet if(!user.isActive()) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Please activate your account before you can log in"); // Here, the user is authenticated session.setAttribute("user", user); return ResponseEntity.ok(user); }
Why the same return message?
Hackers use the login feature to verify the presence of an email address on an application. First, they attempt to login with an email and a random password. If they receive an error message telling them that the email doesn’t exist, they know that the user isn’t registered. If the error message tells them that the password is incorrect, they know there is a user with that email address.
This helps them narrow down the list of users they can attempt bruteforce attacks on. But, our secure application sends the same generic message whether the user exists or not. Attackers wouldn’t know if the user exists or not.
Remember me
Relying only on sessions isn’t practical for users. A user’s session gets destroyed after 30 minutes of idle time, or when he closes the browser. This could get annoying for users who use your application regularly—They would need to login all the time.
We can fix this inconvenience by adding a remember me feature. We add to the login form a radio button that allows the user to be remembered. When enabled, we create a more persistent login token in the form of a remember_me cookie.
Let’s get into the 2 functionalities:
Remember me token creation
After a successful login, we generate a token composed of 2 parts:
- A random string that will play the role of an ID. We will set this to be 12 bytes long.
- A random authentication token. This will be 32 bytes long
We will concatenate these 2 strings together with a separator, and put them in a cookie called remember_me. This cookie should have a reasonable expiration date. Depending on your application, between one and 5 days should be acceptable.
Then, we will save these 2 random strings into the database. But, before saving the authentication token, we will hash it first. This way, if a hacker gains access to the database, he will not be able to use the remember_me tokens he finds there.
We will also add an expiration date to the remember me token in the database.
Here is the login endpoint from the last step extended with the remember me feature:
@Autowired private UserRepository user_repo; private RememberMeRepository remember_me_repo; @RequestMapping(value = "/login", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> login(@RequestBody LoginRequestDTO credsDTO, HttpServletRequest request, HttpServletResponse response) { HttpSession session = request.getSession(); // If the user is already logged in, there is no point in doing that again if(session.getAttribute("user")!=null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You are already logged in"); // Generate a new session ID to avoid session fixation request.changeSessionId(); session = request.getSession(); // Check that the user passed the Captcha Check if ( ! CaptchaManager.checkGoogleCaptchaToken(credsDTO.getCaptchaToken())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Incorrect username or password"); // Convert email to lowercase credsDTO.setEmail(userInfo.getEmail().toLowerCase); User user = user_repo.findByEmail(credsDTO.getEmail()); // We didn't find any users with the email address provided if(user==null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Incorrect username or password"); PasswordManager pass_manager = new PasswordManager(); // The password the user provided is not the same as the one in the database if (! pass_manager.checkPassword(credsDTO.getPassword(), user.getPassword())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Incorrect username or password"); // The user didn't activate his account yet if(!user.isActive()) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Please activate your account before you can log in"); // Here, the user is authenticated session.setAttribute("user", user); if (credsDTO.isRemember_me()) { // Generate a random token that plays the role of the token ID byte[] remember_token_selector = new byte[12]; new Random().nextBytes(remember_token_selector); String remember_token_selector_hex = Hex.encodeHexString(remember_token_selector); // Generate a random token byte[] remember_token_value = new byte[32]; new Random().nextBytes(remember_token_value); String remember_token_value_hex = Hex.encodeHexString(remember_token_value); // Calculate the hash of the generated token SecretKeyFactory skf; try { skf = SecretKeyFactory.getInstance( "PBKDF2WithHmacSHA512" ); } catch (NoSuchAlgorithmException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } PBEKeySpec spec = new PBEKeySpec(remember_token_value_hex.toCharArray(), remember_token_selector_hex.getBytes(), 1000, 512 ); SecretKey key; try { key = skf.generateSecret( spec ); } catch (InvalidKeySpecException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } byte[] hash_bytes = key.getEncoded( ); String hashed_token = Hex.encodeHexString(hash_bytes); // Set the expiration date Date expiration = new Date(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(3)); // Save the new remember_me token ID and hash in the database RememberMeToken token = new RememberMeToken(); token.setUserId(user.getUserId()); token.setSelector(remember_token_selector_hex); token.setToken(hashed_token); token.setTokenExpirationDate(expiration); remember_me_repo.save(token); // Set a cookie that is valid for 3 days Cookie cookie = new Cookie("remember_me", remember_token_selector_hex+":"+remember_token_value_hex); cookie.setMaxAge(60 * 24 * 3); cookie.setPath("/"); cookie.setSecure(true); cookie.setHttpOnly(true); response.addCookie(cookie); } return ResponseEntity.ok(user); }
Remember me token validation
If a user with an invalid session performs an API call, we will check for the presence of the remember_me cookie. If present, we will check if the remember me token is valid. Here is the process:
- We split the cookie value by the delimiter we used earlier. This will get us an ID and a remember me token.
- We use the ID to search the database. We are looking for a token hash that has the same ID as the one found in the cookie.
- If the token already expired, we abort the operation.
- We hash the remember me token that we found in the cookie, and compare it to the hash we retrieved from the database.
- If the 2 hashes are identical, then the remember me cookie is valid. We will use the user_id stored in the remember_me table to retrieve the user’s record from the database and create a new session for him.
Here is the function that we call to initiate the session. It will check the remember_me cookie if needed:
public HttpSession initiate_session(HttpServletRequest request, HttpServletResponse response, RememberMeRepository remember_me_repo, UserRepository user_repo) { HttpSession session = request.getSession(); if(session.getAttribute("user")!=null) return session; // If session inactive, and remember_me cookie present, check the token String remember_me=""; try { remember_me=WebUtils.getCookie(request, "remember_me").getValue(); } catch (Exception e) { return session; } // Check if the cookie has the expected format boolean cookie_conform = Pattern.compile("^[0-9a-fA-F]{24}:[0-9a-fA-F]{64}$") .matcher(remember_me) .matches(); // If not, we delete the cookie if(!cookie_conform) { Cookie cookie = new Cookie("remember_me", ""); cookie.setMaxAge(0); cookie.setPath("/"); response.addCookie(cookie); return session; } // We get the token ID and the token value from the cookie String remember_token_selector_hex = remember_me.split(":")[0]; String remember_token_value_hex = remember_me.split(":")[1]; // We use the token ID to retrieve the remember_me token record RememberMeToken token = remember_me_repo.findBySelector(remember_token_selector_hex); if (token == null) return session; // If the token expired, delete the cookie if(token.getTokenExpirationDate().before(new Date())) { Cookie cookie = new Cookie("remember_me", ""); cookie.setMaxAge(0); cookie.setPath("/"); response.addCookie(cookie); return session; } String hashed_token = ""; try { // We hash the token found in the cookie SecretKeyFactory skf = SecretKeyFactory.getInstance( "PBKDF2WithHmacSHA512" ); PBEKeySpec spec = new PBEKeySpec(remember_token_value_hex.toCharArray(), remember_token_selector_hex.getBytes(), 1000, 512 ); SecretKey key = skf.generateSecret( spec ); byte[] hash_bytes = key.getEncoded( ); hashed_token = Hex.encodeHexString(hash_bytes); } catch (Exception e) { return session; } // We compare the calculated hash to the hash we stored in the database if( ! token.getToken().equals(hashed_token)) return session; // If the 2 hashes are identical, we consider that the user is logged in User remembered_user = user_repo.findById(token.getUserId()); session.setAttribute("user", remembered_user); return session; }
2FA
This is optional, but highly recommended.
You might want to allow your users to set up 2FA on their account. This will add a security layer to protect their accounts from being compromised. If a hacker compromises a user’s password (database breach, user’s laptop compromised, user having a weak password…), he would not be able to use it to log into the victim’s account. He would also need access to the victim’s phone to pass the 2FA verification.
The simplest 2FA method available to developers is One Time Password using Google Authenticator. We need to put in place an interface that allows users to set up their Google Authenticator device. Then, we need to add a 2FA verification step in the login process.
Setup 2FA
This process requires the user to be authenticated. So, on the endpoints we are going to create here, we will check that the user is logged in.
The process is straightforward. We need to generate a new seed for the user and save it in his database record. Then, we show the user the seed in the form of a QR code, along with a form to submit an OTP. The user then needs to do 2 things:
- Scan the QR code using the Google Authenticator app;
- Input an OTP from Google Authenticator into the 2FA form we provided
Once the user submits the form, we check if the OTP is valid. For that, we need to use an OTP library. We need to give it the user’s seed that we stored in the database, and the OTP he provided. If the OTP is valid, we set the field “2FA_enable” to true in the user’s database record. This will enable 2FA verification for his account on the next login attempt.
Here is an example implementation of the “setup 2FA” endpoints:
We will use the following helper class to generate 2FA secret keys and to check the validity of OTP codes that users submit. You will need to add the dependencies “com.google.zxing” and “com.warrenstrange” to your pom.xml file:
import com.google.zxing.BarcodeFormat; import com.google.zxing.EncodeHintType; import com.google.zxing.WriterException; import com.google.zxing.client.j2se.MatrixToImageWriter; import com.google.zxing.common.BitMatrix; import com.google.zxing.qrcode.QRCodeWriter; import com.warrenstrange.googleauth.GoogleAuthenticator; import com.warrenstrange.googleauth.GoogleAuthenticatorConfig; import com.warrenstrange.googleauth.GoogleAuthenticatorKey; import com.warrenstrange.googleauth.GoogleAuthenticatorQRGenerator; import org.springframework.stereotype.Service; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.Base64; import java.util.HashMap; import java.util.Map; public class OTPManager { private static final String ISSUER = "Your Application Name"; // Generate a new TOTP key public static String generateKey() { GoogleAuthenticator gAuth = new GoogleAuthenticator(); final GoogleAuthenticatorKey key = gAuth.createCredentials(); return key.getKey(); } // Validate the TOTP code public static boolean isValid(String secret, int code) { GoogleAuthenticator gAuth = new GoogleAuthenticator( new GoogleAuthenticatorConfig.GoogleAuthenticatorConfigBuilder().build() ); return gAuth.authorize(secret, code); } // Generate a QR code URL for Google Authenticator public static String generateQRUrl(String secret, String username) { String url = GoogleAuthenticatorQRGenerator.getOtpAuthTotpURL( ISSUER, username, new GoogleAuthenticatorKey.Builder(secret).build()); try { return generateQRBase64(url); } catch (Exception e) { return null; } } // Generate a QR code image in Base64 format public static String generateQRBase64(String qrCodeText) { try { QRCodeWriter qrCodeWriter = new QRCodeWriter(); MaphintMap = new HashMap<>(); hintMap.put(EncodeHintType.CHARACTER_SET, "UTF-8"); BitMatrix bitMatrix = qrCodeWriter.encode(qrCodeText, BarcodeFormat.QR_CODE, 200, 200, hintMap); BufferedImage bufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix); ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageIO.write(bufferedImage, "png", baos); byte[] imageBytes = baos.toByteArray(); return Base64.getEncoder().encodeToString(imageBytes); } catch (WriterException | IOException e) { e.printStackTrace(); return null; } } }
The controllers to generate an OTP QR code and the one to enable 2FA look like the following:
@Autowired private UserRepository user_repo; private RememberMeRepository remember_me_repo; @RequestMapping(value = "/generate2FAQR", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> generate2FA(HttpServletRequest request, HttpServletResponse response) { HttpSession session = initiate_session(request, response, remember_me_repo, user_repo); if(session.getAttribute("user")==null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You need to log in first"); User user_record = user_repo.findById((int)session.getAttribute("user_id")); // Check that the user does not have a working 2FA configuration if (user.is2FAEnabled()) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("2FA is already enabled on this account"); // Generate a new 2FA secret key String secret = OTPManager.generateKey(); String qrCodeUrl = OTPManager.generateQRUrl(secret, user_record.getUsername()); user_record.set2FASeed(secret); user_repo.save(user_record); return ResponseEntity.ok(qrCodeUrl); } @RequestMapping(value = "/setup2FA", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> setup2FA(@RequestBody OTPCodeDTO otp_code, HttpServletRequest request, HttpServletResponse response) { HttpSession session = initiate_session(request, response, remember_me_repo, user_repo); if(session.getAttribute("user")==null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You need to log in first"); User user = (User) session.getAttribute("user"); // Check that the user does not have a working 2FA configuration if (user.is2FAEnabled()) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("2FA is already enabled on this account"); // Check if the OTP provided is valid boolean isValid = OTPManager.isValid(user.get2FASeed(), otp_code.getOTPCode()); if(!isValid) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("OTP code invalid"); // Enable 2FA verification for this user account user.set2FAEnabled(true); user_repo.save(user); return ResponseEntity.ok(null); }
Check 2FA
When a user logs in, we need to check whether he has 2FA enabled or not. If he has, we don’t fully populate his session yet. We will only keep a record of his successful credentials check, and redirect him to the 2FA form.
The previous login endpoint would look like the following:
@Autowired private UserRepository user_repo; private RememberMeRepository remember_me_repo; @RequestMapping(value = "/login", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> login(@RequestBody LoginRequestDTO credsDTO, HttpServletRequest request, HttpServletResponse response) { HttpSession session = initiate_session(request, response, remember_me_repo, user_repo); // If the user is already logged in, there is no point in doing that again if(session.getAttribute("user")!=null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You are already logged in"); // Generate a new session ID to avoid session fixation request.changeSessionId(); session = request.getSession(); // Check that the user passed the Captcha Check if ( ! CaptchaManager.checkGoogleCaptchaToken(credsDTO.getCaptchaToken())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Incorrect username or password"); // Convert email to lowercase credsDTO.setEmail(userInfo.getEmail().toLowerCase); User user = user_repo.findByEmail(credsDTO.getEmail()); // We didn't find any users with the email address provided if(user==null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Incorrect username or password"); // The user didn't activate his account yet if(!user.isActive()) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Please activate your account before you can log in"); PasswordManager pass_manager = new PasswordManager(); // The password the user provided is not the same as the one in the database if (! pass_manager.checkPassword(credsDTO.getPassword(), user.getPassword())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Incorrect username or password"); // If the user enabled 2FA on his account, we redirect him to 2FA form if (user.is2FAEnabled()) { session.setAttribute("password_correct", true); session.setAttribute("user_id", user.getUserId()); HttpHeaders headers = new HttpHeaders(); headers.add("Location", "/users/2FAForm"); return new ResponseEntity<String>(headers,HttpStatus.FOUND); } // If 2FA is disabled, the user is authenticated session.setAttribute("user", user); if (credsDTO.isRemember_me()) { // Generate a random token that plays the role of the token ID byte[] remember_token_selector = new byte[12]; new Random().nextBytes(remember_token_selector); String remember_token_selector_hex = Hex.encodeHexString(remember_token_selector); // Generate a random token byte[] remember_token_value = new byte[32]; new Random().nextBytes(remember_token_value); String remember_token_value_hex = Hex.encodeHexString(remember_token_value); // Calculate the hash of the generated token SecretKeyFactory skf; try { skf = SecretKeyFactory.getInstance( "PBKDF2WithHmacSHA512" ); } catch (NoSuchAlgorithmException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } PBEKeySpec spec = new PBEKeySpec(remember_token_value_hex.toCharArray(), remember_token_selector_hex.getBytes(), 1000, 512 ); SecretKey key; try { key = skf.generateSecret( spec ); } catch (InvalidKeySpecException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } byte[] hash_bytes = key.getEncoded( ); String hashed_token = Hex.encodeHexString(hash_bytes); // Set the expiration date Date expiration = new Date(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(3)); // Save the new remember_me token ID and hash in the database RememberMeToken token = new RememberMeToken(); token.setUserId(user.getUserId()); token.setSelector(remember_token_selector_hex); token.setToken(hashed_token); token.setTokenExpirationDate(expiration); remember_me_repo.save(token); // Set a cookie that is valid for 3 days Cookie cookie = new Cookie("remember_me", remember_token_selector_hex+":"+remember_token_value_hex); cookie.setMaxAge(60 * 24 * 3); cookie.setPath("/"); cookie.setSecure(true); cookie.setHttpOnly(true); response.addCookie(cookie); } return ResponseEntity.ok(user); }
Once redirected, we will present him with a form. We will ask him to provide an OTP code from his Google Authenticator app.
When he submits the form, the 2FA verification endpoint will perform the following actions:
- Check if we already performed a credential check on the user. If not, we abort the operation.
- Retrieve the user’s database record.
- Check if the OTP he provided is valid. We will retrieve his OTP seed from his database record and pass it to the OTP library, along with the OTP he provided. If the check is successful, we populate his session and consider him to be authenticated.
Here is an example implementation of the “check 2FA” endpoint:
@Autowired private UserRepository user_repo; private RememberMeRepository remember_me_repo; @RequestMapping(value = "/check2FA", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> check2FA(@RequestBody OTPCodeDTO otp_code, HttpServletRequest request, HttpServletResponse response) { HttpSession session = initiate_session(request, response, remember_me_repo, user_repo); if(session.getAttribute("user") != null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You are already logged in"); if(session.getAttribute("password_correct") == null || session.getAttribute("user_id") == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You need to log in first"); User user = user_repo.findById((int)session.getAttribute("user_id")); // Check if the OTP provided is valid boolean isValid = OTPManager.isValid(user.get2FASeed(), otp_code.getOTPCode()); if(!isValid) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("OTP code invalid"); // 2FA verification successful session.setAttribute("user", user); session.removeAttribute("password_correct"); session.removeAttribute("user_id"); return ResponseEntity.ok(user); }
Reset password
When a user forgets his password, he’ll need a way to reset it. We will rely on proof of ownership of his email inbox to verify his identity. To do that, we will send him an email containing a password reset link.
Request a password reset
Here is what happens when a user requests a password reset:
- We generate a random 32 byte password reset token.
- We construct an email with a password reset link. This link contains the user’s email and the password reset token in the parameters.
- We hash the password reset link, and save the hash in the user’s database record.
- We save an expiration date on the password reset token in the user’s database record.
Why do we hash the token?
This is a defense in depth mechanism. If an attacker retrieves the content of the database, we don’t want him to be able to reset all the users’ passwords. He would have access to the hashed reset token. But, he can’t use it to reset a password, since he needs the actual token, and not the hash.
Why the generic return message?
The endpoint will send the same generic message whether the user exists or not. Hackers use the reset password feature to verify the presence of an email address on an application. They attempt a password reset for a certain email, and if they receive an error message telling them that the email doesn’t exist, they know that the user isn’t registered. This helps them narrow down the list of users they can attempt bruteforce attacks on. Since we send the same message, attackers wouldn’t know if the user exists or not.
Here is an example implementation of the “request password reset” endpoint:
@Autowired private UserRepository user_repo; private RememberMeRepository remember_me_repo; @RequestMapping(value = "/requestReset", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> requestReset(@RequestBody RequestResetDTO resetDTO, HttpServletRequest request, HttpServletResponse response) { HttpSession session = initiate_session(request, response, remember_me_repo, user_repo); if(session.getAttribute("user")!=null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You are already logged in"); // Convert email to lowercase resetDTO.setEmail(resetDTO.getEmail().toLowerCase); User user = user_repo.findByEmail(resetDTO.getEmail()); if(user == null) return ResponseEntity.ok(null); // Generate a new password reset token byte[] reset_token = new byte[32]; new Random().nextBytes(reset_token); String reset_token_hex = Hex.encodeHexString(reset_token); // Calculate the token's hash String hashed_token; try { SecretKeyFactory skf = SecretKeyFactory.getInstance( "PBKDF2WithHmacSHA512" ); PBEKeySpec spec = new PBEKeySpec(reset_token_hex.toCharArray(), new byte[0], 1000, 512 ); SecretKey key = skf.generateSecret(spec); byte[] hash_bytes = key.getEncoded(); hashed_token = Hex.encodeHexString(hash_bytes); } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } // Set an expiration date Date expiration = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1)); // Save the hashed token in the user's record user.setPasswordResetToken(hashed_token); user.setPasswordResetTokenExpirationDate(expiration); user_repo.save(user); // Send the password reset link to the user via email String url="https://www.website.com/reset/?email="+resetDTO.getEmail()+"&token="+reset_token_hex; String message="To reset your password, please click on the following link: <a href='"+url+"'>"+url+"</a>"; MailSender ms=new MailSender(); ms.send_email(resetDTO.getEmail(), "Website - reset password", message); return ResponseEntity.ok(null); }
Execute the password reset
When the user clicks on the password reset link, he will arrive at the password reset interface. This interface will allow him to choose a new password. When he submits the password form, the following information will get sent to the password reset endpoint: the email address and the password reset token that were in the reset link, and the new password the user defined.
The endpoint then checks everything before updating the user’s password hash:
- We use the email address sent in the parameters to retrieve the user’s database record.
- We check if the password reset token expired. If so, we abort the operation.
- We hash the password reset token that is in the request parameters.
- We compare the calculated hash with the password reset token hash that we stored in the database.
- If the 2 hashes aren’t identical, we abort the operation.
- If the password and the repeat password aren’t identical, we abort the operation.
- If the new password does not respect the password policy, we abort the operation.
- Hash the user’s password by calling the function “hashPassword(password)”
- Update the user’s database record. Set the password hash to the new calculated hash. Also, set the password reset token expiration date to “NOW()” so that it can’t be used anymore.
Following is an example implementation of the “password reset” endpoint:
@Autowired private UserRepository user_repo; private RememberMeRepository remember_me_repo; @RequestMapping(value = "/resetPassword", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> resetPassword(@RequestBody ResetPasswordDTO resetPasswordDTO, HttpServletRequest request, HttpServletResponse response) { HttpSession session = initiate_session(request, response, remember_me_repo, user_repo); if(session.getAttribute("user")!=null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You are already logged in"); User user = user_repo.findByEmail(resetPasswordDTO.getEmail()); if (user == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid password reset link"); // Hash the password reset token that the user sent String hashed_token; try { SecretKeyFactory skf = SecretKeyFactory.getInstance( "PBKDF2WithHmacSHA512" ); PBEKeySpec spec = new PBEKeySpec(resetPasswordDTO.getToken().toCharArray(), new byte[0], 1000, 512 ); SecretKey key = skf.generateSecret(spec); byte[] hash_bytes = key.getEncoded(); hashed_token = Hex.encodeHexString(hash_bytes); } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } // If the hash is not the same as the one in the database, abort if( user.getPasswordResetToken()==null || ! user.getPasswordResetToken().equals(hashed_token)) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid password reset link"); // If the token expired abort if(user.getPasswordResetTokenExpirationDate().before(new Date())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Password reset link expired"); // Make sure the password and the repeat password are identical if(!resetPasswordDTO.getPassword().equals(resetPasswordDTO.getPassword2())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Password and repeat don't match"); // Make sure the password that the user provided respects the password policy if (! PasswordManager.checkPasswordPolicy(resetPasswordDTO.getPassword())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Password not strong enough"); // Calculate the password's hash, and update the password in the database PasswordManager pass_manager = new PasswordManager(); String new_user_password_hash = pass_manager.hashPassword(resetPasswordDTO.getPassword()); // Save the new password hash in the user's record user.setPasswordResetTokenExpirationDate(new Date()); user.setPassword(new_user_password_hash); user_repo.save(user); return ResponseEntity.ok(null); }
Change password
We need to allow the user to change his password. The user interface needs to ask the user for his old password, his new password, and a repeat of his new password.
Once he submits the form, the API endpoint “change password” does the following:
- Check that the user is logged in. If not, abort the operation.
- Retrieve the user’s record from the database.
- Hash the old password that the user submitted, and compare it to the password hash stored in his database record. If the 2 hashes are not identical, abort the operation.
- Check that the new password and the new password repeat are identical. If not, abort the operation.
- Check that the new password conforms to the password policy. If not, abort the operation.
- Hash the new password. Then, replace the old password hash with the new password hash in the database.
Following is an example implementation of the “change password” endpoint:
@Autowired private UserRepository user_repo; private RememberMeRepository remember_me_repo; @RequestMapping(value = "/changePassword", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> changePassword(@RequestBody ChangePasswordDTO changePasswordDTO, HttpServletRequest request, HttpServletResponse response) { HttpSession session = initiate_session(request, response, remember_me_repo, user_repo); if(session.getAttribute("user")==null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You need to log in first"); User user = (User) session.getAttribute("user"); PasswordManager pass_manager = new PasswordManager(); // Make sure the old password is the same as the one in the database if (! pass_manager.checkPassword(changePasswordDTO.getOldPassword(), user.getPassword())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Old password is incorrect"); // Make sure the password and the repeat password are identical if(!changePasswordDTO.getPassword().equals(changePasswordDTO.getPassword2())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Password and repeat don't match"); // Make sure the password that the user provided respects the password policy if (! PasswordManager.checkPasswordPolicy(changePasswordDTO.getPassword())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Password not strong enough"); // Calculate the password's hash, and create a new user in the database pass_manager = new PasswordManager(); String new_user_password_hash = pass_manager.hashPassword(changePasswordDTO.getPassword()); // Save the new password hash in the user's record user.setPassword(new_user_password_hash); user_repo.save(user); return ResponseEntity.ok(null); }
Change email address
This is optional. But, you might want to allow users to change their email address on your application. The form to change the email address should ask the user to input his new email address, and his password.
We ask for his password because we need to make sure it is the user who performed this request. Thus, we avoid the situation where an attacker gets access to the user’s computer, with his session active, and changes his email address to put the attacker’s address.
Why would an attacker do this? Since we have a password reset feature, the attacker would be able to request a password reset. Since he changed the victim’s profile to have the attacker’s email address, he will be the one receiving the password reset link. He would thus be able to reset the victim’s password, and take over his account.
We also would like to impose the following restrictions:
- The new email the user provides must not be in the database. We want to keep the email address unique to avoid any confusion.
- If the user provides an email address that is present in the database, we will not inform him. We do not want to disclose the presence of other users on the platform.
- The user has to prove his ownership of the new email address before me make the change.
Thus, we implement this feature in 2 steps.
When the user submits the form, the “change email” API endpoint receives the request and does the following:
- Check that the user is logged in. If not, abort the operation.
- Check that the email provided is not present in the database. If it is, we send an email to the new email address.
- Retrieve the user’s record from the database.
- Hash the password that the user submitted, and compare it to the password hash stored in his database record. If the 2 hashes are not identical, abort the operation.
- Create a new validation token, save it in the database, and send the validation email to the new email address.
When the user clicks the validation email, he is redirected to the “confirmChangeEmail
” controller. There we check that the token the user sent is the same as the one in the database. If so, we save the new email address.
Following is an example implementation of the 2 “change email address” endpoints:
@Autowired private UserRepository user_repo; private RememberMeRepository remember_me_repo; @RequestMapping(value = "/changeEmail", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> changeEmail(@RequestBody ChangeEmailDTO changeEmailDTO, HttpServletRequest request, HttpServletResponse response) { HttpSession session = initiate_session(request, response, remember_me_repo, user_repo); if(session.getAttribute("user")==null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You need to log in first"); changeEmailDTO.setEmail(changeEmailDTO.getEmail().toLowerCase); // Check that the email the user sent is a valid email address boolean valid_email = Pattern.compile("^(.+)@(.+)$") .matcher(changeEmailDTO.getEmail()) .matches(); if (! valid_email) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("The email sent is no a valid email"); // If we already have a user with that email, send him an email User user = user_repo.findByEmail(userInfo.getEmail()); if(user!=null) { String message = "Someone tried to create an account with your email address.<br/>"; message+= "If it wasn't you, please ignore this email <br/>"; message+= "If it was you, this is a reminder that you already have an account with us :) <br/>"; MailSender ms=new MailSender(); ms.send_email(userInfo.getEmail(), "Website - email change", message); return ResponseEntity.ok(null); } PasswordManager pass_manager = new PasswordManager(); // Check that the password the user provided is the same as the one in the database if (! pass_manager.checkPassword(changeEmailDTO.getPassword(), user.getPassword())) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Password is incorrect"); // Generate a random validation token byte[] random_token_binary = new byte[16]; new Random().nextBytes(random_token_binary); String random_token_hex = Hex.encodeHexString(random_token_binary); // Save the new Email address in the user's record user = (User) session.getAttribute("user"); user.setActivationToken(changeEmailDTO.getEmail() + ":" + random_token_hex); user_repo.save(user); // Send the validation link to the user via email String url="https://www.website.com/confirmChangeEmail/?token="+random_token_hex; String message="To confirm your email change, please click on the following link: <a href='"+url+"'>"+url+"</a>"; MailSender ms=new MailSender(); ms.send_email(changeEmailDTO.getEmail(), "Website - activate account", message); return ResponseEntity.ok(null); } @RequestMapping(value = "/confirmChangeEmail", method = RequestMethod.GET, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> confirmChangeEmail( @RequestParam(required = true, defaultValue = "", value="token") String token, HttpServletRequest request, HttpServletResponse response) { HttpSession session = initiate_session(request, response, remember_me_repo, user_repo); if(session.getAttribute("user")==null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You need to log in first"); User user = (User) session.getAttribute("user"); User user_record = user_repo.findById(user.getUserId()); // The user did not go through the request email change step if ( !user_record.getActivationToken().contains(":")) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid confirmation link"); String new_email = user_record.getActivationToken().split(":")[0]; String validation_token = user_record.getActivationToken().split(":")[1]; // Check that the token the user provided is the same as the one in the database if (validation_token != token) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid confirmation link"); // Save the new Email address in the user's record user_record.setEmail(new_email); user_repo.save(user_record); return ResponseEntity.ok(null); }
Logout
The last endpoint we are implementing is the logout endpoint. When called, this endpoint deletes all the information relative to the user’s session. So, we will:
- Delete the user’s session on the server side
- Delete the session cookie from the user’s browser
- If the user has a remember me cookie:
- We delete the remember me cookie from the user’s browser
- We set an expiration date to the remember me token in the database
If you set up authentication using JWT tokens or authentication tokens, you need to find a way to invalidate them. For authentication tokens, you can set an expiration date in the past. Expiring existing JWT tokens is more complex. You might need to create a blacklist of revoked JWT tokens; When a user logs out, you add his JWT token to the revoked tokens list.
Here is an example implementation of the logout endpoint:
@Autowired private UserRepository user_repo; private RememberMeRepository remember_me_repo; @RequestMapping(value = "/logout", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> logout(HttpServletRequest request, HttpServletResponse response) { HttpSession session = initiate_session(request, response, remember_me_repo, user_repo); String remember_me; try { remember_me=WebUtils.getCookie(request, "remember_me").getValue(); boolean cookie_valid = Pattern.compile("^[0-9a-fA-F]{24}-[0-9a-fA-F]{64}$") .matcher(remember_me) .matches(); if(cookie_valid) { // Expire the remember_me token String remember_token_selector_hex = remember_me.split(":")[0]; RememberMeToken token = remember_me_repo.findBySelector(remember_token_selector_hex); if(token!=null) { token.setTokenExpirationDate(new Date()); remember_me_repo.save(token); } } // Tell the browser to delete the remember_me cookie Cookie cookie = new Cookie("remember_me", ""); cookie.setMaxAge(0); cookie.setPath("/"); response.addCookie(cookie); // Invalidate the user's session session.invalidate(); } catch (Exception e) { session.invalidate(); } return ResponseEntity.ok(null); }
Hide sensitive data
In the example code we provided for login and 2FA verification, we return the user object to the API caller. We did that because the API caller might need that information to change the user interface. For example, if a front page performed the API call, it might need the user’s email to show it on the user’s dashboard. Or, it might need to see his privileges to decide what part of the website to show him (Admin page? Customer interface?…).
If you send back the user object, make sure to filter all sensitive data before returning it to the user. For example, you shouldn’t return the user’s password hash to the API client. Nor should you return his activation token or his password reset token.
For Spring applications, you can perform this filtering at the model level. You can add the annotation @JsonIgnore for the fields you would like to hide from the JSON serializer.
In our case, we would like to hide the user’s password, activation token, the password reset token, and the 2FA seed. The model “User” would look like the following:
import com.fasterxml.jackson.annotation.JsonIgnore; public class User{ private int user_id; private String email; @JsonIgnore private String password_hash; private Date creation_date; @JsonIgnore private String activation_token; private boolean is_active; @JsonIgnore private String Password_reset_token; private Date password_reset_token_expiration_date; @JsonIgnore private String 2fa_seed; private boolean 2fa_enabled public int getUserId() { return user_id; } public void setUserId(int user_id) { this.user_id = user_id; } [...] }
Just don’t do it
User account management isn’t easy to put in place. There is a big margin for errors, and the risk is too important. By design, user account management is a sensitive part of the system. A compromise of an account could have a devastating effect on the victim and on the platform itself.
We covered so much in this article. Yet, there are many things we didn’t mention. Make sure you aren’t vulnerable to SQL injection and CSFR. You must securely configure your CORS policy. Make sure to secure users’ sessions. And god, there are so many possibilities of XSS injections if you’re not careful. And lots of other things that could go wrong.
There are so many user account management solutions available that will manage all this for you. They have been around for a long time. They audited the solution and patched vulnerabilities. Their developers took courses in cybersecurity. Developers and security experts reviewed the source code. They secured their code via iterations, while suffering many breaches. So, take advantage of their learning journey and use their solutions. We recommend the open source ones, since they got reviewed by the public.
The best security advice I can give is to read this article as entertainment. Just don’t do it.
Leave a Reply