diff --git a/pom.xml b/pom.xml
index b52c113..9cf429c 100755
--- a/pom.xml
+++ b/pom.xml
@@ -58,6 +58,29 @@
2.0.2
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.11.5
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.11.5
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.11.5
+ runtime
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
diff --git a/src/main/java/org/zaine/app/config/OpenApiConfig.java b/src/main/java/org/zaine/app/config/OpenApiConfig.java
new file mode 100644
index 0000000..9d1105b
--- /dev/null
+++ b/src/main/java/org/zaine/app/config/OpenApiConfig.java
@@ -0,0 +1,18 @@
+package org.zaine.app.config;
+
+import io.swagger.v3.oas.annotations.OpenAPIDefinition;
+import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
+import io.swagger.v3.oas.annotations.info.Info;
+import io.swagger.v3.oas.annotations.security.SecurityScheme;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@OpenAPIDefinition(info = @Info(title = "org-backend API", version = "1.0.0"))
+@SecurityScheme(
+ name = "bearerAuth", // referenced by name in controllers
+ type = SecuritySchemeType.HTTP,
+ scheme = "bearer",
+ bearerFormat = "JWT"
+)
+public class OpenApiConfig {
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/controller/AuthController.java b/src/main/java/org/zaine/app/controller/AuthController.java
new file mode 100644
index 0000000..41144ce
--- /dev/null
+++ b/src/main/java/org/zaine/app/controller/AuthController.java
@@ -0,0 +1,77 @@
+// src/main/java/org/zaine/app/controller/AuthController.java
+package org.zaine.app.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.zaine.app.security.JwtUtil;
+import org.zaine.app.user.User;
+import org.zaine.app.user.UserRepository;
+import org.springframework.http.*;
+import org.springframework.security.authentication.*;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/auth")
+@Tag(name = "Authentication")
+public class AuthController {
+
+ private final JwtUtil jwtUtil;
+ private final AuthenticationManager authenticationManager;
+ private final UserRepository userRepository;
+ private final PasswordEncoder passwordEncoder;
+
+ public AuthController(JwtUtil jwtUtil,
+ AuthenticationManager authenticationManager,
+ UserRepository userRepository,
+ PasswordEncoder passwordEncoder) {
+ this.jwtUtil = jwtUtil;
+ this.authenticationManager = authenticationManager;
+ this.userRepository = userRepository;
+ this.passwordEncoder = passwordEncoder;
+ }
+
+ @Operation(summary = "Register a new user")
+ @PostMapping("/register")
+ public ResponseEntity> register(@RequestBody RegisterRequest req) {
+ if (userRepository.existsByUsername(req.username())) {
+ return ResponseEntity
+ .status(HttpStatus.CONFLICT)
+ .body(Map.of("error", "Username already taken"));
+ }
+
+ User user = new User();
+ user.setUsername(req.username());
+ user.setPassword(passwordEncoder.encode(req.password())); // BCrypt hash
+ user.setRole("ROLE_USER");
+ userRepository.save(user);
+
+ return ResponseEntity
+ .status(HttpStatus.CREATED)
+ .body(Map.of("message", "User registered successfully"));
+ }
+
+ @Operation(summary = "Login and receive a JWT token")
+ @PostMapping("/login")
+ public ResponseEntity> login(@RequestBody LoginRequest req) {
+ try {
+ // This checks credentials against the DB via UserDetailsServiceImpl
+ authenticationManager.authenticate(
+ new UsernamePasswordAuthenticationToken(req.username(), req.password())
+ );
+ } catch (AuthenticationException e) {
+ return ResponseEntity
+ .status(HttpStatus.UNAUTHORIZED)
+ .body(Map.of("error", "Invalid username or password"));
+ }
+
+ String token = jwtUtil.generateToken(req.username());
+ return ResponseEntity.ok(Map.of("token", token));
+ }
+
+ public record LoginRequest(String username, String password) {}
+ public record RegisterRequest(String username, String password) {}
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/controller/WirdController.java b/src/main/java/org/zaine/app/controller/WirdController.java
index 68a11e4..913d3ec 100644
--- a/src/main/java/org/zaine/app/controller/WirdController.java
+++ b/src/main/java/org/zaine/app/controller/WirdController.java
@@ -74,5 +74,16 @@ public class WirdController {
WirdEntry saved = wirdService.createEntry(dto);
return ResponseEntity.ok(saved);
}
+
+ /**
+ * DELETE /api/wird/entries/{id}
+ * Deletes a log entry.
+ */
+ @DeleteMapping("/entries/{id}")
+ public ResponseEntity deleteEntry(@PathVariable Long id) {
+ logger.log(Logger.Level.INFO, "Deleting wird entry with id: {0}", id);
+ wirdService.deleteEntry(id);
+ return ResponseEntity.noContent().build();
+ }
}
diff --git a/src/main/java/org/zaine/app/security/JwtAuthFilter.java b/src/main/java/org/zaine/app/security/JwtAuthFilter.java
new file mode 100644
index 0000000..9b5d4d1
--- /dev/null
+++ b/src/main/java/org/zaine/app/security/JwtAuthFilter.java
@@ -0,0 +1,74 @@
+package org.zaine.app.security;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.HandlerExecutionChain;
+import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
+
+import java.io.IOException;
+import java.util.List;
+
+@Component
+public class JwtAuthFilter extends OncePerRequestFilter {
+
+ private final JwtUtil jwtUtil;
+ private final RequestMappingHandlerMapping requestMappingHandlerMapping;
+
+ public JwtAuthFilter(JwtUtil jwtUtil, RequestMappingHandlerMapping requestMappingHandlerMapping) {
+ this.jwtUtil = jwtUtil;
+ this.requestMappingHandlerMapping = requestMappingHandlerMapping;
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+ if (!endpointRequiresAuth(request)) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ String authHeader = request.getHeader("Authorization");
+
+ if (authHeader == null || !authHeader.startsWith("Bearer ")) {
+ response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing or invalid Authorization header");
+ return;
+ }
+
+ String token = authHeader.substring(7);
+
+ if (!jwtUtil.isTokenValid(token)) {
+ response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or expired token");
+ return;
+ }
+
+ String username = jwtUtil.extractUsername(token);
+ var auth = new UsernamePasswordAuthenticationToken(username, null, List.of());
+ auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+ SecurityContextHolder.getContext().setAuthentication(auth);
+
+ filterChain.doFilter(request, response);
+ }
+
+ private boolean endpointRequiresAuth(HttpServletRequest request) {
+ try {
+ HandlerExecutionChain chain = requestMappingHandlerMapping.getHandler(request);
+ if (chain == null) return false;
+ Object handler = chain.getHandler();
+ if (!(handler instanceof HandlerMethod method)) return false;
+
+ return method.hasMethodAnnotation(RequiresAuth.class)
+ || method.getBeanType().isAnnotationPresent(RequiresAuth.class);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/security/JwtUtil.java b/src/main/java/org/zaine/app/security/JwtUtil.java
new file mode 100644
index 0000000..5160fd9
--- /dev/null
+++ b/src/main/java/org/zaine/app/security/JwtUtil.java
@@ -0,0 +1,53 @@
+package org.zaine.app.security;
+
+import io.jsonwebtoken.*;
+import io.jsonwebtoken.security.Keys;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.security.Key;
+import java.util.Date;
+
+@Component
+public class JwtUtil {
+
+ @Value("${jwt.secret}")
+ private String secret;
+
+ @Value("${jwt.expiration-ms:86400000}")
+ private long expirationMs;
+
+ private Key getSigningKey() {
+ return Keys.hmacShaKeyFor(secret.getBytes());
+ }
+
+ public String generateToken(String username) {
+ return Jwts.builder()
+ .setSubject(username)
+ .setIssuedAt(new Date())
+ .setExpiration(new Date(System.currentTimeMillis() + expirationMs))
+ .signWith(getSigningKey(), SignatureAlgorithm.HS256)
+ .compact();
+ }
+
+ public String extractUsername(String token) {
+ return parseClaims(token).getSubject();
+ }
+
+ public boolean isTokenValid(String token) {
+ try {
+ parseClaims(token);
+ return true;
+ } catch (JwtException | IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+ private Claims parseClaims(String token) {
+ return Jwts.parserBuilder()
+ .setSigningKey(getSigningKey())
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/security/RequiresAuth.java b/src/main/java/org/zaine/app/security/RequiresAuth.java
new file mode 100644
index 0000000..a7d61d0
--- /dev/null
+++ b/src/main/java/org/zaine/app/security/RequiresAuth.java
@@ -0,0 +1,9 @@
+package org.zaine.app.security;
+
+import java.lang.annotation.*;
+
+@Target({ElementType.METHOD, ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface RequiresAuth {
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/security/SecurityConfig.java b/src/main/java/org/zaine/app/security/SecurityConfig.java
new file mode 100644
index 0000000..6c98e48
--- /dev/null
+++ b/src/main/java/org/zaine/app/security/SecurityConfig.java
@@ -0,0 +1,66 @@
+// src/main/java/org/zaine/app/security/SecurityConfig.java
+package org.zaine.app.security;
+
+import org.zaine.app.user.UserDetailsServiceImpl;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.*;
+import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
+import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig {
+
+ private final JwtAuthFilter jwtAuthFilter;
+ private final UserDetailsServiceImpl userDetailsService;
+
+ public SecurityConfig(JwtAuthFilter jwtAuthFilter, UserDetailsServiceImpl userDetailsService) {
+ this.jwtAuthFilter = jwtAuthFilter;
+ this.userDetailsService = userDetailsService;
+ }
+
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf(csrf -> csrf.disable())
+ .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers(
+ "/api/auth/**",
+ "/v3/api-docs/**",
+ "/swagger-ui/**",
+ "/swagger-ui.html"
+ ).permitAll()
+ .anyRequest().permitAll()
+ )
+ .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
+
+ return http.build();
+ }
+
+ @Bean
+ public DaoAuthenticationProvider authenticationProvider() {
+ DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
+ provider.setUserDetailsService(userDetailsService);
+ provider.setPasswordEncoder(passwordEncoder());
+ return provider;
+ }
+
+ @Bean
+ public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
+ return config.getAuthenticationManager();
+ }
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/service/WirdService.java b/src/main/java/org/zaine/app/service/WirdService.java
index 077e02f..619792a 100644
--- a/src/main/java/org/zaine/app/service/WirdService.java
+++ b/src/main/java/org/zaine/app/service/WirdService.java
@@ -5,13 +5,16 @@ import org.springframework.stereotype.Service;
import org.zaine.app.dto.WirdEntryDTO;
import org.zaine.app.model.WirdEntry;
import org.zaine.app.repositories.WirdEntryRepository;
-
+ import java.lang.System.Logger;
+
import java.time.LocalDate;
import java.util.List;
@Service
public class WirdService {
+ private static final Logger logger = System.getLogger(WirdService.class.getName());
+
@Autowired
private WirdEntryRepository repo;
@@ -39,4 +42,12 @@ public class WirdService {
entry.setNotes(dto.getNotes());
return repo.save(entry);
}
+
+ public void deleteEntry(Long id) {
+ if (id == null) {
+ logger.log(System.Logger.Level.WARNING, "Entry id is null. Operation aborted.");
+ return;
+ }
+ repo.deleteById(id);
+ }
}
diff --git a/src/main/java/org/zaine/app/user/User.java b/src/main/java/org/zaine/app/user/User.java
new file mode 100644
index 0000000..3efb7c8
--- /dev/null
+++ b/src/main/java/org/zaine/app/user/User.java
@@ -0,0 +1,30 @@
+package org.zaine.app.user;
+
+import jakarta.persistence.*;
+
+@Entity
+@Table(name = "users")
+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; // stored as BCrypt hash
+
+ @Column(nullable = false)
+ private String role; // e.g. "ROLE_USER", "ROLE_ADMIN"
+
+ // Getters & setters
+ public Long getId() { return id; }
+ public String getUsername() { return username; }
+ public void setUsername(String username) { this.username = username; }
+ public String getPassword() { return password; }
+ public void setPassword(String password) { this.password = password; }
+ public String getRole() { return role; }
+ public void setRole(String role) { this.role = role; }
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/user/UserDetailsServiceImpl.java b/src/main/java/org/zaine/app/user/UserDetailsServiceImpl.java
new file mode 100644
index 0000000..fe54c7c
--- /dev/null
+++ b/src/main/java/org/zaine/app/user/UserDetailsServiceImpl.java
@@ -0,0 +1,30 @@
+// src/main/java/org/zaine/app/user/UserDetailsServiceImpl.java
+package org.zaine.app.user;
+
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.*;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class UserDetailsServiceImpl implements UserDetailsService {
+
+ private final UserRepository userRepository;
+
+ public UserDetailsServiceImpl(UserRepository userRepository) {
+ this.userRepository = userRepository;
+ }
+
+ @Override
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+ User user = userRepository.findByUsername(username)
+ .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
+
+ return new org.springframework.security.core.userdetails.User(
+ user.getUsername(),
+ user.getPassword(),
+ List.of(new SimpleGrantedAuthority(user.getRole()))
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/user/UserRepository.java b/src/main/java/org/zaine/app/user/UserRepository.java
new file mode 100644
index 0000000..77cc4cd
--- /dev/null
+++ b/src/main/java/org/zaine/app/user/UserRepository.java
@@ -0,0 +1,9 @@
+package org.zaine.app.user;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import java.util.Optional;
+
+public interface UserRepository extends JpaRepository {
+ Optional findByUsername(String username);
+ boolean existsByUsername(String username);
+}
\ No newline at end of file