diff --git a/build.gradle b/build.gradle index 939fa6d..0160cc1 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,9 @@ dependencies { // Spring Security. // implementation 'org.springframework.boot:spring-boot-starter-security' + + // Kotlin support + implementation "org.jetbrains.kotlin:kotlin-reflect:1.4.21" } test { diff --git a/src/main/java/org/hydev/clock_api/ClockApiApplication.java b/src/main/java/org/hydev/clock_api/ClockApiApplication.java deleted file mode 100644 index aea9c9b..0000000 --- a/src/main/java/org/hydev/clock_api/ClockApiApplication.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.hydev.clock_api; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class ClockApiApplication { - public static void main(String[] args) { - SpringApplication.run(ClockApiApplication.class, args); - } -} diff --git a/src/main/java/org/hydev/clock_api/ClockApiApplication.kt b/src/main/java/org/hydev/clock_api/ClockApiApplication.kt new file mode 100644 index 0000000..310a75e --- /dev/null +++ b/src/main/java/org/hydev/clock_api/ClockApiApplication.kt @@ -0,0 +1,12 @@ +package org.hydev.clock_api + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +open class ClockApiApplication + +fun main(args: Array) +{ + runApplication(*args) +} diff --git a/src/main/java/org/hydev/clock_api/Extensions.kt b/src/main/java/org/hydev/clock_api/Extensions.kt new file mode 100644 index 0000000..3ab0ba3 --- /dev/null +++ b/src/main/java/org/hydev/clock_api/Extensions.kt @@ -0,0 +1,49 @@ +package org.hydev.clock_api + +import org.hydev.clock_api.entity.User +import org.hydev.clock_api.repository.UserRepository +import org.springframework.http.ResponseEntity +import org.springframework.util.DigestUtils +import org.springframework.web.bind.annotation.RequestHeader + + + +typealias H = RequestHeader +typealias Str = String +typealias Bool = Boolean + +fun T.http(code: Int): ResponseEntity = ResponseEntity.status(code).body(this) +fun pinHash(fid: Long, pin: Str) = DigestUtils.md5DigestAsHex("$fid-$pin".toByteArray()).toLowerCase() +fun List.csv() = joinToString(";") +fun Str.csv() = split(";").toMutableList().apply { removeIf { it.isEmpty() } } + +/** + * Create salted hash for user's password + * Format: "$username + $password".toLowerMd5(); + * + * @param username Unique username used as a salt + * @param password Password initial hash + * @return Salted hash + */ +fun userToSaltedMd5(username: String?, password: String?): String +{ + val beforeMd5 = String.format("%s + %s", username, password) + return DigestUtils.md5DigestAsHex(beforeMd5.toByteArray()).toLowerCase() +} + +/** + * Login and do callback + * + * @param username + * @param password + * @param callback + * @return Execution result + */ +fun UserRepository.login(username: Str, password: Str, callback: (User) -> Any?): Any +{ + // Verify login + val user: User = queryByUsername(username) ?: return "Username not found".http(404) + if (user.passwordMd5 != userToSaltedMd5(username, password)) return "Login invalid".http(401) + + return callback(user) ?: "Success" +} diff --git a/src/main/java/org/hydev/clock_api/controller/FamilyController.kt b/src/main/java/org/hydev/clock_api/controller/FamilyController.kt new file mode 100644 index 0000000..eaa66c4 --- /dev/null +++ b/src/main/java/org/hydev/clock_api/controller/FamilyController.kt @@ -0,0 +1,155 @@ +package org.hydev.clock_api.controller + +import org.hydev.clock_api.* +import org.hydev.clock_api.entity.Family +import org.hydev.clock_api.entity.FamilyRepo +import org.hydev.clock_api.entity.User +import org.hydev.clock_api.repository.UserRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/family") +open class FamilyController(private val userRepo: UserRepository, private val familyRepo: FamilyRepo) +{ + /** + * Login, check family, and callback + * + * @param username + * @param password + * @param fid Family ID + * @param pin Family Pin + * @param checkInFamily Verify the user is in the family or not + * @param callback + * @return Execution result + */ + private fun family(username: Str, password: Str, fid: Long, pin: Str, checkInFamily: Bool = true, callback: (User, Family) -> Any?): Any + { + return userRepo.login(username, password) { user -> + + val family = familyRepo.findByIdOrNull(fid) ?: return@login "Family not found".http(404) + + // Verify + if (checkInFamily && user.family != fid) return@login "You're not in this family.".http(400) + if (family.pinHash != pinHash(fid, pin)) return@login "Family pin incorrect.".http(401) + + // Done + callback(user, family) ?: "Success" + } + } + + @PostMapping("/get") + fun get(@H username: Str, @H password: Str): Any + { + return userRepo.login(username, password) + { + if (it.family == null) "You didn't join any family".http(404) + else familyRepo.findByIdOrNull(it.family)!! + } + } + + @PostMapping("/create") + fun create(@H username: Str, @H password: Str, @H name: Str, @H pin: Str): Any + { + return userRepo.login(username, password) { user -> + + // Verify that the user isn't already in a family + if (user.family != null) return@login "You already have a family.".http(400) + + // Create family and save once to get ID + var family = Family(name = name, pinHash = "", members = username) + family = familyRepo.save(family) + + // Create pin hash using id and save again + family.pinHash = pinHash(family.fid, pin) + familyRepo.save(family) + + // Set user's family + user.family = family.fid + userRepo.save(user) + + // Done + family + } + } + + @PostMapping("/update_pin") + fun updatePin(@H username: Str, @H password: Str, @H fid: Long, @H pin: Str, @H newPin: Str): Any + { + return family(username, password, fid, pin) { _, family -> + + // Change pin + family.pinHash = pinHash(family.fid, newPin) + familyRepo.save(family) + } + } + + @PostMapping("/action") + fun action(@H username: Str, @H password: Str, @H fid: Long, @H pin: Str, @H action: Str): Any + { + return family(username, password, fid, pin, checkInFamily = action.toLowerCase() != "join") { user, family -> + + // Do action + when (action.toLowerCase()) + { + "join" -> + { + if (user.family != null) return@family "You're already in a family.".http(400) + user.family = fid + userRepo.save(user) + family.members = family.members.csv().apply { add(username) }.csv() + familyRepo.save(family) + } + "delete" -> + { + // TODO: Test this + family.members.csv().forEach { + val member = userRepo.queryByUsername(it) ?: return@forEach + member.family = null + userRepo.save(member) + } + familyRepo.delete(family) + family + } + "leave" -> + { + // TODO: Test this + user.family = null + userRepo.save(user) + family.members = family.members.csv().apply { remove(username) }.csv() + familyRepo.save(family) + } + else -> "".http(404) + } + } + } + + @PostMapping("/add_alarm") + fun addAlarm(@H username: Str, @H password: Str, @H fid: Long, @H pin: Str, @H to: Str, @H alarm: Str): Any + { + return family(username, password, fid, pin) { _, _ -> + + val toUser = userRepo.queryByUsername(to) ?: return@family "User not found in database".http(404) + if (toUser.family != fid) return@family "User not in your family".http(401) + + toUser.notifications = toUser.notifications.csv().apply { add(alarm) }.csv() + userRepo.save(toUser) + "Success" + } + } + + @PostMapping("/get_alarm_updates") + fun getUpdates(@H username: Str, @H password: Str): Any + { + return userRepo.login(username, password) { user -> + + // TODO: test this + val response = user.notifications + user.notifications = "" + userRepo.save(user) + response + } + } + + +} diff --git a/src/main/java/org/hydev/clock_api/controller/UserController.java b/src/main/java/org/hydev/clock_api/controller/UserController.java deleted file mode 100644 index 6f36d23..0000000 --- a/src/main/java/org/hydev/clock_api/controller/UserController.java +++ /dev/null @@ -1,118 +0,0 @@ -package org.hydev.clock_api.controller; - -import org.hydev.clock_api.entity.User; -import org.hydev.clock_api.error.ErrorCode; -import org.hydev.clock_api.repository.UserRepository; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.util.DigestUtils; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import javax.validation.constraints.Pattern; -import java.util.List; -import java.util.function.Function; - -@Validated -@RestController -@RequestMapping("/user") -public class UserController { - private final UserRepository userRepository; - - @Autowired - public UserController(UserRepository userRepository) { - this.userRepository = userRepository; - } - - /** - * Create salted hash for user's password - * Format: "$username + $password".toLowerMd5(); - * - * @param username Unique username used as a salt - * @param password Password initial hash - * @return Salted hash - */ - private static String userToSaltedMd5(String username, String password) { - String beforeMd5 = String.format("%s + %s", username, password); - return DigestUtils.md5DigestAsHex(beforeMd5.getBytes()).toLowerCase(); - } - - /** - * Register a user to the database. - *

- * https://www.baeldung.com/spring-rest-http-headers - * This method should be synchronized to avoid race condition. - * Also, this method should not be private, or else cannot use userRepository. - *

- * TODO: 2021/1/22 Need a better design! - * Controller Return error code list as List, or return uuid as String. - * - * @param username Unique username (Not empty, and should match the regex {@code User.RE_USERNAME}) - * @param password Password initial hash (Not empty) - * @return Success or error - */ - @PostMapping("/register") - @SuppressWarnings("rawtypes") - public synchronized ResponseEntity register( - // [!] @RequestHeader(required = false) makes no need make another error handler. - // [!] And also, ExceptionHandler of MissingRequestHeaderException cannot deal with all missing fields. - @Pattern(regexp = User.RE_USERNAME, message = ErrorCode.USER_NAME_NOT_MATCH_REGEX) - @RequestHeader String username, - @Pattern(regexp = User.RE_PASSWORD, message = ErrorCode.USER_PASSWORD_NOT_MATCH_REGEX) - @RequestHeader String password - ) { - // First, spring will check args. If not pass there regex, raise ConstraintViolationException. - // Then, we check if username not exists. If username already exists, return ErrorCode with 409 conflict. - if (userRepository.existsByUsername(username)) - // TODO: 2021/1/22 Using library instead of hand make. - return ResponseEntity.status(HttpStatus.CONFLICT).body(List.of(ErrorCode.USER_NAME_ALREADY_EXISTS)); - - User user = new User(); - user.setUsername(username); - user.setPasswordMd5(userToSaltedMd5(username, password)); - - // After save and flush, uuid field will be generated automatically. - userRepository.saveAndFlush(user); - return ResponseEntity.ok(user.getUuid()); - } - - /** - * Check username & password. - * - User doesn't exist -> http 404 - * - Password doesn't match -> http 401 - * - All match -> Execute operation and return the resulting String. - * - * @param username Unique username - * @param password Password initial hash - * @param operation Callback on success - * @return Callback result or the error response - */ - private ResponseEntity checkPasswordAndDo(String username, String password, - Function operation) { - User user = userRepository.queryByUsername(username); - if (user == null) return ResponseEntity.notFound().build(); - - if (!user.getPasswordMd5().equals(userToSaltedMd5(username, password))) - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - - String result = operation.apply(user); - return ResponseEntity.ok(result); - } - - @PostMapping("/delete") - public synchronized ResponseEntity delete(@RequestHeader String username, @RequestHeader String password) { - return checkPasswordAndDo(username, password, user -> { - userRepository.delete(user); - return ""; - }); - } - - @PostMapping("/login") - public synchronized ResponseEntity login(@RequestHeader String username, @RequestHeader String password) { - return checkPasswordAndDo(username, password, user -> userRepository.queryByUsername(username).getUuid()); - } -} diff --git a/src/main/java/org/hydev/clock_api/controller/UserController.kt b/src/main/java/org/hydev/clock_api/controller/UserController.kt new file mode 100644 index 0000000..d091765 --- /dev/null +++ b/src/main/java/org/hydev/clock_api/controller/UserController.kt @@ -0,0 +1,105 @@ +package org.hydev.clock_api.controller + +import org.hydev.clock_api.* +import org.hydev.clock_api.entity.FamilyRepo +import org.hydev.clock_api.entity.User +import org.hydev.clock_api.error.ErrorCode +import org.hydev.clock_api.repository.UserRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import javax.validation.constraints.Pattern + +@RestController +@RequestMapping("/user") +open class UserController(private val userRepo: UserRepository, private val familyRepo: FamilyRepo) +{ + /** + * Register a user to the database. + * + * + * https://www.baeldung.com/spring-rest-http-headers + * This method should be synchronized to avoid race condition. + * Also, this method should not be private, or else cannot use userRepository. + * + * + * TODO: 2021/1/22 Need a better design! + * Controller Return error code list as List, or return uuid as String. + * + * @param username Unique username (Not empty, and should match the regex `User.RE_USERNAME`) + * @param password Password initial hash (Not empty) + * @return Success or error + */ + @PostMapping("/register") + @Synchronized + fun register( + // [!] @H(required = false) makes no need make another error handler. + // [!] And also, ExceptionHandler of MissingHException cannot deal with all missing fields. + @H username: @Pattern(regexp = User.RE_USERNAME, message = ErrorCode.USER_NAME_NOT_MATCH_REGEX) Str, + @H password: @Pattern(regexp = User.RE_PASSWORD, message = ErrorCode.USER_PASSWORD_NOT_MATCH_REGEX) Str): Any + { + // First, spring will check args. If not pass there regex, raise ConstraintViolationException. + // Then, we check if username not exists. If username already exists, return ErrorCode with 409 conflict. + // TODO: 2021/1/22 Using library instead of hand make. + if (userRepo.existsByUsername(username)) return listOf(ErrorCode.USER_NAME_ALREADY_EXISTS).http(409) + + val user = User().apply { + this.username = username + passwordMd5 = userToSaltedMd5(username, password) + } + + // After save and flush, uuid field will be generated automatically. + userRepo.saveAndFlush(user) + return user.uuid + } + + @PostMapping("/delete") + @Synchronized + fun delete(@H username: Str, @H password: Str): Any + { + return userRepo.login(username, password) { user -> + + // Remove user from family + if (user.family != null) + { + val family = familyRepo.findByIdOrNull(user.family) + if (family != null) + { + family.members = family.members.csv().apply { remove(username) }.csv() + familyRepo.save(family) + } + } + + // Remove user + userRepo.delete(user) + + // Done + "Success" + } + } + + @PostMapping("/login") + @Synchronized + fun login(@H username: Str, @H password: Str): Any + { + return userRepo.login(username, password) { it.uuid } + } + + @PostMapping("/backup/upload") + fun backupUpload(@H username: Str, @H password: Str, @H config: Str): Any + { + return userRepo.login(username, password) { user -> + + user.backup = config + userRepo.save(user) + "Success" + } + } + + @PostMapping("/backup/download") + fun backupDownload(@H username: Str, @H password: Str): Any + { + return userRepo.login(username, password) { user -> user.backup } + } +} diff --git a/src/main/java/org/hydev/clock_api/entity/Family.kt b/src/main/java/org/hydev/clock_api/entity/Family.kt new file mode 100644 index 0000000..23bca57 --- /dev/null +++ b/src/main/java/org/hydev/clock_api/entity/Family.kt @@ -0,0 +1,33 @@ +package org.hydev.clock_api.entity + +import com.sun.istack.NotNull +import org.springframework.data.jpa.repository.JpaRepository +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.Id + +/** + * TODO: Write a description for this class! + * + * @author HyDEV Team (https://github.com/HyDevelop) + * @author Hykilpikonna (https://github.com/hykilpikonna) + * @author Vanilla (https://github.com/VergeDX) + * @since 2021-01-24 10:18 + */ +@Entity(name = "family") +data class Family( + @Id + @GeneratedValue + var fid: Long = 0, + + @NotNull + var name: String = "", + + @NotNull + var members: String = "", + + @NotNull + var pinHash: String = "", +) + +interface FamilyRepo: JpaRepository diff --git a/src/main/java/org/hydev/clock_api/entity/User.java b/src/main/java/org/hydev/clock_api/entity/User.java index 9687868..974a0df 100644 --- a/src/main/java/org/hydev/clock_api/entity/User.java +++ b/src/main/java/org/hydev/clock_api/entity/User.java @@ -4,13 +4,14 @@ import lombok.Data; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.GenericGenerator; import org.hydev.clock_api.error.ErrorCode; +import org.w3c.dom.stylesheets.LinkStyle; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.Id; +import javax.persistence.*; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Data @Entity(name = "users") @@ -18,7 +19,7 @@ public class User { // https://digitalfortress.tech/tricks/top-15-commonly-used-regex/ // https://www.regexpal.com/?fam=104030 - public static final String RE_USERNAME = "^[a-z0-9_-]{3,16}$"; + public static final String RE_USERNAME = "^[A-Za-z0-9_-]{3,16}$"; // https://www.regexpal.com/?fam=104029 public static final String RE_PASSWORD = "(?=(.*[0-9]))((?=.*[A-Za-z0-9])(?=.*[A-Z])(?=.*[a-z]))^.{8,}$"; @@ -33,17 +34,27 @@ public class User { // https://thorben-janssen.com/generate-uuids-primary-keys-hibernate/ @GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator") @GeneratedValue(generator = "UUID") - private String uuid; + public String uuid; @NotNull(message = ErrorCode.INNER_USERNAME_IS_NULL) @Pattern(regexp = RE_USERNAME, message = ErrorCode.INNER_USERNAME_NOT_MATCH_REGEX) - private String username; + public String username; @NotNull(message = ErrorCode.INNER_PASSWORD_MD5_IS_NULL) @Pattern(regexp = RE_LOWER_MD5, message = ErrorCode.INNER_PASSWORD_MD5_NOT_MATCH_REGEX) - private String passwordMd5; + public String passwordMd5; // https://stackoverflow.com/questions/8202154/how-to-create-an-auto-generated-date-timestamp-field-in-a-play-jpa/8207652 @CreationTimestamp - private LocalDateTime joinDate; + public LocalDateTime joinDate; + + /** The id of the family that the user joined */ + public Long family; + + @NotNull + public String notifications = ""; + + @NotNull + @Column(length = 5000) + public String backup = ""; } diff --git a/src/test/java/org/hydev/clock_api/UserRegisterDeleteNodeTest.kt b/src/test/java/org/hydev/clock_api/UserRegisterDeleteNodeTest.kt index 932e497..45a0241 100644 --- a/src/test/java/org/hydev/clock_api/UserRegisterDeleteNodeTest.kt +++ b/src/test/java/org/hydev/clock_api/UserRegisterDeleteNodeTest.kt @@ -1,6 +1,5 @@ package org.hydev.clock_api -import org.hamcrest.Matchers import org.hamcrest.Matchers.matchesPattern import org.hydev.clock_api.entity.User import org.hydev.clock_api.error.ErrorCode.* @@ -122,7 +121,7 @@ class UserRegisterDeleteNodeTest { fun testWhenUserAlreadyExists() { mockMvc.perform(post(REGISTER_NODE).header(H_USERNAME, V_USERNAME).header(H_PASSWORD, V_PASSWORD)) .andExpect(status().isOk) - .andExpect(content().string(Matchers.matchesPattern(R_UUID))) + .andExpect(content().string(matchesPattern(R_UUID))) .andDo { result: MvcResult -> // Notice: inserted user should be delete. val tempUuid = result.response.contentAsString @@ -179,7 +178,7 @@ class UserRegisterDeleteNodeTest { fun testDeleteUser() { mockMvc.perform(post(REGISTER_NODE).header(H_USERNAME, V_USERNAME).header(H_PASSWORD, V_PASSWORD)) .andExpect(status().isOk) - .andExpect(content().string(Matchers.matchesPattern(R_UUID))) + .andExpect(content().string(matchesPattern(R_UUID))) .andDo { // Missing headers, 400 bad request. pTDWHsAEHS(mapOf(), HttpStatus.BAD_REQUEST)