4 Commits

Author SHA1 Message Date
e7961576ca cleanup finalize jwt auth
- Add sample login page
2025-01-17 01:14:59 +01:00
b3762373d4 finished base auth with spring jpa 2025-01-17 00:44:18 +01:00
ed30a5f712 Refactor to use SpringJPA 2025-01-17 00:20:32 +01:00
7ebd6a2587 zwischenstand 2025-01-16 23:24:42 +01:00
24 changed files with 465 additions and 375 deletions

View File

@ -1,6 +1,6 @@
plugins { plugins {
id 'java' 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' id 'io.spring.dependency-management' version '1.1.6'
} }
@ -26,6 +26,8 @@ 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'

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

@ -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"

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

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

View File

@ -1,16 +1,19 @@
package de.w665.biblenotes.config; package de.w665.biblenotes.config;
import de.w665.biblenotes.rest.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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,7 +38,9 @@ public class SecurityConfig {
// TODO: Fix security config for this project (currently old state from sharepulse) // TODO: Fix security config for this project (currently old state from sharepulse)
http http
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/v1/**")) // Disable CSRF for API routes .csrf(csrf -> csrf
.ignoringRequestMatchers("/api/v1/**")
.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
) )
@ -43,10 +48,19 @@ public class SecurityConfig {
.requestMatchers("/api/v1/secure/**").authenticated() // Secure these endpoints .requestMatchers("/api/v1/secure/**").authenticated() // Secure these endpoints
.anyRequest().permitAll() // All other requests are allowed without authentication .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 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // Apply JWT filter
.logout(LogoutConfigurer::permitAll) .logout(LogoutConfigurer::permitAll);
.rememberMe(Customizer.withDefaults());
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

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

View File

@ -1,67 +1,10 @@
package de.w665.biblenotes.db.repo; package de.w665.biblenotes.db.repo;
import com.fasterxml.jackson.databind.ObjectMapper; import de.w665.biblenotes.db.entity.UserLogin;
import com.google.gson.Gson; import org.springframework.data.jpa.repository.JpaRepository;
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;
@Slf4j public interface UserLoginRepository extends JpaRepository<UserLogin, Long> {
@Repository List<UserLogin> findByUserIdOrderByLoginTimeDesc(Long userId);
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,55 +1,20 @@
package de.w665.biblenotes.db.repo; package de.w665.biblenotes.db.repo;
import com.rethinkdb.RethinkDB; import de.w665.biblenotes.db.entity.User;
import com.rethinkdb.net.Connection; import org.springframework.data.jpa.repository.JpaRepository;
import de.w665.biblenotes.db.RethinkDBConfig; import org.springframework.data.jpa.repository.Modifying;
import de.w665.biblenotes.db.RethinkDBConnector; import org.springframework.data.jpa.repository.Query;
import de.w665.biblenotes.model.User; import org.springframework.data.repository.query.Param;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Repository;
import java.util.*;
@Repository import java.time.LocalDateTime;
public class UserRepository { import java.util.Optional;
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 Optional<User> retrieveUserByUsername(String username) { public interface UserRepository extends JpaRepository<User, Long> {
try { Optional<User> findByUsername(String username);
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 void updateLastLoginForUser(String username, Date lastLogin) { @Modifying
r.db(config.getDatabase()).table("users") @Transactional
.filter(r.hashMap("username", username)) @Query("UPDATE User u SET u.updatedAt = :lastLogin WHERE u.username = :username")
.update(r.hashMap("lastLogin", lastLogin.getTime())) void updateLastLoginForUser(@Param("username") String username, @Param("lastLogin") LocalDateTime lastLogin);
.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,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.ro.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("/api/v1/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,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

@ -0,0 +1,15 @@
package de.w665.biblenotes.rest.ro;
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

@ -1,12 +1,13 @@
package de.w665.biblenotes.rest.security; package de.w665.biblenotes.rest.security;
import de.w665.biblenotes.service.AuthenticationService;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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.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;
@ -33,33 +34,43 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
logger.debug("Filtering request: " + request.getRequestURI()); logger.debug("Filtering request: " + request.getRequestURI());
// Skip filter for all paths except the secure path
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;
} }
try { // Extract the JWT token from the request
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));
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, null, authorities); if (jwt == null) { // Check if the JWT token is missing
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
SecurityContextHolder.getContext().setAuthentication(authentication); response.setContentType("application/json");
// SUCCESSFUL AUTHENTICATION response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \"JWT token is missing.\"}");
filterChain.doFilter(request, response); logger.warn("Unauthorized: JWT token is missing.");
} else { return;
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);
} }
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;
}
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));
JwtAuthenticationToken auth = new JwtAuthenticationToken(username, 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

@ -0,0 +1,30 @@
package de.w665.biblenotes.rest.security;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private final String principal;
private final String token;
public JwtAuthenticationToken(String principal, String token, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.token = token;
super.setAuthenticated(true); // Set this to true only if authentication is verified
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return null;
}
}

View File

@ -1,5 +1,13 @@
package de.w665.biblenotes.service; 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 jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -7,6 +15,7 @@ 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;
@ -41,18 +50,24 @@ public class AuthenticationService {
if(expirationTime.length > 0) { if(expirationTime.length > 0) {
this.expirationTime = expirationTime[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())) { if (optionalUser.isPresent() && passwordEncoder.matches(password, optionalUser.get().getPassword())) {
User user = optionalUser.get(); User user = optionalUser.get();
userLoginRepository.insertUserLogin(new UserLogin(""/*Auto generated*/, user.getId(), new Date(), remoteAddr)); UserLogin userLogin = new UserLogin();
userRepository.updateLastLoginForUser(user.getUsername(), new Date()); userLogin.setUserId(user.getId());
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 username) { private String generateToken(User user) {
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);
@ -60,8 +75,8 @@ public class AuthenticationService {
return Jwts.builder() return Jwts.builder()
.subject("SharePulse Authentication Token") .subject("SharePulse Authentication Token")
.issuedAt(now) .issuedAt(now)
.claim("role", username.getRole()) .claim("role", user.getRole())
.claim("username", username.getUsername()) .claim("username", user.getUsername())
.expiration(expiryDate) .expiration(expiryDate)
.signWith(secretKey) .signWith(secretKey)
.compact(); .compact();

View File

@ -1 +1,18 @@
spring.application.name=biblenotes 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/

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>