Add /auth/login
This commit is contained in:
6
Makefile
6
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
|
||||
|
||||
5
scripts/login
Executable file
5
scripts/login
Executable 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"}'
|
||||
@@ -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<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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
123
src/main/java/com/seanstarkey/notesvault/auth/JwtUtil.java
Normal file
123
src/main/java/com/seanstarkey/notesvault/auth/JwtUtil.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user