diff --git a/src/main/java/com/justjournal/client/Auth.java b/src/main/java/com/justjournal/client/Auth.java index 45ce269..f8e8b25 100644 --- a/src/main/java/com/justjournal/client/Auth.java +++ b/src/main/java/com/justjournal/client/Auth.java @@ -1,23 +1,26 @@ package com.justjournal.client; import com.justjournal.client.model.Login; +import com.justjournal.client.model.LoginResponse; +import com.justjournal.client.model.TwoFactorRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.ws.rs.ProcessingException; import javax.ws.rs.client.Client; import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.client.Entity; +import javax.ws.rs.client.Invocation; import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.NewCookie; import javax.ws.rs.core.Response; -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.io.OutputStreamWriter; -import java.net.HttpURLConnection; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** + * Logs in through the REST API and keeps the session cookie for later requests. + * * @author caryn */ public class Auth { @@ -25,11 +28,20 @@ public class Auth { final Logger log = LoggerFactory.getLogger(Auth.class); private static final String JJ_LOGIN_OK = "JJ.LOGIN.OK"; + private static final String JJ_LOGIN_2FA = "JJ.LOGIN.2FA"; + private static final String JJ_LOGIN_NONE = "JJ.LOGIN.NONE"; static final String BAD_LOGIN = "The username or password is incorrect. " + "After a failed login the server makes you wait a few seconds before trying again."; + static final String BAD_CODE = "That code is incorrect. Check it and try again."; + static final String TOO_MANY_CODES = "Too many incorrect codes. Log in again to get a new one."; + static final String UNEXPECTED_REPLY = "The server sent an unexpected reply. Try again later."; private final String siteUrl; private final String userName; private final String password; + // the session lives only in memory, it is never saved + private final Map cookies = new LinkedHashMap<>(); + private boolean loggedIn; + private String secondFactorMethod; private String lastError; /** @@ -56,84 +68,124 @@ public Auth(final String username, final String password) { } /** - * Checks account over secure channel + * Logs in using the REST API. Accounts with two-factor login are not logged in yet when this + * returns; check {@link #isSecondFactorRequired()} and finish with {@link #verifySecondFactor}. * - * @return true if account is valid + * @return true if login was successful */ - public boolean secureCheckAccount() { + public boolean restLogin() { + loggedIn = false; + secondFactorMethod = null; + cookies.clear(); if (!isValid()) { return false; } + final Client client = ClientBuilder.newClient(); try { - // sending the post request - String data = "username=" + URLEncoder.encode(userName, StandardCharsets.UTF_8.displayName()); - data += "&password=" + URLEncoder.encode(password, StandardCharsets.UTF_8.displayName()); - final HttpURLConnection conn = HttpUtils.getConnection(siteUrl + "loginAccount"); - final OutputStreamWriter writer = - new OutputStreamWriter(conn.getOutputStream()); - - writer.write(data); - writer.flush(); - writer.close(); - // getting the response - final BufferedReader input = new BufferedReader(new InputStreamReader(conn.getInputStream())); - final char[] returnCode = new char[512]; - int i = 0; - int tempChar = input.read(); - while (tempChar != -1 && i < 512) { - returnCode[i] = (char) tempChar; - tempChar = input.read(); - i++; - } - - String code = new String(returnCode); - code = code.trim(); - input.close(); - - log.debug("Code is {}", code); + final Login login = new Login(); + login.setUsername(userName); + login.setPassword(password); - if (code.equals(JJ_LOGIN_OK)) - return true; - lastError = BAD_LOGIN; + final Response res = request(client, "api/login") + .post(Entity.entity(login, MediaType.APPLICATION_JSON)); + try { + keepCookies(res); + final int status = res.getStatus(); + if (status != Response.Status.OK.getStatusCode()) { + log.error("Failed login, status {}", status); + lastError = status == Response.Status.UNAUTHORIZED.getStatusCode() + ? BAD_LOGIN : HttpUtils.describeStatus(status); + return false; + } + final LoginResponse reply = readLoginResponse(res); + final String code = reply == null ? null : reply.getStatus(); + if (JJ_LOGIN_OK.equals(code)) { + loggedIn = true; + lastError = null; + return true; + } + if (JJ_LOGIN_2FA.equals(code)) { + log.info("Login needs a second factor ({})", reply.getTwoFactorMethod()); + secondFactorMethod = reply.getTwoFactorMethod() == null ? "" : reply.getTwoFactorMethod(); + lastError = "This account uses two-factor login. Enter the code to finish logging in."; + return false; + } + log.error("Failed login, unexpected status {}", code); + lastError = UNEXPECTED_REPLY; + return false; + } finally { + res.close(); + } } catch (Exception e) { - log.error("Unable to validate secure login", e); + log.error("Unexpected error, failed login", e); lastError = HttpUtils.describeFailure(siteUrl); + return false; + } finally { + client.close(); } - return false; } /** - * Checks account using the REST API + * Finishes a two-factor login with the code from the e-mail, the authenticator app or the + * recovery list. A wrong code can be retried until the server gives up on the login. * + * @param code code the user typed * @return true if login was successful */ - public boolean restLogin() { - if (!isValid()) { + public boolean verifySecondFactor(final String code) { + if (!isSecondFactorRequired()) { + lastError = "Log in with your username and password first."; + return false; + } + if (code == null || code.trim().isEmpty()) { + lastError = "Enter the code."; return false; } final Client client = ClientBuilder.newClient(); try { - final Login login = new Login(); - login.setUsername(userName); - login.setPassword(password); + final TwoFactorRequest body = new TwoFactorRequest(); + body.setCode(code.trim()); - final Response res = client.target(siteUrl).path("api/login") - .request(MediaType.APPLICATION_JSON) - .post(Entity.entity(login, MediaType.APPLICATION_JSON)); + final Response res = request(client, "api/login/2fa") + .post(Entity.entity(body, MediaType.APPLICATION_JSON)); try { + keepCookies(res); final int status = res.getStatus(); if (status == Response.Status.OK.getStatusCode()) { - return true; + final LoginResponse reply = readLoginResponse(res); + if (reply != null && JJ_LOGIN_OK.equals(reply.getStatus())) { + loggedIn = true; + secondFactorMethod = null; + lastError = null; + return true; + } + log.error("Failed second factor, unexpected status {}", + reply == null ? null : reply.getStatus()); + lastError = UNEXPECTED_REPLY; + return false; + } + log.error("Failed second factor, status {}", status); + if (status == Response.Status.UNAUTHORIZED.getStatusCode()) { + final LoginResponse reply = readLoginResponse(res); + if (reply != null && JJ_LOGIN_NONE.equals(reply.getStatus())) { + // the server forgot the half finished login, so the password is needed again + secondFactorMethod = null; + lastError = "The login timed out. Log in again."; + } else { + lastError = BAD_CODE; + } + } else if (status == Response.Status.TOO_MANY_REQUESTS.getStatusCode()) { + secondFactorMethod = null; + lastError = TOO_MANY_CODES; + } else { + lastError = HttpUtils.describeStatus(status); } - log.error("Failed login, status {}", status); - lastError = status == Response.Status.UNAUTHORIZED.getStatusCode() - ? BAD_LOGIN : HttpUtils.describeStatus(status); return false; } finally { res.close(); } } catch (Exception e) { - log.error("Unexpected error, failed login", e); + log.error("Unexpected error, failed second factor", e); lastError = HttpUtils.describeFailure(siteUrl); return false; } finally { @@ -141,6 +193,53 @@ public boolean restLogin() { } } + /** + * Starts a JSON request to the site that carries this login's session. + * + * @param client client to send it with + * @param path path under the site url, without a leading / + * @return request ready to send + */ + Invocation.Builder request(final Client client, final String path) { + final Invocation.Builder builder = client.target(siteUrl).path(path) + .request(MediaType.APPLICATION_JSON) + .header(HttpUtils.USER_AGENT_HEADER, HttpUtils.USER_AGENT); + if (!cookies.isEmpty()) { + // plain name=value pairs; JAX-RS Cookie objects would be sent in the old RFC 2109 form + final StringBuilder header = new StringBuilder(); + for (final Map.Entry cookie : cookies.entrySet()) { + if (header.length() > 0) { + header.append("; "); + } + header.append(cookie.getKey()).append('=').append(cookie.getValue()); + } + builder.header("Cookie", header.toString()); + } + return builder; + } + + /** + * Remembers the cookies the server set, such as a new session id after logging in. + */ + private void keepCookies(final Response res) { + for (final NewCookie cookie : res.getCookies().values()) { + if (cookie.getMaxAge() == 0) { + cookies.remove(cookie.getName()); + } else { + cookies.put(cookie.getName(), cookie.getValue()); + } + } + } + + private LoginResponse readLoginResponse(final Response res) { + try { + return res.readEntity(LoginResponse.class); + } catch (ProcessingException | IllegalStateException e) { + log.error("Could not read login reply", e); + return null; + } + } + /** * Skips the request when the server would reject the credentials anyway, since a rejected * login gets the caller's ip blocked for a while. @@ -155,6 +254,32 @@ private boolean isValid() { return false; } + /** + * @return true once the login has finished, including any second factor + */ + public boolean isLoggedIn() { + return loggedIn; + } + + /** + * @return true if the password was accepted and the server is waiting for a two-factor code + */ + public boolean isSecondFactorRequired() { + return secondFactorMethod != null; + } + + /** + * @return EMAIL or TOTP while a two-factor code is needed, otherwise null. Empty if the + * server didn't say. + */ + public String getSecondFactorMethod() { + return secondFactorMethod; + } + + String getSiteUrl() { + return siteUrl; + } + /** * @return why the last login failed, in words for the user, or null if it hasn't failed */ diff --git a/src/main/java/com/justjournal/client/Entry.java b/src/main/java/com/justjournal/client/Entry.java index 9a97b04..10d64c5 100644 --- a/src/main/java/com/justjournal/client/Entry.java +++ b/src/main/java/com/justjournal/client/Entry.java @@ -4,54 +4,40 @@ * Created on October 17, 2005, 9:33 AM */ +import com.justjournal.client.model.EntryTo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.io.OutputStreamWriter; -import java.net.HttpURLConnection; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.List; +import javax.ws.rs.ProcessingException; +import javax.ws.rs.client.Client; +import javax.ws.rs.client.ClientBuilder; +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.GenericType; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.util.Map; /** + * Posts journal entries to {@code /api/entry} using the session from {@link Auth}. + * * @author caryn */ public class Entry { final Logger log = LoggerFactory.getLogger(Entry.class); - public static final String JJ_JOURNAL_UPDATE_OK = "JJ.JOURNAL.UPDATE.OK"; - private static final String JJ_JOURNAL_UPDATE_FAIL = "JJ.JOURNAL.UPDATE.FAIL"; - private static final String JJ_LOGIN_FAIL = "JJ.LOGIN.FAIL"; - private final String siteUrl; - // account information - private final String username; - private final String password; + private static final String STATUS_OK = "ok"; + static final String SESSION_EXPIRED = "Your login has expired. Log in again, then post the entry."; + private final Auth auth; private String lastError; /** * Constructor * - * @param jjUsername - * @param jjPassword - */ - public Entry(final String jjUsername, final String jjPassword) { - this(jjUsername, jjPassword, HttpUtils.SITE_URL); - } - - /** - * Constructor for a specific server - * - * @param jjUsername - * @param jjPassword - * @param siteUrl base url of the justjournal site, ending in / + * @param auth a login that has succeeded */ - Entry(final String jjUsername, final String jjPassword, final String siteUrl) { - username = Credentials.normalizeUsername(jjUsername); - password = jjPassword; - this.siteUrl = siteUrl; + public Entry(final Auth auth) { + this.auth = auth; } /** @@ -72,111 +58,80 @@ public boolean update(final String subject, final String body, final String mood final String location, final String security, final String music, final boolean format, final boolean email, final boolean allowComment) { - final List problems = Credentials.problems(username, password); - if (!problems.isEmpty()) { - log.error("Not sending entry: {}", String.join(" ", problems)); - lastError = String.join(" ", problems); + if (auth == null || !auth.isLoggedIn()) { + log.error("Not sending entry: not logged in"); + lastError = "Log in before posting an entry."; + return false; + } + if (body == null || body.trim().isEmpty()) { + lastError = "Write something in the body before posting."; return false; } - - // location, mood, security are all int values - // aformat, subject, body, allow_comments, email_comments (checked, unchecked) are strings // translate location to integer value int locationInteger = 0; - if (location.equals("Home")) + if ("Home".equals(location)) locationInteger = 1; - else if (location.equals("Work")) + else if ("Work".equals(location)) locationInteger = 2; - else if (location.equals("School")) + else if ("School".equals(location)) locationInteger = 3; - else if (location.equals("Other")) + else if ("Other".equals(location)) locationInteger = 5; // translate security to integer value // default value is public int securityInteger = 2; - if (security.equals("Private")) + if ("Private".equals(security)) securityInteger = 0; - else if (security.equals("Friends Only")) + else if ("Friends Only".equals(security)) securityInteger = 1; - // translate format, email and allow comments to string - // boolean to string for auto format - String strFormat = "checked"; - if (!format) - strFormat = "unchecked"; - - // boolean to string for email comments - String strEmail = "unchecked"; - if (email) - strEmail = "checked"; - - String strAllow = "checked"; - if (!allowComment) - strAllow = "unchecked"; - + final EntryTo entry = new EntryTo(); + entry.setSubject(subject == null ? "" : subject); + entry.setBody(body); + entry.setSecurity(securityInteger); + entry.setLocation(locationInteger); + entry.setMood(Moods.idFor(mood)); + entry.setMusic(music == null ? "" : music); + entry.setAutoFormat(format); + entry.setAllowComments(allowComment); + entry.setEmailComments(email); + + final Client client = ClientBuilder.newClient(); try { - - String data = ""; - data += "user=" + URLEncoder.encode(username, StandardCharsets.UTF_8.displayName()); - data += "&pass=" + URLEncoder.encode(password, StandardCharsets.UTF_8.displayName()); - data += "&security=" + securityInteger; - data += "&location=" + locationInteger; - data += "&mood=" + Moods.idFor(mood); - data += "&music=" + URLEncoder.encode(music, StandardCharsets.UTF_8.displayName()); - data += "&aformat=" + strFormat; - data += "&allow_comment=" + strAllow; - data += "&email_comment=" + strEmail; - //data += "&date=" + URLEncoder.encode(strDate, "UTF-8"); This is handled server side for now - data += "&subject=" + URLEncoder.encode(subject, StandardCharsets.UTF_8.displayName()); - data += "&body=" + URLEncoder.encode(body, StandardCharsets.UTF_8.displayName()); - - final HttpURLConnection conn = HttpUtils.getConnection(siteUrl + "updateJournal"); - conn.setRequestProperty("Content-Type", HttpUtils.FORM_URLENCODED); - conn.setRequestProperty("Content-Length", Integer.toString(data.length())); - - final OutputStreamWriter writer = - new OutputStreamWriter(conn.getOutputStream()); - - writer.write(data); - writer.flush(); - writer.close(); - // getting the response - final int status = conn.getResponseCode(); - if (status != HttpURLConnection.HTTP_OK) { - log.error("Could not save entry, status {}", status); - lastError = HttpUtils.describeStatus(status); - return false; - } - final BufferedReader input = new BufferedReader(new InputStreamReader - (conn.getInputStream())); - int response = input.read(); - final char[] returnCode = new char[50]; - int i = 0; - // for debugging - while (response != -1 && i < returnCode.length) { - returnCode[i] = (char) response; - i++; - response = input.read(); - } - input.close(); - String code = new String(returnCode); - code = code.trim(); - - if (code.compareTo(JJ_JOURNAL_UPDATE_OK) == 0) - return true; - log.debug("JJUpdate(): {}", code); - if (code.startsWith(JJ_LOGIN_FAIL)) { - lastError = Auth.BAD_LOGIN; - } else if (code.startsWith(JJ_JOURNAL_UPDATE_FAIL)) { - lastError = "The server couldn't save the entry. Check the entry and try again."; - } else { - lastError = "The server sent an unexpected reply, so the entry may not have been saved."; + final Response res = auth.request(client, "api/entry") + .post(Entity.entity(entry, MediaType.APPLICATION_JSON)); + try { + final int status = res.getStatus(); + final Map reply = readReply(res); + if (status == Response.Status.OK.getStatusCode()) { + if (reply != null && STATUS_OK.equals(reply.get("status"))) { + lastError = null; + return true; + } + log.error("Unexpected reply saving entry: {}", reply); + lastError = "The server sent an unexpected reply, so the entry may not have been saved."; + return false; + } + log.error("Could not save entry, status {} {}", status, reply); + final String error = reply == null ? null : reply.get("error"); + if (status == Response.Status.FORBIDDEN.getStatusCode() + || status == Response.Status.UNAUTHORIZED.getStatusCode()) { + lastError = SESSION_EXPIRED; + } else if (status == Response.Status.BAD_REQUEST.getStatusCode() && error != null) { + lastError = "The server couldn't save the entry: " + error; + } else { + lastError = HttpUtils.describeStatus(status); + } + } finally { + res.close(); } } catch (final Exception e) { log.error("Could not save entry", e); - lastError = HttpUtils.describeFailure(siteUrl); + lastError = HttpUtils.describeFailure(auth.getSiteUrl()); + } finally { + client.close(); } // if we get this far, we failed @@ -184,6 +139,19 @@ else if (security.equals("Friends Only")) return false; } + /** + * @return the JSON object the server replied with, or null if it isn't one + */ + private Map readReply(final Response res) { + try { + return res.readEntity(new GenericType>() { + }); + } catch (ProcessingException | IllegalStateException e) { + log.debug("Reply was not a JSON object", e); + return null; + } + } + /** * @return why the last update failed, in words for the user, or null if it hasn't failed */ diff --git a/src/main/java/com/justjournal/client/FormGui.java b/src/main/java/com/justjournal/client/FormGui.java index ffd09bb..f8e122c 100644 --- a/src/main/java/com/justjournal/client/FormGui.java +++ b/src/main/java/com/justjournal/client/FormGui.java @@ -25,6 +25,8 @@ public class FormGui implements ActionListener { private JTextField username; private JPasswordField password; private JLabel loginProblems; + // the session entries are posted with, set once a login succeeds + private Auth auth; private JTextField subject; private JTextArea body; @@ -302,12 +304,18 @@ private boolean login() { } final Auth jjLogin = new Auth(username.getText(), new String(password.getPassword()), siteUrl); - final boolean result; - - result = jjLogin.restLogin(); - //jjLogin.secureCheckAccount(); + boolean result = jjLogin.restLogin(); + if (!result && jjLogin.isSecondFactorRequired()) { + result = verifySecondFactor(jjLogin); + if (!result && jjLogin.isSecondFactorRequired()) { + // the user cancelled the code prompt + return false; + } + } log.info("Login successful? {}", result); - if (!result) { + if (result) { + auth = jjLogin; + } else { showError("Login failed", jjLogin.getLastError()); } @@ -315,6 +323,31 @@ private boolean login() { return result; } + /** + * Asks for the two-factor code until it is accepted, the user cancels, or the server gives up. + * + * @param jjLogin login whose password was accepted + * @return true if login was successful; false with the code still required if the user cancelled + */ + private boolean verifySecondFactor(final Auth jjLogin) { + final String where = "EMAIL".equals(jjLogin.getSecondFactorMethod()) + ? "Enter the code that was e-mailed to you." + : "Enter the code from your authenticator app."; + String prompt = where + " A recovery code also works."; + while (jjLogin.isSecondFactorRequired()) { + final String code = JOptionPane.showInputDialog(frame, prompt, "Two-factor login", + JOptionPane.QUESTION_MESSAGE); + if (code == null) { + return false; + } + if (jjLogin.verifySecondFactor(code)) { + return true; + } + prompt = jjLogin.getLastError() + " " + where; + } + return false; + } + /** * Shows what the server would reject about the username and password. * @@ -380,7 +413,7 @@ public void actionPerformed(ActionEvent e) { * @return true if update was successful */ private boolean update() { - final Entry updateJJ = new Entry(username.getText(), new String(password.getPassword()), siteUrl); + final Entry updateJJ = new Entry(auth); final boolean result = updateJJ.update(subject.getText(), body.getText(), selectedValue(mood), selectedValue(location), selectedValue(security), music.getText(), diff --git a/src/main/java/com/justjournal/client/HttpUtils.java b/src/main/java/com/justjournal/client/HttpUtils.java index 028fd9a..c5f96a5 100644 --- a/src/main/java/com/justjournal/client/HttpUtils.java +++ b/src/main/java/com/justjournal/client/HttpUtils.java @@ -1,9 +1,6 @@ package com.justjournal.client; -import java.io.IOException; -import java.net.HttpURLConnection; import java.net.URI; -import java.net.URL; /** * @author Lucas Holt @@ -13,34 +10,12 @@ public class HttpUtils { /** The justjournal site the client talks to. */ static final String SITE_URL = "https://www.justjournal.com/"; - public static final String FORM_URLENCODED = "application/x-www-form-urlencoded"; - private static final String USER_AGENT = "JustJournal"; - public static final String HTTP_POST = "POST"; + static final String USER_AGENT = "JustJournal"; public static final String USER_AGENT_HEADER = "User-Agent"; private HttpUtils() { } - /** - * Opens a POST connection. An https url gives a TLS connection with the JDK's default certificate checks. - * - * @param url url to post to - * @return connection ready for writing the request body - * @throws IOException if the connection can't be opened - */ - public static HttpURLConnection getConnection(final String url) throws IOException { - final URL jj = new URL(url); - final HttpURLConnection conn = (HttpURLConnection) jj.openConnection(); - - // set user-agent header in POST request - conn.setRequestProperty(USER_AGENT_HEADER, USER_AGENT); - - conn.setRequestMethod(HTTP_POST); - conn.setDoOutput(true); - conn.setDoInput(true); - return conn; - } - /** * Describes an HTTP error status for the user. * diff --git a/src/main/java/com/justjournal/client/model/EntryTo.java b/src/main/java/com/justjournal/client/model/EntryTo.java new file mode 100644 index 0000000..d319501 --- /dev/null +++ b/src/main/java/com/justjournal/client/model/EntryTo.java @@ -0,0 +1,159 @@ +package com.justjournal.client.model; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.ArrayList; +import java.util.List; + +/** + * Body for {@code POST /api/entry}. Every field is sent, since the server doesn't fill in + * defaults for all of them. + */ +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) +public final class EntryTo { + + public static final String FORMAT_TEXT = "TEXT"; + + @JsonProperty("subject") + private String subject = ""; + + @JsonProperty("body") + private String body = ""; + + /** 0 private, 1 friends, 2 public */ + @JsonProperty("security") + private int security; + + /** location id, 0 for none */ + @JsonProperty("location") + private int location; + + /** mood id, 0 for none */ + @JsonProperty("mood") + private int mood; + + @JsonProperty("music") + private String music = ""; + + @JsonProperty("format") + private String format = FORMAT_TEXT; + + @JsonProperty("autoFormat") + private boolean autoFormat; + + @JsonProperty("allowComments") + private boolean allowComments; + + @JsonProperty("emailComments") + private boolean emailComments; + + @JsonProperty("draft") + private boolean draft; + + // the server fails after saving the entry if tags is missing + @JsonProperty("tags") + private List tags = new ArrayList<>(); + + @JsonCreator + public EntryTo() { + super(); + } + + public String getSubject() { + return subject; + } + + public String getBody() { + return body; + } + + public int getSecurity() { + return security; + } + + public int getLocation() { + return location; + } + + public int getMood() { + return mood; + } + + public String getMusic() { + return music; + } + + public String getFormat() { + return format; + } + + public boolean isAutoFormat() { + return autoFormat; + } + + public boolean isAllowComments() { + return allowComments; + } + + public boolean isEmailComments() { + return emailComments; + } + + public boolean isDraft() { + return draft; + } + + public List getTags() { + return tags; + } + + public void setSubject(final String subject) { + this.subject = subject; + } + + public void setBody(final String body) { + this.body = body; + } + + public void setSecurity(final int security) { + this.security = security; + } + + public void setLocation(final int location) { + this.location = location; + } + + public void setMood(final int mood) { + this.mood = mood; + } + + public void setMusic(final String music) { + this.music = music; + } + + public void setFormat(final String format) { + this.format = format; + } + + public void setAutoFormat(final boolean autoFormat) { + this.autoFormat = autoFormat; + } + + public void setAllowComments(final boolean allowComments) { + this.allowComments = allowComments; + } + + public void setEmailComments(final boolean emailComments) { + this.emailComments = emailComments; + } + + public void setDraft(final boolean draft) { + this.draft = draft; + } + + public void setTags(final List tags) { + this.tags = tags; + } +} diff --git a/src/main/java/com/justjournal/client/model/LoginResponse.java b/src/main/java/com/justjournal/client/model/LoginResponse.java new file mode 100644 index 0000000..e2323ab --- /dev/null +++ b/src/main/java/com/justjournal/client/model/LoginResponse.java @@ -0,0 +1,53 @@ +package com.justjournal.client.model; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Reply from {@code /api/login} and {@code /api/login/2fa}. + */ +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) +@JsonIgnoreProperties(ignoreUnknown = true) +public final class LoginResponse { + + @JsonProperty("status") + private String status; + + @JsonProperty("username") + private String username; + + /** EMAIL or TOTP when the status is JJ.LOGIN.2FA */ + @JsonProperty("twoFactorMethod") + private String twoFactorMethod; + + @JsonCreator + public LoginResponse() { + super(); + } + + public String getStatus() { + return status; + } + + public String getUsername() { + return username; + } + + public String getTwoFactorMethod() { + return twoFactorMethod; + } + + public void setStatus(final String status) { + this.status = status; + } + + public void setUsername(final String username) { + this.username = username; + } + + public void setTwoFactorMethod(final String twoFactorMethod) { + this.twoFactorMethod = twoFactorMethod; + } +} diff --git a/src/main/java/com/justjournal/client/model/TwoFactorRequest.java b/src/main/java/com/justjournal/client/model/TwoFactorRequest.java new file mode 100644 index 0000000..8c9c015 --- /dev/null +++ b/src/main/java/com/justjournal/client/model/TwoFactorRequest.java @@ -0,0 +1,28 @@ +package com.justjournal.client.model; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Body for {@code POST /api/login/2fa}. + */ +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) +public final class TwoFactorRequest { + + @JsonProperty("code") + private String code; + + @JsonCreator + public TwoFactorRequest() { + super(); + } + + public String getCode() { + return code; + } + + public void setCode(final String code) { + this.code = code; + } +} diff --git a/src/test/java/com/justjournal/client/AuthTest.java b/src/test/java/com/justjournal/client/AuthTest.java index fd2cbc4..5f156c2 100644 --- a/src/test/java/com/justjournal/client/AuthTest.java +++ b/src/test/java/com/justjournal/client/AuthTest.java @@ -6,8 +6,11 @@ import org.junit.Before; import org.junit.Test; +import javax.ws.rs.client.Client; +import javax.ws.rs.client.ClientBuilder; import java.io.IOException; -import java.util.Map; +import java.util.Arrays; +import java.util.HashSet; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -23,12 +26,16 @@ public class AuthTest { private static final String USER = "testuser"; private static final String PASS = "testpass"; + static final String LOGIN_OK = "{\"status\":\"JJ.LOGIN.OK\",\"username\":\"testuser\",\"twoFactorMethod\":null}"; + private static final String LOGIN_2FA = "{\"status\":\"JJ.LOGIN.2FA\",\"username\":\"testuser\",\"twoFactorMethod\":\"TOTP\"}"; private StubServer server; @Before public void setUp() throws IOException { server = new StubServer(); + server.contentType = "application/json"; + server.responseBody = LOGIN_OK; } @After @@ -40,13 +47,28 @@ private Auth auth(final String user, final String pass) { return new Auth(user, pass, server.url()); } + /** + * @return the cookie header the login sends with its next request + */ + private String nextCookieHeader(final Auth auth) { + final Client client = ClientBuilder.newClient(); + try { + server.requestCookie = null; + auth.request(client, "next").get().close(); + return server.requestCookie; + } finally { + client.close(); + } + } + // restLogin @Test public void testRestLoginSuccess() { - server.contentType = "application/json"; - server.responseBody = "{}"; - assertTrue(auth(USER, PASS).restLogin()); + final Auth auth = auth(USER, PASS); + assertTrue(auth.restLogin()); + assertTrue(auth.isLoggedIn()); + assertFalse(auth.isSecondFactorRequired()); } @Test @@ -56,6 +78,7 @@ public void testRestLoginSendsJsonCredentials() throws IOException { assertEquals("POST", server.requestMethod); assertEquals("/api/login", server.requestPath); assertTrue(server.requestContentType.startsWith("application/json")); + assertEquals("JustJournal", server.requestUserAgent); final JsonNode json = new ObjectMapper().readTree(server.requestBody); assertEquals(USER, json.get("username").asText()); @@ -85,9 +108,36 @@ public void testRestLoginSuccessHasNoError() { assertNull(auth.getLastError()); } + @Test + public void testRestLoginOkWithoutStatusFails() { + server.responseBody = "{}"; + final Auth auth = auth(USER, PASS); + assertFalse(auth.restLogin()); + assertFalse(auth.isLoggedIn()); + assertEquals(Auth.UNEXPECTED_REPLY, auth.getLastError()); + } + + @Test + public void testRestLoginOkWithFailStatusFails() { + server.responseBody = "{\"status\":\"JJ.LOGIN.FAIL\"}"; + final Auth auth = auth(USER, PASS); + assertFalse(auth.restLogin()); + assertFalse(auth.isLoggedIn()); + } + + @Test + public void testRestLoginOkNotJsonFails() { + server.contentType = "text/html"; + server.responseBody = "login"; + final Auth auth = auth(USER, PASS); + assertFalse(auth.restLogin()); + assertEquals(Auth.UNEXPECTED_REPLY, auth.getLastError()); + } + @Test public void testRestLoginUnauthorized() { server.status = 401; + server.responseBody = "{\"status\":\"JJ.LOGIN.FAIL\"}"; final Auth auth = auth(USER, PASS); assertFalse(auth.restLogin()); assertEquals(Auth.BAD_LOGIN, auth.getLastError()); @@ -116,63 +166,144 @@ public void testRestLoginInvalidError() { assertEquals(Credentials.usernameProblem("ab"), auth.getLastError()); } - // secureCheckAccount + // session cookies @Test - public void testSecureCheckAccountSuccess() { - server.responseBody = "JJ.LOGIN.OK"; - assertTrue(auth(USER, PASS).secureCheckAccount()); - assertEquals("POST", server.requestMethod); - assertEquals("/loginAccount", server.requestPath); - assertEquals("JustJournal", server.requestUserAgent); + public void testSessionCookieSentAfterLogin() { + server.reply("/api/login", 200, LOGIN_OK, "JSESSIONID=abc123; Path=/; Secure; HttpOnly; SameSite=Lax"); + final Auth auth = auth(USER, PASS); + assertTrue(auth.restLogin()); + assertEquals("JSESSIONID=abc123", nextCookieHeader(auth)); } @Test - public void testSecureCheckAccountRejected() { - server.responseBody = "JJ.LOGIN.FAIL"; + public void testEveryCookieSentAsPlainPairs() { + server.reply("/api/login", 200, LOGIN_OK, "JSESSIONID=abc123; Path=/", "other=xyz; Path=/"); final Auth auth = auth(USER, PASS); - assertFalse(auth.secureCheckAccount()); - assertEquals(Auth.BAD_LOGIN, auth.getLastError()); + assertTrue(auth.restLogin()); + // in any order + assertEquals(new HashSet<>(Arrays.asList("JSESSIONID=abc123", "other=xyz")), + new HashSet<>(Arrays.asList(nextCookieHeader(auth).split("; ")))); + } + + @Test + public void testNoCookieBeforeLogin() { + assertNull(nextCookieHeader(auth(USER, PASS))); + } + + @Test + public void testNewLoginDropsOldSession() { + server.reply("/api/login", 200, LOGIN_OK, "JSESSIONID=old; Path=/"); + final Auth auth = auth(USER, PASS); + assertTrue(auth.restLogin()); + + server.reply("/api/login", 401, "{\"status\":\"JJ.LOGIN.FAIL\"}"); + assertFalse(auth.restLogin()); + assertFalse(auth.isLoggedIn()); + assertNull(nextCookieHeader(auth)); + } + + // two-factor login + + private Auth startSecondFactor() { + server.reply("/api/login", 200, LOGIN_2FA, "JSESSIONID=pending; Path=/"); + final Auth auth = auth(USER, PASS); + assertFalse(auth.restLogin()); + return auth; + } + + @Test + public void testRestLoginSecondFactorRequired() { + final Auth auth = startSecondFactor(); + assertFalse(auth.isLoggedIn()); + assertTrue(auth.isSecondFactorRequired()); + assertEquals("TOTP", auth.getSecondFactorMethod()); + assertTrue(auth.getLastError(), auth.getLastError().contains("two-factor")); + } + + @Test + public void testVerifySecondFactorSuccess() throws IOException { + final Auth auth = startSecondFactor(); + server.reply("/api/login/2fa", 200, LOGIN_OK, "JSESSIONID=rotated; Path=/"); + + assertTrue(auth.verifySecondFactor(" 123456 ")); + assertTrue(auth.isLoggedIn()); + assertFalse(auth.isSecondFactorRequired()); + assertNull(auth.getLastError()); + + // the code goes with the session from the password step + assertEquals("JSESSIONID=pending", server.requestCookies.get("/api/login/2fa")); + final JsonNode json = new ObjectMapper().readTree(server.requestBodies.get("/api/login/2fa")); + assertEquals("123456", json.get("code").asText()); + assertFalse(json.has("password")); + + // and later requests use the session the server switched to + assertEquals("JSESSIONID=rotated", nextCookieHeader(auth)); } @Test - public void testSecureCheckAccountEncodesCredentials() { - server.responseBody = "JJ.LOGIN.OK"; - final String pass = "p&ss wo?d#^&username"; - auth(" " + USER + " ", pass).secureCheckAccount(); + public void testVerifySecondFactorWrongCode() { + final Auth auth = startSecondFactor(); + server.reply("/api/login/2fa", 401, "{\"status\":\"JJ.LOGIN.FAIL\"}"); - final Map fields = server.formFields(); - assertEquals(2, fields.size()); - assertEquals(USER, fields.get("username")); - assertEquals(pass, fields.get("password")); + assertFalse(auth.verifySecondFactor("000000")); + assertFalse(auth.isLoggedIn()); + assertTrue(auth.isSecondFactorRequired()); + assertEquals(Auth.BAD_CODE, auth.getLastError()); } @Test - public void testSecureCheckAccountLowercasesUsername() { - server.responseBody = "JJ.LOGIN.OK"; - auth("TestUser", PASS).secureCheckAccount(); - assertEquals(USER, server.formFields().get("username")); + public void testVerifySecondFactorLoginForgotten() { + final Auth auth = startSecondFactor(); + server.reply("/api/login/2fa", 401, "{\"status\":\"JJ.LOGIN.NONE\"}"); + + assertFalse(auth.verifySecondFactor("123456")); + assertFalse(auth.isSecondFactorRequired()); + assertTrue(auth.getLastError(), auth.getLastError().contains("Log in again")); } @Test - public void testSecureCheckAccountInvalidNotSent() { - server.responseBody = "JJ.LOGIN.OK"; - assertFalse(auth(USER, "pass=word").secureCheckAccount()); - assertNull(server.requestMethod); + public void testVerifySecondFactorLocked() { + final Auth auth = startSecondFactor(); + server.reply("/api/login/2fa", 429, "{\"status\":\"JJ.LOGIN.FAIL\"}"); + + assertFalse(auth.verifySecondFactor("123456")); + assertFalse(auth.isLoggedIn()); + assertFalse(auth.isSecondFactorRequired()); + assertEquals(Auth.TOO_MANY_CODES, auth.getLastError()); } @Test - public void testSecureCheckAccountServerError() { - server.status = 500; - server.responseBody = "JJ.LOGIN.OK"; - assertFalse(auth(USER, PASS).secureCheckAccount()); + public void testVerifySecondFactorOkWithoutStatusFails() { + final Auth auth = startSecondFactor(); + server.reply("/api/login/2fa", 200, "{}"); + + assertFalse(auth.verifySecondFactor("123456")); + assertFalse(auth.isLoggedIn()); + assertEquals(Auth.UNEXPECTED_REPLY, auth.getLastError()); } @Test - public void testSecureCheckAccountServerUnavailable() { - server.close(); + public void testVerifySecondFactorBlankNotSent() { + final Auth auth = startSecondFactor(); + assertFalse(auth.verifySecondFactor(" ")); + assertFalse(server.requestBodies.containsKey("/api/login/2fa")); + assertTrue(auth.isSecondFactorRequired()); + } + + @Test + public void testVerifySecondFactorWithoutLoginNotSent() { final Auth auth = auth(USER, PASS); - assertFalse(auth.secureCheckAccount()); + assertFalse(auth.verifySecondFactor("123456")); + assertNull(server.requestMethod); + } + + @Test + public void testVerifySecondFactorServerUnavailable() { + final Auth auth = startSecondFactor(); + server.close(); + assertFalse(auth.verifySecondFactor("123456")); + assertTrue(auth.isSecondFactorRequired()); assertTrue(auth.getLastError(), auth.getLastError().startsWith("Couldn't connect to 127.0.0.1")); } } diff --git a/src/test/java/com/justjournal/client/EntryTest.java b/src/test/java/com/justjournal/client/EntryTest.java index 328ed30..ad56c69 100644 --- a/src/test/java/com/justjournal/client/EntryTest.java +++ b/src/test/java/com/justjournal/client/EntryTest.java @@ -1,11 +1,12 @@ package com.justjournal.client; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.After; import org.junit.Before; import org.junit.Test; import java.io.IOException; -import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -19,14 +20,21 @@ */ public class EntryTest { + private static final String SAVED = "{\"status\":\"ok\",\"id\":\"42\"}"; + private StubServer server; private Entry entry; @Before public void setUp() throws IOException { server = new StubServer(); - server.responseBody = Entry.JJ_JOURNAL_UPDATE_OK; - entry = new Entry("testuser", "testpass", server.url()); + server.reply("/api/login", 200, AuthTest.LOGIN_OK, "JSESSIONID=abc123; Path=/; HttpOnly"); + server.contentType = "application/json"; + server.responseBody = SAVED; + + final Auth auth = new Auth("testuser", "testpass", server.url()); + assertTrue(auth.restLogin()); + entry = new Entry(auth); } @After @@ -40,85 +48,123 @@ private boolean post(final String location, final String security, format, email, allowComment); } + private JsonNode sent() throws IOException { + return new ObjectMapper().readTree(server.requestBodies.get("/api/entry")); + } + @Test public void testUpdateSuccess() { assertTrue(post("Home", "Public", true, false, true)); assertEquals("POST", server.requestMethod); - assertEquals("/updateJournal", server.requestPath); - assertEquals(HttpUtils.FORM_URLENCODED, server.requestContentType); + assertEquals("/api/entry", server.requestPath); + assertTrue(server.requestContentType.startsWith("application/json")); assertEquals("JustJournal", server.requestUserAgent); } @Test - public void testUpdateSendsFields() { - entry.update("Hello & welcome", "Line one\nüñí=+", "Happy", "Home", "Public", "AC/DC", - true, false, true); + public void testUpdateSendsSessionNotPassword() throws IOException { + post("Home", "Public", true, false, true); + assertEquals("JSESSIONID=abc123", server.requestCookies.get("/api/entry")); - final Map fields = server.formFields(); - assertEquals("testuser", fields.get("user")); - assertEquals("testpass", fields.get("pass")); - assertEquals("Hello & welcome", fields.get("subject")); - assertEquals("Line one\nüñí=+", fields.get("body")); - assertEquals("AC/DC", fields.get("music")); - assertEquals("1", fields.get("mood")); - assertEquals("1", fields.get("location")); - assertEquals("2", fields.get("security")); - assertEquals("checked", fields.get("aformat")); - assertEquals("unchecked", fields.get("email_comment")); - assertEquals("checked", fields.get("allow_comment")); + final JsonNode json = sent(); + assertFalse(json.has("user")); + assertFalse(json.has("pass")); + assertFalse(json.has("username")); + assertFalse(json.has("password")); } @Test - public void testUpdateLocationValues() { + public void testUpdateSendsFields() throws IOException { + entry.update("Hello & welcome", "Line one\nüñí=+\"", "Happy", "Home", "Public", "AC/DC", + true, false, true); + + final JsonNode json = sent(); + assertEquals("Hello & welcome", json.get("subject").asText()); + assertEquals("Line one\nüñí=+\"", json.get("body").asText()); + assertEquals("AC/DC", json.get("music").asText()); + assertEquals(1, json.get("mood").asInt()); + assertEquals(1, json.get("location").asInt()); + assertEquals(2, json.get("security").asInt()); + assertTrue(json.get("security").isInt()); + assertTrue(json.get("autoFormat").asBoolean()); + assertFalse(json.get("emailComments").asBoolean()); + assertTrue(json.get("allowComments").asBoolean()); + assertEquals("TEXT", json.get("format").asText()); + assertFalse(json.get("draft").asBoolean()); + assertTrue(json.get("tags").isArray()); + assertEquals(0, json.get("tags").size()); + } + + @Test + public void testUpdateLocationValues() throws IOException { final String[][] cases = {{"Home", "1"}, {"Work", "2"}, {"School", "3"}, {"Other", "5"}, {"Mars", "0"}}; for (final String[] c : cases) { post(c[0], "Public", true, false, true); - assertEquals(c[0], c[1], server.formFields().get("location")); + assertEquals(c[0], c[1], sent().get("location").asText()); } } @Test - public void testUpdateSecurityValues() { + public void testUpdateSecurityValues() throws IOException { final String[][] cases = {{"Private", "0"}, {"Friends Only", "1"}, {"Public", "2"}}; for (final String[] c : cases) { post("Home", c[0], true, false, true); - assertEquals(c[0], c[1], server.formFields().get("security")); + assertEquals(c[0], c[1], sent().get("security").asText()); } } @Test - public void testUpdateMoodValues() { + public void testUpdateMoodValues() throws IOException { final String[][] cases = {{"Happy", "1"}, {"Sad", "2"}, {"Pissed off", "36"}, {"Sick", "125"}, {"Not Specified", "12"}, {"happy", "12"}, {"Meh", "12"}, {null, "12"}}; for (final String[] c : cases) { entry.update("subject", "body", c[0], "Home", "Public", "music", true, false, true); - assertEquals(c[0], c[1], server.formFields().get("mood")); + assertEquals(c[0], c[1], sent().get("mood").asText()); } } @Test - public void testUpdateCheckboxesInverted() { + public void testUpdateCheckboxesInverted() throws IOException { post("Home", "Public", false, true, false); - final Map fields = server.formFields(); - assertEquals("unchecked", fields.get("aformat")); - assertEquals("checked", fields.get("email_comment")); - assertEquals("unchecked", fields.get("allow_comment")); + final JsonNode json = sent(); + assertFalse(json.get("autoFormat").asBoolean()); + assertTrue(json.get("emailComments").asBoolean()); + assertFalse(json.get("allowComments").asBoolean()); } @Test - public void testUpdateLowercasesUsername() { - new Entry(" TestUser ", "testpass", server.url()).update("subject", "body", "Happy", "Home", "Public", - "music", true, false, true); - assertEquals("testuser", server.formFields().get("user")); + public void testUpdateOnlyBodyRequired() throws IOException { + assertTrue(entry.update(null, "body", null, null, null, null, true, false, true)); + + final JsonNode json = sent(); + assertEquals("", json.get("subject").asText()); + assertEquals("", json.get("music").asText()); + assertEquals(Moods.NOT_SPECIFIED_ID, json.get("mood").asInt()); + assertEquals(0, json.get("location").asInt()); + assertEquals(2, json.get("security").asInt()); } @Test - public void testUpdateInvalidCredentialsNotSent() { - final Entry bad = new Entry("testuser", "bad%pass", server.url()); - assertFalse(bad.update("subject", "body", "Happy", "Home", "Public", "music", true, false, true)); - assertNull(server.requestMethod); - assertEquals(Credentials.passwordProblem("bad%pass"), bad.getLastError()); + public void testUpdateBlankBodyNotSent() { + assertFalse(entry.update("subject", " \n ", "Happy", "Home", "Public", "music", true, false, true)); + assertFalse(server.requestBodies.containsKey("/api/entry")); + assertTrue(entry.getLastError(), entry.getLastError().contains("body")); + } + + @Test + public void testUpdateNotLoggedInNotSent() { + final Auth failed = new Auth("testuser", "bad%pass", server.url()); + assertFalse(failed.restLogin()); + + final Entry notLoggedIn = new Entry(failed); + assertFalse(notLoggedIn.update("subject", "body", "Happy", "Home", "Public", "music", true, false, true)); + assertFalse(server.requestBodies.containsKey("/api/entry")); + assertTrue(notLoggedIn.getLastError(), notLoggedIn.getLastError().startsWith("Log in")); + + assertFalse(new Entry(null).update("subject", "body", "Happy", "Home", "Public", "music", + true, false, true)); + assertFalse(server.requestBodies.containsKey("/api/entry")); } @Test @@ -129,36 +175,39 @@ public void testUpdateSuccessHasNoError() { @Test public void testUpdateRejected() { - server.responseBody = "JJ.JOURNAL.UPDATE.FAIL"; + server.status = 400; + server.responseBody = "{\"error\":\"Entry does not contain a body.\"}"; assertFalse(post("Home", "Public", true, false, true)); - assertTrue(entry.getLastError(), entry.getLastError().startsWith("The server couldn't save the entry")); + assertEquals("The server couldn't save the entry: Entry does not contain a body.", entry.getLastError()); } @Test - public void testUpdateBadLogin() { - // the server writes the failure code twice when authentication fails - server.responseBody = "JJ.LOGIN.FAILJJ.LOGIN.FAIL"; + public void testUpdateSessionExpired() { + server.status = 403; + server.responseBody = "{\"error\":\"The login timed out or is invalid.\"}"; assertFalse(post("Home", "Public", true, false, true)); - assertEquals(Auth.BAD_LOGIN, entry.getLastError()); + assertEquals(Entry.SESSION_EXPIRED, entry.getLastError()); } @Test public void testUpdateUnexpectedResponse() { - server.responseBody = "JJ.ERROR"; + server.responseBody = "{\"status\":\"error\"}"; assertFalse(post("Home", "Public", true, false, true)); assertTrue(entry.getLastError(), entry.getLastError().startsWith("The server sent an unexpected reply")); } @Test - public void testUpdateLongResponse() { - server.responseBody = "x".repeat(500); + public void testUpdateNotJsonResponse() { + server.contentType = "text/plain"; + server.responseBody = "JJ.JOURNAL.UPDATE.OK"; assertFalse(post("Home", "Public", true, false, true)); + assertTrue(entry.getLastError(), entry.getLastError().startsWith("The server sent an unexpected reply")); } @Test public void testUpdateServerError() { server.status = 500; - server.responseBody = Entry.JJ_JOURNAL_UPDATE_OK; + server.responseBody = SAVED; assertFalse(post("Home", "Public", true, false, true)); assertEquals("The server had a problem (HTTP 500). Try again later.", entry.getLastError()); } diff --git a/src/test/java/com/justjournal/client/StubServer.java b/src/test/java/com/justjournal/client/StubServer.java index 430027d..5a538c7 100644 --- a/src/test/java/com/justjournal/client/StubServer.java +++ b/src/test/java/com/justjournal/client/StubServer.java @@ -7,20 +7,27 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; -import java.net.URLDecoder; import java.nio.charset.StandardCharsets; -import java.util.HashMap; +import java.util.Arrays; +import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** - * Local stand-in for the justjournal.com server. Every path answers with the - * configured status and body, and the last request is recorded. + * Local stand-in for the justjournal.com server. Paths given a {@link #reply} answer with it, + * every other path answers with the configured status and body. The last request is recorded, + * and the last body and cookie header sent to each path. * * @author Lucas Holt */ class StubServer implements AutoCloseable { + /** A canned JSON response for one path. */ + record Reply(int status, String body, List setCookies) { + } + private final HttpServer server; + private final Map replies = new ConcurrentHashMap<>(); volatile int status = 200; volatile String contentType = "text/plain"; @@ -31,6 +38,9 @@ class StubServer implements AutoCloseable { volatile String requestContentType; volatile String requestUserAgent; volatile String requestBody; + volatile String requestCookie; + final Map requestBodies = new ConcurrentHashMap<>(); + final Map requestCookies = new ConcurrentHashMap<>(); StubServer() throws IOException { server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); @@ -39,11 +49,23 @@ class StubServer implements AutoCloseable { requestPath = exchange.getRequestURI().getPath(); requestContentType = exchange.getRequestHeaders().getFirst("Content-Type"); requestUserAgent = exchange.getRequestHeaders().getFirst("User-Agent"); + requestCookie = exchange.getRequestHeaders().getFirst("Cookie"); requestBody = readAll(exchange.getRequestBody()); + requestBodies.put(requestPath, requestBody); + if (requestCookie != null) { + requestCookies.put(requestPath, requestCookie); + } - final byte[] response = responseBody.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", contentType); - exchange.sendResponseHeaders(status, response.length == 0 ? -1 : response.length); + final Reply reply = replies.get(requestPath); + final byte[] response = (reply != null ? reply.body() : responseBody).getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", reply != null ? "application/json" : contentType); + if (reply != null) { + for (final String cookie : reply.setCookies()) { + exchange.getResponseHeaders().add("Set-Cookie", cookie); + } + } + exchange.sendResponseHeaders(reply != null ? reply.status() : status, + response.length == 0 ? -1 : response.length); try (OutputStream os = exchange.getResponseBody()) { os.write(response); } @@ -52,23 +74,22 @@ class StubServer implements AutoCloseable { } /** - * @return base url of the stub, ending in / + * Makes one path answer with a JSON body. + * + * @param path request path, starting with / + * @param status HTTP status + * @param body JSON body + * @param setCookies Set-Cookie header values to send */ - String url() { - return "http://127.0.0.1:" + server.getAddress().getPort() + "/"; + void reply(final String path, final int status, final String body, final String... setCookies) { + replies.put(path, new Reply(status, body, Arrays.asList(setCookies))); } /** - * @return the last request body decoded as form fields + * @return base url of the stub, ending in / */ - Map formFields() { - final Map fields = new HashMap<>(); - for (final String pair : requestBody.split("&")) { - final String[] kv = pair.split("=", 2); - fields.put(URLDecoder.decode(kv[0], StandardCharsets.UTF_8), - kv.length > 1 ? URLDecoder.decode(kv[1], StandardCharsets.UTF_8) : ""); - } - return fields; + String url() { + return "http://127.0.0.1:" + server.getAddress().getPort() + "/"; } @Override diff --git a/src/test/java/com/justjournal/client/jj_authTest.java b/src/test/java/com/justjournal/client/jj_authTest.java index 1159341..f78148e 100644 --- a/src/test/java/com/justjournal/client/jj_authTest.java +++ b/src/test/java/com/justjournal/client/jj_authTest.java @@ -16,8 +16,8 @@ public class jj_authTest { @Ignore("Integration test: calls the live justjournal.com server") @Test - public void testSecureCheckAccountFail() { + public void testRestLoginFail() { final Auth jjAuth = new Auth(FAIL_USER, FAIL_PASS); - assertFalse(jjAuth.secureCheckAccount()); + assertFalse(jjAuth.restLogin()); } }