From 14ecac13c6c4a54a3918f08c81c1ed92b85999d7 Mon Sep 17 00:00:00 2001 From: Sylvain Cadilhac Date: Thu, 5 Sep 2024 23:20:13 +0200 Subject: [PATCH] Password policy --- pom.xml | 4 +- .../java/onl/netfishers/netshot/Netshot.java | 2 + .../netshot/aaa/PasswordPolicy.java | 111 +++++++ .../onl/netfishers/netshot/aaa/Radius.java | 5 +- .../onl/netfishers/netshot/aaa/Tacacs.java | 32 +- .../onl/netfishers/netshot/aaa/UiUser.java | 98 +++++- .../netshot/device/Network4Address.java | 2 +- .../netfishers/netshot/rest/LoggerFilter.java | 30 +- ...etshotAuthenticationRequiredException.java | 2 +- .../rest/NetshotBadRequestException.java | 8 +- .../netfishers/netshot/rest/RestService.java | 168 +++++++--- src/main/resources/migration/netshot0.xml | 11 + src/main/resources/www/css/app.css | 14 +- src/main/resources/www/index.html | 44 ++- src/main/resources/www/js/router.js | 49 ++- .../netfishers/netshot/NetshotApiClient.java | 24 +- .../netfishers/netshot/RestServiceTest.java | 294 +++++++++++++++++- 17 files changed, 757 insertions(+), 141 deletions(-) create mode 100644 src/main/java/onl/netfishers/netshot/aaa/PasswordPolicy.java diff --git a/pom.xml b/pom.xml index b4c66e3a..8a69b79e 100644 --- a/pom.xml +++ b/pom.xml @@ -8,11 +8,11 @@ UTF-8 23.1.4 - 6.5.2.Final + 6.6.0.Final 4.0.2 2.17.2 3.1.7 - 2.2.22 + 2.2.23 2.0.13 1.9.3 1.18.34 diff --git a/src/main/java/onl/netfishers/netshot/Netshot.java b/src/main/java/onl/netfishers/netshot/Netshot.java index 2648df33..272b50be 100644 --- a/src/main/java/onl/netfishers/netshot/Netshot.java +++ b/src/main/java/onl/netfishers/netshot/Netshot.java @@ -54,6 +54,7 @@ import ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy; import ch.qos.logback.core.util.FileSize; import lombok.extern.slf4j.Slf4j; +import onl.netfishers.netshot.aaa.PasswordPolicy; import onl.netfishers.netshot.aaa.Radius; import onl.netfishers.netshot.aaa.Tacacs; import onl.netfishers.netshot.cluster.ClusterManager; @@ -589,6 +590,7 @@ public void handle(Signal sig) { TakeSnapshotTask.loadConfig(); JavaScriptRule.loadConfig(); PythonRule.loadConfig(); + PasswordPolicy.loadConfig(); } }); log.warn("Netshot is started"); diff --git a/src/main/java/onl/netfishers/netshot/aaa/PasswordPolicy.java b/src/main/java/onl/netfishers/netshot/aaa/PasswordPolicy.java new file mode 100644 index 00000000..1ea41320 --- /dev/null +++ b/src/main/java/onl/netfishers/netshot/aaa/PasswordPolicy.java @@ -0,0 +1,111 @@ +package onl.netfishers.netshot.aaa; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import lombok.Getter; +import lombok.Setter; +import onl.netfishers.netshot.Netshot; + +/** + * Password policy + */ +public class PasswordPolicy { + + /** + * Exception to be thrown in case of password policy check failure + */ + static public class PasswordPolicyException extends Exception { + public PasswordPolicyException(String message) { + super(message); + } + } + + /** + * Match for specific characters in the password. + */ + static public enum CharMatch { + ANY("mintotalchars", "minimum total length", ".", 1), + SPECIAL("minspecialchars", "minimum special characters count", "[!\"#$%&'()*+,-./:;<=>?@\\[\\]\\^_{}|~]", 0), + NUMERICAL("minnumericalchars", "minimum numerical character count", "[0-9]", 0), + LOWERCASE("minlowercasechars", "minimum lowercase character count", "[a-z]", 0), + UPPERCASE("minuppercasechars", "minimum uppercase character count", "[A-Z]", 0); + + @Getter + private String name; + + @Getter + private String description; + + @Getter + private Pattern pattern; + + @Getter + private int defaultValue; + + private CharMatch(String name, String description, String pattern, int defaultValue) { + this.name = name; + this.description = description; + this.pattern = Pattern.compile(pattern); + this.defaultValue = defaultValue; + } + + /** + * Count the number of matches in the given target string. + * @param target Where to find characters + * @return the number of matches + */ + public long countMatches(String target) { + Matcher m = this.pattern.matcher(target); + return m.results().count(); + } + } + + static private PasswordPolicy mainPolicy; + + /** + * Load the main policy from configuration. + */ + public static void loadConfig() { + final String configPrefix = "netshot.aaa.passwordpolicy."; + PasswordPolicy policy = new PasswordPolicy(); + policy.maxHistory = Netshot.getConfig(configPrefix + "maxhistory", 0, 0, Integer.MAX_VALUE); + policy.maxDuration = Netshot.getConfig(configPrefix + "maxduration", 0, 0, Integer.MAX_VALUE); + for (CharMatch m : CharMatch.values()) { + int min = Netshot.getConfig(configPrefix + m.getName(), m.getDefaultValue(), 0, 1024); + if (min > 0) { + policy.minCharMatchCounts.put(m, min); + } + } + PasswordPolicy.mainPolicy = policy; + } + + static { + PasswordPolicy.loadConfig(); + } + + /** + * Get the main password policy (as configured in Netshot config file) + * @return the main password policy + */ + static public PasswordPolicy getMainPolicy() { + return PasswordPolicy.mainPolicy; + } + + /** Max history count */ + @Getter + @Setter + private int maxHistory = 0; + + /** Max duration of the password, in days */ + @Getter + @Setter + private int maxDuration = 0; + + /** Min number of characters per character match */ + @Getter + @Setter + private Map minCharMatchCounts = new HashMap<>(); +} diff --git a/src/main/java/onl/netfishers/netshot/aaa/Radius.java b/src/main/java/onl/netfishers/netshot/aaa/Radius.java index fe0e08c6..72f35627 100644 --- a/src/main/java/onl/netfishers/netshot/aaa/Radius.java +++ b/src/main/java/onl/netfishers/netshot/aaa/Radius.java @@ -25,7 +25,6 @@ import java.util.List; import onl.netfishers.netshot.Netshot; -import onl.netfishers.netshot.device.NetworkAddress; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -158,7 +157,7 @@ public static boolean isAvailable() { * @param password the password * @return true, if successful */ - public static UiUser authenticate(String username, String password, NetworkAddress remoteAddress) { + public static UiUser authenticate(String username, String password, String remoteAddress) { if (!isAvailable()) { return null; } @@ -170,7 +169,7 @@ public static UiUser authenticate(String username, String password, NetworkAddre attributeList.add(new Attr_NASIdentifier(nasIdentifier)); } if (remoteAddress != null) { - attributeList.add(new Attr_CallingStationId(remoteAddress.getIp())); + attributeList.add(new Attr_CallingStationId(remoteAddress)); } boolean first = true; diff --git a/src/main/java/onl/netfishers/netshot/aaa/Tacacs.java b/src/main/java/onl/netfishers/netshot/aaa/Tacacs.java index d42bf2d5..56e3afd1 100644 --- a/src/main/java/onl/netfishers/netshot/aaa/Tacacs.java +++ b/src/main/java/onl/netfishers/netshot/aaa/Tacacs.java @@ -43,7 +43,6 @@ import org.slf4j.MarkerFactory; import onl.netfishers.netshot.Netshot; -import onl.netfishers.netshot.device.NetworkAddress; /** * The Tacacs class authenticates the users against a TACACS+ server. @@ -122,13 +121,13 @@ public static boolean isAvailable() { * @param password the password * @return true, if successful */ - public static UiUser authenticate(String username, String password, NetworkAddress remoteAddress) { + public static UiUser authenticate(String username, String password, String remoteAddress) { if (!isAvailable()) { return null; } try { - SessionClient authenSession = client.newSession(SVC.LOGIN, "rest", remoteAddress == null ? "0.0.0.0" : remoteAddress.getIp(), PRIV_LVL.USER.code()); + SessionClient authenSession = client.newSession(SVC.LOGIN, "rest", remoteAddress, PRIV_LVL.USER.code()); AuthenReply authenReply = authenSession.authenticate_ASCII(username, password); String roleAttribute = Netshot.getConfig("netshot.aaa.tacacs.role.attributename", "role"); @@ -138,7 +137,7 @@ public static UiUser authenticate(String username, String password, NetworkAddre String operatorLevelRole = Netshot.getConfig("netshot.aaa.tacacs.role.operatorrole", "operator"); if (authenReply.isOK()) { - SessionClient authoSession = client.newSession(SVC.LOGIN, "rest", remoteAddress == null ? "0.0.0.0" : remoteAddress.getIp(), PRIV_LVL.USER.code()); + SessionClient authoSession = client.newSession(SVC.LOGIN, "rest", remoteAddress, PRIV_LVL.USER.code()); AuthorReply authoReply = authoSession.authorize( username, METH.TACACSPLUS, @@ -167,7 +166,8 @@ else if (role.equals(operatorLevelRole)) { level = UiUser.LEVEL_OPERATOR; } - aaaLogger.info(MarkerFactory.getMarker("AAA"), "The user {} passed TACACS+ authentication (with permission level {}).", + aaaLogger.info(MarkerFactory.getMarker("AAA"), + "The user {} passed TACACS+ authentication (with permission level {}).", username, level); UiUser user = new UiUser(username, level); return user; @@ -175,30 +175,36 @@ else if (role.equals(operatorLevelRole)) { else { // Authorization failed if (authoReply.getData() != null) { - aaaLogger.info(MarkerFactory.getMarker("AAA"), "The user {} failed TACACS+ authorization. Server data: {}.", + aaaLogger.info(MarkerFactory.getMarker("AAA"), + "The user {} failed TACACS+ authorization. Server data: {}.", username, authoReply.getData()); } else if (authoReply.getServerMsg() != null) { - aaaLogger.info(MarkerFactory.getMarker("AAA"), "The user {} failed TACACS+ authorization. Server message: {}.", + aaaLogger.info(MarkerFactory.getMarker("AAA"), + "The user {} failed TACACS+ authorization. Server message: {}.", username, authoReply.getServerMsg()); } else { - aaaLogger.info(MarkerFactory.getMarker("AAA"), "The user {} failed TACACS+ authorization.", username); + aaaLogger.info(MarkerFactory.getMarker("AAA"), + "The user {} failed TACACS+ authorization.", username); } } } else { // Authentication failed if (authenReply.getData() != null) { - aaaLogger.info(MarkerFactory.getMarker("AAA"), "The user {} failed TACACS+ authentication. Server data: {}.", + aaaLogger.info(MarkerFactory.getMarker("AAA"), + "The user {} failed TACACS+ authentication. Server data: {}.", username, authenReply.getData()); } else if (authenReply.getServerMsg() != null) { - aaaLogger.info(MarkerFactory.getMarker("AAA"), "The user {} failed TACACS+ authentication. Server message: {}.", + aaaLogger.info(MarkerFactory.getMarker("AAA"), + "The user {} failed TACACS+ authentication. Server message: {}.", username, authenReply.getServerMsg()); } else { - aaaLogger.info(MarkerFactory.getMarker("AAA"), "The user {} failed TACACS+ authentication.", username); + aaaLogger.info(MarkerFactory.getMarker("AAA"), + "The user {} failed TACACS+ authentication.", username); } } } @@ -214,11 +220,11 @@ else if (authenReply.getServerMsg() != null) { /** * Log a message with TACACS+ accounting. */ - public static void account(String method, String path, String username, String response, NetworkAddress remoteAddress) { + public static void account(String method, String path, String username, String response, String remoteAddress) { if (!Tacacs.enableAccounting || !Tacacs.isAvailable()) { return; } - SessionClient acctSession = client.newSession(SVC.LOGIN, "rest", remoteAddress == null ? "0.0.0.0" : remoteAddress.getIp(), PRIV_LVL.USER.code()); + SessionClient acctSession = client.newSession(SVC.LOGIN, "rest", remoteAddress, PRIV_LVL.USER.code()); try { acctSession.account(ACCT.FLAG.STOP.code(), username, METH.TACACSPLUS, TYPE.ASCII, SVC.LOGIN, new Argument[] { new Argument(String.format("%s %s => %s", method, path, response)) diff --git a/src/main/java/onl/netfishers/netshot/aaa/UiUser.java b/src/main/java/onl/netfishers/netshot/aaa/UiUser.java index 4c55c05a..b20ceae0 100644 --- a/src/main/java/onl/netfishers/netshot/aaa/UiUser.java +++ b/src/main/java/onl/netfishers/netshot/aaa/UiUser.java @@ -36,8 +36,15 @@ import lombok.Getter; import lombok.Setter; import onl.netfishers.netshot.Netshot; +import onl.netfishers.netshot.aaa.PasswordPolicy.PasswordPolicyException; import onl.netfishers.netshot.rest.RestViews.DefaultView; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.Map; + import org.hibernate.annotations.NaturalId; import org.jasypt.util.password.BasicPasswordEncryptor; @@ -52,12 +59,31 @@ @EqualsAndHashCode public class UiUser implements User { + /** + * Exception to be thrown in case of password check failure + */ + static public class WrongPasswordException extends Exception { + public WrongPasswordException(String message) { + super(message); + } + } + /** The max idle time. */ public static int MAX_IDLE_TIME; /** The password encryptor. */ private static BasicPasswordEncryptor passwordEncryptor = new BasicPasswordEncryptor(); + /** + * Hash the password using the password encryptor. + * + * @param password the plaintext password + * @return the hashed password + */ + static private String hash(String password) { + return passwordEncryptor.encryptPassword(password); + } + static { UiUser.MAX_IDLE_TIME = Netshot.getConfig("netshot.aaa.maxidletime", 1800, 30, Integer.MAX_VALUE); } @@ -83,6 +109,16 @@ public class UiUser implements User { @Setter private String hashedPassword; + /** The previous password hashes. */ + @Getter + @Setter + private List oldHashedPasswords = new ArrayList<>(); + + /** Date of last password change */ + @Getter + @Setter + private Date lastPasswordChangeDate; + /** The username. */ @Getter(onMethod=@__({ @XmlElement, @JsonView(DefaultView.class), @@ -130,27 +166,69 @@ public UiUser(String name, int level) { * @param password the new password */ public void setPassword(String password) { - this.setHashedPassword(this.hash(password)); + this.setHashedPassword(UiUser.hash(password)); + this.setLastPasswordChangeDate(new Date()); } /** - * Check password. + * Sets the password if it complies with the given policy. * - * @param password the password - * @return true, if successful + * @param password the new password + * @param policy the policy to check */ - public boolean checkPassword(String password) { - return passwordEncryptor.checkPassword(password, hashedPassword); + public void setPassword(String password, PasswordPolicy policy) throws PasswordPolicyException { + for (Map.Entry e : policy.getMinCharMatchCounts().entrySet()) { + if (e.getKey().countMatches(password) < e.getValue()) { + throw new PasswordPolicyException(String.format( + "The password doesn't match the defined policy: %s", e.getKey().getDescription())); + } + } + if (this.oldHashedPasswords == null) { + this.oldHashedPasswords = new ArrayList<>(); + } + int size = Math.min(policy.getMaxHistory(), this.oldHashedPasswords.size()); + for (int i = 0; i < size; i++) { + if (passwordEncryptor.checkPassword(password, this.oldHashedPasswords.get(i))) { + throw new PasswordPolicyException("Password was already used for this account"); + } + } + if (this.getHashedPassword() != null) { + this.oldHashedPasswords.addFirst(this.getHashedPassword()); + } + while (this.oldHashedPasswords.size() > policy.getMaxHistory()) { + this.oldHashedPasswords.removeLast(); + } + this.setPassword(password); } /** - * Hash. + * Check password against user hash, and against policy. * * @param password the password - * @return the string + * @param policy The policy to check + * @throws WrongPasswordException + * @throws PasswordPolicyException */ - private String hash(String password) { - return passwordEncryptor.encryptPassword(password); + public void checkPassword(String password, PasswordPolicy policy) + throws WrongPasswordException, PasswordPolicyException { + if (!passwordEncryptor.checkPassword(password, hashedPassword)) { + throw new WrongPasswordException("Wrong password"); + } + if (policy == null) { + return; + } + if (this.lastPasswordChangeDate == null) { + return; + } + int days = policy.getMaxDuration(); + if (days == 0) { + return; + } + Calendar lastValidCal = Calendar.getInstance(); + lastValidCal.add(Calendar.DATE, -1 * policy.getMaxDuration()); + if (this.lastPasswordChangeDate.before(lastValidCal.getTime())) { + throw new PasswordPolicyException("Password has expired, it must be changed"); + } } /* (non-Javadoc) diff --git a/src/main/java/onl/netfishers/netshot/device/Network4Address.java b/src/main/java/onl/netfishers/netshot/device/Network4Address.java index d549dad4..550745c8 100644 --- a/src/main/java/onl/netfishers/netshot/device/Network4Address.java +++ b/src/main/java/onl/netfishers/netshot/device/Network4Address.java @@ -181,7 +181,7 @@ public Network4Address deserialize(JsonParser p, DeserializationContext ctxt) th /** * Instantiates a new network4 address. */ - protected Network4Address() { + public Network4Address() { } /** diff --git a/src/main/java/onl/netfishers/netshot/rest/LoggerFilter.java b/src/main/java/onl/netfishers/netshot/rest/LoggerFilter.java index 41a97d5a..10217d60 100644 --- a/src/main/java/onl/netfishers/netshot/rest/LoggerFilter.java +++ b/src/main/java/onl/netfishers/netshot/rest/LoggerFilter.java @@ -1,7 +1,6 @@ package onl.netfishers.netshot.rest; import java.io.IOException; -import java.net.UnknownHostException; import jakarta.servlet.http.HttpServletRequest; import jakarta.ws.rs.container.ContainerRequestContext; @@ -16,8 +15,6 @@ import onl.netfishers.netshot.Netshot; import onl.netfishers.netshot.aaa.Tacacs; import onl.netfishers.netshot.aaa.User; -import onl.netfishers.netshot.device.Network4Address; -import onl.netfishers.netshot.device.NetworkAddress; /** * Filter to log requests. @@ -28,7 +25,7 @@ public class LoggerFilter implements ContainerResponseFilter { static private boolean trustXForwardedFor = false; static public void init() { - trustXForwardedFor = Netshot.getConfig("netshot.http.trustxforwardedfor", "false").equals("true"); + trustXForwardedFor = Netshot.getConfig("netshot.http.trustxforwardedfor", false); } @Context @@ -38,17 +35,17 @@ static public void init() { * Guess the client IP address based on X-Forwarded-For header (if present). * @return the probable client IP address */ - private String getClientAddress() { + static public String getClientAddress(HttpServletRequest request) { String address = null; if (trustXForwardedFor) { - String forwardedFor = httpRequest.getHeader("X-Forwarded-For"); + String forwardedFor = request.getHeader("X-Forwarded-For"); if (forwardedFor != null) { String[] addresses = forwardedFor.split(","); address = addresses[0].trim(); } } if (address == null) { - address = httpRequest.getRemoteAddr(); + address = request.getRemoteAddr(); } return address; } @@ -64,9 +61,9 @@ public void filter(ContainerRequestContext requestContext, ContainerResponseCont // } String method = requestContext.getMethod().toUpperCase(); - String remoteAddr = this.getClientAddress(); + String remoteAddr = LoggerFilter.getClientAddress(this.httpRequest); if ("GET".equals(method)) { - Netshot.aaaLogger.debug("Request from {} ({}) - {} - \"{} {}\" - {}.",remoteAddr, + Netshot.aaaLogger.debug("Request from {} ({}) - {} - \"{} {}\" - {}.", remoteAddr, requestContext.getHeaderString(HttpHeaders.USER_AGENT), user == null ? "" : user.getUsername(), requestContext.getMethod(), requestContext.getUriInfo().getRequestUri(), responseContext.getStatus()); } @@ -74,16 +71,13 @@ public void filter(ContainerRequestContext requestContext, ContainerResponseCont Netshot.aaaLogger.info("Request from {} ({}) - {} - \"{} {}\" - {}.", remoteAddr, requestContext.getHeaderString(HttpHeaders.USER_AGENT), user == null ? "" : user.getUsername(), requestContext.getMethod(), requestContext.getUriInfo().getRequestUri(), responseContext.getStatus()); - NetworkAddress na; try { - na = NetworkAddress.getNetworkAddress(remoteAddr); - } - catch (UnknownHostException e) { - na = new Network4Address(0, 0); - } - try { - Tacacs.account(requestContext.getMethod(), requestContext.getUriInfo().getRequestUri().getPath(), - user == null ? "" : user.getUsername(), Integer.toString(responseContext.getStatus()), na); + Tacacs.account( + requestContext.getMethod(), + requestContext.getUriInfo().getRequestUri().getPath(), + user == null ? "" : user.getUsername(), + Integer.toString(responseContext.getStatus()), + remoteAddr); } catch (TacacsException e) { log.warn("Unable to send accounting message to TACACS+ server", e); diff --git a/src/main/java/onl/netfishers/netshot/rest/NetshotAuthenticationRequiredException.java b/src/main/java/onl/netfishers/netshot/rest/NetshotAuthenticationRequiredException.java index 39b7c6a6..14361962 100644 --- a/src/main/java/onl/netfishers/netshot/rest/NetshotAuthenticationRequiredException.java +++ b/src/main/java/onl/netfishers/netshot/rest/NetshotAuthenticationRequiredException.java @@ -16,4 +16,4 @@ public class NetshotAuthenticationRequiredException extends WebApplicationExcept public NetshotAuthenticationRequiredException() { super(Response.status(Response.Status.UNAUTHORIZED).build()); } -} \ No newline at end of file +} diff --git a/src/main/java/onl/netfishers/netshot/rest/NetshotBadRequestException.java b/src/main/java/onl/netfishers/netshot/rest/NetshotBadRequestException.java index f052fe8a..16fbc73a 100644 --- a/src/main/java/onl/netfishers/netshot/rest/NetshotBadRequestException.java +++ b/src/main/java/onl/netfishers/netshot/rest/NetshotBadRequestException.java @@ -2,7 +2,7 @@ import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.Response; - +import lombok.Getter; import onl.netfishers.netshot.rest.RestService.RsErrorBean; /** @@ -56,6 +56,8 @@ static public enum Reason { NETSHOT_INVALID_USER_NAME(202, Response.Status.BAD_REQUEST), NETSHOT_INVALID_PASSWORD(203, Response.Status.BAD_REQUEST), NETSHOT_INVALID_API_TOKEN_FORMAT(204, Response.Status.BAD_REQUEST), + NETSHOT_EXPIRED_PASSWORD(205, Response.Status.PRECONDITION_FAILED), + NETSHOT_FAILED_PASSWORD_POLICY(206, Response.Status.BAD_REQUEST), NETSHOT_INVALID_SCRIPT(220, Response.Status.BAD_REQUEST), NETSHOT_DUPLICATE_SCRIPT(222, Response.Status.BAD_REQUEST), NETSHOT_INVALID_DIAGNOSTIC_NAME(230, Response.Status.BAD_REQUEST), @@ -70,8 +72,12 @@ static public enum Reason { NETSHOT_INVALID_HOOK_WEB(244, Response.Status.BAD_REQUEST), NETSHOT_INVALID_HOOK_WEB_URL(245, Response.Status.BAD_REQUEST); + @Getter int code; + + @Getter Response.Status status; + private Reason(int code, Response.Status status) { this.code = code; this.status = status; diff --git a/src/main/java/onl/netfishers/netshot/rest/RestService.java b/src/main/java/onl/netfishers/netshot/rest/RestService.java index 9d66a926..ee2df99e 100644 --- a/src/main/java/onl/netfishers/netshot/rest/RestService.java +++ b/src/main/java/onl/netfishers/netshot/rest/RestService.java @@ -77,9 +77,12 @@ import onl.netfishers.netshot.Netshot; import onl.netfishers.netshot.TaskManager; import onl.netfishers.netshot.aaa.ApiToken; +import onl.netfishers.netshot.aaa.PasswordPolicy; +import onl.netfishers.netshot.aaa.PasswordPolicy.PasswordPolicyException; import onl.netfishers.netshot.aaa.Radius; import onl.netfishers.netshot.aaa.Tacacs; import onl.netfishers.netshot.aaa.UiUser; +import onl.netfishers.netshot.aaa.UiUser.WrongPasswordException; import onl.netfishers.netshot.aaa.User; import onl.netfishers.netshot.cluster.ClusterManager; import onl.netfishers.netshot.cluster.ClusterMember; @@ -6813,7 +6816,7 @@ public static class RsLogin { @XmlElement, @JsonView(DefaultView.class) })) @Setter - private String newPassword = ""; + private String newPassword; } /** @@ -6857,12 +6860,12 @@ public void logout(@Context HttpServletRequest request, @Produces({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML }) @JsonView(RestApiView.class) @Operation( - summary = "Update a user", - description = "Edits a given user, by ID, especially the password for a local user." + summary = "Update self user", + description = "Edits current user, especially password." ) @Tag(name = "Login", description = "Login and password management for standard user") - public UiUser setPassword(RsLogin rsLogin, - @PathParam("id") @Parameter(description = "User ID") Long id) + public UiUser setSelfUser(RsLogin rsLogin, + @PathParam("id") @Parameter(description = "User ID (ignored)") Long id) throws WebApplicationException { log.debug("REST password change request, username {}.", rsLogin.getUsername()); User currentUser = (User) request.getAttribute("user"); @@ -6872,27 +6875,42 @@ public UiUser setPassword(RsLogin rsLogin, Session session = Database.getSession(); try { session.beginTransaction(); - user = session.bySimpleNaturalId(UiUser.class).load(rsLogin.getUsername()); - if (user == null || !user.getUsername().equals(currentUser.getUsername()) || !user.isLocal()) { + user = session.bySimpleNaturalId(UiUser.class).load(currentUser.getUsername()); + if (user == null || !user.getUsername().equals(rsLogin.getUsername()) || !user.isLocal()) { throw new NetshotBadRequestException("Invalid user.", NetshotBadRequestException.Reason.NETSHOT_INVALID_USER); } - if (!user.checkPassword(rsLogin.getPassword())) { + try { + user.checkPassword(rsLogin.getPassword(), PasswordPolicy.getMainPolicy()); + } + catch (WrongPasswordException e) { throw new NetshotBadRequestException("Invalid current password.", - NetshotBadRequestException.Reason.NETSHOT_INVALID_USER); + NetshotBadRequestException.Reason.NETSHOT_INVALID_PASSWORD); + } + catch (PasswordPolicyException e) { + // Ignore to let user update the password } String newPassword = rsLogin.getNewPassword(); - if (newPassword.equals("")) { + if (newPassword == null || newPassword.equals("")) { throw new NetshotBadRequestException("The password cannot be empty.", - NetshotBadRequestException.Reason.NETSHOT_INVALID_USER); + NetshotBadRequestException.Reason.NETSHOT_INVALID_PASSWORD); } - user.setPassword(newPassword); + try { + user.setPassword(newPassword, PasswordPolicy.getMainPolicy()); + } + catch (PasswordPolicyException e) { + log.error("New user password doesn't comply with password policy.", e); + throw new NetshotBadRequestException( + e.getMessage(), + NetshotBadRequestException.Reason.NETSHOT_FAILED_PASSWORD_POLICY); + } session.persist(user); session.getTransaction().commit(); - Netshot.aaaLogger.warn("Password successfully changed by user {} for user {}.", currentUser.getUsername(), rsLogin.getUsername()); + Netshot.aaaLogger.warn("Password successfully changed by user {} for user {}.", + currentUser.getUsername(), rsLogin.getUsername()); return user; } catch (HibernateException e) { @@ -6928,49 +6946,88 @@ public UiUser login(RsLogin rsLogin) throws WebApplicationException { log.debug("REST authentication request, username {}.", rsLogin.getUsername()); Netshot.aaaLogger.info("REST authentication request, username {}.", rsLogin.getUsername()); - NetworkAddress remoteAddress = null; + String remoteAddress = LoggerFilter.getClientAddress(request); + UiUser user = null; + { - String address = null; + Session session = Database.getSession(true); try { - address = request.getHeader("X-Forwarded-For"); - if (address == null) { - address = request.getRemoteAddr(); - } - remoteAddress = NetworkAddress.getNetworkAddress(address, 0); + user = session.bySimpleNaturalId(UiUser.class).load(rsLogin.getUsername()); } - catch (UnknownHostException e) { - log.warn("Unable to parse remote address", address); - try { - remoteAddress = new Network4Address(0, 0); - } - catch (UnknownHostException e1) { - } + catch (HibernateException e) { + log.error("Unable to retrieve the user {}.", rsLogin.getUsername(), e); + throw new NetshotBadRequestException("Unable to retrieve the user.", + NetshotBadRequestException.Reason.NETSHOT_DATABASE_ACCESS_ERROR); + } + finally { + session.close(); } - } - - UiUser user = null; - - Session session = Database.getSession(); - try { - user = session.bySimpleNaturalId(UiUser.class).load(rsLogin.getUsername()); - } - catch (HibernateException e) { - log.error("Unable to retrieve the user {}.", rsLogin.getUsername(), e); - throw new NetshotBadRequestException("Unable to retrieve the user.", - NetshotBadRequestException.Reason.NETSHOT_DATABASE_ACCESS_ERROR); - } - finally { - session.close(); } if (user != null && user.isLocal()) { - if (user.checkPassword(rsLogin.getPassword())) { - Netshot.aaaLogger.info("Local authentication success for user {} from {}.", rsLogin.getUsername(), remoteAddress); + try { + try { + user.checkPassword(rsLogin.getPassword(), PasswordPolicy.getMainPolicy()); + Netshot.aaaLogger.info( + "Local authentication success for user {} from {}.", rsLogin.getUsername(), remoteAddress); + } + catch (PasswordPolicyException e) { + if (rsLogin.getNewPassword() == null) { + throw e; + } + Netshot.aaaLogger.info( + "User {} authenticated from {}, password has to be changed.", rsLogin.getUsername(), remoteAddress); + // If new password, proceed with password change + } + if (rsLogin.getNewPassword() != null) { + Session session = Database.getSession(); + try { + session.beginTransaction(); + UiUser user1 = session.get(UiUser.class, user.getId()); + user1.setPassword(rsLogin.getNewPassword(), PasswordPolicy.getMainPolicy()); + session.merge(user1); + session.getTransaction().commit(); + Netshot.aaaLogger.info( + "User {} changed its password.", rsLogin.getUsername(), remoteAddress); + } + catch (HibernateException e) { + session.getTransaction().rollback(); + log.error("Error while updating a user.", e); + throw new NetshotBadRequestException( + "Unable to update the user in the database", + NetshotBadRequestException.Reason.NETSHOT_DATABASE_ACCESS_ERROR); + } + catch (PasswordPolicyException e) { + session.getTransaction().rollback(); + log.error("New user password doesn't comply with password policy.", e); + user = null; + throw new NetshotBadRequestException( + e.getMessage(), + NetshotBadRequestException.Reason.NETSHOT_FAILED_PASSWORD_POLICY); + } + finally { + session.close(); + } + } + } - else { - Netshot.aaaLogger.warn("Local authentication failure for user {} from {}.", rsLogin.getUsername(), remoteAddress); + catch (WrongPasswordException e) { + Netshot.aaaLogger.warn( + "Local authentication failure for user {} from {}.", + rsLogin.getUsername(), remoteAddress); + user = null; + } + catch (PasswordPolicyException e) { + Netshot.aaaLogger.warn( + "Password of user {} is expired, it must be changed.", rsLogin.getUsername()); user = null; + throw new NetshotBadRequestException( + "Password has expired, it must be changed.", + NetshotBadRequestException.Reason.NETSHOT_EXPIRED_PASSWORD); } + + + } else { UiUser remoteUser = null; @@ -6981,13 +7038,16 @@ public UiUser login(RsLogin rsLogin) throws WebApplicationException { remoteUser = Tacacs.authenticate(rsLogin.getUsername(), rsLogin.getPassword(), remoteAddress); } if (remoteUser == null) { - Netshot.aaaLogger.warn("Remote authentication failure for user {} from {}.", rsLogin.getUsername(), remoteAddress); + Netshot.aaaLogger.warn("Remote authentication failure for user {} from {}.", + rsLogin.getUsername(), remoteAddress); } else { - Netshot.aaaLogger.info("Remote authentication success for user {} from {}.", rsLogin.getUsername(), remoteAddress); + Netshot.aaaLogger.info("Remote authentication success for user {} from {}.", + rsLogin.getUsername(), remoteAddress); if (user != null) { remoteUser.setLevel(user.getLevel()); - Netshot.aaaLogger.info("Level permission for user {} is locally overriden: {}.", rsLogin.getUsername(), user.getLevel()); + Netshot.aaaLogger.info("Level permission for user {} is locally overriden: {}.", + rsLogin.getUsername(), user.getLevel()); } } user = remoteUser; @@ -7218,7 +7278,15 @@ public UiUser setUser(@PathParam("id") @Parameter(description = "User ID") Long user.setLevel(rsUser.getLevel()); if (rsUser.isLocal()) { if (rsUser.getPassword() != null) { - user.setPassword(rsUser.getPassword()); + try { + user.setPassword(rsUser.getPassword(), PasswordPolicy.getMainPolicy()); + } + catch (PasswordPolicyException e) { + log.error("New user password doesn't comply with password policy.", e); + throw new NetshotBadRequestException( + e.getMessage(), + NetshotBadRequestException.Reason.NETSHOT_INVALID_PASSWORD); + } } if (user.getHashedPassword().equals("")) { log.error("The password cannot be empty for user {}.", id); diff --git a/src/main/resources/migration/netshot0.xml b/src/main/resources/migration/netshot0.xml index c04b0c3e..42566f0c 100644 --- a/src/main/resources/migration/netshot0.xml +++ b/src/main/resources/migration/netshot0.xml @@ -4,6 +4,8 @@ + + 8:1534a0df4e73e7c5877f8f03c0ffde51 @@ -1297,4 +1299,13 @@ + + + + + + + + + diff --git a/src/main/resources/www/css/app.css b/src/main/resources/www/css/app.css index 911d9311..512bdb45 100644 --- a/src/main/resources/www/css/app.css +++ b/src/main/resources/www/css/app.css @@ -59,7 +59,6 @@ th[role=columnheader]:not(.no-sort):hover:after { top: 50%; left: 50%; width: 400px; - height: 200px; margin-top: -130px; margin-left: -200px; border: 1px solid #e1e4e8; @@ -108,6 +107,10 @@ th[role=columnheader]:not(.no-sort):hover:after { display: none; } +#splash #passwordchange-warning { + display: none; +} + #splash #authentication-box { display: none; margin-top: 30px; @@ -118,6 +121,11 @@ th[role=columnheader]:not(.no-sort):hover:after { padding: 5px; } +#splash #authentication-box .changepassword { + display: none; +} + + .ui-dialog.ui-widget .ui-dialog-titlebar { border-bottom-right-radius: 0px !important; border-bottom-left-radius: 0px !important; @@ -849,6 +857,10 @@ h1 { border-radius: 5px; } +.nsdialog .nserror.nswarning { + background-color: #f6f8fa; +} + .nsdialog .nserror .ui-icon { float: left; margin-right: .3em; diff --git a/src/main/resources/www/index.html b/src/main/resources/www/index.html index 46669847..23a28bce 100644 --- a/src/main/resources/www/index.html +++ b/src/main/resources/www/index.html @@ -28,29 +28,41 @@
-
- - Unable to connect to the Netshot REST server. -
Netshot version mismatch (frontend vs backend).
+
+ + Your password has expired, you must change it. +
+
+ + Unable to connect to the Netshot REST server. +
- - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + +
diff --git a/src/main/resources/www/js/router.js b/src/main/resources/www/js/router.js index a386257b..b41677b7 100644 --- a/src/main/resources/www/js/router.js +++ b/src/main/resources/www/js/router.js @@ -162,14 +162,27 @@ define([ primary: "ui-icon-circle-triangle-e" } }); + var passwordChangeRequired = false; $("#splash #authentication-box").submit(function() { $("#splash #authentication-box #authenticate").button('disable'); $("#splash #connection-error").hide(); window.user = new CurrentUserModel(); - window.user.save({ + var data = { username: $("#splash #authentication-box #username").val(), - password: $("#splash #authentication-box #password").val() - }, { + password: $("#splash #authentication-box #password").val(), + }; + if (passwordChangeRequired) { + data.newPassword = $("#splash #authentication-box #newpassword1").val(); + if (data.newPassword != $("#splash #authentication-box #newpassword2").val()) { + $("#splash #connection-error #errormsg") + .text("You didn't accurately repeat password."); + $("#splash #connection-error").show(); + $("#splash #authentication-box #newpassword1").focus(); + $("#splash #authentication-box #authenticate").button('enable'); + return false; + } + } + window.user.save(data, { success: function(model, response, options) { model.attributes.password = ""; }, @@ -179,10 +192,34 @@ define([ }).done(function(response) { start(); }).fail(function(response) { - $("#splash #errormsg").text("Authentication error."); - $("#splash #connection-error").show(); + if (response.status === 412) { + // Password change required + passwordChangeRequired = true; + $("#splash #passwordchange-warning").show(); + $("#splash .changepassword").show(); + $("#splash #authentication-box #newpassword1").focus(); + } + else if (response.status === 401) { + passwordChangeRequired = false; + $("#splash #passwordchange-warning").hide(); + $("#splash #connection-error #errormsg") + .text("Authentication error."); + $("#splash #connection-error").show(); + $("#splash #authentication-box #password").val(""); + } + else { + $("#splash #passwordchange-warning").hide(); + var message = "Connection error"; + try { + message = response.responseJSON.errorMsg; + } + catch (e1) { + // + } + $("#splash #connection-error #errormsg").text(message); + $("#splash #connection-error").show(); + } $("#splash #authentication-box #authenticate").button('enable'); - $("#splash #authentication-box #password").val(""); }); return false; }).show(); diff --git a/src/test/java/onl/netfishers/netshot/NetshotApiClient.java b/src/test/java/onl/netfishers/netshot/NetshotApiClient.java index c0e79fc2..9a59bd90 100644 --- a/src/test/java/onl/netfishers/netshot/NetshotApiClient.java +++ b/src/test/java/onl/netfishers/netshot/NetshotApiClient.java @@ -30,7 +30,20 @@ */ public class NetshotApiClient { - protected static final String SESSION_COOKIE_NAME = "NetshotSessionID"; + /** + * Simple exception to catch unexpected response and attach it. + */ + static public class WrongApiResponseException extends IOException { + @Getter + private HttpResponse response; + + public WrongApiResponseException(String message, HttpResponse response) { + super(message); + this.response = response; + } + } + + static protected final String SESSION_COOKIE_NAME = "NetshotSessionID"; @Getter @Setter private String apiUrl; @@ -44,6 +57,10 @@ public class NetshotApiClient { @Getter @Setter private String password; + /** To change the password on login */ + @Getter @Setter + private String newPassword; + @Getter @Setter private HttpCookie sessionCookie; @@ -123,12 +140,15 @@ protected void login() throws IOException, InterruptedException { ObjectNode payload = JsonNodeFactory.instance.objectNode(); payload.put("username", this.username); payload.put("password", this.password); + if (this.newPassword != null) { + payload.put("newPassword", this.newPassword); + } builder.POST(this.jsonNodePublisher(payload)); HttpRequest request = builder.build(); HttpResponse response = this.httpClient.send(request, this.jsonNodeHandler()); if (response.statusCode() != Response.Status.OK.getStatusCode()) { - throw new RuntimeException("Netshot REST API login failed"); + throw new WrongApiResponseException("Netshot REST API login failed", response); } String setCookie = response.headers().firstValue("Set-Cookie").get(); List cookies = HttpCookie.parse(setCookie); diff --git a/src/test/java/onl/netfishers/netshot/RestServiceTest.java b/src/test/java/onl/netfishers/netshot/RestServiceTest.java index ad42001c..26377b17 100644 --- a/src/test/java/onl/netfishers/netshot/RestServiceTest.java +++ b/src/test/java/onl/netfishers/netshot/RestServiceTest.java @@ -4,7 +4,10 @@ import java.net.HttpCookie; import java.net.URI; import java.net.http.HttpResponse; +import java.util.ArrayList; +import java.util.Calendar; import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.Properties; import org.hibernate.Session; @@ -24,11 +27,15 @@ import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriBuilder; +import onl.netfishers.netshot.NetshotApiClient.WrongApiResponseException; import onl.netfishers.netshot.aaa.ApiToken; +import onl.netfishers.netshot.aaa.PasswordPolicy; +import onl.netfishers.netshot.aaa.PasswordPolicy.PasswordPolicyException; import onl.netfishers.netshot.aaa.UiUser; import onl.netfishers.netshot.database.Database; import onl.netfishers.netshot.device.Domain; import onl.netfishers.netshot.device.Network4Address; +import onl.netfishers.netshot.rest.NetshotBadRequestException; import onl.netfishers.netshot.rest.RestService; public class RestServiceTest { @@ -59,8 +66,7 @@ protected static void createApiTokens() { } } - @BeforeAll - protected static void initNetshot() throws Exception { + protected static Properties getNetshotConfig() { Properties config = new Properties(); config.setProperty("netshot.log.file", "CONSOLE"); config.setProperty("netshot.log.level", "INFO"); @@ -71,7 +77,12 @@ protected static void initNetshot() throws Exception { config.setProperty("netshot.http.ssl.enabled", "false"); URI uri = UriBuilder.fromUri(apiUrl).replacePath("/").build(); config.setProperty("netshot.http.baseurl", uri.toString()); - Netshot.initConfig(config); + return config; + } + + @BeforeAll + protected static void initNetshot() throws Exception { + Netshot.initConfig(RestServiceTest.getNetshotConfig()); Database.update(); Database.init(); RestService.init(); @@ -89,7 +100,7 @@ void createData() { @Nested @DisplayName("Authentication Tests") - class AuthenticationTest { + class ApiTokenTest { @AfterEach void cleanUpData() { @@ -150,21 +161,53 @@ void notAdminToken() throws IOException, InterruptedException { Assertions.assertInstanceOf(MissingNode.class, response.body(), "Response body not empty"); } + } - @Test - @DisplayName("Local user authentication and cookie") - @ResourceLock(value = "DB") - void localUserAuth() throws IOException, InterruptedException { - String username = "testuser"; - String password = "testpassword"; + @Nested + @DisplayName("Local Authentication Tests") + class LocalAuthenticationTest { + + private String testUsername = "testuser"; + private String testPassword = "testpassword"; + private String[] testOldPasswords = new String[] { + "testpassword02", + "testpassword01", + "testpassword00", + }; + private int testUserLevel = UiUser.LEVEL_ADMIN; + private int passwordAge = 0; + + private void createTestUser() { try (Session session = Database.getSession()) { session.beginTransaction(); - UiUser user = new UiUser(username, true, password); - user.setLevel(UiUser.LEVEL_ADMIN); + List oldPasswords = new ArrayList<>(List.of(testOldPasswords)); + UiUser user = new UiUser(testUsername, true, oldPasswords.removeLast()); + while (oldPasswords.size() > 0) { + try { + user.setPassword(oldPasswords.removeLast(), PasswordPolicy.getMainPolicy()); + } + catch (PasswordPolicyException e) { + // Ignore + } + } + user.setPassword(testPassword); + user.setLevel(testUserLevel); + if (passwordAge > 0) { + Calendar oneYearAgo = Calendar.getInstance(); + oneYearAgo.add(Calendar.DATE, -1 * passwordAge); + user.setLastPasswordChangeDate(oneYearAgo.getTime()); + } session.persist(user); session.getTransaction().commit(); } - apiClient.setLogin(username, password); + } + + @Test + @DisplayName("Local user authentication and cookie") + @ResourceLock(value = "DB") + void localUserAuth() throws IOException, InterruptedException { + this.createTestUser(); + apiClient.setLogin(testUsername, testPassword); HttpResponse response1 = apiClient.get("/domains"); Assertions.assertEquals( Response.Status.OK.getStatusCode(), response1.statusCode(), @@ -192,6 +235,221 @@ void wrongCookie() throws IOException, InterruptedException { Assertions.assertInstanceOf(MissingNode.class, response.body(), "Response body not empty"); } + + @Test + @DisplayName("Expired password authentication attempt") + @ResourceLock(value = "DB") + void expiredPasswordFailAuth() throws IOException, InterruptedException { + Properties config = RestServiceTest.getNetshotConfig(); + config.setProperty("netshot.aaa.passwordpolicy.maxduration", "90"); + Netshot.initConfig(config); + PasswordPolicy.loadConfig(); + this.passwordAge = 365; + this.createTestUser(); + apiClient.setLogin(testUsername, testPassword); + WrongApiResponseException thrown = Assertions.assertThrows(WrongApiResponseException.class, + () -> apiClient.get("/domains"), + "Login not failing as expected"); + Assertions.assertEquals( + Response.Status.PRECONDITION_FAILED.getStatusCode(), thrown.getResponse().statusCode(), + "Not getting 412 when logging in with expired password"); + } + + @Test + @DisplayName("Expired password, change and authentication attempt") + @ResourceLock(value = "DB") + void expiredPasswordChangeAuth() throws IOException, InterruptedException { + Properties config = RestServiceTest.getNetshotConfig(); + config.setProperty("netshot.aaa.passwordpolicy.maxduration", "90"); + Netshot.initConfig(config); + PasswordPolicy.loadConfig(); + String newPassword = "testpassword1"; + this.passwordAge = 365; + this.createTestUser(); + apiClient.setLogin(testUsername, testPassword); + apiClient.setNewPassword(newPassword); + HttpResponse response = apiClient.get("/domains"); + Assertions.assertEquals( + Response.Status.OK.getStatusCode(), response.statusCode(), + "Not getting 200 response"); + try (Session session = Database.getSession()) { + UiUser user = session + .bySimpleNaturalId(UiUser.class) + .load(testUsername); + Assertions.assertDoesNotThrow(() -> user.checkPassword(newPassword, null), + "Password not changed as expected"); + } + } + + @Test + @DisplayName("The password cannot be changed if auth fails") + @ResourceLock(value = "DB") + void wrongAuthCantChangePassword() throws IOException, InterruptedException { + String newPassword = "testpassword1"; + this.createTestUser(); + apiClient.setLogin(testUsername, "wrongpass"); + apiClient.setNewPassword(newPassword); + Assertions.assertThrows(WrongApiResponseException.class, + () -> apiClient.get("/domains"), + "Login should have failed"); + try (Session session = Database.getSession()) { + UiUser user = session + .bySimpleNaturalId(UiUser.class) + .load(testUsername); + Assertions.assertDoesNotThrow(() -> user.checkPassword(testPassword, null), + "Password should not have changed"); + } + } + + @Test + @DisplayName("Password policy") + @ResourceLock(value = "DB") + void passwordChangeWithPolicy() throws IOException, InterruptedException { + Properties config = RestServiceTest.getNetshotConfig(); + config.setProperty("netshot.aaa.passwordpolicy.maxhistory", "5"); + Netshot.initConfig(config); + PasswordPolicy.loadConfig(); + this.createTestUser(); + config.setProperty("netshot.aaa.passwordpolicy.mintotalchars", "16"); + config.setProperty("netshot.aaa.passwordpolicy.minspecialchars", "3"); + config.setProperty("netshot.aaa.passwordpolicy.minnumericalchars", "3"); + config.setProperty("netshot.aaa.passwordpolicy.minlowercasechars", "3"); + config.setProperty("netshot.aaa.passwordpolicy.minuppercasechars", "3"); + Netshot.initConfig(config); + PasswordPolicy.loadConfig(); + apiClient.setLogin(testUsername, testPassword); + { + HttpResponse response = apiClient.get("/user"); + Assertions.assertEquals( + Response.Status.OK.getStatusCode(), response.statusCode(), + "Not getting 200 response"); + JsonNode userNode = response.body(); + Assertions.assertEquals(testUsername, userNode.get("username").asText()); + Assertions.assertEquals(testUserLevel, userNode.get("level").asInt()); + } + { + ObjectNode data = JsonNodeFactory.instance.objectNode() + .put("username", testUsername) + // missing password + .put("newPassword", "New902C0pml;(EP!$"); + HttpResponse response = apiClient.put("/user/0", data); + Assertions.assertEquals( + Response.Status.BAD_REQUEST.getStatusCode(), response.statusCode(), + "Not getting 400 response"); + Assertions.assertEquals( + response.body().get("errorCode").asInt(), + NetshotBadRequestException.Reason.NETSHOT_INVALID_PASSWORD.getCode(), + "Unexpected Netshot error code"); + } + { + ObjectNode data = JsonNodeFactory.instance.objectNode() + .put("username", testUsername) + .put("password", "invalidpass") + .put("newPassword", "New902C0pml;(EP!$"); + HttpResponse response = apiClient.put("/user/0", data); + Assertions.assertEquals( + Response.Status.BAD_REQUEST.getStatusCode(), response.statusCode(), + "Not getting 400 response"); + Assertions.assertEquals( + response.body().get("errorCode").asInt(), + NetshotBadRequestException.Reason.NETSHOT_INVALID_PASSWORD.getCode(), + "Unexpected Netshot error code"); + } + { + String newPassword = testOldPasswords[2]; + ObjectNode data = JsonNodeFactory.instance.objectNode() + .put("username", testUsername) + .put("password", testPassword) + .put("newPassword", newPassword); + HttpResponse response = apiClient.put("/user/0", data); + Assertions.assertEquals( + Response.Status.BAD_REQUEST.getStatusCode(), response.statusCode(), + "Not getting 400 response"); + Assertions.assertEquals( + response.body().get("errorCode").asInt(), + NetshotBadRequestException.Reason.NETSHOT_FAILED_PASSWORD_POLICY.getCode(), + "Unexpected Netshot error code"); + } + { + String newPassword = "pass"; + ObjectNode data = JsonNodeFactory.instance.objectNode() + .put("username", testUsername) + .put("password", testPassword) + .put("newPassword", newPassword); + HttpResponse response = apiClient.put("/user/0", data); + Assertions.assertEquals( + Response.Status.BAD_REQUEST.getStatusCode(), response.statusCode(), + "Not getting 400 response"); + Assertions.assertEquals( + response.body().get("errorCode").asInt(), + NetshotBadRequestException.Reason.NETSHOT_FAILED_PASSWORD_POLICY.getCode(), + "Unexpected Netshot error code"); + } + { + String newPassword = "newverylongpassword"; + ObjectNode data = JsonNodeFactory.instance.objectNode() + .put("username", testUsername) + .put("password", testPassword) + .put("newPassword", newPassword); + HttpResponse response = apiClient.put("/user/0", data); + Assertions.assertEquals( + Response.Status.BAD_REQUEST.getStatusCode(), response.statusCode(), + "Not getting 400 response"); + Assertions.assertEquals( + response.body().get("errorCode").asInt(), + NetshotBadRequestException.Reason.NETSHOT_FAILED_PASSWORD_POLICY.getCode(), + "Unexpected Netshot error code"); + } + { + String newPassword = "newverylongpassword123"; + ObjectNode data = JsonNodeFactory.instance.objectNode() + .put("username", testUsername) + .put("password", testPassword) + .put("newPassword", newPassword); + HttpResponse response = apiClient.put("/user/0", data); + Assertions.assertEquals( + Response.Status.BAD_REQUEST.getStatusCode(), response.statusCode(), + "Not getting 400 response"); + Assertions.assertEquals( + response.body().get("errorCode").asInt(), + NetshotBadRequestException.Reason.NETSHOT_FAILED_PASSWORD_POLICY.getCode(), + "Unexpected Netshot error code"); + } + { + String newPassword = "newverylongPASSWORD123"; + ObjectNode data = JsonNodeFactory.instance.objectNode() + .put("username", testUsername) + .put("password", testPassword) + .put("newPassword", newPassword); + HttpResponse response = apiClient.put("/user/0", data); + Assertions.assertEquals( + Response.Status.BAD_REQUEST.getStatusCode(), response.statusCode(), + "Not getting 400 response"); + Assertions.assertEquals( + response.body().get("errorCode").asInt(), + NetshotBadRequestException.Reason.NETSHOT_FAILED_PASSWORD_POLICY.getCode(), + "Unexpected Netshot error code"); + } + { + String newPassword = "newverylongPASS123!!$$"; + ObjectNode data = JsonNodeFactory.instance.objectNode() + .put("username", testUsername) + .put("password", testPassword) + .put("newPassword", newPassword); + HttpResponse response = apiClient.put("/user/0", data); + Assertions.assertEquals( + Response.Status.OK.getStatusCode(), response.statusCode(), + "Not getting 200 response"); + try (Session session = Database.getSession()) { + UiUser user = session + .bySimpleNaturalId(UiUser.class) + .load(testUsername); + Assertions.assertDoesNotThrow(() -> user.checkPassword(newPassword, null), + "Password should have changed"); + } + } + + } } @Nested @@ -550,7 +808,7 @@ void deleteUser() throws IOException, InterruptedException { @Test @DisplayName("Update user") @ResourceLock(value = "DB") - void updateDomain() throws IOException, InterruptedException { + void updateUser() throws IOException, InterruptedException { UiUser user = new UiUser("user1", true, "pass1"); user.setLevel(UiUser.LEVEL_EXECUTEREADWRITE); try (Session session = Database.getSession()) { @@ -559,8 +817,8 @@ void updateDomain() throws IOException, InterruptedException { session.getTransaction().commit(); } UiUser targetUser = new UiUser("user2", true, "pass1"); - targetUser.setHashedPassword(user.getHashedPassword()); targetUser.setLevel(UiUser.LEVEL_READONLY); + targetUser.setHashedPassword(user.getHashedPassword()); targetUser.setId(user.getId()); ObjectNode data = JsonNodeFactory.instance.objectNode() .put("local", targetUser.isLocal()) @@ -574,8 +832,10 @@ void updateDomain() throws IOException, InterruptedException { try (Session session = Database.getSession()) { UiUser dbUser = session.byId(UiUser.class) .load(targetUser.getId()); - Assertions.assertEquals( - targetUser, dbUser, "User not updated as expected"); + Assertions.assertEquals(targetUser.getName(), dbUser.getName(), + "User not updated as expected"); + Assertions.assertEquals(targetUser.getLevel(), dbUser.getLevel(), + "User not updated as expected"); } } }