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
|
.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
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;
|
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)
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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.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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user