diff --git a/src/com/fuse/actions/Login.java b/src/com/fuse/actions/Login.java index 4c0b95a6..1bc3bf94 100644 --- a/src/com/fuse/actions/Login.java +++ b/src/com/fuse/actions/Login.java @@ -22,6 +22,7 @@ import org.pac4j.core.profile.UserProfile; import com.fuse.dao.AssessmentType; +import com.fuse.utils.PasswordResets; import com.fuse.dao.AuditLog; import com.fuse.dao.Campaign; import com.fuse.dao.Category; @@ -379,22 +380,13 @@ public String sendReset() { } }*/ - String key = UUID.randomUUID().toString(); - PasswordReset reset = new PasswordReset(); - reset.setKey(key); - reset.setUser(u); - reset.setCreated(new Date()); - // You must register first and create a password to login. + // Short-lived token bound to this user; any earlier token stops working. + String key = PasswordResets.issue(em, u, PasswordResets.RESET_TTL_MILLIS); String message = "Hello " + u.getFname() + " " + u.getLname() + "

"; - message += "Click the link below to reset your password:

"; - String url = request.getRequestURL().toString(); - url = url.replace(request.getRequestURI(), ""); - url = url + request.getContextPath() + "/portal/Register?uid=" + key; + message += "Click the link below to reset your password. The link expires in one hour.

"; + // Built from the configured public address, never from the request's Host header. + String url = FSUtils.publicBaseUrl(request) + "/Register?uid=" + key; message += "Click here to Reset
"; - HibHelper.getInstance().preJoin(); - em.joinTransaction(); - em.persist(reset); - HibHelper.getInstance().commit(); EmailThread emailThread = new EmailThread(u.getEmail(), "Password Reset", message); TaskQueueExecutor.getInstance().execute(emailThread); diff --git a/src/com/fuse/actions/Register.java b/src/com/fuse/actions/Register.java index 2bd4140d..287cb824 100644 --- a/src/com/fuse/actions/Register.java +++ b/src/com/fuse/actions/Register.java @@ -8,12 +8,15 @@ import org.apache.struts2.convention.annotation.Result; import com.fuse.dao.AuditLog; +import com.fuse.utils.PasswordResets; import com.fuse.dao.HibHelper; import com.fuse.dao.PasswordReset; import com.fuse.dao.User; import com.fuse.utils.AccessControl; -@Namespace("/portal") +// Root namespace: reset and invitation links are opened without a session, and the +// AccessControlInterceptor only lets "/" and "/sso" through unauthenticated. +@Namespace("/") @Result(name = "success", location = "/WEB-INF/jsp/register/newuser.jsp") public class Register extends FSActionSupport { @@ -24,13 +27,10 @@ public class Register extends FSActionSupport { private String message = ""; @Action(value = "Register", results = { - @Result(name = "gotologin", type = "redirectAction", location = "../login"), }) + @Result(name = "gotologin", type = "redirectAction", location = "login"), }) public String execute() { - PasswordReset reset = (PasswordReset) em.createQuery("from PasswordReset where key = :key") - .setParameter("key", uid) - .getResultList().stream() - .findFirst().orElse(null); + PasswordReset reset = PasswordResets.redeem(em, uid); if(reset == null) { AuditLog.audit(this, "Registration Link was not valid", AuditLog.Login, true); message = "Link is no longer valid."; diff --git a/src/com/fuse/actions/admin/Users.java b/src/com/fuse/actions/admin/Users.java index c9c516d5..1a6c5089 100644 --- a/src/com/fuse/actions/admin/Users.java +++ b/src/com/fuse/actions/admin/Users.java @@ -10,6 +10,7 @@ import org.apache.struts2.convention.annotation.Result; import com.fuse.actions.FSActionSupport; +import com.fuse.utils.PasswordResets; import com.fuse.authentication.LDAPValidator; import com.fuse.authentication.oauth.SecurityConfigFactory; import com.fuse.dao.APIKeys; @@ -160,7 +161,7 @@ public String addUser() { return this.ERRORJSON; } - PasswordReset reset = null; + boolean invite = false; this.username = this.username.toLowerCase(); User userExists = (User) em.createQuery("from User where username = :username") @@ -202,9 +203,7 @@ public String addUser() { String key = UUID.randomUUID().toString(); u.setPasshash(key); message += "Your account has been created. Please access the link below and click to access with your SSO Credentials

"; - String url = request.getRequestURL().toString(); - url = url.replace(request.getRequestURI(), ""); - url = url + request.getContextPath(); + String url = FSUtils.publicBaseUrl(request); message += "Click here to Login
"; @@ -214,9 +213,7 @@ public String addUser() { if(passErrorMessage.equals("")) { u.setPasshash(AccessControl.HashPass(this.credential)); message += "Your account has been created. Please access the link below and click to access the portal. You will need to ask your Administrator for the credentials.

"; - String url = request.getRequestURL().toString(); - url = url.replace(request.getRequestURI(), ""); - url = url + request.getContextPath(); + String url = FSUtils.publicBaseUrl(request); message += "Click here to Login
"; }else { this._message = passErrorMessage; @@ -224,17 +221,11 @@ public String addUser() { } }else { - String key = UUID.randomUUID().toString(); u.setPasshash(AccessControl.HashPass(UUID.randomUUID().toString())); - reset = new PasswordReset(); - reset.setKey(key); - reset.setUser(u); - reset.setCreated(new Date()); - message += "Click the link below to update your password:

"; - String url = request.getRequestURL().toString(); - url = url.replace(request.getRequestURI(), ""); - url = url + request.getContextPath() + "/portal/Register?uid=" + key; - message += "Click here to Register
"; + // The invitation token is issued after the user is persisted so it is bound + // to the new account; the link is appended to the message then. + invite = true; + message += "Click the link below to set your password. The link expires in 72 hours:

"; } Permissions p = new Permissions(); @@ -257,13 +248,14 @@ public String addUser() { HibHelper.getInstance().preJoin(); em.joinTransaction(); em.persist(u); - if(reset != null) { - em.persist(reset); - } AuditLog.audit(this, "User " + u.getUsername() + " added", AuditLog.UserAction, false); HibHelper.getInstance().commit(); + if (invite) { + String key = PasswordResets.issue(em, u, PasswordResets.INVITE_TTL_MILLIS); + message += "Click here to Register
"; + } EmailThread emailThread = new EmailThread(this.email, "New Account Created", message); TaskQueueExecutor.getInstance().execute(emailThread); if (api) { diff --git a/src/com/fuse/api/users.java b/src/com/fuse/api/users.java index 02d87878..b08ade15 100644 --- a/src/com/fuse/api/users.java +++ b/src/com/fuse/api/users.java @@ -16,6 +16,8 @@ import javax.ws.rs.core.Response; import com.fuse.api.util.Support; +import com.fuse.utils.FSUtils; +import com.fuse.utils.PasswordResets; import com.fuse.dao.HibHelper; import com.fuse.dao.PasswordReset; import com.fuse.dao.Permissions; @@ -88,28 +90,10 @@ public Response createUser( .build(); } newUser.setTeam(t); - if (verify != null && verify == true) { - String key = UUID.randomUUID().toString(); + boolean invite = verify != null && verify == true; + if (invite) { + // A random, unknown password until the invitee registers through the emailed link. newUser.setPasshash(AccessControl.HashPass(UUID.randomUUID().toString())); - // Setting username to an empty string prevents logins with GUID. - // You must register first and create a password to login. - PasswordReset reset = new PasswordReset(); - reset.setKey(key); - reset.setUser(u); - reset.setCreated(new Date()); - HibHelper.getInstance().preJoin(); - em.joinTransaction(); - em.persist(reset); - HibHelper.getInstance().commit(); - String message = "Hello " + fname + " " + lname + "

"; - message += "Click the link below to update your password:

"; - String url = req.getRequestURL().toString(); - url = url.replace(req.getRequestURI(), ""); - url = url + req.getContextPath() + "/portal/Register?uid=" + key; - message += "Click here to Register
"; - EmailThread emailThread = new EmailThread(email, "New Account Created", message); - TaskQueueExecutor.getInstance().execute(emailThread); - } Permissions p = new Permissions(); if (isAdmin != null && isAdmin == true) @@ -128,6 +112,15 @@ public Response createUser( em.joinTransaction(); em.persist(newUser); HibHelper.getInstance().commit(); + if (invite) { + // Issued after persisting so the token is bound to the NEW user, not the calling admin. + String key = PasswordResets.issue(em, newUser, PasswordResets.INVITE_TTL_MILLIS); + String message = "Hello " + fname + " " + lname + "

"; + message += "Click the link below to set your password. The link expires in 72 hours:

"; + String url = FSUtils.publicBaseUrl(req) + "/Register?uid=" + key; + message += "Click here to Register
"; + TaskQueueExecutor.getInstance().execute(new EmailThread(email, "New Account Created", message)); + } return Response.status(200).entity(Support.SUCCESS).build(); } else { diff --git a/src/com/fuse/dao/PasswordReset.java b/src/com/fuse/dao/PasswordReset.java index 2e9199fd..18dbc64c 100644 --- a/src/com/fuse/dao/PasswordReset.java +++ b/src/com/fuse/dao/PasswordReset.java @@ -29,6 +29,8 @@ public class PasswordReset { private Long id; private String key; private Date created; + /** Absolute expiry; null on rows written before 1.8.15, which are treated as created + 1h. */ + private Date expires; @ManyToOne(fetch = FetchType.EAGER) private User user; } diff --git a/src/com/fuse/servlets/FileAccess.java b/src/com/fuse/servlets/FileAccess.java new file mode 100644 index 00000000..771290a1 --- /dev/null +++ b/src/com/fuse/servlets/FileAccess.java @@ -0,0 +1,60 @@ +package com.fuse.servlets; + +import javax.persistence.EntityManager; + +import com.fuse.dao.Assessment; +import com.fuse.dao.Files; +import com.fuse.dao.User; +import com.fuse.dao.Verification; +import com.fuse.dao.query.AssessmentQueries; + +/** + * Object-level authorization for stored attachments. The /service/* servlets + * sit outside the Struts access-control interceptor, so each one has to decide + * for itself; this applies the same rules used to open an assessment. + */ +public final class FileAccess { + + private FileAccess() { + } + + /** May {@code user} read or delete this stored file? */ + public static boolean canAccess(EntityManager em, User user, Files file) { + if (user == null || file == null || file.getEntityId() == null) { + return false; + } + if (Files.VERIFICATION.equals(file.getType())) { + return canAccessVerification(em, user, file.getEntityId()); + } + // Assessment and engagement attachments both hang off an assessment id. + return canAttachToAssessment(em, user, file.getEntityId()); + } + + /** May {@code user} add or remove attachments on this assessment? */ + public static boolean canAttachToAssessment(EntityManager em, User user, Long assessmentId) { + if (user == null || assessmentId == null) { + return false; + } + Assessment asmt = em.find(Assessment.class, assessmentId); + return asmt != null && AssessmentQueries.canAccessAssessment(user, asmt); + } + + /** May {@code user} work with attachments on this verification? */ + public static boolean canAccessVerification(EntityManager em, User user, Long verificationId) { + if (user == null || verificationId == null) { + return false; + } + Verification v = em.find(Verification.class, verificationId); + if (v == null) { + return false; + } + if (isUser(v.getAssessor(), user) || isUser(v.getAssignedRemediation(), user)) { + return true; + } + return v.getAssessment() != null && AssessmentQueries.canAccessAssessment(user, v.getAssessment()); + } + + private static boolean isUser(User candidate, User user) { + return candidate != null && candidate.getId() == user.getId(); + } +} diff --git a/src/com/fuse/servlets/fileUpload.java b/src/com/fuse/servlets/fileUpload.java index c62e26fe..5833d9af 100644 --- a/src/com/fuse/servlets/fileUpload.java +++ b/src/com/fuse/servlets/fileUpload.java @@ -59,6 +59,16 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) EntityManager em = HibHelper.getInstance().getEMF().createEntityManager(); Files f = (Files) em.createQuery("from Files where uuid = :id").setParameter("id", id).getResultList() .stream().findFirst().orElse(null); + if (f == null) { + em.close(); + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + if (!FileAccess.canAccess(em, u, f)) { + em.close(); + response.sendError(HttpServletResponse.SC_FORBIDDEN); + return; + } response.setContentType(f.getContentType()); // response.setContentType("application/octet-stream"); // response.setHeader("Content-Disposition", "attachment; filename=" + @@ -107,6 +117,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) Part filePart = request.getPart("file_data"); Files f = new Files(); f.setUuid(uuid); + f.setCreatorId(u.getId()); f.setContentType(filePart.getContentType()); String fileName = getFileName(filePart); if (!isSafeFileName(fileName)) { @@ -122,6 +133,11 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) f.setType(Files.ASSESSMENT); f.setEntityId(Long.parseLong(apid)); EntityManager em = HibHelper.getInstance().getEMF().createEntityManager(); + if (!FileAccess.canAttachToAssessment(em, u, f.getEntityId())) { + em.close(); + response.sendError(HttpServletResponse.SC_FORBIDDEN); + return; + } HibHelper.getInstance().preJoin(); em.joinTransaction(); em.persist(f); @@ -133,6 +149,11 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) f.setType(Files.VERIFICATION); f.setEntityId(Long.parseLong(verificationId)); EntityManager em = HibHelper.getInstance().getEMF().createEntityManager(); + if (!FileAccess.canAccessVerification(em, u, f.getEntityId())) { + em.close(); + response.sendError(HttpServletResponse.SC_FORBIDDEN); + return; + } HibHelper.getInstance().preJoin(); em.joinTransaction(); em.persist(f); @@ -205,6 +226,11 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) // Session session = HibHelper.getSessionFactory().openSession(); Files f = (Files) em.createQuery("from Files where uuid = :id").setParameter("id", delid).getResultList() .stream().findFirst().orElse(null); + if (f != null && !FileAccess.canAccess(em, u, f)) { + em.close(); + response.sendError(HttpServletResponse.SC_FORBIDDEN); + return; + } if (f != null) { HibHelper.getInstance().preJoin(); em.joinTransaction(); @@ -222,6 +248,11 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) // Session session = HibHelper.getSessionFactory().openSession(); // HibHelper hh = new HibHelper(); EntityManager em = HibHelper.getInstance().getEMF().createEntityManager(); + if (!FileAccess.canAttachToAssessment(em, u, Long.parseLong(apid))) { + em.close(); + response.sendError(HttpServletResponse.SC_FORBIDDEN); + return; + } Files f = (Files) em.createQuery("from Files where name = :name and entityId = :eid") .setParameter("name", name).setParameter("eid", Long.parseLong(apid)).getResultList().stream() .findFirst().orElse(null); diff --git a/src/com/fuse/utils/FSUtils.java b/src/com/fuse/utils/FSUtils.java index f35b2b75..3cee7246 100644 --- a/src/com/fuse/utils/FSUtils.java +++ b/src/com/fuse/utils/FSUtils.java @@ -53,6 +53,7 @@ import org.apache.struts2.ServletActionContext; import javax.persistence.EntityManager; import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; import org.apache.commons.codec.binary.Base64; import org.apache.commons.codec.binary.Hex; @@ -236,6 +237,35 @@ private static List uploadDirectories() { return dirs; } + /** + * Base URL for links we email out (password resets, invitations). Taken from + * FACTION_BASE_URL, then FACTION_OAUTH_CALLBACK, which SSO deployments already + * set to the public address. Only when neither is configured does it fall back + * to the request, whose Host header a sender controls, so set one of them in + * any deployment that sends mail. + */ + public static String publicBaseUrl(HttpServletRequest request) { + String configured = getEnv("FACTION_BASE_URL"); + if (configured.isEmpty()) { + configured = getEnv("FACTION_OAUTH_CALLBACK"); + } + String base; + if (!configured.isEmpty()) { + base = configured.trim(); + } else { + String url = request.getRequestURL().toString(); + String uri = request.getRequestURI(); + if (uri != null && url.endsWith(uri)) { + url = url.substring(0, url.length() - uri.length()); + } + base = url + (request.getContextPath() == null ? "" : request.getContextPath()); + } + while (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + return base; + } + public static boolean containsHTML(String value) { return value != null && (value.indexOf('<') >= 0 || value.indexOf('>') >= 0); } diff --git a/src/com/fuse/utils/PasswordResets.java b/src/com/fuse/utils/PasswordResets.java new file mode 100644 index 00000000..3a13feaf --- /dev/null +++ b/src/com/fuse/utils/PasswordResets.java @@ -0,0 +1,96 @@ +package com.fuse.utils; + +import java.util.Date; +import java.util.List; +import java.util.UUID; + +import javax.persistence.EntityManager; + +import com.fuse.dao.HibHelper; +import com.fuse.dao.PasswordReset; +import com.fuse.dao.User; + +/** + * Password reset and invitation tokens. + * + * A token is bound to the user whose password it will set, expires, and + * supersedes any earlier token for that user. Callers issue a token after the + * target user is persisted and put the returned key in the emailed link; + * {@link com.fuse.actions.Register} redeems it. + */ +public final class PasswordResets { + + /** Self-service password resets: one hour. */ + public static final long RESET_TTL_MILLIS = 60L * 60 * 1000; + /** Invitations for newly created accounts: three days. */ + public static final long INVITE_TTL_MILLIS = 72L * 60 * 60 * 1000; + + private PasswordResets() { + } + + /** + * Issues a fresh token for {@code target}, invalidating any outstanding ones. + * Manages its own transaction, so call it after the target user is committed. + */ + public static String issue(EntityManager em, User target, long ttlMillis) { + if (target == null || target.getId() == 0L) { + throw new IllegalArgumentException("reset target must be a persisted user"); + } + String key = UUID.randomUUID().toString(); + Date now = new Date(); + PasswordReset reset = new PasswordReset(); + reset.setKey(key); + reset.setUser(target); + reset.setCreated(now); + reset.setExpires(new Date(now.getTime() + ttlMillis)); + + HibHelper.getInstance().preJoin(); + em.joinTransaction(); + for (PasswordReset old : outstandingFor(em, target)) { + em.remove(old); + } + em.persist(reset); + HibHelper.getInstance().commit(); + return key; + } + + /** + * Returns the live token for {@code key}, or null when it is unknown or + * expired. Expired tokens are deleted on the way out. + */ + public static PasswordReset redeem(EntityManager em, String key) { + if (key == null || key.isEmpty()) { + return null; + } + PasswordReset reset = (PasswordReset) em.createQuery("from PasswordReset where key = :key") + .setParameter("key", key).getResultList().stream().findFirst().orElse(null); + if (reset == null) { + return null; + } + if (isExpired(reset)) { + HibHelper.getInstance().preJoin(); + em.joinTransaction(); + em.remove(reset); + HibHelper.getInstance().commit(); + return null; + } + return reset; + } + + static boolean isExpired(PasswordReset reset) { + Date expires = reset.getExpires(); + if (expires == null) { + // Pre-1.8.15 row: it never carried an expiry, so give it the reset window. + if (reset.getCreated() == null) { + return true; + } + expires = new Date(reset.getCreated().getTime() + RESET_TTL_MILLIS); + } + return !expires.after(new Date()); + } + + @SuppressWarnings("unchecked") + private static List outstandingFor(EntityManager em, User target) { + return em.createQuery("from PasswordReset where user = :user").setParameter("user", target).getResultList(); + } +} diff --git a/test/com/fuse/actions/RegisterReachabilityTest.java b/test/com/fuse/actions/RegisterReachabilityTest.java new file mode 100644 index 00000000..81501d61 --- /dev/null +++ b/test/com/fuse/actions/RegisterReachabilityTest.java @@ -0,0 +1,26 @@ +package com.fuse.actions; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import org.apache.struts2.junit.StrutsJUnit4TestCase; +import org.junit.Test; + +import com.opensymphony.xwork2.ActionProxy; + +/** + * Password reset and invitation links are opened by people without a session, + * so Register must live in the root namespace, the only one (besides /sso) the + * AccessControlInterceptor lets through unauthenticated. It sat in /portal from + * 1.8.8, which redirected every emailed link to the login page. + */ +public class RegisterReachabilityTest extends StrutsJUnit4TestCase { + + @Test + public void registerIsMappedInTheRootNamespace() throws Exception { + ActionProxy proxy = getActionProxy("/Register"); + assertNotNull("/Register must resolve", proxy); + assertEquals("/", proxy.getNamespace()); + assertEquals(Register.class, proxy.getAction().getClass()); + } +} diff --git a/test/com/fuse/servlets/FileAccessTest.java b/test/com/fuse/servlets/FileAccessTest.java new file mode 100644 index 00000000..1b61a151 --- /dev/null +++ b/test/com/fuse/servlets/FileAccessTest.java @@ -0,0 +1,192 @@ +package com.fuse.servlets; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.fuse.api.MongoTestBase; +import com.fuse.dao.Assessment; +import com.fuse.dao.Files; +import com.fuse.dao.HibHelper; +import com.fuse.dao.Permissions; +import com.fuse.dao.Teams; +import com.fuse.dao.User; +import com.fuse.dao.Verification; + +/** + * /service/fileUpload must apply object-level authorization: a stored file is + * reachable only by users who may access the assessment (or verification) it + * belongs to, using the same rules as opening that assessment. + */ +public class FileAccessTest extends MongoTestBase { + + private static EntityManagerFactory emf; + private static String tag; + private static Teams teamA, teamB; + private static User ownerA, teammateA, strangerB, managerB, remediationB; + private static Assessment asmt; + private static Verification verification; + private static Files asmtFile, verFile; + + @BeforeClass + public static void seed() { + emf = HibHelper.getInstance().getEMF(); + org.junit.Assume.assumeNotNull("EntityManagerFactory unavailable - skipping integration test", emf); + tag = UUID.randomUUID().toString().substring(0, 8); + EntityManager em = emf.createEntityManager(); + try { + teamA = team(em, "A-" + tag); + teamB = team(em, "B-" + tag); + ownerA = user(em, "owner-" + tag, teamA, false, false, Permissions.AccessLevelUserOnly); + teammateA = user(em, "mate-" + tag, teamA, false, false, Permissions.AccessLevelTeamOnly); + strangerB = user(em, "stranger-" + tag, teamB, false, false, Permissions.AccessLevelUserOnly); + managerB = user(em, "manager-" + tag, teamB, true, false, Permissions.AccessLevelAllData); + remediationB = user(em, "rem-" + tag, teamB, false, true, Permissions.AccessLevelUserOnly); + asmt = new Assessment(); + asmt.setName("asmt-" + tag); + asmt.setAppId("APP-" + tag); + List assessors = new ArrayList<>(); + assessors.add(ownerA); + asmt.setAssessor(assessors); + asmt.setStart(new Date()); + asmt.setEnd(new Date()); + persist(em, asmt); + verification = new Verification(); + verification.setAssessment(asmt); + verification.setAssessor(ownerA); + verification.setAssignedRemediation(remediationB); + persist(em, verification); + asmtFile = file(em, Files.ASSESSMENT, asmt.getId()); + verFile = file(em, Files.VERIFICATION, verification.getId()); + } finally { + em.close(); + } + } + + @AfterClass + public static void cleanup() { + if (emf == null) + return; + EntityManager em = emf.createEntityManager(); + try { + for (Object o : new Object[] { asmtFile, verFile, verification, asmt, ownerA, teammateA, strangerB, managerB, + remediationB, teamA, teamB }) + if (o != null) + remove(em, o); + } finally { + em.close(); + } + } + + @Test + public void assessmentFileFollowsAssessmentAccess() { + EntityManager em = emf.createEntityManager(); + try { + assertTrue("assessor on the assessment", FileAccess.canAccess(em, ownerA, asmtFile)); + assertTrue("same team with team-level access", FileAccess.canAccess(em, teammateA, asmtFile)); + assertTrue("manager with all-data access", FileAccess.canAccess(em, managerB, asmtFile)); + assertFalse("user-only assessor from another team", FileAccess.canAccess(em, strangerB, asmtFile)); + } finally { + em.close(); + } + } + + @Test + public void verificationFileFollowsVerificationAccess() { + EntityManager em = emf.createEntityManager(); + try { + assertTrue("verification assessor", FileAccess.canAccess(em, ownerA, verFile)); + assertTrue("assigned remediation user", FileAccess.canAccess(em, remediationB, verFile)); + assertTrue("manager", FileAccess.canAccess(em, managerB, verFile)); + assertFalse("unrelated user-only user", FileAccess.canAccess(em, strangerB, verFile)); + } finally { + em.close(); + } + } + + @Test + public void attachingToAnAssessmentNeedsAccessToIt() { + EntityManager em = emf.createEntityManager(); + try { + assertTrue(FileAccess.canAttachToAssessment(em, ownerA, asmt.getId())); + assertFalse(FileAccess.canAttachToAssessment(em, strangerB, asmt.getId())); + assertFalse("unknown assessment", FileAccess.canAttachToAssessment(em, managerB, -1L)); + } finally { + em.close(); + } + } + + @Test + public void missingFileIsNeverAccessible() { + EntityManager em = emf.createEntityManager(); + try { + assertFalse(FileAccess.canAccess(em, managerB, null)); + } finally { + em.close(); + } + } + + private static Files file(EntityManager em, String type, Long entityId) { + Files f = new Files(); + f.setUuid(UUID.randomUUID().toString()); + f.setName("f-" + tag + ".txt"); + f.setContentType("text/plain"); + f.setType(type); + f.setEntityId(entityId); + f.setRealFile("hello".getBytes()); + persist(em, f); + return f; + } + + private static Teams team(EntityManager em, String name) { + Teams t = new Teams(); + t.setTeamName(name); + persist(em, t); + return t; + } + + private static User user(EntityManager em, String name, Teams team, boolean manager, boolean remediation, Integer level) { + Permissions p = new Permissions(); + p.setAssessor(!remediation); + p.setManager(manager); + p.setRemediation(remediation); + p.setEngagement(false); + p.setAdmin(false); + p.setExecutive(false); + p.setAccessLevel(level); + User u = new User(); + u.setUsername(name); + u.setFname(name); + u.setLname("t"); + u.setEmail(name + "@example.test"); + u.setTeam(team); + u.setPermissions(p); + persist(em, u); + return u; + } + + private static void persist(EntityManager em, Object entity) { + HibHelper.getInstance().preJoin(); + em.joinTransaction(); + em.persist(entity); + HibHelper.getInstance().commit(); + } + + private static void remove(EntityManager em, Object entity) { + HibHelper.getInstance().preJoin(); + em.joinTransaction(); + em.remove(em.contains(entity) ? entity : em.merge(entity)); + HibHelper.getInstance().commit(); + } +} diff --git a/test/com/fuse/utils/PasswordResetsTest.java b/test/com/fuse/utils/PasswordResetsTest.java new file mode 100644 index 00000000..0065acb0 --- /dev/null +++ b/test/com/fuse/utils/PasswordResetsTest.java @@ -0,0 +1,160 @@ +package com.fuse.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Date; +import java.util.List; +import java.util.UUID; + +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.fuse.api.MongoTestBase; +import com.fuse.dao.HibHelper; +import com.fuse.dao.PasswordReset; +import com.fuse.dao.User; + +/** + * Password reset / invitation tokens: bound to the target user, expiring, and + * one-at-a-time per user. Issued by sendReset, the admin "add user" page and the + * users API; redeemed by Register. + */ +public class PasswordResetsTest extends MongoTestBase { + + private static EntityManagerFactory emf; + private static String tag; + private static User target, admin; + + @BeforeClass + public static void seed() { + emf = HibHelper.getInstance().getEMF(); + org.junit.Assume.assumeNotNull("EntityManagerFactory unavailable - skipping integration test", emf); + tag = UUID.randomUUID().toString().substring(0, 8); + EntityManager em = emf.createEntityManager(); + try { + target = user(em, "target-" + tag); + admin = user(em, "admin-" + tag); + } finally { + em.close(); + } + } + + @AfterClass + public static void cleanup() { + if (emf == null) + return; + EntityManager em = emf.createEntityManager(); + try { + for (PasswordReset r : (List) em.createQuery("from PasswordReset").getResultList()) { + if (r.getUser() != null && (r.getUser().getId() == target.getId() || r.getUser().getId() == admin.getId())) + remove(em, r); + } + remove(em, em.find(User.class, target.getId())); + remove(em, em.find(User.class, admin.getId())); + } finally { + em.close(); + } + } + + @Test + public void issuedTokenIsBoundToTheTargetUserNotTheCaller() { + EntityManager em = emf.createEntityManager(); + try { + String key = PasswordResets.issue(em, target, PasswordResets.INVITE_TTL_MILLIS); + PasswordReset reset = PasswordResets.redeem(em, key); + assertNotNull(reset); + assertEquals(target.getId(), reset.getUser().getId()); + assertNotEquals(admin.getId(), reset.getUser().getId()); + assertNotNull("tokens carry an expiry", reset.getExpires()); + assertTrue(reset.getExpires().after(new Date())); + } finally { + em.close(); + } + } + + @Test + public void issuingANewTokenInvalidatesTheOlderOne() { + EntityManager em = emf.createEntityManager(); + try { + String first = PasswordResets.issue(em, target, PasswordResets.RESET_TTL_MILLIS); + String second = PasswordResets.issue(em, target, PasswordResets.RESET_TTL_MILLIS); + assertNull("superseded token must stop working", PasswordResets.redeem(em, first)); + assertNotNull(PasswordResets.redeem(em, second)); + } finally { + em.close(); + } + } + + @Test + public void expiredTokenIsRejectedAndRemoved() { + EntityManager em = emf.createEntityManager(); + try { + String key = PasswordResets.issue(em, target, -1000L); // already expired + assertNull(PasswordResets.redeem(em, key)); + assertTrue("expired token is cleaned up", + em.createQuery("from PasswordReset where key = :k").setParameter("k", key).getResultList().isEmpty()); + } finally { + em.close(); + } + } + + @Test + public void legacyTokenWithoutExpiryIsTreatedAsExpiringAnHourAfterCreation() { + EntityManager em = emf.createEntityManager(); + try { + PasswordReset legacy = new PasswordReset(); + legacy.setKey("legacy-" + tag); + legacy.setUser(target); + legacy.setCreated(new Date(System.currentTimeMillis() - 2 * 60 * 60 * 1000L)); // two hours old + persist(em, legacy); + assertNull(PasswordResets.redeem(em, "legacy-" + tag)); + } finally { + em.close(); + } + } + + @Test + public void unknownTokenIsRejected() { + EntityManager em = emf.createEntityManager(); + try { + assertNull(PasswordResets.redeem(em, "nope-" + tag)); + assertNull(PasswordResets.redeem(em, null)); + } finally { + em.close(); + } + } + + private static User user(EntityManager em, String name) { + User u = new User(); + u.setUsername(name); + u.setFname(name); + u.setLname("t"); + u.setEmail(name + "@example.test"); + persist(em, u); + return u; + } + + private static void persist(EntityManager em, Object entity) { + HibHelper.getInstance().preJoin(); + em.joinTransaction(); + em.persist(entity); + HibHelper.getInstance().commit(); + } + + private static void remove(EntityManager em, Object entity) { + if (entity == null) + return; + HibHelper.getInstance().preJoin(); + em.joinTransaction(); + em.remove(em.contains(entity) ? entity : em.merge(entity)); + HibHelper.getInstance().commit(); + } +} diff --git a/test/com/fuse/utils/PublicBaseUrlTest.java b/test/com/fuse/utils/PublicBaseUrlTest.java new file mode 100644 index 00000000..9cae66ff --- /dev/null +++ b/test/com/fuse/utils/PublicBaseUrlTest.java @@ -0,0 +1,49 @@ +package com.fuse.utils; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import javax.servlet.http.HttpServletRequest; + +import org.junit.After; +import org.junit.Test; + +/** + * Emailed links (password reset, invitations) must not be built from the + * request's Host header, which an attacker controls. They come from the + * configured public base URL, with the request as a last resort. + */ +public class PublicBaseUrlTest { + + @After + public void clearProps() { + System.clearProperty("FACTION_BASE_URL"); + System.clearProperty("FACTION_OAUTH_CALLBACK"); + } + + private static HttpServletRequest request() { + HttpServletRequest r = mock(HttpServletRequest.class); + when(r.getRequestURL()).thenReturn(new StringBuffer("http://attacker.example/portal/sendReset")); + when(r.getRequestURI()).thenReturn("/portal/sendReset"); + when(r.getContextPath()).thenReturn(""); + return r; + } + + @Test + public void configuredBaseUrlWinsOverTheHostHeader() { + System.setProperty("FACTION_BASE_URL", "https://faction.example.com/"); + assertEquals("https://faction.example.com", FSUtils.publicBaseUrl(request())); + } + + @Test + public void oauthCallbackIsUsedWhenNoBaseUrlIsConfigured() { + System.setProperty("FACTION_OAUTH_CALLBACK", "https://sso.example.com"); + assertEquals("https://sso.example.com", FSUtils.publicBaseUrl(request())); + } + + @Test + public void fallsBackToTheRequestOnlyWhenNothingIsConfigured() { + assertEquals("http://attacker.example", FSUtils.publicBaseUrl(request())); + } +}