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