Public Access
Added jwt token validation and api endpoint versioning
This commit is contained in:
1 parent
161381ebe5
commit
a94b2bf9e7
10 files changed
+249
-8
No files matched your search
@@ -14,8 +14,10 @@
|
|||||||
<name>To be Played - Auth</name>
|
<name>To be Played - Auth</name>
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>25</java.version>
|
<java.version>25</java.version>
|
||||||
|
<jwt.version>0.13.0</jwt.version>
|
||||||
</properties>
|
</properties>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<!-- Spring -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
@@ -32,6 +34,30 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Rest Caching -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
|
<artifactId>caffeine</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- JWT -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>${jwt.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>${jwt.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>${jwt.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Database -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.flywaydb</groupId>
|
<groupId>org.flywaydb</groupId>
|
||||||
<artifactId>flyway-core</artifactId>
|
<artifactId>flyway-core</artifactId>
|
||||||
@@ -42,17 +68,21 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
<artifactId>spring-boot-starter-flyway</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Observability -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-flyway</artifactId>
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.micrometer</groupId>
|
<groupId>io.micrometer</groupId>
|
||||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- testing -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-docker-compose</artifactId>
|
<artifactId>spring-boot-docker-compose</artifactId>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-2
@@ -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.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.cors.CorsConfigurationSource;
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
@@ -23,6 +25,7 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +37,9 @@ public class SecurityConfig {
|
|||||||
"/actuator/prometheus", // Allow Prometheus to scrape metrics
|
"/actuator/prometheus", // Allow Prometheus to scrape metrics
|
||||||
"/error" // Default Spring Boot error page routing
|
"/error" // Default Spring Boot error page routing
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private final JwtAuthenticationFilter jwtAuthFilter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injects the allowed CORS origins from the active application.yml profile.
|
* Injects the allowed CORS origins from the active application.yml profile.
|
||||||
*/
|
*/
|
||||||
@@ -78,7 +84,8 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers(WHITE_LIST_URLS).permitAll()
|
.requestMatchers(WHITE_LIST_URLS).permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
);
|
)
|
||||||
|
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ public class LoginController {
|
|||||||
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
|
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
|
||||||
|
|
||||||
var user = ApiMapper.toModel(request);
|
var user = ApiMapper.toModel(request);
|
||||||
loginService.login(user);
|
var jwt = loginService.login(user);
|
||||||
return ResponseEntity.ok(new LoginResponse("Login successful", user.getEmail()));
|
return ResponseEntity.ok(new LoginResponse("Login successful", jwt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package auth_service.domain.login.service;
|
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.InvalidCredentialsException;
|
||||||
import auth_service.domain.login.exception.UserNotFoundException;
|
import auth_service.domain.login.exception.UserNotFoundException;
|
||||||
import auth_service.domain.login.model.User;
|
import auth_service.domain.login.model.User;
|
||||||
@@ -15,14 +16,18 @@ import org.springframework.stereotype.Service;
|
|||||||
public class LoginService {
|
public class LoginService {
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
|
||||||
public String login(User userToLogin) {
|
public String login(User userToLogin) {
|
||||||
var userEntity = userRepository.findByEmail(userToLogin.getEmail()).orElseThrow(() -> new UserNotFoundException("User not found with given email"));
|
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());
|
var loginSuccessful = passwordEncoder.matches(userToLogin.getEncryptedPassword(), userEntity.getEncryptedPassword());
|
||||||
if (!loginSuccessful) {
|
if (!loginSuccessful) {
|
||||||
throw new InvalidCredentialsException("Invalid credentials");
|
throw new InvalidCredentialsException("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return jwtService.generateToken(userToLogin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<UserProfileResponse> 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,13 @@ spring:
|
|||||||
baseline-on-migrate: true
|
baseline-on-migrate: true
|
||||||
# Explicitly state where to look for scripts
|
# Explicitly state where to look for scripts
|
||||||
locations: classpath:db/migration
|
locations: classpath:db/migration
|
||||||
|
mvc:
|
||||||
|
apiversion:
|
||||||
|
use:
|
||||||
|
query-parameter: version
|
||||||
|
default: "1.0"
|
||||||
|
supported: 1.0, 2,0
|
||||||
|
|
||||||
|
|
||||||
# Actuator & Micrometer Settings
|
# Actuator & Micrometer Settings
|
||||||
management:
|
management:
|
||||||
@@ -29,6 +36,11 @@ management:
|
|||||||
export:
|
export:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
# Security stuff
|
||||||
|
security:
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET}
|
||||||
|
|
||||||
# Custom properties for CORS
|
# Custom properties for CORS
|
||||||
app:
|
app:
|
||||||
cors:
|
cors:
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ CREATE TABLE users
|
|||||||
-- Insert a pragmatic test user for local development and testing
|
-- Insert a pragmatic test user for local development and testing
|
||||||
-- The password hash corresponds to 'password123'
|
-- The password hash corresponds to 'password123'
|
||||||
INSERT INTO users (email, password, enabled)
|
INSERT INTO users (email, password, enabled)
|
||||||
VALUES ('test@example.com', '$2a$10$8.UnVuG9HHgffUDAlk8qfOuVGkqRzgVymGe07xd00dmOn.jptTVyG', true);
|
VALUES ('test@example.com', '$2a$10$3FApyf15qVEvFLEvl/d7aecSMZlslQdXiYlDgbOa4DfGWu4l0VCY.', true);
|
||||||
Reference in new issue
Block a user