diff --git a/pom.xml b/pom.xml index 92de410b..754be8c9 100755 --- a/pom.xml +++ b/pom.xml @@ -158,6 +158,12 @@ akismet-kotlin 1.0.0 + + + com.google.zxing + javase + 3.5.3 + diff --git a/src/main/java/com/erudika/scoold/controllers/SettingsController.java b/src/main/java/com/erudika/scoold/controllers/SettingsController.java index 92aefee7..c1864a26 100755 --- a/src/main/java/com/erudika/scoold/controllers/SettingsController.java +++ b/src/main/java/com/erudika/scoold/controllers/SettingsController.java @@ -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; @@ -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; } @@ -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; } } @@ -145,7 +237,7 @@ private void setFavTags(Profile authUser, String tags) { if (!StringUtils.isBlank(tags)) { Set ts = new LinkedHashSet(); 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); } } @@ -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); } } diff --git a/src/main/java/com/erudika/scoold/controllers/SigninController.java b/src/main/java/com/erudika/scoold/controllers/SigninController.java index 0ee8d802..d5281355 100755 --- a/src/main/java/com/erudika/scoold/controllers/SigninController.java +++ b/src/main/java/com/erudika/scoold/controllers/SigninController.java @@ -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; @@ -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)) { diff --git a/src/main/java/com/erudika/scoold/utils/HttpUtils.java b/src/main/java/com/erudika/scoold/utils/HttpUtils.java index f4700fa4..cbdcc308 100644 --- a/src/main/java/com/erudika/scoold/utils/HttpUtils.java +++ b/src/main/java/com/erudika/scoold/utils/HttpUtils.java @@ -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; @@ -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; @@ -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 = "\n" + " claims, long validitySecond return null; } + public JWTClaimsSet getUnverifiedClaimsFromJWT(String jwt) { + try { + if (jwt != null) { + SignedJWT sjwt = SignedJWT.parse(jwt); + return sjwt.getJWTClaimsSet(); + } + } catch (ParseException e) { + logger.warn(null, e); + } + return null; + } + + /** + * Calcuclate the TOTP code from a secret and check if it matches the one provided by the user. + * @param secret TOTP secret key + * @param code 2FA code + * @param variance number of 30s time frames in which 2FA codes are valid: 0 = valid once, 1 = valid for 60s, etc. + * @return true if codes match + */ + public boolean isValid2FACode(String secret, int code, int variance) { + if (secret != null) { + try { + // time frame is 30 seconds + long timeIndex = System.currentTimeMillis() / 1000 / 30; + byte[] secretBytes = secret.replaceAll("=", "").getBytes(); + for (int i = -variance; i <= variance; i++) { + long calculatedCode = getCode(secretBytes, timeIndex + i); + if (calculatedCode == code) { + return true; + } + } + } catch (Exception ex) { + logger.error(null, ex); + } + } + return false; + } + + private long getCode(byte[] secret, long timeIndex) + throws NoSuchAlgorithmException, InvalidKeyException { + SecretKeySpec signKey = new SecretKeySpec(secret, "HmacSHA1"); + ByteBuffer buffer = ByteBuffer.allocate(8); + buffer.putLong(timeIndex); + byte[] timeBytes = buffer.array(); + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(signKey); + byte[] hash = mac.doFinal(timeBytes); + int offset = hash[19] & 0xf; + long truncatedHash = hash[offset] & 0x7f; + for (int i = 1; i < 4; i++) { + truncatedHash <<= 8; + truncatedHash |= hash[offset + i] & 0xff; + } + truncatedHash = truncatedHash % 1000000; + return truncatedHash; + } + public boolean isApiKeyRevoked(String jti, boolean expired) { if (StringUtils.isBlank(jti)) { return false; diff --git a/src/main/resources/lang_en.properties b/src/main/resources/lang_en.properties index bbed14a3..c39082da 100644 --- a/src/main/resources/lang_en.properties +++ b/src/main/resources/lang_en.properties @@ -258,6 +258,11 @@ signin.slack = Continue with Slack signin.mattermost = Continue with Mattermost signin.amazon = Continue with Amazon signin.password = Continue with Email +signin.twofactor = Two-Factor Authentication +signin.code = Authentication code +signin.invalidcode = Invalid code, try again. +signin.backupcode = Two-factor recovery code +signin.usebackupcode = Use the two-factor recovery code instead users.havingspaces = Users having these spaces users.nothavingspaces = Users not having these spaces @@ -326,6 +331,10 @@ settings.anonymity = Anonymity settings.anonymity.text = Make my profile anonymous settings.darkmode = Dark mode settings.darkmode.text = Enable the dark theme +settings.scancode = Scan the QR code with an app like Google Authenticator and enter the returned code below. +settings.savecode = Save this recovery code - it's only shown once! +settings.twofaon = Two-factor authentication is enabled. +settings.twofaoff = To disable, enter the two-factor authentication code or the recovery code. reports.spam = Spam or commercial content reports.offensive = Offensive content, violence or abuse diff --git a/src/main/resources/static/scripts/scoold.js b/src/main/resources/static/scripts/scoold.js index 482b4c7b..b738affd 100755 --- a/src/main/resources/static/scripts/scoold.js +++ b/src/main/resources/static/scripts/scoold.js @@ -801,6 +801,15 @@ $(function () { } }); + $("#twofaSwitch").on("click", function () { + var div = $("#twofaSetup"); + if (div.hasClass("hide")) { + div.removeClass("hide"); + } else { + div.addClass("hide"); + } + }); + /**************************************************** * MODAL DIALOGS ****************************************************/ diff --git a/src/main/resources/templates/settings.vm b/src/main/resources/templates/settings.vm index c6ba5845..b1c80620 100755 --- a/src/main/resources/templates/settings.vm +++ b/src/main/resources/templates/settings.vm @@ -19,6 +19,69 @@ #else

$!lang.get("settings.title")

+

$!lang.get('signin.twofactor')

+
+
+ + +
+
+
+ #if(!$authUser.user.twoFA && !$backupCode) +
+ +
+ #end +
+ #if($backupCode) +

$!lang.get('settings.twofaon')

+

$!lang.get('settings.savecode')

+ $backupCode + #else +
+ #sectoken(false "TWO_FACTOR") +
+ #if($authUser.user.twoFA) + $!lang.get('settings.twofaoff') + #else + $!lang.get('settings.scancode') + #end + #getmessagebox("red white-text" $error.get("code")) +
+ + +
+ + #if($authUser.user.twoFA) +
+ + +
+ #end +
+ +
+ #end +
+
+
+
+
#if($scooldUtils.isNearMeFeatureEnabled())

$!lang.get('posts.locationfilter')

diff --git a/src/main/resources/templates/signin.vm b/src/main/resources/templates/signin.vm index 7e2f6dc7..f427501c 100755 --- a/src/main/resources/templates/signin.vm +++ b/src/main/resources/templates/signin.vm @@ -124,6 +124,53 @@ +#elseif($twofactor) +
+
+
+
+

$!lang.get("signin.twofactor")

+ + #sectoken(false "TWO_FACTOR") +
+ #getmessagebox("red white-text" $error.get("code")) + +
+
+
+ + + #if($request.getParameter("reset")) + + #end +
+
+
+ +
+ + #if(!$request.getParameter("reset")) + + #end + +
+
+
+
#else