Add /auth/login

This commit is contained in:
2026-06-01 09:15:08 -06:00
parent 925195ec13
commit defe79d242
7 changed files with 266 additions and 5 deletions

View File

@@ -1,12 +1,12 @@
.PHONY: run dev test docker-build docker-run docker-up docker-down .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: 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: 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: test:
mvn test mvn test

5
scripts/login Executable file
View File

@@ -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"}'

View File

@@ -5,6 +5,8 @@
*/ */
package com.seanstarkey.notesvault.auth; 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.RegisterRequest;
import com.seanstarkey.notesvault.dto.UserResponse; import com.seanstarkey.notesvault.dto.UserResponse;
import com.seanstarkey.notesvault.entity.User; import com.seanstarkey.notesvault.entity.User;
@@ -14,6 +16,8 @@ import jakarta.validation.Valid;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; 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.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -27,14 +31,22 @@ public class AuthController {
private final UserRepository userRepository; private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
/** /**
* @param userRepository repository used to create and look up user accounts * @param userRepository repository used to create and look up user accounts
* @param passwordEncoder encoder used to hash plaintext registration passwords * @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.userRepository = userRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
} }
/** /**
@@ -68,4 +80,26 @@ public class AuthController {
private UserResponse toResponse(User user) { private UserResponse toResponse(User user) {
return new UserResponse(user.getId(), user.getUsername(), user.getCreatedAt()); 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<AuthResponse> 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)
));
}
} }

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -6,6 +6,8 @@ package com.seanstarkey.notesvault.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; 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.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@@ -23,7 +25,7 @@ public class SecurityConfig {
return http return http
.csrf(csrf -> csrf.disable()) .csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/auth/register").permitAll() .requestMatchers(HttpMethod.POST, "/auth/register", "/auth/login").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.sessionManagement(session -> session .sessionManagement(session -> session
@@ -32,6 +34,18 @@ public class SecurityConfig {
.build(); .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. * Creates the BCrypt-backed password encoder.
* *

View File

@@ -3,6 +3,7 @@
*/ */
package com.seanstarkey.notesvault.auth; package com.seanstarkey.notesvault.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.seanstarkey.notesvault.repository.UserRepository; import com.seanstarkey.notesvault.repository.UserRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -32,6 +33,12 @@ class AuthControllerTest {
@Autowired @Autowired
private PasswordEncoder passwordEncoder; private PasswordEncoder passwordEncoder;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private ObjectMapper objectMapper;
@Test @Test
void registerCreatesUserAndReturnsPublicProfile() throws Exception { void registerCreatesUserAndReturnsPublicProfile() throws Exception {
mockMvc.perform(post("/auth/register") mockMvc.perform(post("/auth/register")
@@ -68,6 +75,37 @@ class AuthControllerTest {
.andExpect(jsonPath("$.error").value("Username is already registered")); .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 { private org.springframework.test.web.servlet.ResultActions register(String username, String password) throws Exception {
return mockMvc.perform(post("/auth/register") return mockMvc.perform(post("/auth/register")