Compare commits

...

7 Commits

Author SHA1 Message Date
07141f3a1c Mini refactor 2025-04-19 01:41:46 +02:00
Max W.
a8411b6e63 update auth
- add tables and repos
- add dto
2025-01-19 19:31:43 +01:00
Max W.
38c619ae70 Update docker-compose.yml 2025-01-17 01:18:13 +01:00
Max W.
e7961576ca cleanup finalize jwt auth
- Add sample login page
2025-01-17 01:14:59 +01:00
Max W.
b3762373d4 finished base auth with spring jpa 2025-01-17 00:44:18 +01:00
Max W.
ed30a5f712 Refactor to use SpringJPA 2025-01-17 00:20:32 +01:00
Max W.
7ebd6a2587 zwischenstand 2025-01-16 23:24:42 +01:00
34 changed files with 801 additions and 377 deletions

View File

@ -1,6 +1,6 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.3'
id 'org.springframework.boot' version '3.4.1'
id 'io.spring.dependency-management' version '1.1.6'
}
@ -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'

13
db/docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
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"

118
db/sql-scripts/init.sql Normal file
View File

@ -0,0 +1,118 @@
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)
);
CREATE TABLE bible_reading_plans
(
id SERIAL PRIMARY KEY,
plan_name VARCHAR(255) NOT NULL,
start_date TIMESTAMP NOT NULL,
chapter_per_day SMALLINT NOT NULL CHECK (chapter_per_day > 0),
creator_user_id BIGINT NOT NULL,
FOREIGN KEY (creator_user_id) REFERENCES users (id)
);
CREATE TABLE bible_books
(
id SERIAL PRIMARY KEY,
book_name_en VARCHAR(255) NOT NULL,
book_name_de VARCHAR(255) NOT NULL,
chapter_count INT NOT NULL CHECK (chapter_count > 0),
is_new_testament BOOLEAN NOT NULL
);
CREATE TABLE user_statistics
(
id SERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
current_book_id BIGINT,
current_chapter_id BIGINT,
bible_reading_plan_id BIGINT,
FOREIGN KEY (bible_reading_plan_id) REFERENCES bible_reading_plans (id),
FOREIGN KEY (current_chapter_id) REFERENCES bible_books (id),
FOREIGN KEY (user_id) REFERENCES users (id)
);
INSERT INTO bible_books (book_name_en, book_name_de, chapter_count, is_new_testament)
VALUES ('Genesis', '1. Mose (Genesis)', 50, FALSE),
('Exodus', '2. Mose (Exodus)', 40, FALSE),
('Leviticus', '3. Mose (Levitikus)', 27, FALSE),
('Numbers', '4. Mose (Numeri)', 36, FALSE),
('Deuteronomy', '5. Mose (Deuteronomium)', 34, FALSE),
('Joshua', 'Josua', 24, FALSE),
('Judges', 'Richter', 21, FALSE),
('Ruth', 'Ruth', 4, FALSE),
('1. Samuel', '1. Samuel', 31, FALSE),
('2. Samuel', '2. Samuel', 24, FALSE),
('1. Kings', '1. Könige', 22, FALSE),
('2. Kings', '2. Könige', 25, FALSE),
('1. Chronicles', '1. Chronik', 29, FALSE),
('2. Chronicles', '2. Chronik', 36, FALSE),
('Ezra', 'Esra', 10, FALSE),
('Nehemiah', 'Nehemia', 13, FALSE),
('Esther', 'Ester', 10, FALSE),
('Job', 'Hiob', 42, FALSE),
('Psalms', 'Psalmen', 150, FALSE),
('Proverbs', 'Sprüche', 31, FALSE),
('Ecclesiastes', 'Prediger', 12, FALSE),
('Song of Solomon', 'Hohelied', 8, FALSE),
('Isaiah', 'Jesaja', 66, FALSE),
('Jeremiah', 'Jeremia', 52, FALSE),
('Lamentations', 'Klagelieder', 5, FALSE),
('Ezekiel', 'Hesekiel', 48, FALSE),
('Daniel', 'Daniel', 12, FALSE),
('Hosea', 'Hosea', 14, FALSE),
('Joel', 'Joel', 3, FALSE),
('Amos', 'Amos', 9, FALSE),
('Obadiah', 'Obadja', 1, FALSE),
('Jonah', 'Jona', 4, FALSE),
('Micah', 'Micha', 7, FALSE),
('Nahum', 'Nahum', 3, FALSE),
('Habakkuk', 'Habakuk', 3, FALSE),
('Zephaniah', 'Zefanja', 3, FALSE),
('Haggai', 'Haggai', 2, FALSE),
('Zechariah', 'Sacharja', 14, FALSE),
('Malachi', 'Maleachi', 4, FALSE),
('Matthew', 'Matthäus', 28, TRUE),
('Mark', 'Markus', 16, TRUE),
('Luke', 'Lukas', 24, TRUE),
('John', 'Johannes', 21, TRUE),
('Acts', 'Apostelgeschichte', 28, TRUE),
('Romans', 'Römer', 16, TRUE),
('1. Corinthians', '1. Korinther', 16, TRUE),
('2. Corinthians', '2. Korinther', 13, TRUE),
('Galatians', 'Galater', 6, TRUE),
('Ephesians', 'Epheser', 6, TRUE),
('Philippians', 'Philipper', 4, TRUE),
('Colossians', 'Kolosser', 4, TRUE),
('1. Thessalonians', '1. Thessalonicher', 5, TRUE),
('2. Thessalonians', '2. Thessalonicher', 3, TRUE),
('1. Timothy', '1. Timotheus', 6, TRUE),
('2. Timothy', '2. Timotheus', 4, TRUE),
('Titus', 'Titus', 3, TRUE),
('Philemon', 'Philemon', 1, TRUE),
('Hebrews', 'Hebräer', 13, TRUE),
('James', 'Jakobus', 5, TRUE),
('1. Peter', '1. Petrus', 5, TRUE),
('2. Peter', '2. Petrus', 3, TRUE),
('1. John', '1. Johannes', 5, TRUE),
('2. John', '2. Johannes', 1, TRUE),
('3. John', '3. Johannes', 1, TRUE),
('Jude', 'Judas', 1, TRUE),
('Revelation', 'Offenbarung', 22, TRUE);

View File

@ -1,16 +1,19 @@
package de.w665.biblenotes.config;
import de.w665.biblenotes.rest.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
@Configuration
@EnableWebSecurity
@ -35,18 +38,29 @@ public class SecurityConfig {
// TODO: Fix security config for this project (currently old state from sharepulse)
http
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/v1/**")) // Disable CSRF for API routes
.csrf(csrf -> csrf
.ignoringRequestMatchers("/**")
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) // Disable CSRF for API routes
.sessionManagement(sessionManagement -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // No session will be created by Spring Security
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/v1/secure/**").authenticated() // Secure these endpoints
.requestMatchers("/secure/**").authenticated() // Secure these endpoints
.anyRequest().permitAll() // All other requests are allowed without authentication
)
.headers(headers -> headers
.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny) // Prevent clickjacking
//.contentSecurityPolicy(Customizer.withDefaults()) // Blocks loading of resources from other domains
.xssProtection(Customizer.withDefaults())
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // Apply JWT filter
.logout(LogoutConfigurer::permitAll)
.rememberMe(Customizer.withDefaults());
.logout(LogoutConfigurer::permitAll);
return http.build();
}
/**
* Thoughts:
* - Instead of disabling the contentSecurityPolicy we should simply provide our own libraries so that no external cdns are needed
*/
}

View File

@ -0,0 +1,14 @@
package de.w665.biblenotes.config;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Map static resources to the root path
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/");
}
}

View File

@ -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.");
}
}

View File

@ -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;
}

View File

@ -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());
}
}

View File

@ -1,154 +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.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
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 file_uploads exists
try {
r.db(config.getDatabase()).tableCreate("file_uploads").run(connection).stream();
log.debug("Table 'file_uploads' created successfully.");
} catch (ReqlOpFailedError e) {
log.debug("Table 'file_uploads' already exists.");
if(autoResetOnStartup) {
log.debug("Clearing content...");
r.db(config.getDatabase()).table("file_uploads").delete().run(connection);
log.debug("Table 'file_uploads' cleared successfully.");
}
}
// rethinkdb check if table id_store exists
try {
r.db(config.getDatabase()).tableCreate("id_store").run(connection).stream();
log.debug("Table 'id_store' created successfully.");
} catch (ReqlOpFailedError e) {
log.debug("Table 'id_store' already exists.");
if(autoResetOnStartup) {
log.debug("Clearing content...");
r.db(config.getDatabase()).table("id_store").delete().run(connection);
log.debug("Table 'id_store' cleared successfully.");
}
}
// rethinkdb check if table expired_file_uploads exists
try {
r.db(config.getDatabase()).tableCreate("expired_file_uploads").run(connection).stream();
log.debug("Table 'expired_file_uploads' created successfully.");
} catch (ReqlOpFailedError e) {
log.debug("Table 'expired_file_uploads' already exists.");
if(autoResetOnStartup) {
log.debug("Clearing content...");
r.db(config.getDatabase()).table("expired_file_uploads").delete().run(connection);
log.debug("Table 'expired_file_uploads' cleared successfully.");
}
}
// 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<User> adminUser = userRepository.retrieveUserByUsername("admin");
if(adminUser.isEmpty()) {
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();
}
}
}

View File

@ -0,0 +1,28 @@
package de.w665.biblenotes.db.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "bible_books")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class BibleBook {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "book_name_en", nullable = false)
private String bookNameEn;
@Column(name = "book_name_de", nullable = false)
private String bookNameDe;
@Column(name = "chapter_count", nullable = false)
private int chapterCount;
@Column(name = "is_new_testament", nullable = false)
private boolean isNewTestament;
}

View File

@ -0,0 +1,34 @@
package de.w665.biblenotes.db.entity;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "bible_reading_plans")
@Getter
@Setter
@AllArgsConstructor
@ToString
@NoArgsConstructor
public class BibleReadingPlan {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "plan_name", nullable = false)
private String planName;
@Column(name = "start_date", nullable = false)
private LocalDateTime startDate;
@Column(name = "chapter_per_day", nullable = false)
private short chapterPerDay;
@JsonInclude(JsonInclude.Include.NON_NULL)
@ManyToOne
@JoinColumn(name = "creator_user_id", nullable = false)
private User creatorUser;
}

View File

@ -0,0 +1,6 @@
package de.w665.biblenotes.db.entity;
public enum Role {
ADMINISTRATOR,
USER
}

View File

@ -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;
}

View File

@ -0,0 +1,27 @@
package de.w665.biblenotes.db.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@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;
}

View File

@ -0,0 +1,31 @@
package de.w665.biblenotes.db.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "user_statistics")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class UserStatistics {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne
@JoinColumn(name = "current_book_id")
private BibleBook currentBook;
@Column(name = "current_chapter_id")
private Long currentChapterId;
@ManyToOne
@JoinColumn(name = "bible_reading_plan_id")
private BibleReadingPlan bibleReadingPlan;
}

View File

@ -0,0 +1,7 @@
package de.w665.biblenotes.db.repo;
import de.w665.biblenotes.db.entity.BibleBook;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BibleBookRepository extends JpaRepository<BibleBook, Long> {
}

View File

@ -0,0 +1,10 @@
package de.w665.biblenotes.db.repo;
import de.w665.biblenotes.db.entity.BibleReadingPlan;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface BibleReadingPlanRepository extends JpaRepository<BibleReadingPlan, Long> {
List<BibleReadingPlan> getBibleReadingPlanById(Long id);
}

View File

@ -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<UserLogin> 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<UserLogin> getUserLogins(String userId) {
Result<UserLogin> 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<UserLogin, Long> {
List<UserLogin> findByUserIdOrderByLoginTimeDesc(Long userId);
}

View File

@ -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<User> 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<User, Long> {
Optional<User> 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);
}

View File

@ -0,0 +1,7 @@
package de.w665.biblenotes.db.repo;
import de.w665.biblenotes.db.entity.UserStatistics;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserStatisticsRepository extends JpaRepository<UserStatistics, Long> {
}

View File

@ -1,16 +0,0 @@
package de.w665.biblenotes.model;
import lombok.*;
@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;
}

View File

@ -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;
}

View File

@ -0,0 +1,49 @@
package de.w665.biblenotes.rest;
import de.w665.biblenotes.rest.dto.AuthenticationRequest;
import de.w665.biblenotes.service.AuthenticationService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/auth")
public class AuthenticationController {
private final AuthenticationService authenticationService;
public AuthenticationController(AuthenticationService authenticationService) {
this.authenticationService = authenticationService;
}
@PostMapping("/login")
public ResponseEntity<Object> createAuthenticationToken(@RequestBody AuthenticationRequest authenticationRequest, HttpServletRequest request) {
log.debug("Received AuthenticationRequest for username: " + authenticationRequest.getUsername());
String token = authenticationService.authenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword(), request.getRemoteAddr());
Map<String, Object> response = new HashMap<>();
if(token == null) {
log.debug("Authentication failed for username: " + authenticationRequest.getUsername());
response.put("error", "Authentication failed. Username or password incorrect.");
return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED);
}
response.put("token", token);
if(token == null) {
log.debug("Authentication failed for username: " + authenticationRequest.getUsername());
return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED);
}
return new ResponseEntity<>(response, HttpStatus.OK);
}
}

View File

@ -0,0 +1,9 @@
package de.w665.biblenotes.rest;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/secure")
public abstract class SecureApiRestController {
}

View File

@ -0,0 +1,15 @@
package de.w665.biblenotes.rest.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@NoArgsConstructor
@Getter
@Setter
@ToString
public class AuthenticationRequest {
private String username;
private String password;
}

View File

@ -0,0 +1,18 @@
package de.w665.biblenotes.rest.dto;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.time.LocalDateTime;
@Getter
@Setter
@ToString
public class BibleReadingPlanDTO {
private Long id;
private String planName;
private LocalDateTime startDate;
private short chapterPerDay;
private String creatorUserName;
}

View File

@ -0,0 +1,73 @@
package de.w665.biblenotes.rest.mappings;
import de.w665.biblenotes.db.entity.BibleReadingPlan;
import de.w665.biblenotes.db.entity.User;
import de.w665.biblenotes.db.repo.BibleReadingPlanRepository;
import de.w665.biblenotes.db.repo.UserRepository;
import de.w665.biblenotes.rest.SecureApiRestController;
import de.w665.biblenotes.rest.dto.BibleReadingPlanDTO;
import de.w665.biblenotes.rest.security.UserAuthenticationContext;
import jakarta.persistence.EntityManager;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.Optional;
@RestController
@RequestMapping("/secure/bible-reading-plan")
public class BibleReadingPlanMapping {
private final EntityManager entityManager;
private final BibleReadingPlanRepository bibleReadingPlanRepository;
public BibleReadingPlanMapping(UserRepository userRepository, EntityManager entityManager, BibleReadingPlanRepository bibleReadingPlanRepository) {
this.entityManager = entityManager;
this.bibleReadingPlanRepository = bibleReadingPlanRepository;
}
@GetMapping
public ResponseEntity<Object> getBibleReadingPlans(@RequestParam(name = "id", required = true) Long id) {
Optional<BibleReadingPlan> brp = bibleReadingPlanRepository.findById(id);
if(brp.isEmpty()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
BibleReadingPlanDTO bibleReadingPlanDTO = new BibleReadingPlanDTO();
bibleReadingPlanDTO.setId(brp.get().getId());
bibleReadingPlanDTO.setPlanName(brp.get().getPlanName());
bibleReadingPlanDTO.setChapterPerDay(brp.get().getChapterPerDay());
bibleReadingPlanDTO.setStartDate(brp.get().getStartDate());
bibleReadingPlanDTO.setCreatorUserName(brp.get().getCreatorUser().getUsername());
return new ResponseEntity<>(bibleReadingPlanDTO, HttpStatus.OK);
}
@PostMapping
public ResponseEntity<Object> createBibleReadingPlan(@RequestBody BibleReadingPlanDTO bibleReadingPlanDTO) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// Construct entity
BibleReadingPlan plan = new BibleReadingPlan();
plan.setPlanName(bibleReadingPlanDTO.getPlanName());
plan.setChapterPerDay(bibleReadingPlanDTO.getChapterPerDay());
plan.setStartDate(bibleReadingPlanDTO.getStartDate());
// Set user as creator in db entity
User userProxy = entityManager.getReference(User.class, ((UserAuthenticationContext) auth.getPrincipal()).getUserId());
plan.setCreatorUser(userProxy);
BibleReadingPlan createdPlan = bibleReadingPlanRepository.save(plan);
// Construct response (we do this because otherwise the user object would be serialized (which would fail because it's a proxy + i addition we don't want to send the users credentials))
BibleReadingPlanDTO responseDTO = new BibleReadingPlanDTO();
responseDTO.setPlanName(createdPlan.getPlanName());
responseDTO.setChapterPerDay(createdPlan.getChapterPerDay());
responseDTO.setStartDate(createdPlan.getStartDate());
responseDTO.setCreatorUserName(((UserAuthenticationContext) auth.getPrincipal()).getUsername());
responseDTO.setId(createdPlan.getId());
return new ResponseEntity<>(responseDTO, HttpStatus.OK);
}
}

View File

@ -0,0 +1,14 @@
package de.w665.biblenotes.rest.mappings;
import de.w665.biblenotes.rest.SecureApiRestController;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TestMapping extends SecureApiRestController {
@GetMapping("/test")
public String test() {
return "Your authentication works!";
}
}

View File

@ -1,12 +1,14 @@
package de.w665.biblenotes.rest.security;
import de.w665.biblenotes.db.entity.Role;
import de.w665.biblenotes.service.AuthenticationService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.jetbrains.annotations.NotNull;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
@ -33,33 +35,46 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
logger.debug("Filtering request: " + request.getRequestURI());
// Skip filter for all paths except the secure path
if(!requestMatcher.matches(request)) {
logger.debug("Request does not match the secure path. Skipping JWT authentication.");
filterChain.doFilter(request, response);
return;
}
try {
String jwt = getJwtFromRequest(request);
if (jwt != null && authenticationService.validateToken(jwt)) {
String username = authenticationService.extractSubject(jwt);
// Extract the role from the JWT and set it to Spring AuthenticationContext for access control
String role = authenticationService.getClaimValue(jwt, "role", String.class);
List<GrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role));
// Extract the JWT token from the request
String jwt = getJwtFromRequest(request);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
// SUCCESSFUL AUTHENTICATION
filterChain.doFilter(request, response);
} else {
logger.warn("Unauthorized: Authentication token is missing or invalid.");
}
} catch (Exception ex) {
logger.warn("Could not set user authentication in security context. An error occurred during JWT processing.", ex);
if (jwt == null) { // Check if the JWT token is missing
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \"JWT token is missing.\"}");
logger.warn("Unauthorized: JWT token is missing.");
return;
}
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
if (!authenticationService.validateToken(jwt)) { // Validate the JWT token
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \"JWT token is invalid.\"}");
logger.warn("Unauthorized: JWT token is invalid.");
return;
}
// Extract the role and username from the JWT and set it to Spring AuthenticationContext for access control
String username = authenticationService.getClaimValue(jwt, "username", String.class);
String role = authenticationService.getClaimValue(jwt, "role", String.class);
int userId = authenticationService.getClaimValue(jwt, "userId", Integer.class);
UserAuthenticationContext uac = new UserAuthenticationContext(username, userId, Role.valueOf(role));
List<GrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role));
JwtAuthenticationToken auth = new JwtAuthenticationToken(uac, jwt, authorities);
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
// SUCCESSFUL AUTHENTICATION
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {

View File

@ -0,0 +1,31 @@
package de.w665.biblenotes.rest.security;
import lombok.Getter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private final UserAuthenticationContext principal;
@Getter
private final String token;
public JwtAuthenticationToken(UserAuthenticationContext principal, String token, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.token = token;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return "Credentials are not stored.";
}
@Override
public UserAuthenticationContext getPrincipal() {
return this.principal;
}
}

View File

@ -0,0 +1,13 @@
package de.w665.biblenotes.rest.security;
import de.w665.biblenotes.db.entity.Role;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class UserAuthenticationContext {
private String username;
private int userId;
private Role role;
}

View File

@ -1,5 +1,13 @@
package de.w665.biblenotes.service;
import de.w665.biblenotes.db.repo.UserLoginRepository;
import de.w665.biblenotes.db.repo.UserRepository;
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;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@ -7,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;
@ -41,27 +50,34 @@ public class AuthenticationService {
if(expirationTime.length > 0) {
this.expirationTime = expirationTime[0];
}
Optional<User> optionalUser = userRepository.retrieveUserByUsername(username);
log.debug("Authenticating user: {}", username);
Optional<User> 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);
return Jwts.builder()
.subject("SharePulse Authentication Token")
.subject("BibleNotes Authentication Token")
.issuedAt(now)
.claim("role", username.getRole())
.claim("username", username.getUsername())
.claim("role", user.getRole())
.claim("username", user.getUsername())
.claim("userId", user.getId())
.expiration(expiryDate)
.signWith(secretKey)
.compact();

View File

@ -1 +1,21 @@
spring.application.name=biblenotes
server.port=665
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
# Static path
spring.web.resources.static-locations=classpath:/static/
# If this is removed, this prefix must be added to the security config
spring.mvc.servlet.path=/api/v1

View File

@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Log in</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
</head>
<body>
<div class="container d-flex justify-content-center align-items-center min-vh-100">
<div class="card shadow p-4" style="width: 100%; max-width: 400px;">
<h3 class="text-center mb-4">Log In</h3>
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="d-flex justify-content-center">
<button type="submit" class="btn btn-primary w-100">Log In</button>
</div>
</form>
<div class="mt-3 text-center" id="loader" style="display: none;">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div class="mt-3 text-center" id="response" style="display: none;"></div>
</div>
</div>
<script>
$(document).ready(function () {
$('#loginForm').on('submit', function (event) {
event.preventDefault();
// Hide previous messages and show loader
$('#response').hide().empty();
$('#loader').show();
// Get form data
const username = $('#username').val();
const password = $('#password').val();
// Send POST request
$.ajax({
url: 'http://localhost:665/api/v1/auth/login',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ username, password }),
success: function (response) {
// Hide loader and display token
$('#loader').hide();
$('#response').html(`<div class="alert alert-success">Token: ${response.token}</div>`).show();
},
error: function () {
// Hide loader and show error message
$('#loader').hide();
$('#response').html('<div class="alert alert-danger">Failed to log in. Please try again.</div>').show();
}
});
});
// Allow pressing Enter to submit the form
$('#loginForm').on('keypress', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
$('#loginForm').submit();
}
});
});
</script>
<!-- Bootstrap Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>