[+] Finish backend

This commit is contained in:
Hykilpikonna
2021-01-26 21:19:52 -05:00
parent e924334ae5
commit 7d96547814
10 changed files with 378 additions and 140 deletions
+3
View File
@@ -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 {
@@ -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);
}
}
@@ -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<String>)
{
runApplication<ClockApiApplication>(*args)
}
@@ -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> T.http(code: Int): ResponseEntity<T> = ResponseEntity.status(code).body<T>(this)
fun pinHash(fid: Long, pin: Str) = DigestUtils.md5DigestAsHex("$fid-$pin".toByteArray()).toLowerCase()
fun <T> List<T>.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"
}
@@ -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
}
}
}
@@ -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.
* <p>
* 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.
* <p>
* TODO: 2021/1/22 Need a better design!
* Controller Return error code list as List<String>, 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<String> checkPasswordAndDo(String username, String password,
Function<User, String> 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<String> delete(@RequestHeader String username, @RequestHeader String password) {
return checkPasswordAndDo(username, password, user -> {
userRepository.delete(user);
return "";
});
}
@PostMapping("/login")
public synchronized ResponseEntity<String> login(@RequestHeader String username, @RequestHeader String password) {
return checkPasswordAndDo(username, password, user -> userRepository.queryByUsername(username).getUuid());
}
}
@@ -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<String>, 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 }
}
}
@@ -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<Family, Long>
@@ -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 = "";
}
@@ -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)