Compare commits

..

4 Commits

Author SHA1 Message Date
Max W.
3641dbcdf9 Added basic JWT authentication
working state
2024-09-09 17:15:08 +02:00
Max W.
656e0c0e7a Update SecurityConfig.java 2024-09-09 00:37:06 +02:00
Max W.
2e0c93a400 Update SecurityConfig.java 2024-09-09 00:36:52 +02:00
Max W.
ba239764bf Refactored feature critical authentication classes 2024-09-09 00:22:46 +02:00
34 changed files with 379 additions and 724 deletions

View File

@ -1,6 +1,6 @@
plugins { plugins {
id 'java' id 'java'
id 'org.springframework.boot' version '3.4.1' id 'org.springframework.boot' version '3.3.3'
id 'io.spring.dependency-management' version '1.1.6' id 'io.spring.dependency-management' version '1.1.6'
} }
@ -26,8 +26,6 @@ repositories {
dependencies { dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web' 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' compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok'

View File

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

View File

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

@ -6,14 +6,12 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer; import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.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.annotation.web.configurers.LogoutConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@ -35,32 +33,20 @@ public class SecurityConfig {
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// TODO: Fix security config for this project (currently old state from sharepulse)
http http
.csrf(csrf -> csrf .csrf(csrf -> csrf.ignoringRequestMatchers("/api/v1/**")) // Disable CSRF for API routes
.ignoringRequestMatchers("/**")
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) // Disable CSRF for API routes
.sessionManagement(sessionManagement -> sessionManagement .sessionManagement(sessionManagement -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // No session will be created by Spring Security .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // No session will be created by Spring Security
) )
.authorizeHttpRequests(authorize -> authorize .authorizeHttpRequests(authorize -> authorize
.requestMatchers("/secure/**").authenticated() // Secure these endpoints .requestMatchers("/api/v1/auth/login").permitAll() // Allow access to login endpoint
.requestMatchers("/api/v1/**").authenticated() // Secure all other /api/v1/** routes
.anyRequest().permitAll() // All other requests are allowed without authentication .anyRequest().permitAll() // All other requests are allowed without authentication
) )
.headers(headers -> headers .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // Ensure JWT filter is applied after login is allowed
.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny) // Prevent clickjacking .logout(LogoutConfigurer::permitAll)
//.contentSecurityPolicy(Customizer.withDefaults()) // Blocks loading of resources from other domains .rememberMe(Customizer.withDefaults());
.xssProtection(Customizer.withDefaults())
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // Apply JWT filter
.logout(LogoutConfigurer::permitAll);
return http.build(); 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

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

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

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

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

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

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

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

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

View File

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

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

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

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

@ -1,10 +0,0 @@
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,10 +1,67 @@
package de.w665.biblenotes.db.repo; package de.w665.biblenotes.db.repo;
import de.w665.biblenotes.db.entity.UserLogin; import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.jpa.repository.JpaRepository; 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 java.util.List; import java.util.List;
public interface UserLoginRepository extends JpaRepository<UserLogin, Long> { @Slf4j
List<UserLogin> findByUserIdOrderByLoginTimeDesc(Long userId); @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);
}
} }

View File

@ -1,20 +1,55 @@
package de.w665.biblenotes.db.repo; package de.w665.biblenotes.db.repo;
import de.w665.biblenotes.db.entity.User; import com.rethinkdb.RethinkDB;
import org.springframework.data.jpa.repository.JpaRepository; import com.rethinkdb.net.Connection;
import org.springframework.data.jpa.repository.Modifying; import de.w665.biblenotes.db.RethinkDBConfig;
import org.springframework.data.jpa.repository.Query; import de.w665.biblenotes.db.RethinkDBConnector;
import org.springframework.data.repository.query.Param; import de.w665.biblenotes.model.User;
import org.springframework.transaction.annotation.Transactional; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
import java.util.*;
import java.time.LocalDateTime; @Repository
import java.util.Optional; 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;
}
public interface UserRepository extends JpaRepository<User, Long> { public Optional<User> retrieveUserByUsername(String username) {
Optional<User> findByUsername(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();
}
}
@Modifying public void updateLastLoginForUser(String username, Date lastLogin) {
@Transactional r.db(config.getDatabase()).table("users")
@Query("UPDATE User u SET u.updatedAt = :lastLogin WHERE u.username = :username") .filter(r.hashMap("username", username))
void updateLastLoginForUser(@Param("username") String username, @Param("lastLogin") LocalDateTime lastLogin); .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);
}
} }

View File

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

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

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

@ -4,6 +4,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequestMapping("/api/v1/secure") @RequestMapping("/api/v1")
public abstract class SecureApiRestController { public abstract class ApiRestController {
} }

View File

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

@ -1,6 +1,6 @@
package de.w665.biblenotes.rest; package de.w665.biblenotes.rest.mappings;
import de.w665.biblenotes.rest.dto.AuthenticationRequest; import de.w665.biblenotes.rest.ro.AuthenticationRequest;
import de.w665.biblenotes.service.AuthenticationService; import de.w665.biblenotes.service.AuthenticationService;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -16,8 +16,9 @@ import java.util.Map;
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/auth") @RequestMapping("/api/v1/auth")
public class AuthenticationController { public class AuthenticationController {
private final AuthenticationService authenticationService; private final AuthenticationService authenticationService;
public AuthenticationController(AuthenticationService authenticationService) { public AuthenticationController(AuthenticationService authenticationService) {
@ -30,15 +31,9 @@ public class AuthenticationController {
String token = authenticationService.authenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword(), request.getRemoteAddr()); String token = authenticationService.authenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword(), request.getRemoteAddr());
Map<String, Object> response = new HashMap<>(); 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); response.put("token", token);
response.put("success", token != null);
if(token == null) { if(token == null) {
log.debug("Authentication failed for username: " + authenticationRequest.getUsername()); log.debug("Authentication failed for username: " + authenticationRequest.getUsername());
return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED);
@ -46,4 +41,4 @@ public class AuthenticationController {
return new ResponseEntity<>(response, HttpStatus.OK); return new ResponseEntity<>(response, HttpStatus.OK);
} }
} }

View File

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

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

View File

@ -1,4 +1,4 @@
package de.w665.biblenotes.rest.dto; package de.w665.biblenotes.rest.ro;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
@ -13,3 +13,4 @@ public class AuthenticationRequest {
private String username; private String username;
private String password; private String password;
} }

View File

@ -1,6 +1,5 @@
package de.w665.biblenotes.rest.security; package de.w665.biblenotes.rest.security;
import de.w665.biblenotes.db.entity.Role;
import de.w665.biblenotes.service.AuthenticationService; import de.w665.biblenotes.service.AuthenticationService;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
@ -9,6 +8,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
@ -28,53 +28,46 @@ import java.util.List;
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final AuthenticationService authenticationService; private final AuthenticationService authenticationService;
private final RequestMatcher requestMatcher = new AntPathRequestMatcher("/api/v1/secure/**"); private final RequestMatcher requestMatcher = new AntPathRequestMatcher("/api/v1/**"); // The filter will verify authentication for all requests starting with /api/v1/
@Override @Override
protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException { protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException {
logger.debug("Filtering request: " + request.getRequestURI()); logger.debug("Filtering request: " + request.getRequestURI());
// Skip filter for all paths except the secure path if ("/api/v1/auth/login".equals(request.getRequestURI())) {
logger.debug("Login request detected. Skipping JWT authentication.");
filterChain.doFilter(request, response);
return;
}
if(!requestMatcher.matches(request)) { if(!requestMatcher.matches(request)) {
logger.debug("Request does not match the secure path. Skipping JWT authentication."); logger.debug("Request does not match the secure path. Skipping JWT authentication.");
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
} }
// Extract the JWT token from the request try {
String jwt = getJwtFromRequest(request); 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));
if (jwt == null) { // Check if the JWT token is missing UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
response.setContentType("application/json"); SecurityContextHolder.getContext().setAuthentication(authentication);
response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \"JWT token is missing.\"}"); // SUCCESSFUL AUTHENTICATION
logger.warn("Unauthorized: JWT token is missing."); filterChain.doFilter(request, response);
return; } 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 (!authenticationService.validateToken(jwt)) { // Validate the JWT token response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
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) { private String getJwtFromRequest(HttpServletRequest request) {

View File

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

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

@ -2,8 +2,8 @@ package de.w665.biblenotes.service;
import de.w665.biblenotes.db.repo.UserLoginRepository; import de.w665.biblenotes.db.repo.UserLoginRepository;
import de.w665.biblenotes.db.repo.UserRepository; import de.w665.biblenotes.db.repo.UserRepository;
import de.w665.biblenotes.db.entity.User; import de.w665.biblenotes.model.User;
import de.w665.biblenotes.db.entity.UserLogin; import de.w665.biblenotes.model.UserLogin;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwt; import io.jsonwebtoken.Jwt;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
@ -15,7 +15,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import java.time.LocalDateTime;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.Optional; import java.util.Optional;
@ -26,9 +25,9 @@ public class AuthenticationService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserLoginRepository userLoginRepository; private final UserLoginRepository userLoginRepository;
@Value("${secureapi.jwt.secret}") @Value("${jwt.secret}")
private String secretString; private String secretString;
@Value("${secureapi.jwt.expiration}") @Value("${jwt.expiration}")
private long expirationTime; // in milliseconds private long expirationTime; // in milliseconds
private SecretKey secretKey; private SecretKey secretKey;
@ -50,34 +49,27 @@ public class AuthenticationService {
if(expirationTime.length > 0) { if(expirationTime.length > 0) {
this.expirationTime = expirationTime[0]; this.expirationTime = expirationTime[0];
} }
log.debug("Authenticating user: {}", username); Optional<User> optionalUser = userRepository.retrieveUserByUsername(username);
Optional<User> optionalUser = userRepository.findByUsername(username);
if (optionalUser.isPresent() && passwordEncoder.matches(password, optionalUser.get().getPassword())) { if (optionalUser.isPresent() && passwordEncoder.matches(password, optionalUser.get().getPassword())) {
User user = optionalUser.get(); User user = optionalUser.get();
UserLogin userLogin = new UserLogin(); userLoginRepository.insertUserLogin(new UserLogin(""/*Auto generated*/, user.getId(), new Date(), remoteAddr));
userLogin.setUserId(user.getId()); userRepository.updateLastLoginForUser(user.getUsername(), new Date());
userLogin.setLoginTime(LocalDateTime.now());
userLogin.setLoginIp(remoteAddr);
userLoginRepository.save(userLogin);
userRepository.updateLastLoginForUser(user.getUsername(), LocalDateTime.now());
return generateToken(user); return generateToken(user);
} }
return null; return null;
} }
private String generateToken(User user) { private String generateToken(User username) {
long nowMillis = System.currentTimeMillis(); long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis); Date now = new Date(nowMillis);
Date expiryDate = new Date(nowMillis + expirationTime); Date expiryDate = new Date(nowMillis + expirationTime);
return Jwts.builder() return Jwts.builder()
.subject("BibleNotes Authentication Token") .subject("Biblenotes Authentication Token")
.issuedAt(now) .issuedAt(now)
.claim("role", user.getRole()) .claim("role", username.getRole())
.claim("username", user.getUsername()) .claim("username", username.getUsername())
.claim("userId", user.getId())
.expiration(expiryDate) .expiration(expiryDate)
.signWith(secretKey) .signWith(secretKey)
.compact(); .compact();

View File

@ -1,21 +1,19 @@
spring.application.name=biblenotes biblenotes.auto-reset-on-startup=false
server.port=665 biblenotes.management.user.username=admin
database.init.create-default-admin-user=true biblenotes.management.user.password=admin
secureapi.jwt.secret=someSecretChangeMeInProduction # Database
secureapi.jwt.expiration=86400000 rethinkdb.host=localhost
rethinkdb.port=28015
spring.datasource.url=jdbc:postgresql://localhost:5432/biblenotes rethinkdb.database=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
logging.level.de.w665.biblenotes=DEBUG logging.level.de.w665.biblenotes=DEBUG
# Static path # Static path
spring.web.resources.static-locations=classpath:/static/ spring.web.resources.static-locations=classpath:/static/browser/
# If this is removed, this prefix must be added to the security config server.port=80
spring.mvc.servlet.path=/api/v1 spring.application.name=biblenotes
jwt.secret=sampleKeyToChangeInProduction
jwt.expiration=3600000

View File

@ -1,86 +0,0 @@
<!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>