diff --git a/Makefile b/Makefile index 62cbf6f..6192678 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,12 @@ .PHONY: run dev test docker-build docker-run docker-up docker-down -JWT_SECRET ?= dev-secret-change-me +JWT_SECRET ?= dev-secret-change-me-do-not-use-in-production run: - JWT_SECRET=dev-secret-change-me mvn spring-boot:run + JWT_SECRET=dev-secret-change-me-do-not-use-in-production mvn spring-boot:run dev: - SPRING_PROFILES_ACTIVE=dev JWT_SECRET=dev-secret-change-me mvn spring-boot:run + SPRING_PROFILES_ACTIVE=dev JWT_SECRET=dev-secret-change-me-do-not-use-in-production mvn spring-boot:run test: mvn test diff --git a/scripts/login b/scripts/login new file mode 100755 index 0000000..ab4abea --- /dev/null +++ b/scripts/login @@ -0,0 +1,5 @@ +#!/bin/bash + +curl -s -X POST http://localhost:8080/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "starkey", "password": "password"}' \ No newline at end of file diff --git a/src/main/java/com/seanstarkey/notesvault/auth/AuthController.java b/src/main/java/com/seanstarkey/notesvault/auth/AuthController.java index 095ebf2..66897e6 100644 --- a/src/main/java/com/seanstarkey/notesvault/auth/AuthController.java +++ b/src/main/java/com/seanstarkey/notesvault/auth/AuthController.java @@ -5,6 +5,8 @@ */ package com.seanstarkey.notesvault.auth; +import com.seanstarkey.notesvault.dto.AuthResponse; +import com.seanstarkey.notesvault.dto.LoginRequest; import com.seanstarkey.notesvault.dto.RegisterRequest; import com.seanstarkey.notesvault.dto.UserResponse; import com.seanstarkey.notesvault.entity.User; @@ -14,6 +16,8 @@ import jakarta.validation.Valid; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.PostMapping; @@ -27,14 +31,22 @@ public class AuthController { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; /** * @param userRepository repository used to create and look up user accounts * @param passwordEncoder encoder used to hash plaintext registration passwords */ - public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder) { + public AuthController(UserRepository userRepository, + PasswordEncoder passwordEncoder, + AuthenticationManager authenticationManager, + JwtUtil jwtUtil + ) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; + this.authenticationManager = authenticationManager; + this.jwtUtil = jwtUtil; } /** @@ -68,4 +80,26 @@ public class AuthController { private UserResponse toResponse(User user) { return new UserResponse(user.getId(), user.getUsername(), user.getCreatedAt()); } + + /** + * Authenticates an existing user and issues a signed bearer token. + * + * @param request validated login payload containing username and password + * @return 200 OK with JWT token metadata in the response body + * @throws org.springframework.security.core.AuthenticationException when credentials are invalid + */ + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.username(), request.password()) + ); + + String token = jwtUtil.generateToken(request.username()); + return ResponseEntity.ok(new AuthResponse( + token, + "Bearer", + jwtUtil.getExpirationSeconds(), + jwtUtil.getExpirationFromToken(token) + )); + } } diff --git a/src/main/java/com/seanstarkey/notesvault/auth/JwtUtil.java b/src/main/java/com/seanstarkey/notesvault/auth/JwtUtil.java new file mode 100644 index 0000000..68965f2 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/auth/JwtUtil.java @@ -0,0 +1,123 @@ +/** + * JwtUtil.java + */ +package com.seanstarkey.notesvault.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.util.Date; + +/** + * Creates and parses HS256-signed JWTs for authenticated users. Tokens carry the + * username as the subject and expire after the configured lifetime. + */ +@Component +public class JwtUtil { + + private final SecretKey signingKey; + private final long expirationSeconds; + private final Clock clock; + + /** + * @param secret raw JWT signing secret from configuration + * @param expirationSeconds number of seconds each issued token remains valid + */ + @Autowired + public JwtUtil( + @Value("${app.jwt.secret}") String secret, + @Value("${app.jwt.expiration-seconds}") long expirationSeconds + ) { + this(secret, expirationSeconds, Clock.systemUTC()); + } + + /** + * @param secret raw JWT signing secret from configuration + * @param expirationSeconds number of seconds each issued token remains valid + * @param clock clock used to calculate issued-at and expiration timestamps + */ + JwtUtil(String secret, long expirationSeconds, Clock clock) { + this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.expirationSeconds = expirationSeconds; + this.clock = clock; + } + + /** + * @param username authenticated user's unique username + * @return compact JWT string signed with the configured HS256 secret + */ + public String generateToken(String username) { + Instant issuedAt = clock.instant(); + Instant expiresAt = issuedAt.plusSeconds(expirationSeconds); + + return Jwts.builder() + .subject(username) + .issuedAt(Date.from(issuedAt)) + .expiration(Date.from(expiresAt)) + .signWith(signingKey, Jwts.SIG.HS256) + .compact(); + } + + /** + * @param token compact JWT string to parse + * @return username stored in the token subject + * @throws JwtException when the token is malformed, expired, or has an invalid signature + * @throws IllegalArgumentException when the token is null or blank + */ + public String getUsernameFromToken(String token) { + return parseClaims(token).getSubject(); + } + + /** + * @param token compact JWT string to parse + * @return token expiration timestamp + * @throws JwtException when the token is malformed, expired, or has an invalid signature + * @throws IllegalArgumentException when the token is null or blank + */ + public Instant getExpirationFromToken(String token) { + return parseClaims(token).getExpiration().toInstant(); + } + + /** + * @param token compact JWT string to validate + * @param expectedUsername username that must match the token subject + * @return {@code true} when the token is valid for the username; otherwise {@code false} + */ + public boolean isTokenValid(String token, String expectedUsername) { + try { + String tokenUsername = getUsernameFromToken(token); + return tokenUsername.equals(expectedUsername); + } catch (JwtException | IllegalArgumentException ex) { + return false; + } + } + + /** + * @return number of seconds each issued token remains valid + */ + public long getExpirationSeconds() { + return expirationSeconds; + } + + private Claims parseClaims(String token) { + if (token == null || token.isBlank()) { + throw new IllegalArgumentException("JWT token must not be blank"); + } + + return Jwts.parser() + .verifyWith(signingKey) + .clock(() -> Date.from(clock.instant())) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/src/main/java/com/seanstarkey/notesvault/auth/UserDetailsServiceImpl.java b/src/main/java/com/seanstarkey/notesvault/auth/UserDetailsServiceImpl.java new file mode 100644 index 0000000..9c238c5 --- /dev/null +++ b/src/main/java/com/seanstarkey/notesvault/auth/UserDetailsServiceImpl.java @@ -0,0 +1,47 @@ +/** + * UserDetailsServiceImpl.java + * + * Spring Security adapter that loads Secure Notes Vault users from persistence + * and exposes them as UserDetails for authentication filters and providers. + */ +package com.seanstarkey.notesvault.auth; + +import com.seanstarkey.notesvault.repository.UserRepository; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +/** + * Loads application users by username and converts them into Spring Security + * UserDetails instances backed by the stored BCrypt password hash. + */ +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserRepository userRepository; + + /** + * @param userRepository repository used to look up registered users + */ + public UserDetailsServiceImpl(UserRepository userRepository) { + this.userRepository = userRepository; + } + + /** + * @param username unique username supplied by the authentication request or JWT + * @return UserDetails containing the username, password hash, and granted authorities + * @throws UsernameNotFoundException when no registered user exists for the username + */ + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + com.seanstarkey.notesvault.entity.User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + + return User.withUsername(user.getUsername()) + .password(user.getPasswordHash()) + .authorities("USER") + .build(); + } +} diff --git a/src/main/java/com/seanstarkey/notesvault/config/SecurityConfig.java b/src/main/java/com/seanstarkey/notesvault/config/SecurityConfig.java index b167e4d..31f9edc 100644 --- a/src/main/java/com/seanstarkey/notesvault/config/SecurityConfig.java +++ b/src/main/java/com/seanstarkey/notesvault/config/SecurityConfig.java @@ -6,6 +6,8 @@ package com.seanstarkey.notesvault.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -23,7 +25,7 @@ public class SecurityConfig { return http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.POST, "/auth/register").permitAll() + .requestMatchers(HttpMethod.POST, "/auth/register", "/auth/login").permitAll() .anyRequest().authenticated() ) .sessionManagement(session -> session @@ -32,6 +34,18 @@ public class SecurityConfig { .build(); } + /** + * @param authenticationConfiguration Spring Security authentication configuration + * @return authentication manager backed by the configured user details service and encoder + * @throws Exception when Spring Security cannot create the authentication manager + */ + @Bean + public AuthenticationManager authenticationManager( + AuthenticationConfiguration authenticationConfiguration + ) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + /** * Creates the BCrypt-backed password encoder. * diff --git a/src/test/java/com/seanstarkey/notesvault/auth/AuthControllerTest.java b/src/test/java/com/seanstarkey/notesvault/auth/AuthControllerTest.java index 6f63697..d94c693 100644 --- a/src/test/java/com/seanstarkey/notesvault/auth/AuthControllerTest.java +++ b/src/test/java/com/seanstarkey/notesvault/auth/AuthControllerTest.java @@ -3,6 +3,7 @@ */ package com.seanstarkey.notesvault.auth; +import com.fasterxml.jackson.databind.ObjectMapper; import com.seanstarkey.notesvault.repository.UserRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -32,6 +33,12 @@ class AuthControllerTest { @Autowired private PasswordEncoder passwordEncoder; + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private ObjectMapper objectMapper; + @Test void registerCreatesUserAndReturnsPublicProfile() throws Exception { mockMvc.perform(post("/auth/register") @@ -68,6 +75,37 @@ class AuthControllerTest { .andExpect(jsonPath("$.error").value("Username is already registered")); } + /* + * Test: registered users can log in and receive a bearer token. + * Verifies that POST /auth/login authenticates stored BCrypt credentials, + * returns token metadata, and issues a JWT whose subject is the username. + */ + @Test + void loginAuthenticatesUserAndReturnsBearerToken() throws Exception { + register("starkey", "secret123").andExpect(status().isCreated()); + + String responseBody = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "starkey", + "password": "secret123" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").isNotEmpty()) + .andExpect(jsonPath("$.tokenType").value("Bearer")) + .andExpect(jsonPath("$.expiresInSeconds").value(86400)) + .andExpect(jsonPath("$.expiresAt").isNotEmpty()) + .andReturn() + .getResponse() + .getContentAsString(); + + String token = objectMapper.readTree(responseBody).get("token").asText(); + + assertThat(jwtUtil.getUsernameFromToken(token)).isEqualTo("starkey"); + assertThat(jwtUtil.isTokenValid(token, "starkey")).isTrue(); + } private org.springframework.test.web.servlet.ResultActions register(String username, String password) throws Exception { return mockMvc.perform(post("/auth/register")