From a94b2bf9e7c1a428697f3bbcf502946177fb8894 Mon Sep 17 00:00:00 2001 From: Lobo Date: Fri, 3 Apr 2026 12:25:03 +0200 Subject: [PATCH] Added jwt token validation and api endpoint versioning --- pom.xml | 34 ++++++++- .../security/JwtAuthenticationFilter.java | 64 +++++++++++++++++ .../configuration/security/JwtConfig.java | 15 ++++ .../configuration/security/JwtService.java | 70 +++++++++++++++++++ .../{ => security}/SecurityConfig.java | 11 ++- .../login/controller/LoginController.java | 4 +- .../domain/login/service/LoginService.java | 7 +- .../user/controller/UserController.java | 38 ++++++++++ src/main/resources/application.yaml | 12 ++++ .../db/migration/V1__init_schema.sql | 2 +- 10 files changed, 249 insertions(+), 8 deletions(-) create mode 100644 src/main/java/auth_service/configuration/security/JwtAuthenticationFilter.java create mode 100644 src/main/java/auth_service/configuration/security/JwtConfig.java create mode 100644 src/main/java/auth_service/configuration/security/JwtService.java rename src/main/java/auth_service/configuration/{ => security}/SecurityConfig.java (90%) create mode 100644 src/main/java/auth_service/domain/user/controller/UserController.java diff --git a/pom.xml b/pom.xml index e709f52..af21ca3 100644 --- a/pom.xml +++ b/pom.xml @@ -14,8 +14,10 @@ To be Played - Auth 25 + 0.13.0 + org.springframework.boot spring-boot-starter-data-jpa @@ -32,6 +34,30 @@ org.springframework.boot spring-boot-starter-webmvc + + + com.github.ben-manes.caffeine + caffeine + + + + io.jsonwebtoken + jjwt-api + ${jwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jwt.version} + runtime + + org.flywaydb flyway-core @@ -42,17 +68,21 @@ org.springframework.boot - spring-boot-starter-actuator + spring-boot-starter-flyway + + org.springframework.boot - spring-boot-starter-flyway + spring-boot-starter-actuator io.micrometer micrometer-registry-prometheus runtime + + org.springframework.boot spring-boot-docker-compose diff --git a/src/main/java/auth_service/configuration/security/JwtAuthenticationFilter.java b/src/main/java/auth_service/configuration/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..a74b047 --- /dev/null +++ b/src/main/java/auth_service/configuration/security/JwtAuthenticationFilter.java @@ -0,0 +1,64 @@ +package auth_service.configuration.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NonNull; +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 java.io.IOException; +import java.util.Collections; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + final String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + final String jwt = authHeader.substring(7); + if (!jwtService.isTokenValid(jwt)) { + filterChain.doFilter(request, response); + return; + } + + try { + final String userEmail = jwtService.extractEmail(jwt); + + if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userEmail, + null, + Collections.emptyList() + ); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } catch (Exception e) { + log.error("JWT Validation failed: {}", e.getMessage()); + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/auth_service/configuration/security/JwtConfig.java b/src/main/java/auth_service/configuration/security/JwtConfig.java new file mode 100644 index 0000000..b97de6d --- /dev/null +++ b/src/main/java/auth_service/configuration/security/JwtConfig.java @@ -0,0 +1,15 @@ +package auth_service.configuration.security; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "security.jwt") +public class JwtConfig { + + private String secret; +} \ No newline at end of file diff --git a/src/main/java/auth_service/configuration/security/JwtService.java b/src/main/java/auth_service/configuration/security/JwtService.java new file mode 100644 index 0000000..7cb0537 --- /dev/null +++ b/src/main/java/auth_service/configuration/security/JwtService.java @@ -0,0 +1,70 @@ +package auth_service.configuration.security; + +import auth_service.domain.login.model.User; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +/** + * Service responsible for generating and validating JSON Web Tokens (JWT). + */ +@Service +public class JwtService { + + // Token validity: 24 hours in milliseconds + private static final long JWT_EXPIRATION_MS = 86400000; + private final JwtConfig jwtConfig; + + public JwtService(JwtConfig jwtConfig) { + this.jwtConfig = jwtConfig; + } + + /** + * Generates a secure JWT for the authenticated user. + * * @param user The authenticated user entity + * + * @return A signed JWT string + */ + public String generateToken(User user) { + return Jwts.builder() + .subject(user.getEmail()) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + JWT_EXPIRATION_MS)) + .signWith(getSigningKey()) + .compact(); + } + + public String extractEmail(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } + + public boolean isTokenValid(String token) { + try { + Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token); + return true; + } catch (Exception _) { + return false; + } + } + + /** + * Retrieves the cryptographic key used for signing the token. + */ + private SecretKey getSigningKey() { + String secretKey = jwtConfig.getSecret(); + byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } +} \ No newline at end of file diff --git a/src/main/java/auth_service/configuration/SecurityConfig.java b/src/main/java/auth_service/configuration/security/SecurityConfig.java similarity index 90% rename from src/main/java/auth_service/configuration/SecurityConfig.java rename to src/main/java/auth_service/configuration/security/SecurityConfig.java index 0aaeb3f..4894832 100644 --- a/src/main/java/auth_service/configuration/SecurityConfig.java +++ b/src/main/java/auth_service/configuration/security/SecurityConfig.java @@ -1,5 +1,6 @@ -package auth_service.configuration; +package auth_service.configuration.security; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -10,6 +11,7 @@ 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; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -23,6 +25,7 @@ import java.util.List; */ @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { /** @@ -34,6 +37,9 @@ public class SecurityConfig { "/actuator/prometheus", // Allow Prometheus to scrape metrics "/error" // Default Spring Boot error page routing }; + + private final JwtAuthenticationFilter jwtAuthFilter; + /** * Injects the allowed CORS origins from the active application.yml profile. */ @@ -78,7 +84,8 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers(WHITE_LIST_URLS).permitAll() .anyRequest().authenticated() - ); + ) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/auth_service/domain/login/controller/LoginController.java b/src/main/java/auth_service/domain/login/controller/LoginController.java index 91c966b..4042178 100644 --- a/src/main/java/auth_service/domain/login/controller/LoginController.java +++ b/src/main/java/auth_service/domain/login/controller/LoginController.java @@ -33,7 +33,7 @@ public class LoginController { public ResponseEntity login(@RequestBody LoginRequest request) { var user = ApiMapper.toModel(request); - loginService.login(user); - return ResponseEntity.ok(new LoginResponse("Login successful", user.getEmail())); + var jwt = loginService.login(user); + return ResponseEntity.ok(new LoginResponse("Login successful", jwt)); } } \ No newline at end of file diff --git a/src/main/java/auth_service/domain/login/service/LoginService.java b/src/main/java/auth_service/domain/login/service/LoginService.java index 724a7f3..eded0fa 100644 --- a/src/main/java/auth_service/domain/login/service/LoginService.java +++ b/src/main/java/auth_service/domain/login/service/LoginService.java @@ -1,5 +1,6 @@ package auth_service.domain.login.service; +import auth_service.configuration.security.JwtService; import auth_service.domain.login.exception.InvalidCredentialsException; import auth_service.domain.login.exception.UserNotFoundException; import auth_service.domain.login.model.User; @@ -15,14 +16,18 @@ import org.springframework.stereotype.Service; public class LoginService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; public String login(User userToLogin) { var userEntity = userRepository.findByEmail(userToLogin.getEmail()).orElseThrow(() -> new UserNotFoundException("User not found with given email")); + + log.info("Login attempt for user {}", passwordEncoder.encode(userToLogin.getEncryptedPassword()) + " " + userEntity.getEncryptedPassword()); + var loginSuccessful = passwordEncoder.matches(userToLogin.getEncryptedPassword(), userEntity.getEncryptedPassword()); if (!loginSuccessful) { throw new InvalidCredentialsException("Invalid credentials"); } - return ""; + return jwtService.generateToken(userToLogin); } } \ No newline at end of file diff --git a/src/main/java/auth_service/domain/user/controller/UserController.java b/src/main/java/auth_service/domain/user/controller/UserController.java new file mode 100644 index 0000000..7fb4646 --- /dev/null +++ b/src/main/java/auth_service/domain/user/controller/UserController.java @@ -0,0 +1,38 @@ +package auth_service.domain.user.controller; + +import io.micrometer.core.annotation.Timed; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.security.Principal; + +/** + * Controller handling user profile operations for the dashboard. + */ +@RestController +@RequestMapping("/api/users") +public class UserController { + + /** + * Version 1 of the 'me' endpoint, accessed via /api/users/me?v=1 + * * @return The user's dashboard data + */ + @GetMapping(value = "/me", version = "1.0", produces = "application/json") + @Timed(value = "user.me.v1.requests.time", description = "Time taken to process v1 user profile requests") + public ResponseEntity getCurrentUserV1(Principal principal) { + + // principal.getName() enthält die E-Mail, die der Türsteher aus dem Token gelesen hat! + String authenticatedEmail = principal.getName(); + + return ResponseEntity.ok(new UserProfileResponse( + authenticatedEmail, + "USER", + "Welcome to the v1 Dashboard, " + authenticatedEmail + "!" + )); + } + + public record UserProfileResponse(String email, String role, String dashboardMessage) { + } +} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 0077dc8..1230820 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -13,6 +13,13 @@ spring: baseline-on-migrate: true # Explicitly state where to look for scripts locations: classpath:db/migration + mvc: + apiversion: + use: + query-parameter: version + default: "1.0" + supported: 1.0, 2,0 + # Actuator & Micrometer Settings management: @@ -29,6 +36,11 @@ management: export: enabled: true +# Security stuff +security: + jwt: + secret: ${JWT_SECRET} + # Custom properties for CORS app: cors: diff --git a/src/main/resources/db/migration/V1__init_schema.sql b/src/main/resources/db/migration/V1__init_schema.sql index 2252726..4004588 100644 --- a/src/main/resources/db/migration/V1__init_schema.sql +++ b/src/main/resources/db/migration/V1__init_schema.sql @@ -10,4 +10,4 @@ CREATE TABLE users -- Insert a pragmatic test user for local development and testing -- The password hash corresponds to 'password123' INSERT INTO users (email, password, enabled) -VALUES ('test@example.com', '$2a$10$8.UnVuG9HHgffUDAlk8qfOuVGkqRzgVymGe07xd00dmOn.jptTVyG', true); \ No newline at end of file +VALUES ('test@example.com', '$2a$10$3FApyf15qVEvFLEvl/d7aecSMZlslQdXiYlDgbOa4DfGWu4l0VCY.', true); \ No newline at end of file