- findByUserAndDone(User user, boolean done);
+}
diff --git a/src/main/java/co/redeye/spring/challenge/db/User.java b/src/main/java/co/redeye/spring/challenge/db/User.java
new file mode 100644
index 0000000..b945a6f
--- /dev/null
+++ b/src/main/java/co/redeye/spring/challenge/db/User.java
@@ -0,0 +1,82 @@
+package co.redeye.spring.challenge.db;
+
+import javax.persistence.*;
+import java.util.List;
+
+/**
+ * Persistence ORM for registered users.
+ *
+ * This is my first time using automatically generated data definition.
+ */
+@Entity
+@Table(name = "users",
+ indexes = {@Index(name = "username_index", columnList = "username", unique = true),
+ @Index(name = "token_index", columnList = "token", unique = true)})
+public class User {
+ @Id
+ @GeneratedValue(strategy = GenerationType.AUTO)
+ private long id;
+
+ @Column(nullable = false, unique = true)
+ private String username;
+
+ @Column(nullable = false)
+ private String password;
+
+ @Column(nullable = false)
+ private String salt;
+
+ @Column(nullable = false, unique = true)
+ private String token;
+
+ @OneToMany(mappedBy = "user", targetEntity = Item.class, cascade = CascadeType.REMOVE)
+ private List- items;
+
+ public long getId() {
+ return id;
+ }
+
+ public void setId(long id) {
+ this.id = id;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public String getSalt() {
+ return salt;
+ }
+
+ public void setSalt(String salt) {
+ this.salt = salt;
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public void setToken(String token) {
+ this.token = token;
+ }
+
+ public List
- getItems() {
+ return items;
+ }
+
+ public void setItems(List
- items) {
+ this.items = items;
+ }
+}
diff --git a/src/main/java/co/redeye/spring/challenge/db/UserRepository.java b/src/main/java/co/redeye/spring/challenge/db/UserRepository.java
new file mode 100644
index 0000000..8e194a5
--- /dev/null
+++ b/src/main/java/co/redeye/spring/challenge/db/UserRepository.java
@@ -0,0 +1,24 @@
+package co.redeye.spring.challenge.db;
+
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.stereotype.Repository;
+
+/**
+ * Provides custom database access methods we require. Methods declared below are defined by Spring Framework magic.
+ */
+@Repository
+public interface UserRepository extends CrudRepository {
+ /**
+ * Retrieves the user with the given username.
+ * @param username The user to retrieve.
+ * @return The user or null if they do not exist.
+ */
+ User findByUsername(String username);
+
+ /**
+ * Retrieves the user currently using the given authentication token.
+ * @param token The user provided token.
+ * @return The user or null if no user is using this token.
+ */
+ User findByToken(String token);
+}
diff --git a/src/main/java/co/redeye/spring/challenge/exceptions/AuthenticationException.java b/src/main/java/co/redeye/spring/challenge/exceptions/AuthenticationException.java
new file mode 100644
index 0000000..f7c2109
--- /dev/null
+++ b/src/main/java/co/redeye/spring/challenge/exceptions/AuthenticationException.java
@@ -0,0 +1,11 @@
+package co.redeye.spring.challenge.exceptions;
+
+/**
+ * Exception to be used for problems with authentication.
+ * The message will be delivered to the user.
+ */
+public class AuthenticationException extends UserException {
+ public AuthenticationException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/co/redeye/spring/challenge/exceptions/IllegalItemException.java b/src/main/java/co/redeye/spring/challenge/exceptions/IllegalItemException.java
new file mode 100644
index 0000000..38ecc94
--- /dev/null
+++ b/src/main/java/co/redeye/spring/challenge/exceptions/IllegalItemException.java
@@ -0,0 +1,11 @@
+package co.redeye.spring.challenge.exceptions;
+
+/**
+ * Exception class for users attempting to manipulate to do list items which they do not own.
+ * Results in a Forbidden Status.
+ */
+public class IllegalItemException extends UserException {
+ public IllegalItemException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/co/redeye/spring/challenge/exceptions/InvalidItemException.java b/src/main/java/co/redeye/spring/challenge/exceptions/InvalidItemException.java
new file mode 100644
index 0000000..7bdf192
--- /dev/null
+++ b/src/main/java/co/redeye/spring/challenge/exceptions/InvalidItemException.java
@@ -0,0 +1,10 @@
+package co.redeye.spring.challenge.exceptions;
+
+/**
+ * For when a user submits an invalid to do list item.
+ */
+public class InvalidItemException extends UserException {
+ public InvalidItemException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/co/redeye/spring/challenge/exceptions/UserException.java b/src/main/java/co/redeye/spring/challenge/exceptions/UserException.java
new file mode 100644
index 0000000..78da5a5
--- /dev/null
+++ b/src/main/java/co/redeye/spring/challenge/exceptions/UserException.java
@@ -0,0 +1,11 @@
+package co.redeye.spring.challenge.exceptions;
+
+/**
+ * Base checked exception class for all exceptions caused by bad user input.
+ * The message attached to this must be appropriate to send to the end user.
+ */
+public class UserException extends Exception {
+ public UserException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/co/redeye/spring/challenge/services/AuthenticatorService.java b/src/main/java/co/redeye/spring/challenge/services/AuthenticatorService.java
new file mode 100644
index 0000000..e757b21
--- /dev/null
+++ b/src/main/java/co/redeye/spring/challenge/services/AuthenticatorService.java
@@ -0,0 +1,160 @@
+package co.redeye.spring.challenge.services;
+
+import co.redeye.spring.challenge.db.User;
+import co.redeye.spring.challenge.db.UserRepository;
+import co.redeye.spring.challenge.exceptions.AuthenticationException;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import org.springframework.transaction.annotation.Transactional;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Random;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Handles all tasks related to user authentication.
+ */
+@Service
+public class AuthenticatorService {
+ private static final String BAD_AUTH_MESSAGE = "Authentication error";
+ private static final int SALT_LENGTH = 64;
+ private static final int TOKEN_LENGTH = 32;
+ private static final Pattern TOKEN_VALIDATOR = Pattern.compile("Bearer ([a-zA-Z0-9]{" + TOKEN_LENGTH +"})");
+
+ // Constants for random String generation
+ private static final int UNIQUE_CHARACTERS = 10 + 26 + 26; //Numerals, lowercase and uppercase letters
+ private static final int NUMERAL_CUTOFF = 10;
+ private static final int LOWERCASE_CUTOFF = NUMERAL_CUTOFF + 26;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ private final Random random;
+ private final MessageDigest hashEncoder;
+
+ public AuthenticatorService() {
+ random = new Random();
+ try {
+ hashEncoder = MessageDigest.getInstance("MD5");
+ } catch (NoSuchAlgorithmException ignored) {
+ //Impossible
+ throw new RuntimeException(ignored);
+ }
+ }
+
+
+ /**
+ * Registers a new user.
+ *
+ * @param username The user's desired username.
+ * @param password The user's desired password.
+ * @return The user's authentication token.
+ * @throws AuthenticationException When the specified username is taken.
+ */
+ @Transactional
+ public String register(String username, String password) throws AuthenticationException {
+ User existingUser = userRepository.findByUsername(username);
+ if (existingUser != null) {
+ throw new AuthenticationException("This username has already been taken.");
+ }
+
+ String salt = randomString(SALT_LENGTH);
+ String saltedPassword = generateSaltedPassword(salt, password);
+ String token = randomString(TOKEN_LENGTH);
+
+ User newUser = new User();
+ newUser.setUsername(username);
+ newUser.setPassword(saltedPassword);
+ newUser.setSalt(salt);
+ newUser.setToken(token);
+
+ newUser = userRepository.save(newUser);
+ return token;
+ }
+
+ /**
+ * Authenticates a user and returns a token.
+ *
+ * @param username The user's username.
+ * @param password The user's password.
+ * @return The user's authentication token.
+ * @throws AuthenticationException When either credential is invalid.
+ */
+ @Transactional
+ public String login(String username, String password) throws AuthenticationException {
+ User user = userRepository.findByUsername(username);
+ if (user == null) {
+ throw new AuthenticationException(BAD_AUTH_MESSAGE);
+ }
+
+ String saltedPassword = generateSaltedPassword(user.getSalt(), password);
+ if (!saltedPassword.equals(user.getPassword())) {
+ throw new AuthenticationException(BAD_AUTH_MESSAGE);
+ }
+
+ String token = randomString(TOKEN_LENGTH);
+ user.setToken(token);
+ userRepository.save(user);
+ return token;
+ }
+
+ /**
+ * Validates a user login token and returns the user.
+ *
+ * @param authToken The user's authentication toekn.
+ * @return The user's account object if the token is valid.
+ * @throws AuthenticationException If the token is invalid.
+ */
+ @Transactional(readOnly = true)
+ public User fromToken(String authToken) throws AuthenticationException {
+ if (authToken == null) {
+ throw new AuthenticationException(BAD_AUTH_MESSAGE);
+ }
+
+ Matcher matcher = TOKEN_VALIDATOR.matcher(authToken);
+ if (!matcher.matches()) {
+ throw new AuthenticationException(BAD_AUTH_MESSAGE);
+ }
+
+ String cleanedToken = matcher.group(1);
+ User user = userRepository.findByToken(cleanedToken);
+ if (user == null) {
+ throw new AuthenticationException(BAD_AUTH_MESSAGE);
+ }
+
+ return user;
+ }
+
+ /**
+ * Generates the salted password hash.
+ *
+ * @param salt The salt to use on the password.
+ * @param password The user's password.
+ * @return The salted password hash.
+ */
+ private String generateSaltedPassword(String salt, String password) {
+ return new String(hashEncoder.digest((salt + password).getBytes()));
+ }
+
+ /**
+ * Generates a random string of alphanumberic characters
+ *
+ * @return The generated string.
+ */
+ private String randomString(int length) {
+ StringBuilder salt = new StringBuilder(length);
+ for (int i = 0; i < length; ++i) {
+ int current = random.nextInt(UNIQUE_CHARACTERS);
+ if (current < NUMERAL_CUTOFF) {
+ salt.append((char) ('0' + current));
+ } else if (current < LOWERCASE_CUTOFF) {
+ salt.append((char) ('a' + current - NUMERAL_CUTOFF));
+ } else {
+ salt.append((char) ('A' + current - LOWERCASE_CUTOFF));
+ }
+ }
+ return salt.toString();
+ }
+}
diff --git a/src/main/java/co/redeye/spring/challenge/services/TodoListService.java b/src/main/java/co/redeye/spring/challenge/services/TodoListService.java
new file mode 100644
index 0000000..e2f8dde
--- /dev/null
+++ b/src/main/java/co/redeye/spring/challenge/services/TodoListService.java
@@ -0,0 +1,166 @@
+package co.redeye.spring.challenge.services;
+
+import co.redeye.spring.challenge.db.Item;
+import co.redeye.spring.challenge.db.ItemRepository;
+import co.redeye.spring.challenge.db.User;
+import co.redeye.spring.challenge.exceptions.AuthenticationException;
+import co.redeye.spring.challenge.exceptions.IllegalItemException;
+import co.redeye.spring.challenge.exceptions.InvalidItemException;
+import co.redeye.spring.challenge.exceptions.UserException;
+import co.redeye.spring.challenge.views.TodoItem;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * All functionality related to the manipulation of to do lists.
+ */
+@Service
+public class TodoListService {
+ @Autowired
+ private AuthenticatorService authenticatorService;
+ @Autowired
+ private ItemRepository itemRepository;
+
+ /**
+ * Gets all the items a user has in their to do list, complete and incomplete.
+ *
+ * @param token The user's authentication token
+ * @return A list of this user's to do tasks.
+ * @throws AuthenticationException
+ */
+ @Transactional(readOnly = true)
+ public List getItems(String token) throws AuthenticationException {
+ User user = authenticatorService.fromToken(token);
+
+ return user.getItems().stream()
+ .map(TodoItem::new)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Adds a new list item for the current user
+ *
+ * @param token The user's authentication token
+ * @param desc The new task's description
+ * @param complete Whether the user has completed this task.
+ * @
+ */
+ @Transactional
+ public void addItem(String token, String desc, boolean complete) throws AuthenticationException {
+ User user = authenticatorService.fromToken(token);
+
+ Item newItem = new Item();
+ newItem.setUser(user);
+ newItem.setDescription(desc);
+ newItem.setDone(complete);
+
+ itemRepository.save(newItem);
+ }
+
+ /**
+ * Edits an existing item on a user's to do list.
+ *
+ * @param token The user's authentication token.
+ * @param taskId The id of the task.
+ * @param text The new text for the list item.
+ * @param done The new status for the item.
+ * @throws AuthenticationException If the user's token is invalid.
+ * @throws IllegalItemException If the specified task does not belong to the user.
+ */
+ @Transactional
+ public void editItem(String token, long taskId, String text, boolean done) throws UserException {
+ User user = authenticatorService.fromToken(token);
+ Item item = itemRepository.findOne(taskId);
+
+ if (item == null) {
+ throw new InvalidItemException("The specified to do list item does not exist.");
+ }
+
+ if (!user.equals(item.getUser())) {
+ throw new IllegalItemException("This item does not belong to you.");
+ }
+
+ item.setDone(done);
+ item.setDescription(text);
+ itemRepository.save(item);
+ }
+
+ /**
+ * Deletes a specified item from the current user's to do list.
+ *
+ * @param token The user's authentication token.
+ * @param taskId The id of the task to be removed.
+ * @throws UserException If there is an issue with authentication or the item.
+ */
+ @Transactional
+ public void deleteItem(String token, long taskId) throws UserException {
+ User user = authenticatorService.fromToken(token);
+ Item item = itemRepository.findOne(taskId);
+
+ if (item == null) {
+ throw new InvalidItemException("The specified to do list item does not exist.");
+ }
+
+ if (!user.equals(item.getUser())) {
+ throw new IllegalItemException("This item does not belong to you.");
+ }
+
+ itemRepository.delete(item);
+ }
+
+ /**
+ * Gets all of the user's incomplete to do list items.
+ *
+ * @param token The user's authentication token.
+ * @return The user's incomplete items.
+ * @throws AuthenticationException If the user's token is invalid.
+ */
+ public List getIncompleteItems(String token) throws AuthenticationException {
+ return getItemsWithDoneStatus(token, false);
+ }
+
+ /**
+ * Gets all of the user's complete to do list items.
+ *
+ * @param token The user's authentication token.
+ * @return The user's complete items.
+ * @throws AuthenticationException If the user's token is invalid.
+ */
+ public List getCompleteItems(String token) throws AuthenticationException {
+ return getItemsWithDoneStatus(token, true);
+ }
+
+ /**
+ * Method for handling requests for a user's complete/incomplete items.
+ *
+ * @param token The user's authentication token.
+ * @param doneStatus The desired status of the TodoItems to return.
+ * @return The user's items with the desired status.
+ * @throws AuthenticationException If the user's token is invalid.
+ */
+ @Transactional
+ private List getItemsWithDoneStatus(String token, boolean doneStatus) throws AuthenticationException {
+ User user = authenticatorService.fromToken(token);
+
+ return itemRepository.findByUserAndDone(user, doneStatus).stream()
+ .filter(item -> item.isDone() == doneStatus)
+ .map(TodoItem::new)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Deletes all of the user's to do list items.
+ *
+ * @param token The user's authentication token.
+ * @throws AuthenticationException If the user's token is invalid.
+ */
+ @Transactional
+ public void deleteAllItems(String token) throws AuthenticationException {
+ User user = authenticatorService.fromToken(token);
+ itemRepository.delete(user.getItems());
+ }
+}
diff --git a/src/main/java/co/redeye/spring/challenge/views/ErrorResponse.java b/src/main/java/co/redeye/spring/challenge/views/ErrorResponse.java
new file mode 100644
index 0000000..3d30c02
--- /dev/null
+++ b/src/main/java/co/redeye/spring/challenge/views/ErrorResponse.java
@@ -0,0 +1,23 @@
+package co.redeye.spring.challenge.views;
+
+/**
+ * Standard response for when an error has occurred.
+ */
+public class ErrorResponse {
+ String message;
+
+ public ErrorResponse() {
+ }
+
+ public ErrorResponse(String message) {
+ this.message = message;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+}
diff --git a/src/main/java/co/redeye/spring/challenge/views/LoginRequest.java b/src/main/java/co/redeye/spring/challenge/views/LoginRequest.java
new file mode 100644
index 0000000..7554eb3
--- /dev/null
+++ b/src/main/java/co/redeye/spring/challenge/views/LoginRequest.java
@@ -0,0 +1,38 @@
+package co.redeye.spring.challenge.views;
+
+import co.redeye.spring.challenge.Utils;
+import co.redeye.spring.challenge.exceptions.AuthenticationException;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Represents a request to register/login
+ */
+public class LoginRequest {
+ private String username;
+ private String password;
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public void validate() throws AuthenticationException {
+ if (!Utils.stringPresent(username)) {
+ throw new AuthenticationException("Invalid username.");
+ }
+ if (!Utils.stringPresent(password)) {
+ throw new AuthenticationException("Invalid password.");
+ }
+ }
+}
diff --git a/src/main/java/co/redeye/spring/challenge/views/LoginResponse.java b/src/main/java/co/redeye/spring/challenge/views/LoginResponse.java
new file mode 100644
index 0000000..5a857cf
--- /dev/null
+++ b/src/main/java/co/redeye/spring/challenge/views/LoginResponse.java
@@ -0,0 +1,20 @@
+package co.redeye.spring.challenge.views;
+
+/**
+ * Standard response object for login/registering
+ */
+public class LoginResponse {
+ private String token;
+
+ public LoginResponse(String token) {
+ this.token = token;
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public void setToken(String token) {
+ this.token = token;
+ }
+}
diff --git a/src/main/java/co/redeye/spring/challenge/views/TodoItem.java b/src/main/java/co/redeye/spring/challenge/views/TodoItem.java
new file mode 100644
index 0000000..19b1969
--- /dev/null
+++ b/src/main/java/co/redeye/spring/challenge/views/TodoItem.java
@@ -0,0 +1,56 @@
+package co.redeye.spring.challenge.views;
+
+import co.redeye.spring.challenge.Utils;
+import co.redeye.spring.challenge.db.Item;
+import co.redeye.spring.challenge.exceptions.InvalidItemException;
+
+/**
+ * Represents a single to do list item.
+ */
+public class TodoItem {
+ long id;
+ Boolean done;
+ String text;
+
+ public TodoItem() {
+ }
+
+ public TodoItem(Item item) {
+ this.id = item.getId();
+ this.done = item.isDone();
+ this.text = item.getDescription();
+ }
+
+ public long getId() {
+ return id;
+ }
+
+ public void setId(long id) {
+ this.id = id;
+ }
+
+ public boolean isDone() {
+ return done;
+ }
+
+ public void setDone(boolean done) {
+ this.done = done;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ public void setText(String text) {
+ this.text = text;
+ }
+
+ public void validate() throws InvalidItemException {
+ if (!Utils.stringPresent(text)) {
+ throw new InvalidItemException("Missing field 'text'");
+ }
+ if (done == null) {
+ throw new InvalidItemException("Missing field 'done' (true|false)");
+ }
+ }
+}