diff --git a/build.gradle b/build.gradle index e5256ab4..e49c981f 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation 'org.hibernate:hibernate-hikaricp:5.6.1.Final' implementation 'com.zaxxer:HikariCP:5.0.0' implementation 'org.springframework.security:spring-security-crypto:5.6.0' + implementation 'dev.samstevens.totp:totp:1.7.1' implementation 'commons-logging:commons-logging:1.2' } diff --git a/src/main/java/me/kavin/piped/ServerLauncher.java b/src/main/java/me/kavin/piped/ServerLauncher.java index 01b62eba..bb484f6b 100644 --- a/src/main/java/me/kavin/piped/ServerLauncher.java +++ b/src/main/java/me/kavin/piped/ServerLauncher.java @@ -212,7 +212,8 @@ AsyncServlet mainServlet(Executor executor) { try { LoginRequest body = Constants.mapper.readValue(request.loadBody().getResult().asArray(), LoginRequest.class); - return getJsonResponse(ResponseHelper.loginResponse(body.username, body.password), "private"); + return getJsonResponse(ResponseHelper.loginResponse(body.username, body.password, body.totp), + "private"); } catch (Exception e) { return getErrorResponse(e, request.getPath()); } diff --git a/src/main/java/me/kavin/piped/utils/ResponseHelper.java b/src/main/java/me/kavin/piped/utils/ResponseHelper.java index d8dfb23d..a2aa5794 100644 --- a/src/main/java/me/kavin/piped/utils/ResponseHelper.java +++ b/src/main/java/me/kavin/piped/utils/ResponseHelper.java @@ -21,6 +21,7 @@ import java.util.Date; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -69,6 +70,12 @@ import com.rometools.rome.io.FeedException; import com.rometools.rome.io.SyndFeedOutput; +import dev.samstevens.totp.code.CodeGenerator; +import dev.samstevens.totp.code.CodeVerifier; +import dev.samstevens.totp.code.DefaultCodeGenerator; +import dev.samstevens.totp.code.DefaultCodeVerifier; +import dev.samstevens.totp.time.SystemTimeProvider; +import dev.samstevens.totp.time.TimeProvider; import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import it.unimi.dsi.fastutil.objects.ObjectArrayList; import me.kavin.piped.consts.Constants; @@ -97,6 +104,7 @@ import me.kavin.piped.utils.resp.CompromisedPasswordResponse; import me.kavin.piped.utils.resp.DisabledRegistrationResponse; import me.kavin.piped.utils.resp.IncorrectCredentialsResponse; +import me.kavin.piped.utils.resp.InvalidOldPasswordResponse; import me.kavin.piped.utils.resp.InvalidRequestResponse; import me.kavin.piped.utils.resp.LoginResponse; import me.kavin.piped.utils.resp.SubscribeStatusResponse; @@ -597,7 +605,11 @@ public static final byte[] registerResponse(String user, String pass) throws IOE private static final BCryptPasswordEncoder bcryptPasswordEncoder = new BCryptPasswordEncoder(); - public static final byte[] loginResponse(String user, String pass) + private static final TimeProvider timeProvider = new SystemTimeProvider(); + private static final CodeGenerator codeGenerator = new DefaultCodeGenerator(); + private static final CodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); + + public static final byte[] loginResponse(String user, String pass, String totp) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { if (user == null || pass == null) @@ -616,14 +628,20 @@ public static final byte[] loginResponse(String user, String pass) if (dbuser != null) { String hash = dbuser.getPassword(); if (hash.startsWith("$argon2")) { - if (argon2PasswordEncoder.matches(pass, hash)) { + if (!argon2PasswordEncoder.matches(pass, hash)) { s.close(); - return Constants.mapper.writeValueAsBytes(new LoginResponse(dbuser.getSessionId())); + return Constants.mapper.writeValueAsBytes(new IncorrectCredentialsResponse()); } - } else if (bcryptPasswordEncoder.matches(pass, hash)) { + } else if (!bcryptPasswordEncoder.matches(pass, hash)) { s.close(); - return Constants.mapper.writeValueAsBytes(new LoginResponse(dbuser.getSessionId())); + return Constants.mapper.writeValueAsBytes(new IncorrectCredentialsResponse()); } + + String totpSecret = dbuser.getTotp(); + if (totpSecret != null && !verifier.isValidCode(totpSecret, totp)) + return Constants.mapper.writeValueAsBytes(new IncorrectCredentialsResponse()); + + return Constants.mapper.writeValueAsBytes(new LoginResponse(dbuser.getSessionId())); } s.close(); @@ -632,6 +650,83 @@ public static final byte[] loginResponse(String user, String pass) } + public static final byte[] changePasswordResponse(String session, String oldpass, String newpass) + throws IOException, InterruptedException, URISyntaxException { + + if (oldpass == null || newpass == null) + return Constants.mapper.writeValueAsBytes(new InvalidRequestResponse()); + + Session s = DatabaseSessionFactory.createSession(); + + User user = DatabaseHelper.getUserFromSession(s, session); + + if (user != null) { + String hash = user.getPassword(); + if (hash.startsWith("$argon2")) { + if (!argon2PasswordEncoder.matches(oldpass, hash)) { + s.close(); + return Constants.mapper.writeValueAsBytes(new InvalidOldPasswordResponse()); + } + } else if (!bcryptPasswordEncoder.matches(oldpass, hash)) { + s.close(); + return Constants.mapper.writeValueAsBytes(new InvalidOldPasswordResponse()); + } + + if (Constants.COMPROMISED_PASSWORD_CHECK) { + String sha1Hash = DigestUtils.sha1Hex(newpass).toUpperCase(); + String prefix = sha1Hash.substring(0, 5); + String suffix = sha1Hash.substring(5); + String[] entries = RequestUtils + .sendGet("https://api.pwnedpasswords.com/range/" + prefix, "github.com/TeamPiped/Piped-Backend") + .split("\n"); + for (String entry : entries) + if (StringUtils.substringBefore(entry, ":").equals(suffix)) + return Constants.mapper.writeValueAsBytes(new CompromisedPasswordResponse()); + } + + user.setPassword(argon2PasswordEncoder.encode(newpass)); + s.saveOrUpdate(user); + s.getTransaction().begin(); + s.getTransaction().commit(); + } + + s.close(); + + return Constants.mapper.writeValueAsBytes(new AuthenticationFailureResponse()); + } + + public static final byte[] authValidResponse(String session) throws JsonProcessingException { + Session s = DatabaseSessionFactory.createSession(); + + if (((Long) s.createQuery("SELECT COUNT(user) from User user where user.sessionId = :sessionId") + .setParameter("sessionId", session).uniqueResult()).intValue() > 0) { + s.close(); + return Constants.mapper.writeValueAsBytes(new AcceptedResponse()); + } + + s.close(); + + return Constants.mapper.writeValueAsBytes(new AuthenticationFailureResponse()); + } + + public static final byte[] logoutResponse(String session) throws JsonProcessingException { + Session s = DatabaseSessionFactory.createSession(); + + s.getTransaction().begin(); + + if (s.createQuery("UPDATE User user SET user.sessionId = :newSessionId where user.sessionId = :sessionId") + .setParameter("sessionId", session).setParameter("newSessionId", String.valueOf(UUID.randomUUID())) + .executeUpdate() > 0) { + s.getTransaction().commit(); + s.close(); + return Constants.mapper.writeValueAsBytes(new AcceptedResponse()); + } + + s.close(); + + return Constants.mapper.writeValueAsBytes(new AuthenticationFailureResponse()); + } + public static final byte[] subscribeResponse(String session, String channelId) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { diff --git a/src/main/java/me/kavin/piped/utils/obj/db/User.java b/src/main/java/me/kavin/piped/utils/obj/db/User.java index f3d2b54a..f37f4a19 100644 --- a/src/main/java/me/kavin/piped/utils/obj/db/User.java +++ b/src/main/java/me/kavin/piped/utils/obj/db/User.java @@ -37,6 +37,9 @@ public class User implements Serializable { @Column(name = "session_id", length = 36) private String sessionId; + @Column(name = "totp_token", length = 32, insertable = false) + private String totp; + @ElementCollection @CollectionTable(name = "users_subscribed", joinColumns = @JoinColumn(name = "subscriber"), indexes = { @Index(columnList = "subscriber", name = "users_subscribed_subscriber_idx"), @@ -86,6 +89,14 @@ public void setPassword(String password) { this.password = password; } + public String getTotp() { + return totp; + } + + public void setTotp(String totp) { + this.totp = totp; + } + public List getSubscribed() { return subscribed_ids; } diff --git a/src/main/java/me/kavin/piped/utils/resp/IncorrectCredentialsResponse.java b/src/main/java/me/kavin/piped/utils/resp/IncorrectCredentialsResponse.java index 990c3246..5e2d1922 100644 --- a/src/main/java/me/kavin/piped/utils/resp/IncorrectCredentialsResponse.java +++ b/src/main/java/me/kavin/piped/utils/resp/IncorrectCredentialsResponse.java @@ -2,6 +2,6 @@ public class IncorrectCredentialsResponse { - public String error = "The username or password you have entered is incorrect."; + public String error = "Invalid credentials. Re-check your username, password and totp."; } diff --git a/src/main/java/me/kavin/piped/utils/resp/InvalidOldPasswordResponse.java b/src/main/java/me/kavin/piped/utils/resp/InvalidOldPasswordResponse.java new file mode 100644 index 00000000..bac8f227 --- /dev/null +++ b/src/main/java/me/kavin/piped/utils/resp/InvalidOldPasswordResponse.java @@ -0,0 +1,7 @@ +package me.kavin.piped.utils.resp; + +public class InvalidOldPasswordResponse { + + public String error = "The old password you provided is incorrect."; + +} diff --git a/src/main/java/me/kavin/piped/utils/resp/LoginRequest.java b/src/main/java/me/kavin/piped/utils/resp/LoginRequest.java index 164a0db4..de3b3b65 100644 --- a/src/main/java/me/kavin/piped/utils/resp/LoginRequest.java +++ b/src/main/java/me/kavin/piped/utils/resp/LoginRequest.java @@ -2,6 +2,6 @@ public class LoginRequest { - public String username, password; + public String username, password, totp; }