Skip to content

Commit 3e77215

Browse files
authored
feat: implement self-contained JWT authentication for Phase 1 (#2)
### Lab Phase & Objective This PR completes **Phase 1: The Standalone Secure Monolith**. The objective is to establish a robust security baseline for a standalone REST API. The service is responsible for both authenticating users and issuing its own JSON Web Tokens (JWTs) without any external identity providers. ### Key Architectural Decisions & Trade-offs - **Custom JWT Filter Pattern:** Adopted a custom `JwtAuthenticationFilter` extending `OncePerRequestFilter`. This provides explicit, granular control over the token validation lifecycle, which is the canonical pattern for a self-contained JWT system. It avoids configuration conflicts present in other approaches. - **`io.jsonwebtoken` (`jjwt`) Library:** Chose the `jjwt` library for token generation and parsing. Its fluent, purpose-built API is the industry standard for this task. All token-related logic is encapsulated in a dedicated `TokenService` to adhere to the Single Responsibility Principle. - **Delegation to `AuthenticationManager`:** The `AuthController` does not handle credential validation directly. It correctly delegates this responsibility to Spring Security's `AuthenticationManager`, which is configured with our `UserDetailsService` and `PasswordEncoder`. This decouples the controller from the authentication mechanism. - **Explicit `AuthenticationEntryPoint`:** Configured a `HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)` in the security chain. This is the correct, modern way to ensure that authentication failures in a stateless API return a semantically correct `401 Unauthorized` status code. ### Verification Strategy - [x] **Unit/Integration Tests:** All new and existing tests pass via `./mvnw clean verify`. Key test classes: - `com.apenlor.lab.resourceserver.controller.ApiControllerTests` - `com.apenlor.lab.resourceserver.controller.AuthControllerTests` - [ ] **Docker Compose:** N/A. Deferred to Phase 2, where containerization is a primary objective. - [x] **Manual Verification:** The application can be run locally with `./mvnw -pl resource-server spring-boot:run`. The following flow can be used to manually verify: 1. **Get a token:** ```bash TOKEN=$(curl -s -X POST http://localhost:8081/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"user", "password":"password"}' | jq -r .jwtToken) ``` 2. **Access the public endpoint (succeeds):** ```bash curl http://localhost:8081/api/public/info ``` 3. **Access the secure endpoint without a token (fails with 401):** ```bash curl -i http://localhost:8081/api/secure/data ``` 4. **Access the secure endpoint with the token (succeeds):** ```bash curl http://localhost:8081/api/secure/data -H "Authorization: Bearer $TOKEN" ``` ### Definition of Done Checklist - [x] The PR title follows the Conventional Commits specification (e.g., `feat: ...`, `fix: ...`). - [x] The code adheres to SOLID principles and project conventions. - [x] The `README.md` has been updated to reflect the new state of the project. - [x] All new logic is covered by automated tests. - [x] Security implications of the changes have been considered. - [ ] Observability implications (metrics, logs, traces) have been considered. *(N/A for this phase)*
1 parent a782808 commit 3e77215

File tree

13 files changed

+541
-4
lines changed

13 files changed

+541
-4
lines changed

pom.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@
3434
</dependencyManagement>
3535

3636
<build>
37+
<plugins>
38+
<plugin>
39+
<groupId>org.apache.maven.plugins</groupId>
40+
<artifactId>maven-compiler-plugin</artifactId>
41+
<version>3.14.0</version>
42+
<configuration>
43+
<source>${java.version}</source>
44+
<target>${java.version}</target>
45+
</configuration>
46+
</plugin>
47+
</plugins>
3748
<pluginManagement>
3849
<plugins>
3950
<plugin>

resource-server/pom.xml

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,21 @@
2424
<artifactId>spring-boot-starter-security</artifactId>
2525
</dependency>
2626

27-
<!-- OAuth2 / JWT Support -->
27+
<!-- JWT Support -->
2828
<dependency>
29-
<groupId>org.springframework.boot</groupId>
30-
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
29+
<groupId>io.jsonwebtoken</groupId>
30+
<artifactId>jjwt-api</artifactId>
31+
<version>0.12.6</version>
32+
</dependency>
33+
<dependency>
34+
<groupId>io.jsonwebtoken</groupId>
35+
<artifactId>jjwt-impl</artifactId>
36+
<version>0.12.6</version>
37+
</dependency>
38+
<dependency>
39+
<groupId>io.jsonwebtoken</groupId>
40+
<artifactId>jjwt-jackson</artifactId>
41+
<version>0.12.6</version>
3142
</dependency>
3243

3344
<!-- Test dependencies -->
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.apenlor.lab.resourceserver.config;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.security.authentication.AuthenticationManager;
6+
import org.springframework.security.authentication.ProviderManager;
7+
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
8+
import org.springframework.security.core.userdetails.User;
9+
import org.springframework.security.core.userdetails.UserDetailsService;
10+
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
11+
import org.springframework.security.crypto.password.PasswordEncoder;
12+
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
13+
14+
@Configuration
15+
public class ApplicationConfig {
16+
17+
/**
18+
* Creates a PasswordEncoder bean that uses a delegating strategy. This standard
19+
* allows for multiple encoding algorithms to coexist (e.g., bcrypt, scrypt).
20+
*
21+
* @return A PasswordEncoder instance.
22+
*/
23+
@Bean
24+
public PasswordEncoder passwordEncoder() {
25+
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
26+
}
27+
28+
/**
29+
* Defines the user details service that provides user information to Spring Security.
30+
* For Phase 1, we use a simple in-memory user.
31+
*
32+
* @param passwordEncoder The password encoder to use for encoding the user's password.
33+
* @return A UserDetailsService instance.
34+
*/
35+
@Bean
36+
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
37+
// --- Architectural Note: In-Memory User Store ---
38+
// For this lab phase, we define a single user in memory. The password is
39+
// encoded using the application's standard PasswordEncoder. In a production
40+
// scenario, this would be replaced with a database-backed UserDetailsService.
41+
var user = User.withUsername("user")
42+
.password(passwordEncoder.encode("password"))
43+
.authorities("read", "write").build();
44+
return new InMemoryUserDetailsManager(user);
45+
}
46+
47+
/**
48+
* Defines the AuthenticationManager, which is the core of Spring Security's
49+
* authentication mechanism. It processes an authentication request.
50+
*
51+
* @param userDetailsService The service to load user-specific data.
52+
* @param passwordEncoder The encoder to use for password verification.
53+
* @return An AuthenticationManager instance.
54+
*/
55+
@Bean
56+
public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
57+
var authProvider = new DaoAuthenticationProvider(userDetailsService);
58+
authProvider.setPasswordEncoder(passwordEncoder);
59+
return new ProviderManager(authProvider);
60+
}
61+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.apenlor.lab.resourceserver.config;
2+
3+
import com.apenlor.lab.resourceserver.service.TokenService;
4+
import io.jsonwebtoken.JwtException;
5+
import jakarta.servlet.FilterChain;
6+
import jakarta.servlet.ServletException;
7+
import jakarta.servlet.http.HttpServletRequest;
8+
import jakarta.servlet.http.HttpServletResponse;
9+
import org.slf4j.Logger;
10+
import org.slf4j.LoggerFactory;
11+
import org.springframework.lang.NonNull;
12+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
13+
import org.springframework.security.core.context.SecurityContextHolder;
14+
import org.springframework.security.core.userdetails.UserDetails;
15+
import org.springframework.security.core.userdetails.UserDetailsService;
16+
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
17+
import org.springframework.web.filter.OncePerRequestFilter;
18+
19+
import java.io.IOException;
20+
21+
/**
22+
* Custom JWT authentication filter that runs once per request.
23+
* This filter is responsible for validating the JWT from the Authorization header
24+
* and setting the user's authentication in the Spring Security context.
25+
*/
26+
public class JwtAuthenticationFilter extends OncePerRequestFilter {
27+
28+
private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
29+
30+
private final TokenService tokenService;
31+
private final UserDetailsService userDetailsService;
32+
33+
public JwtAuthenticationFilter(TokenService tokenService, UserDetailsService userDetailsService) {
34+
this.tokenService = tokenService;
35+
this.userDetailsService = userDetailsService;
36+
}
37+
38+
@Override
39+
protected void doFilterInternal(
40+
@NonNull HttpServletRequest request,
41+
@NonNull HttpServletResponse response,
42+
@NonNull FilterChain filterChain) throws ServletException, IOException {
43+
44+
final String authHeader = request.getHeader("Authorization");
45+
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
46+
filterChain.doFilter(request, response);
47+
return;
48+
}
49+
50+
final String jwt = authHeader.substring(7);
51+
52+
try {
53+
final String username = tokenService.getUsernameFromToken(jwt);
54+
55+
// If a username is extracted and the user is not already authenticated in the current context
56+
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
57+
// Load the user details from the database in real world (or in-memory store for our lab)
58+
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
59+
60+
// Validate the token against the user details
61+
if (tokenService.isTokenValid(jwt, userDetails.getUsername())) {
62+
// Create an authentication token for the user
63+
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
64+
userDetails,
65+
null,
66+
userDetails.getAuthorities()
67+
);
68+
// Add details from the current request to the authentication token
69+
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
70+
SecurityContextHolder.getContext().setAuthentication(authToken);
71+
log.info("Authenticated user '{}', setting security context", username);
72+
}
73+
}
74+
} catch (JwtException e) {
75+
log.warn("JWT token processing failed: {}", e.getMessage());
76+
}
77+
78+
filterChain.doFilter(request, response);
79+
}
80+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.apenlor.lab.resourceserver.config;
2+
3+
import com.apenlor.lab.resourceserver.service.TokenService;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.http.HttpStatus;
7+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
8+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
9+
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
10+
import org.springframework.security.config.http.SessionCreationPolicy;
11+
import org.springframework.security.core.userdetails.UserDetailsService;
12+
import org.springframework.security.web.SecurityFilterChain;
13+
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
14+
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
15+
16+
@Configuration
17+
@EnableWebSecurity
18+
public class SecurityConfig {
19+
20+
private final TokenService tokenService;
21+
private final UserDetailsService userDetailsService;
22+
23+
public SecurityConfig(TokenService tokenService, UserDetailsService userDetailsService) {
24+
this.tokenService = tokenService;
25+
this.userDetailsService = userDetailsService;
26+
}
27+
28+
/**
29+
* Defines the main security filter chain for the application. This bean is the primary
30+
* point of configuration for all security aspects of the web layer.
31+
*
32+
* @param http The HttpSecurity object to configure.
33+
* @return The configured SecurityFilterChain.
34+
* @throws Exception If an error occurs during configuration.
35+
*/
36+
@Bean
37+
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
38+
return http
39+
// Disable CSRF protection, as it's not needed for stateless, token-based APIs.
40+
.csrf(AbstractHttpConfigurer::disable)
41+
// Configure authorization rules for all incoming HTTP requests.
42+
.authorizeHttpRequests(auth -> auth
43+
// Allow unauthenticated access to the public info and login endpoints.
44+
.requestMatchers("/api/public/info", "/auth/login").permitAll()
45+
// All other requests must be authenticated.
46+
.anyRequest().authenticated())
47+
// Add our custom JWT filter before the standard UsernamePasswordAuthenticationFilter.
48+
.addFilterBefore(new JwtAuthenticationFilter(tokenService, userDetailsService), UsernamePasswordAuthenticationFilter.class)
49+
// Configure auth exceptions to return 401 Unauthorized response.
50+
.exceptionHandling(exceptions -> exceptions
51+
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
52+
)
53+
// Configure session management to be stateless.
54+
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).build();
55+
}
56+
57+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.apenlor.lab.resourceserver.controller;
2+
3+
import org.springframework.http.ResponseEntity;
4+
import org.springframework.security.core.Authentication;
5+
import org.springframework.web.bind.annotation.GetMapping;
6+
import org.springframework.web.bind.annotation.RequestMapping;
7+
import org.springframework.web.bind.annotation.RestController;
8+
9+
@RestController
10+
@RequestMapping("/api")
11+
public class ApiController {
12+
13+
@GetMapping("/public/info")
14+
public ResponseEntity<String> getPublicInfo() {
15+
String body = "This is PUBLIC information. Anyone can see this.";
16+
return ResponseEntity.ok(body);
17+
}
18+
19+
@GetMapping("/secure/data")
20+
public ResponseEntity<String> getSecureData(Authentication authentication) {
21+
String username = authentication.getName();
22+
String body = String.format("This is SECURE data for user: %s. You should only see this if you are authenticated.", username);
23+
return ResponseEntity.ok(body);
24+
}
25+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// File: resource-server/src/main/java/com/apenlor/lab/resourceserver/controller/AuthController.java
2+
3+
package com.apenlor.lab.resourceserver.controller;
4+
5+
import com.apenlor.lab.resourceserver.dto.LoginRequest;
6+
import com.apenlor.lab.resourceserver.dto.LoginResponse;
7+
import com.apenlor.lab.resourceserver.service.TokenService;
8+
import org.springframework.http.ResponseEntity;
9+
import org.springframework.security.authentication.AuthenticationManager;
10+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
11+
import org.springframework.security.core.Authentication;
12+
import org.springframework.web.bind.annotation.PostMapping;
13+
import org.springframework.web.bind.annotation.RequestBody;
14+
import org.springframework.web.bind.annotation.RequestMapping;
15+
import org.springframework.web.bind.annotation.RestController;
16+
17+
/**
18+
* Controller responsible for handling user authentication and issuing JWTs.
19+
*/
20+
@RestController
21+
@RequestMapping("/auth")
22+
public class AuthController {
23+
24+
private final AuthenticationManager authenticationManager;
25+
private final TokenService tokenService;
26+
27+
public AuthController(AuthenticationManager authenticationManager, TokenService tokenService) {
28+
this.authenticationManager = authenticationManager;
29+
this.tokenService = tokenService;
30+
}
31+
32+
/**
33+
* Handles the login request from a user.
34+
*
35+
* @param loginRequest The request body containing username and password.
36+
* @return A ResponseEntity containing the JWT if authentication is successful.
37+
*/
38+
@PostMapping("/login")
39+
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest loginRequest) {
40+
Authentication authentication = authenticationManager.authenticate(
41+
new UsernamePasswordAuthenticationToken(loginRequest.username(), loginRequest.password())
42+
);
43+
44+
String token = tokenService.generateToken(authentication);
45+
46+
return ResponseEntity.ok(new LoginResponse(token));
47+
}
48+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.apenlor.lab.resourceserver.dto;
2+
3+
/**
4+
* Represents the data structure for a login request.
5+
*
6+
* @param username The user's username.
7+
* @param password The user's password.
8+
*/
9+
public record LoginRequest(String username, String password) {
10+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.apenlor.lab.resourceserver.dto;
2+
3+
/**
4+
* Represents the data structure for a successful login response.
5+
*
6+
* @param jwtToken The generated JSON Web Token.
7+
*/
8+
public record LoginResponse(String jwtToken) {
9+
}

0 commit comments

Comments
 (0)