diff --git a/build.gradle b/build.gradle index 5ab377b..a497750 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,8 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'org.postgresql:postgresql' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' annotationProcessor 'org.projectlombok:lombok' diff --git a/db/docker-compose.yml b/db/docker-compose.yml new file mode 100644 index 0000000..7afd7ef --- /dev/null +++ b/db/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3.8' +services: + cc-database: + image: postgres + restart: always + environment: + POSTGRES_USER: biblenotes + POSTGRES_PASSWORD: ujhfdogiuhfdgiusdfhoviufdnpviusdhdfiuqbfoiudzsfzidfugofduszbdv + POSTGRES_DB: biblenotes + volumes: + - ./sql-scripts:/docker-entrypoint-initdb.d + ports: + - "5432:5432" + + adminer: + image: adminer + restart: always + environment: + ADMINER_DEFAULT_SERVER: biblenotes + ports: + - "8081:8081" diff --git a/db/sql-scripts/init.sql b/db/sql-scripts/init.sql new file mode 100644 index 0000000..f50b206 --- /dev/null +++ b/db/sql-scripts/init.sql @@ -0,0 +1,19 @@ +CREATE TABLE users +( + id SERIAL PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + role VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE user_logins +( + id SERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + login_time TIMESTAMP NOT NULL, + login_ip VARCHAR(255) NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) +); \ No newline at end of file diff --git a/src/main/java/de/w665/biblenotes/db/DatabaseInitService.java b/src/main/java/de/w665/biblenotes/db/DatabaseInitService.java new file mode 100644 index 0000000..429abb8 --- /dev/null +++ b/src/main/java/de/w665/biblenotes/db/DatabaseInitService.java @@ -0,0 +1,45 @@ +package de.w665.biblenotes.db; + +import de.w665.biblenotes.db.entity.Role; +import de.w665.biblenotes.db.entity.User; +import de.w665.biblenotes.db.repo.UserRepository; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +@Slf4j +public class DatabaseInitService { + + @Value("${database.init.create-default-admin-user}") + private boolean createAdminUser; + + private final UserRepository userRepository; + + public DatabaseInitService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @PostConstruct + public void init() { + if(createAdminUser && userRepository.count() == 0) { + createDefaultAdminUser(); + } + } + + private void createDefaultAdminUser() { + log.debug("Inserting admin user..."); + User user = new User(); + user.setUsername("admin"); + user.setPassword("$2a$10$y5u4UL6JwacDrMzXEJqqQO.1Cf3xoZTRhUTab4We/zxnWneI5jTf2"); + user.setEmail("admin@example.com"); + user.setRole(Role.ADMINISTRATOR); + user.setCreatedAt(LocalDateTime.now()); + user.setUpdatedAt(LocalDateTime.now()); + userRepository.save(user); + log.debug("Admin user insertion done."); + } +} diff --git a/src/main/java/de/w665/biblenotes/db/RethinkDBConfig.java b/src/main/java/de/w665/biblenotes/db/RethinkDBConfig.java deleted file mode 100644 index d893c9b..0000000 --- a/src/main/java/de/w665/biblenotes/db/RethinkDBConfig.java +++ /dev/null @@ -1,18 +0,0 @@ -package de.w665.biblenotes.db; - -import lombok.Getter; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; - -@Getter -@Configuration -public class RethinkDBConfig { - @Value("${rethinkdb.host}") - private String host; - - @Value("${rethinkdb.port}") - private int port; - - @Value("${rethinkdb.database}") - private String database; -} diff --git a/src/main/java/de/w665/biblenotes/db/RethinkDBConnector.java b/src/main/java/de/w665/biblenotes/db/RethinkDBConnector.java deleted file mode 100644 index 72c9bca..0000000 --- a/src/main/java/de/w665/biblenotes/db/RethinkDBConnector.java +++ /dev/null @@ -1,27 +0,0 @@ -package de.w665.biblenotes.db; - -import com.rethinkdb.RethinkDB; -import com.rethinkdb.net.Connection; -import jakarta.annotation.PostConstruct; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -@RequiredArgsConstructor -public class RethinkDBConnector { - - private final RethinkDBConfig config; - @Getter - private final RethinkDB r = RethinkDB.r; - @Getter - private Connection connection; - - @PostConstruct - public void connectToDatabae() { - connection = r.connection().hostname(config.getHost()).port(config.getPort()).connect(); - log.info("Connected to RethinkDB at " + config.getHost() + ":" + config.getPort() + " on database " + config.getDatabase()); - } -} diff --git a/src/main/java/de/w665/biblenotes/db/RethinkDBService.java b/src/main/java/de/w665/biblenotes/db/RethinkDBService.java deleted file mode 100644 index ab788a8..0000000 --- a/src/main/java/de/w665/biblenotes/db/RethinkDBService.java +++ /dev/null @@ -1,123 +0,0 @@ -package de.w665.biblenotes.db; - -import com.rethinkdb.RethinkDB; -import com.rethinkdb.gen.exc.ReqlOpFailedError; -import com.rethinkdb.net.Connection; -import de.w665.biblenotes.db.repo.UserRepository; -import de.w665.biblenotes.model.User; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.cglib.core.Local; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Date; -import java.util.Optional; - -@Slf4j -@Service -public class RethinkDBService { - - private final RethinkDBConfig config; - private final RethinkDB r; - private final Connection connection; - private final UserRepository userRepository; - - @Value("${biblenotes.auto-reset-on-startup}") - private boolean autoResetOnStartup; - @Value("${biblenotes.management.user.username}") - private String defaultUsername; - @Value("${biblenotes.management.user.password}") - private String defaultPassword; - - private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); - - @Autowired - public RethinkDBService(RethinkDBConfig config, RethinkDBConnector connector, UserRepository userRepository) { - this.config = config; - - // mapping to private vars for easier access - this.r = connector.getR(); - this.connection = connector.getConnection(); - this.userRepository = userRepository; - } - - @PostConstruct - public void initialize() { - - // rethinkdb check if database exists - try { - r.dbCreate(config.getDatabase()).run(connection).stream(); - log.debug("Database " + config.getDatabase() + " created"); - } catch (ReqlOpFailedError e) { - log.debug("Database " + config.getDatabase() + " already exists. Error: " + e.getClass().getSimpleName()); - } - - // rethinkdb check if table users exists - try { - r.db(config.getDatabase()).tableCreate("users").run(connection).stream(); - log.debug("Table 'users' created successfully."); - } catch (ReqlOpFailedError e) { - log.debug("Table 'users' already exists."); - if(autoResetOnStartup) { - log.debug("Clearing content..."); - r.db(config.getDatabase()).table("users").delete().run(connection); - log.debug("Table 'users' cleared successfully."); - } - } - - // rethinkdb check if table user_logins exists - try { - r.db(config.getDatabase()).tableCreate("user_logins").run(connection).stream(); - log.debug("Table 'user_logins' created successfully."); - } catch (ReqlOpFailedError e) { - log.debug("Table 'user_logins' already exists."); - if(autoResetOnStartup) { - log.debug("Clearing content..."); - r.db(config.getDatabase()).table("user_logins").delete().run(connection); - log.debug("Table 'user_logins' cleared successfully."); - } - } finally { - try { - r.db(config.getDatabase()).table("user_logins").indexCreate("loginTime").run(connection); - log.debug("Secondary index 'loginTime' on table 'user_logins' successfully created."); - } catch (ReqlOpFailedError e) { - log.debug("Secondary index 'loginTime' already exists."); - } finally { - r.db(config.getDatabase()).table("user_logins").indexWait("loginTime").run(connection); - } - } - - initializeAdminUser(); - - log.info("Database ready for operation!"); - } - - private void initializeAdminUser() { - Optional adminUser = userRepository.retrieveUserByUsername("admin"); - if(adminUser.isEmpty()) { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd-MM-yyyy'T'HH:mm:ss.SSSXXX"); - ZonedDateTime now = ZonedDateTime.now(); - - User user = new User(); - user.setUsername(defaultUsername); - user.setPassword(passwordEncoder.encode(defaultPassword)); - user.setRole("ADMIN"); - userRepository.insertUser(user); - log.debug("Admin user created with default credentials. Username: admin, Password: admin"); - } - } - - @PreDestroy - public void close() { - if (connection != null) { - connection.close(); - } - } -} diff --git a/src/main/java/de/w665/biblenotes/db/entity/Role.java b/src/main/java/de/w665/biblenotes/db/entity/Role.java new file mode 100644 index 0000000..a84ab95 --- /dev/null +++ b/src/main/java/de/w665/biblenotes/db/entity/Role.java @@ -0,0 +1,6 @@ +package de.w665.biblenotes.db.entity; + +public enum Role { + ADMINISTRATOR, + USER +} diff --git a/src/main/java/de/w665/biblenotes/db/entity/User.java b/src/main/java/de/w665/biblenotes/db/entity/User.java new file mode 100644 index 0000000..2a9e12a --- /dev/null +++ b/src/main/java/de/w665/biblenotes/db/entity/User.java @@ -0,0 +1,37 @@ +package de.w665.biblenotes.db.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String username; + + @Column(nullable = false) + private String password; + + @Column(nullable = false, unique = true) + private String email; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; +} diff --git a/src/main/java/de/w665/biblenotes/db/entity/UserLogin.java b/src/main/java/de/w665/biblenotes/db/entity/UserLogin.java new file mode 100644 index 0000000..a738952 --- /dev/null +++ b/src/main/java/de/w665/biblenotes/db/entity/UserLogin.java @@ -0,0 +1,28 @@ +package de.w665.biblenotes.db.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.Date; + +@Entity +@Table(name = "user_logins") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class UserLogin { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private LocalDateTime loginTime; + + @Column(nullable = false) + private String loginIp; +} diff --git a/src/main/java/de/w665/biblenotes/db/repo/UserLoginRepository.java b/src/main/java/de/w665/biblenotes/db/repo/UserLoginRepository.java index d10aa14..f7d4833 100644 --- a/src/main/java/de/w665/biblenotes/db/repo/UserLoginRepository.java +++ b/src/main/java/de/w665/biblenotes/db/repo/UserLoginRepository.java @@ -1,67 +1,10 @@ package de.w665.biblenotes.db.repo; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.gson.Gson; -import com.rethinkdb.RethinkDB; -import com.rethinkdb.net.Connection; -import com.rethinkdb.net.Result; -import de.w665.biblenotes.db.RethinkDBConfig; -import de.w665.biblenotes.db.RethinkDBConnector; -import de.w665.biblenotes.model.UserLogin; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Repository; +import de.w665.biblenotes.db.entity.UserLogin; +import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; -@Slf4j -@Repository -public class UserLoginRepository { - private final RethinkDB r; - private final Connection connection; - private final RethinkDBConfig config; - private final String TABLE_NAME = "user_logins"; - - private final Gson gson = new Gson(); - private final ObjectMapper mapper = new ObjectMapper(); - - - @Autowired - public UserLoginRepository(RethinkDBConnector connector, RethinkDBConfig config) { - this.r = connector.getR(); - this.connection = connector.getConnection(); - this.config = config; - } - - public void insertUserLogin(UserLogin userLogin) { - String uuid = r.uuid().run(connection, String.class).first(); - userLogin.setId(uuid); - r.db(config.getDatabase()).table(TABLE_NAME).insert(userLogin).run(connection); - } - - public UserLogin getLastLogin(String userId) { - // Get the second most recent login (the most recent is the current one) - Result result = r.db(config.getDatabase()).table(TABLE_NAME) - .orderBy().optArg("index", r.desc("loginTime")) - .filter(r.hashMap("userId", userId)) - .skip(1).limit(1) - .run(connection, UserLogin.class); - // Return the second most recent login if exists - return result.hasNext() ? result.next() : null; - } - - public List getUserLogins(String userId) { - Result result = r.db(config.getDatabase()).table(TABLE_NAME) - .orderBy().optArg("index", r.desc("loginTime")) - .filter(r.hashMap("userId", userId)) - .run(connection, UserLogin.class); - return result.toList(); - } - - public void deleteAllUserLogins(String userId) { - r.db(config.getDatabase()).table(TABLE_NAME) - .filter(r.hashMap("userId", userId)) - .delete() - .run(connection); - } +public interface UserLoginRepository extends JpaRepository { + List findByUserIdOrderByLoginTimeDesc(Long userId); } diff --git a/src/main/java/de/w665/biblenotes/db/repo/UserRepository.java b/src/main/java/de/w665/biblenotes/db/repo/UserRepository.java index 5155f69..2c0a1a7 100644 --- a/src/main/java/de/w665/biblenotes/db/repo/UserRepository.java +++ b/src/main/java/de/w665/biblenotes/db/repo/UserRepository.java @@ -1,55 +1,20 @@ package de.w665.biblenotes.db.repo; -import com.rethinkdb.RethinkDB; -import com.rethinkdb.net.Connection; -import de.w665.biblenotes.db.RethinkDBConfig; -import de.w665.biblenotes.db.RethinkDBConnector; -import de.w665.biblenotes.model.User; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Repository; -import java.util.*; +import de.w665.biblenotes.db.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; -@Repository -public class UserRepository { - private final RethinkDB r; - private final Connection connection; - private final RethinkDBConfig config; - @Autowired - public UserRepository(RethinkDBConnector connector, RethinkDBConfig config) { - this.r = connector.getR(); - this.connection = connector.getConnection(); - this.config = config; - } +import java.time.LocalDateTime; +import java.util.Optional; - public Optional retrieveUserByUsername(String username) { - try { - User user = r.db(config.getDatabase()).table("users") - .filter(r.hashMap("username", username)) - .run(connection, User.class) - .next(); - return Optional.ofNullable(user); - } catch (NoSuchElementException e) { - return Optional.empty(); - } - } +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); - public void updateLastLoginForUser(String username, Date lastLogin) { - r.db(config.getDatabase()).table("users") - .filter(r.hashMap("username", username)) - .update(r.hashMap("lastLogin", lastLogin.getTime())) - .run(connection); - } - - public void updateUser(User user) { - r.db(config.getDatabase()).table("users") - .filter(r.hashMap("id", user.getId())) - .update(user) - .run(connection); - } - - public void insertUser(User user) { - String optionalUuid = r.uuid().run(connection, String.class).first(); - user.setId(optionalUuid); - r.db(config.getDatabase()).table("users").insert(user).run(connection); - } + @Modifying + @Transactional + @Query("UPDATE User u SET u.updatedAt = :lastLogin WHERE u.username = :username") + void updateLastLoginForUser(@Param("username") String username, @Param("lastLogin") LocalDateTime lastLogin); } diff --git a/src/main/java/de/w665/biblenotes/model/User.java b/src/main/java/de/w665/biblenotes/model/User.java deleted file mode 100644 index 5613610..0000000 --- a/src/main/java/de/w665/biblenotes/model/User.java +++ /dev/null @@ -1,18 +0,0 @@ -package de.w665.biblenotes.model; - -import lombok.*; - -import java.time.LocalDateTime; - -@Getter -@Setter -@ToString -@NoArgsConstructor -@AllArgsConstructor -public class User { - private String id; // ID is auto mapped by RethinkDB - private String username; - private String password; - private String email; - private String role; -} \ No newline at end of file diff --git a/src/main/java/de/w665/biblenotes/model/UserLogin.java b/src/main/java/de/w665/biblenotes/model/UserLogin.java deleted file mode 100644 index 6a23c28..0000000 --- a/src/main/java/de/w665/biblenotes/model/UserLogin.java +++ /dev/null @@ -1,21 +0,0 @@ -package de.w665.biblenotes.model; - -import com.fasterxml.jackson.annotation.JsonFormat; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.util.Date; - -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -public class UserLogin { - String id; - String userId; - @JsonFormat(timezone = "ETC") - Date loginTime; - String loginIp; -} \ No newline at end of file diff --git a/src/main/java/de/w665/biblenotes/service/AuthenticationService.java b/src/main/java/de/w665/biblenotes/service/AuthenticationService.java index a42d960..a2ac11f 100644 --- a/src/main/java/de/w665/biblenotes/service/AuthenticationService.java +++ b/src/main/java/de/w665/biblenotes/service/AuthenticationService.java @@ -2,8 +2,8 @@ package de.w665.biblenotes.service; import de.w665.biblenotes.db.repo.UserLoginRepository; import de.w665.biblenotes.db.repo.UserRepository; -import de.w665.biblenotes.model.User; -import de.w665.biblenotes.model.UserLogin; +import de.w665.biblenotes.db.entity.User; +import de.w665.biblenotes.db.entity.UserLogin; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwt; import io.jsonwebtoken.Jwts; @@ -15,6 +15,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import javax.crypto.SecretKey; +import java.time.LocalDateTime; import java.util.Base64; import java.util.Date; import java.util.Optional; @@ -49,18 +50,24 @@ public class AuthenticationService { if(expirationTime.length > 0) { this.expirationTime = expirationTime[0]; } - Optional optionalUser = userRepository.retrieveUserByUsername(username); + log.debug("Authenticating user: {}", username); + Optional optionalUser = userRepository.findByUsername(username); if (optionalUser.isPresent() && passwordEncoder.matches(password, optionalUser.get().getPassword())) { User user = optionalUser.get(); - userLoginRepository.insertUserLogin(new UserLogin(""/*Auto generated*/, user.getId(), new Date(), remoteAddr)); - userRepository.updateLastLoginForUser(user.getUsername(), new Date()); + UserLogin userLogin = new UserLogin(); + userLogin.setUserId(user.getId()); + userLogin.setLoginTime(LocalDateTime.now()); + userLogin.setLoginIp(remoteAddr); + + userLoginRepository.save(userLogin); + userRepository.updateLastLoginForUser(user.getUsername(), LocalDateTime.now()); return generateToken(user); } return null; } - private String generateToken(User username) { + private String generateToken(User user) { long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); Date expiryDate = new Date(nowMillis + expirationTime); @@ -68,8 +75,8 @@ public class AuthenticationService { return Jwts.builder() .subject("SharePulse Authentication Token") .issuedAt(now) - .claim("role", username.getRole()) - .claim("username", username.getUsername()) + .claim("role", user.getRole()) + .claim("username", user.getUsername()) .expiration(expiryDate) .signWith(secretKey) .compact(); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 958f992..03bd7c1 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,14 +1,15 @@ spring.application.name=biblenotes server.port=665 - -biblenotes.auto-reset-on-startup=true -biblenotes.management.user.username=admin -biblenotes.management.user.password=admin - -rethinkdb.host=localhost -rethinkdb.port=28015 -rethinkdb.database=biblenotes +database.init.create-default-admin-user=true secureapi.jwt.secret=someSecretChangeMeInProduction secureapi.jwt.expiration=86400000 +spring.datasource.url=jdbc:postgresql://localhost:5432/biblenotes +spring.datasource.username=biblenotes +spring.datasource.password=ujhfdogiuhfdgiusdfhoviufdnpviusdhdfiuqbfoiudzsfzidfugofduszbdv +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.database=postgresql + +logging.level.de.w665.biblenotes=DEBUG \ No newline at end of file