Skip to content

Commit

Permalink
added two-factor authentication implemented with TOTP codes
Browse files Browse the repository at this point in the history
  • Loading branch information
albogdano committed Jun 6, 2024
1 parent 5b46703 commit 51b46d6
Show file tree
Hide file tree
Showing 9 changed files with 390 additions and 12 deletions.
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@
<artifactId>akismet-kotlin</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<!--QR Code generator-->
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>

<!-- TESTING -->
<dependency>
Expand Down
108 changes: 100 additions & 8 deletions src/main/java/com/erudika/scoold/controllers/SettingsController.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,38 @@
*/
package com.erudika.scoold.controllers;

import com.erudika.para.client.ParaClient;
import com.erudika.para.core.Sysprop;
import com.erudika.para.core.User;
import com.erudika.para.core.utils.Config;
import com.erudika.para.core.utils.Utils;
import com.erudika.scoold.ScooldConfig;
import static com.erudika.scoold.ScooldServer.SETTINGSLINK;
import static com.erudika.scoold.ScooldServer.SIGNINLINK;
import com.erudika.scoold.core.Profile;
import com.erudika.scoold.utils.HttpUtils;
import com.erudika.scoold.utils.ScooldUtils;
import com.erudika.scoold.utils.avatars.AvatarRepository;
import com.erudika.scoold.utils.avatars.AvatarRepositoryProxy;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.StreamingOutput;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.codec.binary.Base32;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
Expand All @@ -51,11 +65,14 @@
public class SettingsController {

private final ScooldUtils utils;
private final ParaClient pc;
private final AvatarRepository avatarRepository;
private static final ScooldConfig CONF = ScooldUtils.getConfig();

@Inject
public SettingsController(ScooldUtils utils, AvatarRepositoryProxy avatarRepository) {
this.utils = utils;
this.pc = utils.getParaClient();
this.avatarRepository = avatarRepository;
}

Expand Down Expand Up @@ -123,18 +140,93 @@ public String deleteAccount(HttpServletRequest req, HttpServletResponse res) {
utils.getAuthUser(req).delete();
utils.clearSession(req, res);
}
return "redirect:" + ScooldUtils.getConfig().signoutUrl(4);
return "redirect:" + CONF.signoutUrl(4);
}

@PostMapping("/toggle-twofa")
public String toggle2FA(@RequestParam String code, @RequestParam(required = false, defaultValue = "") String backupCode,
HttpServletRequest req, HttpServletResponse res, Model model) {
if (utils.isAuthenticated(req)) {
String jwt = HttpUtils.getStateParam(CONF.authCookie(), req);
User user = pc.me(jwt);
if (user != null && (!StringUtils.isBlank(code) || !StringUtils.isBlank(backupCode))) {
if (utils.isValid2FACode(user.getTwoFAkey(), NumberUtils.toInt(code, 0), 0) ||
Utils.bcryptMatches(backupCode, user.getTwoFAbackupKeyHash())) {
user.setTwoFA(!user.getTwoFA());
Date issueTime = utils.getUnverifiedClaimsFromJWT(jwt).getIssueTime();
if (user.getTwoFA()) {
String backup = Utils.generateSecurityToken(20, true);
user.setTwoFAbackupKeyHash(Utils.bcrypt(backup));
model.addAttribute("backupCode", backup);
HttpUtils.set2FACookie(user, issueTime, req, res);
} else {
user.setTwoFAkey("");
user.setTwoFAbackupKeyHash("");
HttpUtils.set2FACookie(null, null, req, res);
}
pc.update(user);
utils.getAuthUser(req).setUser(user);
return get(req, model);
}
return "redirect:" + SETTINGSLINK + "?code=signin.invalidcode&error=true";
}
}
return "redirect:" + SETTINGSLINK;
}

@PostMapping("/reset-2fa")
public String reset2FA(HttpServletRequest req, HttpServletResponse res) {
if (!utils.isAuthenticated(req)) {
return null;
}
return "redirect:" + SETTINGSLINK;
}

@GetMapping(path = "/qr", produces = "image/png")
public void generate2FAQRCode(HttpServletRequest req, HttpServletResponse res) {
if (!utils.isAuthenticated(req)) {
return;
}
String jwt = HttpUtils.getStateParam(CONF.authCookie(), req);
User user = pc.me(jwt);
if (user == null) {
return;
}
if (StringUtils.isBlank(user.getTwoFAkey())) {
user.setTwoFAkey(Utils.generateSecurityToken(32, true));
pc.update(user);
}
String otpProtocol = Utils.formatMessage("otpauth://totp/" + CONF.appName() + ":{0}?secret={1}&issuer=Scoold",
user.getEmail(), new Base32().encodeAsString(user.getTwoFAkey().
replaceAll("=", "").getBytes()).replaceAll("=", ""));

QRCodeWriter writer = new QRCodeWriter();
final BitMatrix matrix;
try {
matrix = writer.encode(otpProtocol, BarcodeFormat.QR_CODE, 300, 300);
StreamingOutput qrCode = new StreamingOutput() {
@Override
public void write(OutputStream os) throws IOException, WebApplicationException {
MatrixToImageWriter.writeToStream(matrix, "PNG", os);
os.flush();
}
};
res.setContentType("image/png");
qrCode.write(res.getOutputStream());
} catch (Exception ex) {
return;
}
}

private boolean resetPasswordAndUpdate(User u, String pass, String newpass) {
if (u != null && !StringUtils.isBlank(pass) && !StringUtils.isBlank(newpass) &&
u.getIdentityProvider().equals("generic")) {
Sysprop s = utils.getParaClient().read(u.getEmail());
Sysprop s = pc.read(u.getEmail());
if (s != null && Utils.bcryptMatches(pass, (String) s.getProperty(Config._PASSWORD))) {
String hashed = Utils.bcrypt(newpass);
s.addProperty(Config._PASSWORD, hashed);
u.setPassword(hashed);
utils.getParaClient().update(s);
pc.update(s);
return true;
}
}
Expand All @@ -145,7 +237,7 @@ private void setFavTags(Profile authUser, String tags) {
if (!StringUtils.isBlank(tags)) {
Set<String> ts = new LinkedHashSet<String>();
for (String tag : tags.split(",")) {
if (!StringUtils.isBlank(tag) && ts.size() <= ScooldUtils.getConfig().maxFavoriteTags()) {
if (!StringUtils.isBlank(tag) && ts.size() <= CONF.maxFavoriteTags()) {
ts.add(tag);
}
}
Expand Down Expand Up @@ -180,7 +272,7 @@ private void setAnonymity(Profile authUser, String anonParam) {
private void setDarkMode(Profile authUser, String darkParam) {
if (utils.isDarkModeEnabled()) {
authUser.setDarkmodeEnabled("true".equalsIgnoreCase(darkParam));
utils.getParaClient().update(authUser);
pc.update(authUser);
}
}

Expand Down
52 changes: 49 additions & 3 deletions src/main/java/com/erudika/scoold/controllers/SigninController.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@
import static com.erudika.scoold.utils.HttpUtils.getBackToUrl;
import static com.erudika.scoold.utils.HttpUtils.setAuthCookie;
import com.erudika.scoold.utils.ScooldUtils;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.hc.core5.http.HttpHeaders;
Expand Down Expand Up @@ -276,6 +276,52 @@ public String changePass(@RequestParam String email,
return "redirect:" + SIGNINLINK + "/iforgot";
}

@GetMapping("/signin/two-factor")
public String twoFA(HttpServletRequest req, Model model) {
String jwt = HttpUtils.getStateParam(CONF.authCookie(), req);
if (StringUtils.isBlank(jwt)) {
return "redirect:" + SIGNINLINK;
}
model.addAttribute("path", "signin.vm");
model.addAttribute("title", utils.getLang(req).get("signin.twofactor"));
model.addAttribute("signinSelected", "navbtn-hover");
model.addAttribute("twofactor", true);
return "base";
}

@PostMapping("/signin/two-factor")
public String twoFAVerify(@RequestParam(name = "reset", required = false, defaultValue = "false") Boolean reset,
@RequestParam String code, HttpServletRequest req, HttpServletResponse res, Model model) {
String jwt = HttpUtils.getStateParam(CONF.authCookie(), req);
if (StringUtils.isBlank(jwt)) {
return "redirect:" + SIGNINLINK;
}
User u = pc.me(jwt);
if (u == null) {
return "redirect:" + SIGNINLINK + "?code=3&error=true";
}
if (reset && Utils.bcryptMatches(code, u.getTwoFAbackupKeyHash())) {
u.setTwoFA(false);
u.setTwoFAkey("");
u.setTwoFAbackupKeyHash("");
pc.update(u);
return "redirect:" + getBackToUrl(req);
} else {
int totpKey = NumberUtils.toInt(code, 0);
if (utils.isValid2FACode(u.getTwoFAkey(), totpKey, 0)) {
HttpUtils.set2FACookie(u, utils.getUnverifiedClaimsFromJWT(jwt).getIssueTime(), req, res);
return "redirect:" + getBackToUrl(req);
} else {
model.addAttribute("path", "signin.vm");
model.addAttribute("title", utils.getLang(req).get("signin.twofactor"));
model.addAttribute("signinSelected", "navbtn-hover");
model.addAttribute("twofactor", true);
model.addAttribute("error", Map.of("code", utils.getLang(req).get("signin.invalidcode")));
return "base";
}
}
}

@PostMapping("/signout")
public String post(HttpServletRequest req, HttpServletResponse res) {
if (utils.isAuthenticated(req)) {
Expand Down
39 changes: 39 additions & 0 deletions src/main/java/com/erudika/scoold/utils/HttpUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
*/
package com.erudika.scoold.utils;

import com.erudika.para.core.User;
import com.erudika.para.core.utils.Para;
import com.erudika.para.core.utils.ParaObjectUtils;
import com.erudika.para.core.utils.Utils;
import com.erudika.scoold.ScooldConfig;
Expand All @@ -28,6 +30,7 @@
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
Expand Down Expand Up @@ -56,6 +59,7 @@ public final class HttpUtils {

private static final Logger logger = LoggerFactory.getLogger(HttpUtils.class);
private static final ScooldConfig CONF = ScooldUtils.getConfig();
public static final String TWO_FA_COOKIE = CONF.authCookie() + "-2fa";
private static CloseableHttpClient httpclient;
private static final String DEFAULT_AVATAR = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n"
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" id=\"svg8\" width=\"756\" height=\"756\" "
Expand Down Expand Up @@ -276,6 +280,41 @@ public static void setAuthCookie(String jwt, HttpServletRequest req, HttpServlet
setRawCookie(CONF.authCookie(), jwt, req, res, "Lax", CONF.sessionTimeoutSec());
}

/**
* Sets the 2FA cookie.
* @param authUser auth user
* @param loginTime login time
* @param req req
* @param res res
*/
public static void set2FACookie(User authUser, Date loginTime, HttpServletRequest req, HttpServletResponse res) {
if (authUser != null && !StringUtils.isBlank(authUser.getTwoFAkey())) {
String cookieValue = Utils.hmacSHA256(authUser.getId(),
loginTime.getTime() + Para.getConfig().separator() + authUser.getTwoFAkey());
setRawCookie(TWO_FA_COOKIE, cookieValue, req, res, "Lax", CONF.sessionTimeoutSec());
} else {
removeStateParam(TWO_FA_COOKIE, req, res);
}
}

/**
* Checks the validity of the 2FA cookie.
* @param authUser auth user
* @param loginTime login time
* @param req req
* @param res res
* @return true if valid
*/
public static boolean isValid2FACookie(User authUser, Date loginTime, HttpServletRequest req, HttpServletResponse res) {
String twoFACookie = getStateParam(TWO_FA_COOKIE, req);
if (!StringUtils.isBlank(twoFACookie) && authUser != null) {
String computed = Utils.hmacSHA256(authUser.getId(),
loginTime.getTime() + Para.getConfig().separator() + authUser.getTwoFAkey());
return StringUtils.equals(computed, twoFACookie);
}
return false;
}

/**
* @param req req
* @return the original protected URL visited before authentication
Expand Down
Loading

0 comments on commit 51b46d6

Please sign in to comment.